ملف Dockerfile الخاص بك على الأرجح كبير جدًا للإنتاج

لقد انتهيت للتو من كتابة تطبيقك. يتم تجميعه، ويعمل محليًا، وأنت مستعد لشحنه. تكتب ملف Dockerfile، وتبني صورة، وتدفعها إلى سجل، وتنشرها على خادم. يعمل النشر، لكن هناك شيئًا غير مريح. حجم الصورة 1.2 جيجابايت. يستغرق سحبها على الخادم ثلاث دقائق. تتزايد تكاليف التخزين. وفي مكان ما في عقلك الباطن، تتساءل: هل أحتاج حقًا إلى نظام تشغيل كامل مع مدير حزم وشيل فقط لتشغيل ملف Go الثنائي؟

هذه هي اللحظة التي يدرك فيها معظم الفرق أن بناء صورة وبناء صورة مناسبة للتسليم هما شيئان مختلفان. ملف Dockerfile الذي تكتبه للاختبار المحلي ليس هو نفسه الذي يجب استخدامه في الإنتاج. إذا كانت صورتك كبيرة، أو بطيئة في البناء، أو مليئة بالأدوات غير الضرورية، فإن خط أنابيبك بالكامل يعاني. كل عملية سحب تستغرق وقتًا أطول. كل بناء يهدر وقتًا. كل ماسح ضوئي للثغرات يجد مشكلات أكثر مما ينبغي.

الخبر السار هو أن إصلاح هذا لا يتطلب إعادة كتابة كاملة للبنية التحتية. يتطلب فهم ثلاثة أشياء: كيف تعمل ملفات Dockerfile فعليًا، وكيفية الحفاظ على الصور صغيرة، وكيفية جعل عمليات البناء قابلة للتكرار.

كيف يصبح ملف Dockerfile صورة

ملف Dockerfile هو ملف نصي يحتوي على تعليمات. كل تعليمة تنشئ طبقة واحدة. عند إعادة بناء الصورة، يتحقق Docker من الطبقات التي لم تتغير ويعيد استخدامها من ذاكرة التخزين المؤقت. هذا هو السبب في أن البناء الثاني أسرع من الأول: لا يتم إعادة بناء الطبقات غير المتغيرة.

المشكلة هي أن معظم الأشخاص يكتبون ملفات Dockerfile كما لو كانوا يكتبون سكريبت إعداد. يقومون بتثبيت كل شيء، ونسخ كل شيء، ولا يدركون إلا لاحقًا أن الصورة النهائية تحتوي على مترجمات، وأدوات تصحيح أخطاء، وأدوات نظام لا علاقة لها بتشغيل التطبيق. كل أداة إضافية في الصورة هي وزن إضافي أثناء السحب، وسطح إضافي للثغرات، وتعقيد إضافي عندما تحاول فهم ما هو موجود بالفعل في الصورة.

الحل لا يتعلق بكتابة كود أقل. يتعلق بفصل بيئة البناء عن بيئة التشغيل.

البناء متعدد المراحل: النمط الأكثر أهمية على الإطلاق

البناء متعدد المراحل يتيح لك تعريف مراحل متعددة في ملف Dockerfile واحد. تحتوي المرحلة الأولى على كل ما هو مطلوب لتجميع التطبيق: حزمة SDK الكاملة، والمترجمات، وأدوات البناء، والتبعيات. تحتوي المرحلة الثانية فقط على ما هو مطلوب لتشغيل القطعة المجمعة: الملف الثنائي، ومكتبات التشغيل، ولا شيء غير ذلك.

إليك ما يبدو عليه ذلك لتطبيق Go:

يوضح الرسم البياني أدناه كيف تعمل المرحلتان معًا:

flowchart TD A["ملف Dockerfile"] --> B["المرحلة 1: البناء"] B --> C["FROM golang:1.22-alpine"] C --> D["COPY go.mod go.sum"] D --> E["RUN go mod download"] E --> F["COPY كود المصدر"] F --> G["RUN go build -o myapp"] G --> H["الملف الثنائي: myapp"] A --> I["المرحلة 2: التشغيل"] I --> J["FROM alpine:3.19"] J --> K["COPY --from=builder /app/myapp /myapp"] K --> L["CMD [\"/myapp\"]"] L --> M["صورة نهائية صغيرة (~20-30 ميجابايت)"] H -.-> K
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp .

# Stage 2: Run
FROM alpine:3.19
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]

تستخدم المرحلة الأولى حزمة Go SDK الكاملة. تستخدم المرحلة الثانية صورة Alpine مصغرة تحتوي فقط على الأساسيات. حزمة Go SDK، وكود المصدر، وذاكرة التخزين المؤقت للبناء لا تصل أبدًا إلى الصورة النهائية. النتيجة هي صورة غالبًا ما تكون بحجم 20 إلى 30 ميجابايت بدلاً من أكثر من جيجابايت.

ينطبق نفس النمط على أي لغة مجمعة. بالنسبة للغات المفسرة مثل Python أو Node.js، يمكنك استخدام البناء متعدد المراحل لتثبيت التبعيات في مرحلة واحدة ونسخ الحزم المثبتة فقط إلى المرحلة النهائية، مع ترك ذاكرة التخزين المؤقت لمدير الحزم وأدوات البناء وراءك.

الأمان يبدأ بما تتركه خارجًا

كل أداة في صورتك هي ثغرة أمنية محتملة. قد تبدو شيل مثل Bash أو مدير حزم مثل apt غير ضارة، ولكن إذا تمكن مهاجم من الوصول إلى حاويتك، فإن هذه الأدوات تصبح أسلحة. تسمح للمهاجم بتثبيت برامج جديدة، أو تنزيل حمولات، أو الانتقال إلى أنظمة أخرى.

