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

کوچک کردن image

توی این درس یاد می‌گیری چرا image کوچک‌تر بهتر است (pull سریع‌تر، deploy سریع‌تر، سطح حمله‌ی کمتر) و چطور اندازه‌اش را با docker images و docker history بسنجی. بعد چهار ابزار اصلی کوچک کردن را عملی می‌بینی: انتخاب base image مناسب (full، slim، alpine)، ترکیب دستورهای RUN و پاک‌کردن کش در همان لایه، multi-stage و ساخت از scratch، و آشنایی با distroless. در تمرین اصلی حجم یک image را حداقل نصف می‌کنی.

هر بار که یک سرور جدید به سرویس اضافه می‌شود، یا CI یک build تازه می‌کند، image باید دانلود شود. image چند صد مگابایتی یعنی دقایق انتظار، هزینه‌ی ترافیک و فضای دیسک؛ و هر ابزار اضافه داخلش (شل، کامپایلر، curl) یک راه اضافه برای مهاجم است. در شبکه‌ای که دانلود کند یا گران است (مثل خیلی از جاها در ایران)، فرقش محسوس‌تر هم هست.

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

هر لایه‌ی بالاتر به حجم کل اضافه می‌کند و هیچ لایه‌ای کم نمی‌شود، حتی اگر در لایه‌ی بعدی فایلی را پاک کنی. پس باید در همان لایه‌ای که ساخته شد پاک کنی یا از multi-stage استفاده کنی.

مثال ۱: اندازه‌ی base های رایج

Section titled “مثال ۱: اندازه‌ی base های رایج”
Terminal window
docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -E '^(ubuntu:24.04|python:3.12-slim|python:3.12-alpine|node:22-alpine) ' | sort -k2 -h | column -t
echo "alpine:latest $(( $(docker image inspect alpine --format '{{.Size}}') / 1000000 )) MB (اندازه‌ی واقعی برای معماری این ماشین)"
خروجی
python:3.12-alpine 88.5MB
ubuntu:24.04 141MB
python:3.12-slim 216MB
node:22-alpine 234MB
alpine:latest 13 MB (اندازه‌ی واقعی برای معماری این ماشین)

همان زبان پایتون در دو base: python:3.12-slim (Debian کوچک‌شده) حدود ۲۱۰ مگابایت و python:3.12-alpine (Alpine) حدود ۹۰ مگابایت. و alpine خالی فقط چند مگابایت. انتخاب base بزرگ‌ترین اهرم کوچک کردن است.

base معمول نکته
python:3.12 (کامل) ~۱ گیگابایت کامپایلر و ابزار زیاد؛ فقط برای build
python:3.12-slim ~۲۰۰ مگابایت Debian با glibc؛ سازگاری بالا، نقطه‌ی شروع خوب
python:3.12-alpine ~۹۰ مگابایت Alpine با musl؛ کوچک ولی بعضی کتابخانه‌های باینری نیاز به ساخت دارند
alpine / busybox ۱–۱۵ مگابایت پایه‌ی کوچک برای ابزارها
scratch ۰ خالی؛ فقط برای باینری استاتیک

مثال ۲: همان اپ، سه ساخت

Section titled “مثال ۲: همان اپ، سه ساخت”

یک اپ Flask کوچک:

sz/app/app.py
from flask import Flask
app = Flask(__name__)
@app.get("/")
def home():
return "hello\n"
sz/app/requirements.txt
flask==3.0.3

نسخه‌ی اول، ساده و بدون فکر (کش pip در image می‌ماند):

sz/slim.Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY app/requirements.txt .
RUN pip install -r requirements.txt
COPY app/app.py .
CMD ["python", "-m", "flask", "run", "--host", "0.0.0.0"]

نسخه‌ی دوم: همان base، ولی --no-cache-dir:

sz/slim-nocache.Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/app.py .
CMD ["python", "-m", "flask", "run", "--host", "0.0.0.0"]

نسخه‌ی سوم: روی alpine:

sz/alpine.Dockerfile
FROM python:3.12-alpine
WORKDIR /app
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/app.py .
CMD ["python", "-m", "flask", "run", "--host", "0.0.0.0"]
Terminal window
cd sz
docker build -q -f slim.Dockerfile -t lx-sz-slim . >/dev/null
docker build -q -f slim-nocache.Dockerfile -t lx-sz-slim-nocache . >/dev/null
docker build -q -f alpine.Dockerfile -t lx-sz-alpine . >/dev/null
docker image ls --format '{{.Repository}} {{.Size}}' | grep '^lx-sz-' | sort | column -t
خروجی
lx-sz-alpine 108MB
lx-sz-slim 237MB
lx-sz-slim-nocache 235MB

