DOCKER

نوشتن Dockerfile بهینه و امن: Multi-stage، کش لایه‌ها و کاهش حجم ایمیج

اصول نوشتن Dockerfile برای تولید: ترتیب دستورها برای کش، multi-stage build، انتخاب ایمیج پایه، کاربر غیر root و .dockerignore — با نمونه‌ی بد و خوب.

ایمیجی که با هر Dockerfile ساخته شود اجرا می‌شود، ولی ایمیج تولیدی باید کوچک، سریع در build، قابل‌بازتولید و امن باشد. تفاوت بین این دو در تعداد کمی اصل است: ترتیب دستورها، جداکردن مرحله‌ی build از مرحله‌ی اجرا، انتخاب آگاهانه‌ی ایمیج پایه و محدودکردن دسترسی برنامه. این راهنما این اصول را با نمونه‌ی «بد و خوب» توضیح می‌دهد.

IMGDocker Image Size Calculatorحجم لایه‌های یک ایمیج را ببینید و Dockerfile خود را برای مشکلات رایج بررسی کنید.

انتخاب ایمیج پایه

ایمیج پایه بیشترین سهم را در حجم و آسیب‌پذیری‌های شناخته‌شده‌ی ایمیج نهایی دارد. گزینه‌های رایج:

نوعمثالمزیتنکته
کاملpython:3.12ابزارهای ساخت آمادهحجم بالا؛ مناسب مرحله‌ی build
slimpython:3.12-slimکوچک و سازگار با glibcپیش‌فرض خوب برای اجرا
Alpinepython:3.12-alpineبسیار کوچکmusl؛ مراقب وابستگی‌های native باشید
Distrolessgcr.io/distroless/staticبدون شل و مدیر بسته؛ سطح حمله‌ی کمعیب‌یابی سخت‌تر؛ مناسب باینری‌های استاتیک
scratchscratchخالیفقط برای باینری کاملاً استاتیک (مثلاً Go)

همیشه تگ دقیق بنویسید (python:3.12.7-slim نه python یا latest). برای بازتولیدپذیری کامل می‌توانید digest را هم ثابت کنید: FROM python:3.12.7-slim@sha256:….

ترتیب دستورها و کش لایه‌ها

Docker هر لایه را کش می‌کند و تا اولین لایه‌ای که تغییر کرده، همه‌ی لایه‌های بعدی را دوباره می‌سازد. بنابراین چیزهایی که کمتر تغییر می‌کنند (وابستگی‌ها) باید قبل از چیزهایی که زیاد تغییر می‌کنند (کد برنامه) بیایند.

# بد: هر تغییر کوچک در کد، نصب مجدد همه‌ی وابستگی‌ها را باعث می‌شود
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]
# خوب: لایه‌ی وابستگی‌ها تا وقتی package-lock.json عوض نشود کش می‌ماند
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]

همین الگو برای هر زبان دیگری هم برقرار است: فایل‌های تعریف وابستگی (requirements.txt، go.mod، pom.xml) را اول کپی و نصب کنید و کد را بعد از آن.

Multi-stage build

کامپایلر، ابزارهای build و وابستگی‌های توسعه برای اجرای برنامه لازم نیستند. با multi-stage، در مرحله‌ی اول برنامه را می‌سازید و فقط خروجی آن را به یک ایمیج تمیز منتقل می‌کنید.

# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot
ENTRYPOINT ["/app"]

برای یک فرانت‌اند، همین الگو با Node در مرحله‌ی build و Nginx در مرحله‌ی نهایی به کار می‌رود:

FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html

نتیجه معمولاً ایمیجی است که چند برابر کوچک‌تر است و ابزارهایی مثل کامپایلر یا مدیر بسته را ندارد؛ یعنی مهاجم هم چیزی برای سوءاستفاده پیدا نمی‌کند.

کاهش حجم در هر لایه

هر دستور RUN یک لایه‌ی فقط‌افزودنی می‌سازد؛ فایلی که در یک لایه ساخته شود، حتی اگر بعداً پاک شود، حجمش در ایمیج می‌ماند. پس دانلود، نصب و پاک‌سازی باید در یک RUN باشد:

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*
  • apt-get update را هرگز در یک RUN جدا از install ننویسید؛ کش لایه ممکن است فهرست بسته‌های قدیمی را نگه دارد.
  • در پایتون از pip install --no-cache-dir و در Node از npm ci --omit=dev استفاده کنید.
  • برای کپی‌کردن فایل‌های محلی از COPY استفاده کنید، نه ADD؛ ADD رفتارهای اضافی (استخراج آرشیو، دانلود URL) دارد که معمولاً لازم نیست.

ابزار بررسی Dockerfile بک‌سرور همین الگوها (جداکردن update از install، پاک‌نکردن کش بسته‌ها، استفاده از ADD و اجرا با root) را شناسایی می‌کند.

فایل .dockerignore

Docker هنگام build کل پوشه‌ی context را به دیمن می‌فرستد. بدون .dockerignore، پوشه‌هایی مثل node_modules، .git و فایل‌های .env هم ارسال می‌شوند؛ build کند می‌شود و ممکن است رمزها به ایمیج برسند.

.git
node_modules
dist
*.log
.env
.env.*
Dockerfile
docker-compose*.yml

اجرای امن و درست برنامه

کاربر غیر root