المبدأ بسيط: لا تقم بتضمين أي شيء غير مطلوب لتشغيل التطبيق. إذا كنت بحاجة إلى تصحيح أخطاء حاوية قيد التشغيل من حين لآخر، فلا تحتفظ بأدوات التصحيح في صورة الإنتاج. بدلاً من ذلك، قم بإنشاء صورة تصحيح أخطاء منفصلة أو استخدم حاويات مؤقتة تقوم بتركيب الأدوات الضرورية في وقت التشغيل.

بالنسبة لصور الإنتاج، فكر في استخدام صور أساسية "distroless". تحتوي هذه الصور فقط على بيئة التشغيل والمكتبات التي يحتاجها تطبيقك. لا شيل، ولا مدير حزم، ولا أدوات مساعدة. تحتفظ Google بصور distroless للعديد من اللغات، بما في ذلك Go وPython وJava وNode.js. إنها صغيرة وبسيطة وتقلل بشكل كبير من سطح الهجوم.

إذا كانت distroless مقيدة جدًا، فإن Alpine بديل معقول. إنها صغيرة وتتضمن شيل، لكنها تستخدم musl libc بدلاً من glibc، مما قد يسبب مشاكل توافق مع بعض التطبيقات. اختبر تطبيقك على Alpine قبل الالتزام به.

قابلية التكرار ليست اختيارية

الصورة التي لا يمكن إعادة بنائها بشكل مماثل بعد ستة أشهر من الآن هي التزام. السبب الأكثر شيوعًا لعمليات البناء غير القابلة للتكرار هو استخدام علامة latest للصور الأساسية. latest تتغير كلما دفع المشرف إصدارًا جديدًا. قد يستخدم بناء اليوم Go 1.22، لكن بناء الشهر القادم قد يستخدم Go 1.23، ولن تعرف حتى يحدث خطأ ما.

قم دائمًا بتثبيت صورتك الأساسية على علامة إصدار محددة أو، الأفضل من ذلك، على تجزئة digest. تجزئة digest هي المجموع الاختباري المشفر لمحتوى الصورة. لا تتغير أبدًا. إذا استخدمت golang:1.22-alpine@sha256:abc123...، فأنت مضمون للحصول على نفس الصورة الأساسية تمامًا في كل مرة، بغض النظر عن وقت أو مكان البناء.

FROM golang:1.22-alpine@sha256:abc123def456 AS builder

ينطبق هذا على كل صورة تسحبها، بما في ذلك الصور الوسيطة المستخدمة في عمليات البناء متعددة المراحل. إذا لم تتمكن من العثور على digest لإصدار معين، استخدم العلامة الأكثر تحديدًا المتاحة، مثل golang:1.22.0-alpine3.19 بدلاً من golang:1.22-alpine.

ترتيب الطبقات يؤثر على سرعة البناء

يقوم Docker بتخزين الطبقات مؤقتًا بناءً على ترتيب التعليمات. إذا قمت بتغيير ملف يتم نسخه مبكرًا في ملف Dockerfile، فسيتم إبطال جميع الطبقات اللاحقة وإعادة بنائها. إذا قمت بتغيير ملف يتم نسخه متأخرًا، فسيتم إعادة بناء الطبقات بعد تلك النقطة فقط.

القاعدة العملية هي: ضع التعليمات التي نادرًا ما تتغير في الأعلى، والتعليمات التي تتغير بشكل متكرر في الأسفل.

  • قم بتثبيت تبعيات النظام أولاً.
  • انسخ ملفات بيان التبعية (مثل go.mod و go.sum) وقم بتشغيل خطوة تنزيل التبعية.
  • انسخ كود المصدر أخيرًا.

بهذه الطريقة، إذا قمت بتغيير كود المصدر فقط، فسيتم إعادة استخدام خطوة تثبيت التبعية من ذاكرة التخزين المؤقت. يكون البناء أسرع لأن Docker لا يعيد تنزيل الحزم التي لم تتغير.

قائمة تحقق عملية لملف Dockerfile الخاص بك

قبل دفع صورتك التالية إلى الإنتاج، راجع قائمة التحقق هذه:

  • هل تحتوي الصورة النهائية فقط على ما هو مطلوب لتشغيل التطبيق؟
  • هل تم استبعاد أدوات البناء وحزم SDK وكود المصدر من المرحلة النهائية؟
  • هل الصورة الأساسية مثبتة على إصدار محدد أو digest، وليس latest؟
  • هل الصورة الأساسية بسيطة (distroless أو Alpine) بدلاً من صورة نظام تشغيل كاملة؟
  • هل تم وضع التعليمات التي نادرًا ما تتغير قبل التعليمات التي تتغير بشكل متكرر؟
  • هل لا يوجد شيل أو مدير حزم في صورة الإنتاج إلا إذا كان ضروريًا للغاية؟

ما هو الأكثر أهمية

ملف Dockerfile ليس مجرد سكريبت بناء. إنه عقد بين عملية البناء وبيئة الإنتاج الخاصة بك. ملف Dockerfile جيد ينتج صورة صغيرة وآمنة وقابلة للتكرار. ملف Dockerfile سيء ينتج صورة بطيئة في السحب، وصعبة في تصحيح الأخطاء، ومن المستحيل إعادة بنائها بثقة.

في المرة القادمة التي تكتب فيها ملف Dockerfile، ابدأ بالصورة النهائية في ذهنك. اسأل نفسك: ما الذي يحتاجه هذا التطبيق بالفعل لتشغيله؟ ثم اترك كل شيء آخر خارجًا. سيكون خط أنابيبك أسرع، وستكون عمليات النشر أكثر سلاسة، وسيكون لدى فريق الأمان لديك شيئًا أقل للقلق بشأنه.