اگر برای build برنامه به compiler، dependency توسعه و ابزار تست نیاز دارید، لزوماً نباید همه آنها را همراه برنامه به production ببرید. Multi-stage Build در Dockerfile ساخت و اجرا را به چند مرحله جدا تقسیم میکند. هر مرحله با FROM آغاز میشود و مرحله نهایی فقط فایلهایی را میگیرد که صریحاً از stageهای قبلی کپی کردهاید.
پاسخ سریع: از یک stage برای نصب وابستگی و ساخت artifact، از stage احتمالی دیگر برای تست، و از runtime stage برای اجرای همان artifact استفاده کنید. COPY --from=build خروجی موردنیاز را منتقل میکند، نه کل filesystem ابزارساز را. نتیجه اغلب image کوچکتر و سطح package کمتر است؛ ولی سرعت، امنیت و صحت فقط با اندازه image سنجیده نمیشوند.
مشکل Dockerfile تکمرحلهای چیست؟
در ساخت تکمرحلهای معمولاً compiler، package manager، source، cache و dependency توسعه در همان image اجرای برنامه میمانند. حذفکردن فایل در دستور بعدی همیشه اثر لایه قبلی را از history پاک نمیکند. image بزرگتر دیرتر منتقل میشود و اجزای غیرضروری بیشتری برای patch و بررسی دارد. البته اگر برنامه واقعاً هیچ مرحله build یا dependency اضافی ندارد، یک stage ساده ممکن است کافی باشد.
هر FROM یک مرز است
در Dockerfile چندمرحلهای هر FROM مرحله تازهای میسازد. نامگذاری با AS build یا AS test ارجاع را خوانا میکند. stageها میتوانند base یکسان یا متفاوت داشته باشند. بهصورت پیشفرض stage آخر خروجی build است، مگر target دیگری انتخاب شود. فقط فایلهایی که با COPY --from یا مکانیزم صریح مشابه منتقل میشوند وارد stage بعدی خواهند شد.
مثال قابلفهم با خروجی کامپایلشده
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/service ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
COPY --from=build /out/service /service
USER nonroot:nonroot
ENTRYPOINT ["/service"]
در این نمونه compiler و source در مرحله build هستند و فقط فایل اجرایی به runtime میرود. دستورها برای ساختار ./cmd/server و باینری بدون نیاز به CGO نوشته شدهاند، نه برای هر برنامه Go. اگر برنامه فایل template، migration یا certificate لازم دارد، آنها را جدا و آگاهانه اضافه کنید. image پایه و dependency باید در پروژه pin و مرتب بهروز شوند.
چرا فقط حجم کمتر هدف نیست؟
image نهایی کمحجمتر میتواند انتقال سریعتر و اجزای کمتری داشته باشد، اما image بیشازحد مینیمال ممکن است CA، timezone یا کتابخانه لازم را نداشته باشد. شکستن برنامه برای چند مگابایت صرفهجویی ارزش ندارد. هدف، حذف آن چیزی است که runtime نیاز ندارد؛ نه حذف کورکورانه هر package. اندازه، تعداد vulnerability مرتبط، زمان build و توان debug را کنار هم بسنجید.
مرحله تست را کجا قرار دهیم؟
میتوانید stage مجزا برای تست بنویسید یا در builder پس از نصب dependency تست را اجرا کنید. اگر stage تست در مسیر stage نهایی dependency ندارد، ممکن است build تولیدی بدون اجرای تست موفق شود؛ پس CI باید stage تست را صریح هدف بگیرد و سپس image نهایی را بسازد. موفقیت test روی builder نیز تضمین نمیکند runtime درست است؛ smoke test خود image نهایی لازم است.
Build cache و ترتیب COPY
کپی کل source پیش از دانلود dependency، هر تغییر کد را به invalidation لایه dependency تبدیل میکند. برای بسیاری از stackها ابتدا manifest و lockfile را کپی میکنند، dependency قفلشده را میگیرند و بعد source را اضافه میکنند. cache mount میتواند زمان build را کم کند، ولی بسته به toolchain باید مطمئن شوید artifact نهایی به cache محلیِ غیرقابلانتقال وابسته نشده است.
آیا stageها موازی اجرا میشوند؟
BuildKit میتواند stageهای مستقل را بهینه و بعضاً موازی اجرا کند، اما سرعت قطعی به گراف dependency، cache، شبکه و منابع builder بستگی دارد. Multi-stage را نباید وعده «همیشه build سریعتر» تعبیر کرد؛ حتی ممکن است در آغاز بازسازی سرد طولانیتر باشد. سنجش روی CI و context واقعی معیار است.
کپی artifact در برابر کپی محیط کامل
وقتی COPY --from=build / / یا کپی پوشههای بزرگ انجام میدهید، عملاً مرز stage را خنثی میکنید. در برنامههای تفسیرشونده ممکن است runtime به library، bytecode یا packageهای نصبشده نیاز داشته باشد. اینها را در مسیر کنترلشده بسازید و کپی کنید؛ compatibility نسخه runtime و path را تست کنید. انتقال virtual environment بین imageهای ناهمسان بدون بررسی ABI میتواند در اجرا خطا بدهد.
Stageها جای مدیریت Secret نیستند
اینکه secret فقط در builder مصرف میشود، بهتنهایی کافی نیست. قرار دادن token در ARG یا ENV میتواند در metadata یا log ساخت رد بگذارد. برای دسترسی خصوصیِ زمان build از secret/SSH mount مناسب استفاده کنید، اجازه ندهید ابزار build آن را داخل artifact کپی کند و پس از build image را scan کنید. secret زمان اجرا هم باید خارج image بماند.
Targetهای توسعه و تولید
یک Dockerfile میتواند target توسعه با debugger و target production بدون آن داشته باشد. این کار اختلاف نسخه runtime را کم میکند، اما نباید production را به volume source و hot reload محیط توسعه وابسته کند. نام stageها را معنادار بگذارید و در pipeline روشن کنید کدام target، با چه context و چه build argumentی ساخته میشود.
انتخاب baseهای متفاوت
builder شاید compiler و header نیاز داشته باشد؛ runtime فقط کتابخانههای اجرای برنامه را میخواهد. برای برنامه native بررسی کنید باینری ساختهشده با libc، معماری و نسخه library در runtime سازگار است. خطای «file not found» برای باینری موجود گاهی از interpreter یا library ناموجود میآید، نه نبود خود فایل. این تفاوت را در staging تست کنید.
وقتی خروجی frontend تولید میکنید
برای یک برنامه frontend، stage اول میتواند dependency قفلشده را نصب و فایلهای static را build کند؛ stage runtime فقط خروجی build را با وبسرور مناسب ارائه دهد. توجه کنید متغیری که هنگام build داخل bundle جاگذاری شده، دیگر secret نیست و در مرورگر کاربر دیده میشود. تنظیمات عمومی client را از کلیدهای خصوصی server جدا کنید. همچنین اگر مسیر public، base URL یا سیاست cache بین staging و production متفاوت است، تصمیم بگیرید یک artifact مشترک قابل promotion دارید یا build محیطی لازم است؛ ادعای «همان image در همه محیطها» را بدون این بررسی نکنید.
چه چیزی را اندازه بگیریم؟
پیش و پس از تغییر، حجم image منتقلشده، زمان build سرد و گرم، تعداد packageهای runtime و مدت startup را ثبت کنید. اگر فقط حجم کم شده اما build ناپایدار یا زمان بازیابی طولانیتر است، بهینهسازی عملیاتی کامل نشده. معیار را به مسئله واقعی تیم وصل کنید: هزینه registry، سرعت release یا قابلیت patch. از عدد benchmark پروژه دیگر برای تصمیم خود استفاده نکنید.
مسیر بررسی یک build مشکلدار
- مشخص کنید خطا در کدام stage و کدام دستور رخ داده است.
- فایلهای context و
.dockerignoreرا بررسی کنید. - وجود خروجی در مسیر مورد انتظار builder را بسنجید.
- فهرست فایلهای منتقلشده به runtime را محدود و بازبینی کنید.
- معماری، library و مجوز اجرای artifact را بررسی کنید.
- image نهایی را با user، config و volume واقعی smoke test کنید.
برای عیبیابی، stage builder را موقتاً هدف build قرار دهید؛ تغییر مستقیم container production روش قابلتکرار نیست. log ساخت ممکن است اطلاعات داخلی داشته باشد، پس آن را بدون پالایش عمومی نکنید.
خطاهای رایج
- نام stage ناپایدار یا ارجاع عددی شکننده
- کپی کل filesystem builder به runtime
- تفاوت معماری build و اجرا
- فراموشکردن فایلهای runtime مانند template
- اجرای تست فقط در stageای که CI نمیسازد
- فرض امن بودن صرفاً به خاطر کمشدن حجم
- انباشت secret در build argument
چه وقت ارزش افزودن Multi-stage دارد؟
اگر build به compiler، ابزار frontend، dependency توسعه یا artifact بستهبندیشده نیاز دارد، جداسازی بسیار منطقی است. برای imageهای ساده بدون build، سود آن را با پیچیدگی اضافه بسنجید. چکلیست Dockerfile production بقیه تصمیمهای runtime مانند user و signal را پوشش میدهد. اگر stageها زیاد و ساخت وابسته به cache محلی شده، داکرایز اپلیکیشن میتواند pipeline ساخت تا اجرا را قابلبازبینی کند.
پرسشهای متداول
آیا stageهای قبلی روی سرور اجرا میشوند؟
نه؛ image stage نهایی deploy میشود، مگر عمداً target دیگری را build و اجرا کنید.
آیا Multi-stage آسیبپذیریها را صفر میکند؟
خیر؛ dependencyهای runtime، base image و کد برنامه همچنان باید scan و patch شوند.
آیا میتوان از stage دیگر فایل کپی کرد؟
بله، COPY --from برای انتقال artifact میان stageها طراحی شده است؛ مسیر و مجوز فایل را آزمون کنید.
آیا build چندمرحلهای همیشه سریعتر است؟
نه؛ cache و گراف مراحل تعیینکنندهاند. مزیت اصلی، تفکیک خروجی ساخت از محیط اجراست.