اگر برنامه نیازی به root ندارد، با یک کاربر معمولی اجرایش کنید تا در صورت نفوذ، دسترسی مهاجم محدود بماند:

RUN groupadd --system app && useradd --system --gid app --no-create-home app
COPY --chown=app:app . .
USER app

exec form و مدیریت سیگنال

اگر CMD node server.js (shell form) بنویسید، فرایند اصلی یک شل است و سیگنال SIGTERM هنگام توقف به برنامه نمی‌رسد؛ Docker بعد از ۱۰ ثانیه آن را با SIGKILL می‌کشد و خاموشی تمیز (graceful shutdown) رخ نمی‌دهد. همیشه از exec form استفاده کنید:

CMD ["node", "server.js"]

اگر برنامه‌ی شما فرایندهای فرزند می‌سازد، docker run --init (یا init: true در Compose) یک init سبک اضافه می‌کند که zombie processها را جمع می‌کند.

HEALTHCHECK

HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
  CMD curl -fsS http://localhost:8080/healthz || exit 1

در ایمیج‌های بدون curl، یک زیردستور داخل خود برنامه برای بررسی سلامت بنویسید. بدون healthcheck، docker ps و depends_on: condition: service_healthy نمی‌توانند آمادگی واقعی را تشخیص دهند.

ویژگی‌های BuildKit: secret و کش دانلود

BuildKit (پیش‌فرض Docker در نسخه‌های جدید) دو قابلیت مهم دارد. اول، secret mount: رمز فقط در همان RUN قابل‌خواندن است و در هیچ لایه‌ای ذخیره نمی‌شود:

# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc -t app:1.0 .

دوم، کش دانلود بین buildها که زمان نصب وابستگی‌ها را کم می‌کند:

RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
warn رمز را هرگز با ARG یا ENV به build ندهید؛ هر دو در تاریخچه‌ی ایمیج می‌مانند و با docker history دیده می‌شوند.

بررسی نتیجه

docker build -t app:1.0 .
docker images app:1.0
docker history app:1.0        # حجم هر لایه

لایه‌ای که بیشترین حجم را دارد معمولاً اولین هدف بهینه‌سازی است. برای ایمیج‌هایی که روی رجیستری هستند، می‌توانید حجم فشرده‌ی هر لایه را بدون دانلود ببینید.

چک‌لیست Dockerfile آماده‌ی تولید

  1. ایمیج پایه با تگ دقیق (یا digest)، و نوع مناسب (slim یا distroless برای اجرا).
  2. فایل‌های وابستگی قبل از کد کپی شده‌اند.
  3. multi-stage برای جداکردن ابزارهای build از ایمیج نهایی.
  4. دستورهای نصب بسته و پاک‌سازی کش در یک RUN.
  5. .dockerignore موجود است و .env و .git را شامل می‌شود.
  6. کاربر غیر root و exec form برای CMD/ENTRYPOINT.
  7. HEALTHCHECK تعریف شده است.
  8. هیچ رمزی در ARG، ENV یا لایه‌ها نیست.

جمع‌بندی و مطالعه‌ی بعدی

ایمیج خوب از چند تصمیم ساده ساخته می‌شود: پایه‌ی سبک و ثابت، کش‌پذیری، multi-stage و اجرای محدود. بعد از ساخت ایمیج، نوشتن فایل Compose سرویس‌ها را کنار هم می‌گذارد و امن‌سازی Docker اقدام‌های امنیتی لازم برای کانتینر و میزبان را فهرست می‌کند. اگر ایمیج پایه یا ابزارها را روی سرور ایران نصب می‌کنید، راهنمای نصب از مخزن داخلی را ببینید.

پرسش‌های متداول

Alpine بهتر است یا Debian slim؟
Alpine کوچک‌تر است ولی از musl به‌جای glibc استفاده می‌کند و بعضی کتابخانه‌های بومی (مثل بسته‌های پایتون با کد C یا Node.js با ماژول‌های native) ممکن است روی آن مشکل بسازند یا کندتر باشند. اگر برنامه‌ی شما وابستگی native دارد، -slim (مبتنی بر Debian) انتخاب امن‌تری است.
تفاوت CMD و ENTRYPOINT چیست؟
ENTRYPOINT فرمان اصلی کانتینر است و CMD آرگومان‌های پیش‌فرض آن. اگر هر دو باشند، CMD به‌عنوان آرگومان به ENTRYPOINT داده می‌شود و هنگام docker run قابل‌جایگزینی است. هر دو را به‌صورت exec form (آرایه‌ی JSON) بنویسید تا سیگنال‌ها به برنامه برسند.
چرا حجم ایمیج بعد از حذف فایل‌ها کم نشد؟
هر دستور RUN یک لایه‌ی فقط‌افزودنی می‌سازد. فایلی که در یک لایه ساخته و در لایه‌ی بعد پاک شود، همچنان در لایه‌ی قبلی هست. دانلود و پاک‌سازی باید در همان RUN انجام شود یا از multi-stage استفاده کنید.
آیا می‌توانم رمز را با ARG به build بدهم؟
خیر. مقدار ARG در تاریخچه‌ی ایمیج (docker history) باقی می‌ماند. برای رمزهای زمان build از BuildKit secret mount استفاده کنید که در هیچ لایه‌ای ذخیره نمی‌شود.