توی این درس یاد میگیری چطور با یک Dockerfile که چند FROM دارد، ابزارهای ساخت (کامپایلر، npm، کد منبع) را از image نهایی دور نگه داری. هر FROM یک مرحله (stage) شروع میکند؛ نتیجهی مرحلهی build را با COPY --from=build به یک image کوچک و تمیز میبری. این تکنیک حجم image و سطح حمله را بهشدت کم میکند. با مثال Node به nginx اندازهها را مقایسه میکنی و با --target فقط تا یک مرحله میسازی.
تشبیه: کارگاه و فروشگاه
Section titled “تشبیه: کارگاه و فروشگاه”یک نجار در کارگاهش اره، رنده، چوبهای خام و براده دارد؛ ولی مشتری فقط میز تمامشده را میخواهد، نه تمام کارگاه را. multi-stage یعنی «در کارگاه (stage اول) بساز، و فقط میز نهایی را به فروشگاه (stage آخر) ببر». اگر image نهایی خود کارگاه باشد، سنگین و پر از ابزارهایی است که موقع اجرا هیچ لازم نیست (و راه نفوذ هم میشوند).
دو مرحله، یک Dockerfile
Section titled “دو مرحله، یک Dockerfile”مثالهای عملی
Section titled “مثالهای عملی”مثال ۱: پروژهی نمونه و روش تکمرحلهای
Section titled “مثال ۱: پروژهی نمونه و روش تکمرحلهای”یک «سایت» کوچک با یک اسکریپت build که از marked (کتابخانهی تبدیل Markdown) استفاده میکند:
mkdir -p sitedocker run --rm -v "$PWD/site":/app -w /app node:22-alpine sh -c 'npm init -y >/dev/null 2>&1 && npm install marked --silent 2>&1 | tail -1'ls site | tr '\n' ' 'node_modules package-lock.json package.json# سایت من
این صفحه از **Markdown** ساخته شد.const fs = require('fs');const { marked } = require('marked');const html = marked.parse(fs.readFileSync('content.md', 'utf8'));fs.mkdirSync('dist', { recursive: true });fs.writeFileSync('dist/index.html', `<!doctype html><meta charset="utf-8"><body>${html}</body>`);console.log('dist/index.html ساخته شد');FROM node:22-alpineWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN node build.jsEXPOSE 8080CMD ["npx", "--yes", "http-server", "dist", "-p", "8080"](برای سادگی در حالت تکمرحلهای سرور را هم Node میگذاریم تا هر دو روش یک خروجی بدهند.) اندازه را ببین:
echo "node_modules" > site/.dockerignoredocker build -q -f site/Dockerfile.single -t lx-ms-single ./site >/dev/null 2>&1docker images lx-ms-single --format 'تکمرحلهای: {{.Size}}'تکمرحلهای: 238MBimage شامل Node، npm، node_modules، کد منبع و dist است؛ حدود ۲۴۰ مگابایت برای سرو یک صفحهی HTML.
مثال ۲: multi-stage
Section titled “مثال ۲: multi-stage”# مرحلهی ۱: ساختFROM node:22-alpine AS buildWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN node build.js
# مرحلهی ۲: اجراFROM nginx:alpineCOPY --from=build /app/dist /usr/share/nginx/htmldocker build -q -t lx-ms-multi ./site >/dev/null 2>&1docker images --format 'table {{.Repository}}\t{{.Size}}' | grep -E "REPOSITORY|lx-ms-(single|multi)"docker run -d --name lx-ms1 -p 8355:80 lx-ms-multi >/dev/null; sleep 2curl -s localhost:8355REPOSITORY SIZElx-ms-multi 93MBlx-ms-single 238MB<!doctype html><meta charset="utf-8"><body><h1>سایت من</h1><p>این صفحه از <strong>Markdown</strong> ساخته شد.</p></body>FROM node:22-alpine AS buildیک مرحله به اسمbuildشروع میکند.FROM nginx:alpineمرحلهی دوم است و image نهایی همین مرحله است.COPY --from=build /app/dist ...فقط پوشهیdistرا از مرحلهی build برمیدارد.
اندازه به کمتر از نصف رسید و صفحه سرو شد. هیچچیز از Node داخل image نهایی نیست:
docker exec lx-ms1 sh -c 'which node npm || echo "node و npm در image نهایی وجود ندارند"; ls /usr/share/nginx/html'docker rm -f lx-ms1 >/dev/nullnode و npm در image نهایی وجود ندارند50x.htmlindex.htmlدر multi-stage، کدام مرحله به image نهایی تبدیل میشود؟
هر FROM یک stage است؛ image نهایی حاصل مرحلهی آخر است (مگر با --target چیز دیگری بخواهی).
مثال ۳: متوقف شدن در یک مرحله (--target)
Section titled “مثال ۳: متوقف شدن در یک مرحله (--target)”با --target میتوانی build را در یک مرحلهی مشخص متوقف کنی (مثلاً برای دیباگ یا اجرای تست):
docker build -q --target build -t lx-ms-build ./site >/dev/null 2>&1docker run --rm lx-ms-build sh -c 'ls /app/dist && node --version'docker images lx-ms-build --format 'image مرحلهی build: {{.Size}}'index.htmlv22.23.3image مرحلهی build: 238MBimage مرحلهی build همهی ابزارها را دارد (Node و …) و حجیم است؛ نتیجهی نهایی که ما میخواهیم فقط مرحلهی آخر است. --target build برای اجرای تستها در CI و دیباگ مرحلهی ساخت عالی است.
مثال ۴: کپی از یک image بیرونی
Section titled “مثال ۴: کپی از یک image بیرونی”COPY --from فقط برای stage های خودت نیست؛ میتواند از هر image کپی کند:
FROM alpineCOPY --from=nginx:alpine /etc/nginx/nginx.conf /extracted/nginx.confCMD ["head", "-3", "/extracted/nginx.conf"]docker build -q -t lx-ms-ext ./ext >/dev/null 2>&1docker run --rm lx-ms-extuser nginx;worker_processes auto;فایل nginx.conf را از image رسمی nginx برداشتیم و داخل یک image Alpine گذاشتیم، بدون نصب nginx. برای برداشتن یک باینری یا فایل تنظیم از image دیگر کاربرد دارد.
مثال ۵: stage های بیاستفاده ساخته نمیشوند
Section titled “مثال ۵: stage های بیاستفاده ساخته نمیشوند”BuildKit فقط مرحلههایی را اجرا میکند که برای هدف نهایی لازماند:
FROM alpine AS unusedRUN echo "این مرحله اجرا نمیشود"
FROM alpineRUN echo "مرحلهی نهایی"docker build --no-cache --progress=plain ./unused 2>&1 | grep -E 'RUN echo' | sed -E 's/^#[0-9]+ //' | awk '!s[$0]++'[stage-1 2/2] RUN echo "مرحلهی نهایی"فقط RUN echo "مرحلهی نهایی" اجرا شد و مرحلهی unused کلاً نادیده گرفته شد (چون هیچ مرحلهای به آن وابسته نیست).
پشت پرده
Section titled “پشت پرده”هر FROM یک گراف جدا از لایهها شروع میکند. وقتی COPY --from=build میزنی، فقط فایلهای انتخابشده از آن گراف به مرحلهی دیگر میروند؛ لایههای مرحلهی build (که صدها مگابایتاند) اصلاً وارد image نهایی نمیشوند. BuildKit مراحل را بهصورت DAG میبیند: مرحلههای مستقل را همزمان اجرا میکند و مرحلههای بیاستفاده را رد میکند. کش هم برای هر مرحله جداگانه کار میکند.
دو نکتهی عملی: ۱) آخرین مرحله باید کوچکترین پایهی ممکن باشد (nginx:alpine، یک image distroless، یا حتی scratch). ۲) فقط چیزی را کپی کن که واقعاً لازم است (dist، یک باینری)؛ COPY --from=build /app /app همهچیز را میبرد و سودش را از بین میبرد.
جدولهای مرجع
Section titled “جدولهای مرجع”| شکل | معنی |
|---|---|
FROM img AS name |
شروع یک مرحله با اسم |
COPY --from=name src dst |
کپی از مرحلهی دیگر |
COPY --from=image:tag src dst |
کپی از یک image بیرونی |
docker build --target name . |
توقف در یک مرحله |
COPY --from=0 ... |
کپی با شمارهی مرحله (کمخوانا؛ اسم بهتر است) |
| الگو | مرحلهی build | مرحلهی runtime |
|---|---|---|
| فرانتاند استاتیک | node + npm |
nginx:alpine |
| Go / Rust | golang / rust |
scratch یا alpine |
| Python | python + کامپایل |
python:slim (فقط وابستگیهای نصبشده) |
| Java | maven / gradle |
eclipse-temurin (JRE) |
اشتباهات رایج
Section titled “اشتباهات رایج”۱) نام مرحلهی اشتباه
Section titled “۱) نام مرحلهی اشتباه”FROM alpine AS buildRUN echo hi > /out.txtFROM alpineCOPY --from=buildd /out.txt /out.txtdocker build -t lx-ms-bad ./bad 2>&1 | grep -E 'ERROR' | head -2 | cut -c1-160#3 ERROR: pull access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failedERROR: failed to build: failed to solve: buildd: failed to resolve source metadata for docker.io/library/buildd:latest: pull access denied, repository does notاسم buildd (غلط املایی) با هیچ مرحلهای نمیخواند، پس داکر فکر میکند اسم یک image است و سعی میکند آن را از registry بگیرد (خطای pull access denied). راهحل: اسم مرحله را دقیق بنویس.
۲) کپی همهچیز از مرحلهی build
Section titled “۲) کپی همهچیز از مرحلهی build”COPY --from=build /app /app تمام node_modules و کد منبع را میبرد. راهحل: فقط dist یا باینری نهایی.
۳) فراموش کردن وابستگیهای اجرا
Section titled “۳) فراموش کردن وابستگیهای اجرا”اگر برنامه به کتابخانهای در زمان اجرا نیاز دارد (مثلاً libc)، در مرحلهی runtime باید وجود داشته باشد. راهحل: پایهی runtime را متناسب انتخاب کن (alpine یا slim، نه scratch، مگر باینری static باشد).
۴) انتظار اینکه --target فقط لایهی مرحله را بدهد
Section titled “۴) انتظار اینکه --target فقط لایهی مرحله را بدهد”image --target build همهی ابزارهای مرحلهی build را دارد (مثال ۳) و حجیم است. راهحل: فقط برای تست و دیباگ از آن استفاده کن و در production مرحلهی آخر را بفرست.
یک Dockerfile دومرحلهای بنویس که در مرحلهی اول فایل /out.txt را بسازد و در مرحلهی دوم آن را به /final.txt کپی کند و چاپش کند.
دیدن جواب
mkdir -p e1printf 'FROM alpine AS build\nRUN echo "از مرحلهی build" > /out.txt\nFROM alpine\nCOPY --from=build /out.txt /final.txt\nCMD ["cat","/final.txt"]\n' > e1/Dockerfiledocker build -q -t lx-ms-multi ./e1 >/dev/null 2>&1docker run --rm lx-ms-multiاز مرحلهی buildتمرین اصلی: حجم image قبل و بعد از multi-stage را مقایسه کن. برای یک «build» که ۳۰ مگابایت فایل موقت میسازد، Dockerfile تکمرحلهای و دومرحلهای بنویس و اندازههایشان را کنار هم بگذار.
دیدن جواب
mkdir -p e2printf 'FROM alpine\nRUN dd if=/dev/zero of=/tmp.bin bs=1M count=30 && echo result > /result.txt\nCMD ["cat","/result.txt"]\n' > e2/Dockerfile.singleprintf 'FROM alpine AS build\nRUN dd if=/dev/zero of=/tmp.bin bs=1M count=30 && echo result > /result.txt\nFROM alpine\nCOPY --from=build /result.txt /result.txt\nCMD ["cat","/result.txt"]\n' > e2/Dockerfile.multidocker build -q -f e2/Dockerfile.single -t lx-ms-single ./e2 >/dev/null 2>&1docker build -q -f e2/Dockerfile.multi -t lx-ms-multi ./e2 >/dev/null 2>&1docker images --format 'table {{.Repository}}\t{{.Size}}' | grep -E "REPOSITORY|lx-ms-(single|multi)"REPOSITORY SIZElx-ms-multi 13.5MBlx-ms-single 45MBبا COPY --from= از image رسمی nginx:alpine فقط فایل /etc/nginx/mime.types را در یک image alpine بگذار، و با --target ثابت کن میتوانی build را در مرحلهی میانی نگه داری و داخلش فایل را ببینی.
دیدن جواب
mkdir -p e3printf 'FROM alpine AS stage1\nCOPY --from=nginx:alpine /etc/nginx/mime.types /mime.types\nFROM stage1\nCMD ["head","-2","/mime.types"]\n' > e3/Dockerfiledocker build -q --target stage1 -t lx-ms-build ./e3 >/dev/null 2>&1docker run --rm lx-ms-build head -2 /mime.typestypes {آزمونک
Section titled “آزمونک”multi-stage چه مشکلی را حل میکند؟
فقط نتیجهی build به image نهایی میرود.
COPY --from=build /app/dist /x یعنی چه؟
build نام مرحلهای است که با FROM ... AS build تعریف شده.
کدام مرحله به image نهایی تبدیل میشود؟
با --target میتوانی مرحلهی دیگری را انتخاب کنی.
اگر اسم مرحله را غلط بنویسی در COPY --from چه میشود؟
اسم دقیق stage را بنویس.
BuildKit با مرحلهای که هیچکس به آن وابسته نیست چه میکند؟
فقط مراحل لازم برای هدف نهایی ساخته میشوند.
جمعبندی
Section titled “جمعبندی”- multi-stage = چند
FROM؛ آخرین مرحله image نهایی است. FROM img AS buildاسم میدهد وCOPY --from=build src dstفقط فایلهای لازم را به مرحلهی بعد میبرد.- نتیجه: image کوچکتر، تمیز و امنتر (بدون compiler و npm و کد منبع).
docker build --target nameتا یک مرحله میسازد؛COPY --from=image:tagاز هر image میتواند کپی کند.- BuildKit مراحل بیاستفاده را رد میکند و مستقلها را موازی اجرا میکند؛ فقط چیزی را کپی کن که لازم است.
| دستور | کاری که میکند |
|---|---|
FROM node:22-alpine AS build | شروع مرحلهی build با اسم |
COPY --from=build /app/dist /usr/share/nginx/html | بردن نتیجه به مرحلهی نهایی |
FROM nginx:alpine | مرحلهی نهایی کوچک |
docker build --target build -t dbg . | توقف در مرحلهی build |
COPY --from=nginx:alpine /etc/nginx/nginx.conf /x | کپی از image بیرونی |
docker images | مقایسهی حجم |