سه عدد را مقایسه کن: --no-cache-dir چند مگابایت (کش دانلودهای pip) را حذف کرد و alpine بقیه‌ی تفاوت را. اپ همان است، فقط base و یک flag فرق کرد. (روی alpine ممکن است بعضی بسته‌های پایتون که وابسته به glibc یا کامپایلر هستند مشکل بسازند؛ همیشه اپ را بعد از تغییر base تست کن.)

⚡ بررسی سریع

کدام یک معمولاً بیشترین کاهش حجم را در image پایتونی می‌دهد؟

مثال ۳: دیدن لایه‌ها با docker history

Section titled “مثال ۳: دیدن لایه‌ها با docker history”
Terminal window
docker history lx-sz-slim --format '{{.Size}}\t{{.CreatedBy}}' | cut -c1-90 | head -8
خروجی
0B CMD ["python" "-m" "flask" "run" "--host" "0…
12.3kB COPY app/app.py . # buildkit
16.9MB RUN /bin/sh -c pip install -r requirements.t…
12.3kB COPY app/requirements.txt . # buildkit
8.19kB WORKDIR /app
0B CMD ["python3"]
16.4kB RUN /bin/sh -c set -eux; for src in idle3 p…
44.6MB RUN /bin/sh -c set -eux; savedAptMark="$(a…

هر ردیف یک لایه است: اندازه‌اش و دستوری که ساختش. لایه‌ی pip install در نسخه‌ی اول را پیدا کن و با نسخه‌ی دوم مقایسه کن:

Terminal window
for t in lx-sz-slim lx-sz-slim-nocache; do
echo "$t: $(docker history $t --format '{{.Size}} {{.CreatedBy}}' | grep 'pip install' | awk '{print $1}')"
done
خروجی
lx-sz-slim: 16.9MB
lx-sz-slim-nocache: 15.5MB

لایه‌ی pip نسخه‌ی بدون کش کوچک‌تر است. docker history اولین ابزار تشخیص است: بزرگ‌ترین لایه را پیدا کن و بپرس «این چرا این‌قدر بزرگ است؟». (ابزار گرافیکی dive هم همین را تعاملی نشان می‌دهد.)

مثال ۴: حذف در لایه‌ی بعد، حجم را کم نمی‌کند

Section titled “مثال ۴: حذف در لایه‌ی بعد، حجم را کم نمی‌کند”

یک base سنگین‌تر (Ubuntu) با نصب یک بسته با apt. نسخه‌ی بد: سه RUN جدا و پاک کردن در لایه‌ی آخر:

sz/apt1.Dockerfile
FROM ubuntu:24.04
RUN apt-get update
RUN apt-get install -y -o Acquire::Retries=5 curl
RUN rm -rf /var/lib/apt/lists/*

نسخه‌ی درست: یک RUN، بدون بسته‌های پیشنهادی (--no-install-recommends)، و پاک‌کردن کش در همان لایه:

sz/apt2.Dockerfile
FROM ubuntu:24.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends -o Acquire::Retries=5 curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Terminal window
cd sz
docker build -q -f apt1.Dockerfile -t lx-sz-apt1 . >/dev/null
docker build -q -f apt2.Dockerfile -t lx-sz-apt2 . >/dev/null
docker image ls --format '{{.Repository}} {{.Size}}' | grep -E '^lx-sz-apt' | sort | column -t
echo "--- لایه‌های apt1 (rm در لایه‌ی جدا):"
docker history lx-sz-apt1 --format '{{.Size}}\t{{.CreatedBy}}' | grep -E 'apt-get|rm -rf' | cut -c1-80
خروجی
lx-sz-apt1 260MB
lx-sz-apt2 161MB
--- لایه‌های apt1 (rm در لایه‌ی جدا):
20.5kB RUN /bin/sh -c rm -rf /var/lib/apt/lists/* #…
17.5MB RUN /bin/sh -c apt-get install -y -o Acquire…
57.4MB RUN /bin/sh -c apt-get update # buildkit

در نسخه‌ی اول لایه‌ی rm -rf تقریباً صفر بایت است؛ فایل‌های پاک‌شده هنوز در لایه‌ی apt-get update هستند و حجم کم نشد. در نسخه‌ی دوم فایل‌ها هیچ‌وقت در یک لایه ثبت نمی‌شوند (ساخت و پاک‌کردن در یک لایه)، و --no-install-recommends بسته‌های اضافی را هم نیاورد. (این همان اصل «لایه فقط جمع می‌شود» است.)

برنامه‌ای که باینری استاتیک می‌سازد (مثل C یا Go) در مرحله‌ی build به کامپایلر نیاز دارد، ولی برای اجرا هیچ چیز لازم نیست. یک برنامه‌ی C:

sz/c/hello.c
#include <stdio.h>
int main(void) {
puts("hello from a tiny image");
return 0;
}

نسخه‌ی بدون multi-stage (کامپایلر در image می‌ماند):

sz/c/full.Dockerfile
FROM alpine
RUN apk add --no-cache build-base
COPY hello.c .
RUN gcc -static -o /hello hello.c
CMD ["/hello"]

نسخه‌ی multi-stage با image نهایی scratch (یعنی کاملاً خالی، فقط باینری):

sz/c/scratch.Dockerfile
FROM alpine AS build
RUN apk add --no-cache build-base
COPY hello.c .
RUN gcc -static -o /hello hello.c
FROM scratch
COPY --from=build /hello /hello
CMD ["/hello"]
Terminal window
cd sz/c
docker build -q -f full.Dockerfile -t lx-sz-c-full . >/dev/null
docker build -q -f scratch.Dockerfile -t lx-sz-c-scratch . >/dev/null
docker image ls --format '{{.Repository}} {{.Size}}' | grep -E '^lx-sz-c-' | sort | column -t
docker run --rm lx-sz-c-scratch
خروجی
lx-sz-c-full 359MB
lx-sz-c-scratch 184kB
hello from a tiny image

نسخه‌ی کامل با کامپایلر چند صد مگابایت است؛ نسخه‌ی scratch فقط حدود ۱۸۰ کیلوبایت: همان برنامه، همان خروجی. نسخه‌ی scratch هیچ شل، ls یا مدیر بسته ندارد؛ برای مهاجم چیزی نمی‌ماند (و برای دیباگ هم سخت‌تر است: docker exec ... sh کار نمی‌کند).

Terminal window
docker run --rm --entrypoint sh lx-sz-c-scratch -c 'echo hi' 2>&1 | grep -o 'exec: .*'
خروجی
exec: "sh": executable file not found in $PATH

مثال ۶: distroless: حد وسط (نمونه)

Section titled “مثال ۶: distroless: حد وسط (نمونه)”

distroless imageهایی از گوگل هستند که فقط runtime زبان را دارند (بدون شل و مدیر بسته): امن و کوچک، ولی برای زبان‌هایی که به کتابخانه‌های سیستمی (glibc، گواهی‌های CA) نیاز دارند آسان‌تر از scratch است. استفاده‌ی معمول:

نمونه (اجرا نشده)
FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target /deps -r requirements.txt
FROM gcr.io/distroless/python3-debian12
COPY --from=build /deps /deps
COPY app.py /app/app.py
ENV PYTHONPATH=/deps
CMD ["/app/app.py"]

این مثال اجرا نشده؛ دلیلش این است که registry آن (gcr.io) روی شبکه‌ی من 403 Forbidden می‌داد (درس «محدودیت Docker Hub» را ببین). اگر به آن دسترسی داری امتحانش کن؛ اگر نه، slim یا alpine همراه کاربر غیر root انتخاب‌های خوبی هستند.

image مجموعه‌ای از لایه‌های فقط-افزایشی (union filesystem) است. هر دستور RUN، COPY و ADD یک لایه‌ی جدید می‌سازد که تفاوت نسبت به لایه‌ی قبل را نگه می‌دارد. پاک کردن فایل در لایه‌ی بعدی یک «نشانه‌ی حذف» (whiteout) می‌گذارد که فایل را از دید کانتینر پنهان می‌کند، ولی بایت‌های فایل هنوز در لایه‌ی اصلی‌اش می‌مانند. به همین دلیل مثال ۴ حجم را کم نکرد.

نتیجه‌ی عملی: ۱) ساخت و پاک‌کردن در یک RUN؛ ۲) ابزار ساخت را در مرحله‌ی جدا (multi-stage) نگه دار و فقط نتیجه را COPY --from کن؛ ۳) base را کوچک انتخاب کن؛ ۴) فقط فایل‌های لازم را کپی کن (.dockerignore).

یک نکته‌ی تکمیلی: حجمی که docker images نشان می‌دهد حجم باز-شده (فضای دیسک) است، و حجم دانلود از registry معمولاً کوچک‌تر است چون لایه‌ها فشرده منتقل می‌شوند. و لایه‌های مشترک بین image ها فقط یک بار روی دیسک می‌نشینند.

ترفند چه کار می‌کند هزینه
base کوچک‌تر (slim، alpine) مهم‌ترین کاهش سازگاری (musl) و دیباگ
pip install --no-cache-dir کش pip در image نمی‌ماند ندارد
apt-get install --no-install-recommends بسته‌های پیشنهادی نمی‌آیند ممکن است بسته‌ای کم بیاید
پاک‌کردن کش در همان RUN rm -rf /var/lib/apt/lists/* ندارد
multi-stage ابزار ساخت داخل image نهایی نمی‌ماند چند مرحله در Dockerfile
scratch خالی، فقط باینری استاتیک بدون شل؛ فقط برنامه‌های استاتیک
.dockerignore فایل اضافی (.git، node_modules) کپی نمی‌شود ندارد
دستور کار
docker images / docker image ls حجم image ها
docker history IMG حجم و دستور هر لایه
docker image inspect IMG --format '{{.Size}}' حجم دقیق به بایت
docker system df مصرف دیسک داکر (image، کانتینر، volume، کش build)

۱) حذف فایل در لایه‌ی جدا

Section titled “۱) حذف فایل در لایه‌ی جدا”

مثال ۴: rm در لایه‌ی بعد حجم را کم نکرد. راه‌حل: ساخت و حذف در یک RUN.

FROM python:3.12 (حدود ۱ گیگابایت) فقط برای اجرای یک اپ ساده. راه‌حل: slim یا alpine، یا multi-stage.

۳) رفتن روی alpine بدون تست

Section titled “۳) رفتن روی alpine بدون تست”
Terminal window
mkdir -p m3 && printf 'FROM alpine\nRUN apk add --no-cache bash >/dev/null\nCMD ["/bin/bash","-c","echo ok"]\n' > m3/Dockerfile
docker build -q -t lx-sz-m3 m3 >/dev/null && docker run --rm lx-sz-m3
docker run --rm --entrypoint /bin/bash alpine -c 'echo hi' 2>&1 | grep -o 'exec: .*'
docker rmi lx-sz-m3 >/dev/null
خروجی
ok
exec: "/bin/bash": stat /bin/bash: no such file or directory

alpine به‌طور پیش‌فرض bash ندارد (فقط sh از busybox)؛ آخرین خط بالا خطای اجرای /bin/bash در image خالی alpine است. اسکریپتی که #!/bin/bash دارد روی آن خطا می‌دهد. راه‌حل: apk add bash یا اسکریپت را با sh سازگار کن؛ و همیشه بعد از تغییر base تست کن.

۴) ابزار ساخت در image نهایی

Section titled “۴) ابزار ساخت در image نهایی”

اگر gcc و build-essential در image اجرایی بمانند هم حجم بیشتر می‌شود هم سطح حمله. راه‌حل: multi-stage (مثال ۵).

COPY . . همه‌ی پوشه‌ی پروژه (از جمله .git و node_modules محلی) را وارد image می‌کند. راه‌حل: .dockerignore (درس مربوطه).

✎ تمرینآسان

حجم lx-sz-slim را با docker image inspect --format بر حسب بایت بگیر و با docker image ls مقایسه کن.

دیدن جواب
Terminal window
docker image inspect lx-sz-slim --format 'بایت: {{.Size}}'
docker image ls lx-sz-slim --format 'نمایش docker image ls: {{.Size}}'
خروجی
بایت: 236961480
نمایش docker image ls: 237MB
✎ تمرینمتوسط

تمرین اصلی: حجم یک image را حداقل نصف کن. اپ پایتونی با FROM ubuntu:24.04 و نصب python3 با apt (بدون ترفند) را بساز، سپس نسخه‌ی بهینه‌ی آن را با python:3.12-alpine بنویس و نسبت حجم‌ها را چاپ کن.

دیدن جواب
Terminal window
mkdir -p e2 && cd e2
printf 'print("ok")\n' > main.py
cat > before.Dockerfile <<'LXEOF'
FROM ubuntu:24.04
RUN apt-get update
RUN apt-get install -y -o Acquire::Retries=5 python3
COPY main.py /main.py
CMD ["python3", "/main.py"]
LXEOF
cat > after.Dockerfile <<'LXEOF'
FROM python:3.12-alpine
COPY main.py /main.py
CMD ["python", "/main.py"]
LXEOF
docker build -q -f before.Dockerfile -t lx-sz-before . >/dev/null
docker build -q -f after.Dockerfile -t lx-sz-after . >/dev/null
b=$(docker image inspect lx-sz-before --format '{{.Size}}')
a=$(docker image inspect lx-sz-after --format '{{.Size}}')
echo "قبل: $((b/1000000)) مگابایت — بعد: $((a/1000000)) مگابایت"
echo "نسبت بعد/قبل: $((a*100/b))%"
[ $((a*2)) -le $b ] && echo "حجم حداقل نصف شد"
docker run --rm lx-sz-after
docker rmi lx-sz-before lx-sz-after >/dev/null
خروجی
قبل: 306 مگابایت — بعد: 87 مگابایت
نسبت بعد/قبل: 28%
حجم حداقل نصف شد
ok
✎ تمرینسخت

با multi-stage، برنامه‌ی C مثال ۵ را طوری بساز که image نهایی کمتر از ۱ مگابایت باشد و ثابت کن (۱) اجرا می‌شود، (۲) شل ندارد، (۳) تعداد لایه‌هایش یک است.

دیدن جواب
Terminal window
docker image inspect lx-sz-c-scratch --format 'حجم: {{.Size}} بایت'
[ "$(docker image inspect lx-sz-c-scratch --format '{{.Size}}')" -lt 1000000 ] && echo "کمتر از ۱ مگابایت"
docker run --rm lx-sz-c-scratch
docker run --rm --entrypoint /bin/sh lx-sz-c-scratch 2>&1 | grep -o 'exec: .*'
echo "تعداد لایه‌ها: $(docker image inspect lx-sz-c-scratch --format '{{len .RootFS.Layers}}')"
خروجی
حجم: 181776 بایت
کمتر از ۱ مگابایت
hello from a tiny image
exec: "/bin/sh": stat /bin/sh: no such file or directory
تعداد لایه‌ها: 1
؟ آزمونک
  1. چرا rm -rf در یک RUN جدا حجم image را کم نمی‌کند؟

  2. کدام ابزار حجم هر لایه را نشان می‌دهد؟

  3. multi-stage چه کمکی می‌کند؟

  4. scratch برای چه چیزی مناسب است؟

  5. ریسک رایج استفاده از alpine؟

  6. pip install --no-cache-dir چه می‌کند؟

  • اندازه‌گیری: docker images برای کل، docker history برای لایه‌ها.
  • بزرگ‌ترین اهرم: base کوچک (slim، alpine)، بعد multi-stage و scratch.
  • لایه‌ها فقط جمع می‌شوند: ساخت و پاک‌کردن در یک RUN؛ --no-cache-dir و --no-install-recommends.
  • ابزار ساخت را از image نهایی دور نگه دار؛ فقط artefact را COPY --from کن.
  • بعد از هر تغییر base برنامه را تست کن؛ کوچک‌تر همیشه یعنی «تحمل‌ناپذیرتر برای ابزار و دیباگ».
برگه‌ی تقلب این درس
دستورکاری که می‌کند
docker image lsفهرست و حجم image ها
docker history IMGحجم و دستور هر لایه
docker image inspect IMG --format '{{.Size}}'حجم دقیق (بایت)
FROM python:3.12-slimbase متوسط و سازگار
FROM python:3.12-alpinebase کوچک (musl)
pip install --no-cache-dir -r requirements.txtبدون کش pip
apt-get install -y --no-install-recommends X && rm -rf /var/lib/apt/lists/*نصب و پاک‌کردن در یک لایه
COPY --from=build /out /outبرداشتن فقط نتیجه از مرحله‌ی build
FROM scratchimage خالی برای باینری استاتیک