رفتن به محتوا
LoopX

multi-stage build

توی این درس یاد می‌گیری چطور با یک Dockerfile که چند FROM دارد، ابزارهای ساخت (کامپایلر، npm، کد منبع) را از image نهایی دور نگه داری. هر FROM یک مرحله (stage) شروع می‌کند؛ نتیجه‌ی مرحله‌ی build را با COPY --from=build به یک image کوچک و تمیز می‌بری. این تکنیک حجم image و سطح حمله را به‌شدت کم می‌کند. با مثال Node به nginx اندازه‌ها را مقایسه می‌کنی و با --target فقط تا یک مرحله می‌سازی.

تشبیه: کارگاه و فروشگاه

Section titled “تشبیه: کارگاه و فروشگاه”

یک نجار در کارگاهش اره، رنده، چوب‌های خام و براده دارد؛ ولی مشتری فقط میز تمام‌شده را می‌خواهد، نه تمام کارگاه را. multi-stage یعنی «در کارگاه (stage اول) بساز، و فقط میز نهایی را به فروشگاه (stage آخر) ببر». اگر image نهایی خود کارگاه باشد، سنگین و پر از ابزارهایی است که موقع اجرا هیچ لازم نیست (و راه نفوذ هم می‌شوند).

مرحله‌ی build (بزرگ، با ابزارها) نتیجه‌اش را به مرحله‌ی runtime (کوچک) می‌دهد؛ فقط مرحله‌ی آخر به image نهایی تبدیل می‌شود.

مثال ۱: پروژه‌ی نمونه و روش تک‌مرحله‌ای

Section titled “مثال ۱: پروژه‌ی نمونه و روش تک‌مرحله‌ای”

یک «سایت» کوچک با یک اسکریپت build که از marked (کتابخانه‌ی تبدیل Markdown) استفاده می‌کند:

Terminal window
mkdir -p site
docker 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
site/content.md
# سایت من
این صفحه از **Markdown** ساخته شد.
site/build.js
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 ساخته شد');
site/Dockerfile.single
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN node build.js
EXPOSE 8080
CMD ["npx", "--yes", "http-server", "dist", "-p", "8080"]

(برای سادگی در حالت تک‌مرحله‌ای سرور را هم Node می‌گذاریم تا هر دو روش یک خروجی بدهند.) اندازه را ببین:

Terminal window
echo "node_modules" > site/.dockerignore
docker build -q -f site/Dockerfile.single -t lx-ms-single ./site >/dev/null 2>&1
docker images lx-ms-single --format 'تک‌مرحله‌ای: {{.Size}}'
خروجی
تک‌مرحله‌ای: 238MB

image شامل Node، npm، node_modules، کد منبع و dist است؛ حدود ۲۴۰ مگابایت برای سرو یک صفحه‌ی HTML.

site/Dockerfile
# مرحله‌ی ۱: ساخت
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN node build.js
# مرحله‌ی ۲: اجرا
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
Terminal window
docker build -q -t lx-ms-multi ./site >/dev/null 2>&1
docker 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 2
curl -s localhost:8355
خروجی
REPOSITORY SIZE
lx-ms-multi 93MB
lx-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 نهایی نیست:

Terminal window
docker exec lx-ms1 sh -c 'which node npm || echo "node و npm در image نهایی وجود ندارند"; ls /usr/share/nginx/html'
docker rm -f lx-ms1 >/dev/null
خروجی
node و npm در image نهایی وجود ندارند
50x.html
index.html
⚡ بررسی سریع

در multi-stage، کدام مرحله به image نهایی تبدیل می‌شود؟

مثال ۳: متوقف شدن در یک مرحله (--target)

Section titled “مثال ۳: متوقف شدن در یک مرحله (--target)”

با --target می‌توانی build را در یک مرحله‌ی مشخص متوقف کنی (مثلاً برای دیباگ یا اجرای تست):

Terminal window
docker build -q --target build -t lx-ms-build ./site >/dev/null 2>&1
docker run --rm lx-ms-build sh -c 'ls /app/dist && node --version'
docker images lx-ms-build --format 'image مرحله‌ی build: {{.Size}}'
خروجی
index.html
v22.23.3
image مرحله‌ی build: 238MB

image مرحله‌ی build همه‌ی ابزارها را دارد (Node و …) و حجیم است؛ نتیجه‌ی نهایی که ما می‌خواهیم فقط مرحله‌ی آخر است. --target build برای اجرای تست‌ها در CI و دیباگ مرحله‌ی ساخت عالی است.

مثال ۴: کپی از یک image بیرونی

Section titled “مثال ۴: کپی از یک image بیرونی”

COPY --from فقط برای stage های خودت نیست؛ می‌تواند از هر image کپی کند:

ext/Dockerfile
FROM alpine
COPY --from=nginx:alpine /etc/nginx/nginx.conf /extracted/nginx.conf
CMD ["head", "-3", "/extracted/nginx.conf"]
Terminal window
docker build -q -t lx-ms-ext ./ext >/dev/null 2>&1
docker run --rm lx-ms-ext
خروجی
user nginx;
worker_processes auto;

فایل nginx.conf را از image رسمی nginx برداشتیم و داخل یک image Alpine گذاشتیم، بدون نصب nginx. برای برداشتن یک باینری یا فایل تنظیم از image دیگر کاربرد دارد.

مثال ۵: stage های بی‌استفاده ساخته نمی‌شوند

Section titled “مثال ۵: stage های بی‌استفاده ساخته نمی‌شوند”

BuildKit فقط مرحله‌هایی را اجرا می‌کند که برای هدف نهایی لازم‌اند:

unused/Dockerfile
FROM alpine AS unused
RUN echo "این مرحله اجرا نمی‌شود"
FROM alpine
RUN echo "مرحله‌ی نهایی"
Terminal window
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 کلاً نادیده گرفته شد (چون هیچ مرحله‌ای به آن وابسته نیست).

هر FROM یک گراف جدا از لایه‌ها شروع می‌کند. وقتی COPY --from=build می‌زنی، فقط فایل‌های انتخاب‌شده از آن گراف به مرحله‌ی دیگر می‌روند؛ لایه‌های مرحله‌ی build (که صدها مگابایت‌اند) اصلاً وارد image نهایی نمی‌شوند. BuildKit مراحل را به‌صورت DAG می‌بیند: مرحله‌های مستقل را هم‌زمان اجرا می‌کند و مرحله‌های بی‌استفاده را رد می‌کند. کش هم برای هر مرحله جداگانه کار می‌کند.

دو نکته‌ی عملی: ۱) آخرین مرحله باید کوچک‌ترین پایه‌ی ممکن باشد (nginx:alpine، یک image distroless، یا حتی scratch). ۲) فقط چیزی را کپی کن که واقعاً لازم است (dist، یک باینری)؛ COPY --from=build /app /app همه‌چیز را می‌برد و سودش را از بین می‌برد.

شکل معنی
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)
bad/Dockerfile
FROM alpine AS build
RUN echo hi > /out.txt
FROM alpine
COPY --from=buildd /out.txt /out.txt
Terminal window
docker 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 failed
ERROR: 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 کپی کند و چاپش کند.

دیدن جواب
Terminal window
mkdir -p e1
printf 'FROM alpine AS build\nRUN echo "از مرحله‌ی build" > /out.txt\nFROM alpine\nCOPY --from=build /out.txt /final.txt\nCMD ["cat","/final.txt"]\n' > e1/Dockerfile
docker build -q -t lx-ms-multi ./e1 >/dev/null 2>&1
docker run --rm lx-ms-multi
خروجی
از مرحله‌ی build
✎ تمرینمتوسط

تمرین اصلی: حجم image قبل و بعد از multi-stage را مقایسه کن. برای یک «build» که ۳۰ مگابایت فایل موقت می‌سازد، Dockerfile تک‌مرحله‌ای و دو‌مرحله‌ای بنویس و اندازه‌هایشان را کنار هم بگذار.

دیدن جواب
Terminal window
mkdir -p e2
printf '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.single
printf '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.multi
docker build -q -f e2/Dockerfile.single -t lx-ms-single ./e2 >/dev/null 2>&1
docker build -q -f e2/Dockerfile.multi -t lx-ms-multi ./e2 >/dev/null 2>&1
docker images --format 'table {{.Repository}}\t{{.Size}}' | grep -E "REPOSITORY|lx-ms-(single|multi)"
خروجی
REPOSITORY SIZE
lx-ms-multi 13.5MB
lx-ms-single 45MB
✎ تمرینسخت

با COPY --from= از image رسمی nginx:alpine فقط فایل /etc/nginx/mime.types را در یک image alpine بگذار، و با --target ثابت کن می‌توانی build را در مرحله‌ی میانی نگه داری و داخلش فایل را ببینی.

دیدن جواب
Terminal window
mkdir -p e3
printf 'FROM alpine AS stage1\nCOPY --from=nginx:alpine /etc/nginx/mime.types /mime.types\nFROM stage1\nCMD ["head","-2","/mime.types"]\n' > e3/Dockerfile
docker build -q --target stage1 -t lx-ms-build ./e3 >/dev/null 2>&1
docker run --rm lx-ms-build head -2 /mime.types
خروجی
types {
؟ آزمونک
  1. multi-stage چه مشکلی را حل می‌کند؟

  2. COPY --from=build /app/dist /x یعنی چه؟

  3. کدام مرحله به image نهایی تبدیل می‌شود؟

  4. اگر اسم مرحله را غلط بنویسی در COPY --from چه می‌شود؟

  5. BuildKit با مرحله‌ای که هیچ‌کس به آن وابسته نیست چه می‌کند؟

  • 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مقایسه‌ی حجم