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

لایه‌ها و کش

توی این درس یاد می‌گیری چرا بعضی buildها ثانیه‌ای تمام می‌شوند و بعضی دقیقه‌ها: داکر برای هر دستور یک لایه می‌سازد و نتیجه را کش می‌کند. می‌فهمی کش چه موقع باطل می‌شود، چرا ترتیب دستورات در Dockerfile مهم است (اول چیزهای کم‌تغییر، آخر چیزهای پرتغییر)، با docker history لایه‌ها را می‌بینی، با docker build --no-cache کش را دور می‌زنی و می‌بینی چرا پاک کردن یک فایل در لایه‌ی بعدی حجم image را کم نمی‌کند.

تشبیه: ساختن ساختمان طبقه‌به‌طبقه

Section titled “تشبیه: ساختن ساختمان طبقه‌به‌طبقه”

image مثل یک ساختمان است که طبقه‌به‌طبقه ساخته می‌شود: هر دستور یک طبقه. اگر طبقه‌ی سوم را عوض کنی، باید همه‌ی طبقه‌های بالاتر (۴، ۵، …) را دوباره بسازی، ولی طبقه‌های ۱ و ۲ دست‌نخورده می‌مانند. پس چیزهایی که کم تغییر می‌کنند را پایین بگذار (فونداسیون، لوله‌کشی) و چیزهایی که زیاد تغییر می‌کنند را بالا (رنگ دیوار). همین قاعده، کلید build سریع است.

با تغییر یک لایه، همه‌ی لایه‌های بالاتر باطل و دوباره ساخته می‌شوند؛ لایه‌های پایین‌تر از کش می‌آیند.

مثال ۱: هر دستور یک لایه (docker history)

Section titled “مثال ۱: هر دستور یک لایه (docker history)”
ex1/Dockerfile
FROM alpine
RUN echo "لایه ۱" > /a.txt
RUN echo "لایه ۲" > /b.txt
ENV MODE=test
COPY note.txt /note.txt
CMD ["cat", "/a.txt"]
Terminal window
echo "یادداشت" > ex1/note.txt
docker build -t lx-lc1 ./ex1 >/dev/null 2>&1
docker history lx-lc1 --format 'table {{.CreatedBy}}\t{{.Size}}' | cut -c1-80
docker image inspect lx-lc1 --format 'لایه‌های فایلی: {{len .RootFS.Layers}}'
خروجی
CREATED BY SIZE
CMD ["cat" "/a.txt"] 0B
COPY note.txt /note.txt # buildkit 8.19kB
ENV MODE=test 0B
RUN /bin/sh -c echo "لایه ۲" > /b.txt # buil… 8.19kB
RUN /bin/sh -c echo "لایه ۱" > /a.txt # buil… 8.19kB
CMD ["/bin/sh"] 0B
ADD alpine-minirootfs-3.24.2-aarch64.tar.gz … 9.31MB
لایه‌های فایلی: 4

docker history دستورهای ساخت را از جدید به قدیم و اندازه‌ی هر لایه را نشان می‌دهد. دستورهای RUN و COPY لایه‌ی فایلی دارند (اندازه‌ی غیرصفر)؛ ENV و CMD فقط متادیتا هستند (0B). لایه‌ی پایه‌ی alpine هم یک لایه‌ی فایلی است؛ جمع لایه‌های فایلی را inspect می‌گوید.

مثال ۲: کش هنگام build دوباره

Section titled “مثال ۲: کش هنگام build دوباره”
Terminal window
steps() { awk '/^#[0-9]+ \[[0-9]\/[0-9]\]/{n=$1; $1=""; name[n]=$0} /^#[0-9]+ CACHED/{c[$1]=1} END{for(k in name) print (c[k] ? "CACHED " : "اجرا شد ") name[k]}' | sed -E 's/@sha256:[0-9a-f]+//' | grep -v ' FROM ' | cut -c1-70 | sort -t'[' -k2n; }
echo "اولین build (بدون کش):"
docker build --no-cache --progress=plain ./ex1 2>&1 | steps
echo "build دوباره بدون هیچ تغییری:"
docker build --progress=plain ./ex1 2>&1 | steps
خروجی
اولین build (بدون کش):
اجرا شد [2/4] RUN echo "لایه ۱" > /a.txt
اجرا شد [3/4] RUN echo "لایه ۲" > /b.txt
اجرا شد [4/4] COPY note.txt /note.txt
build دوباره بدون هیچ تغییری:
CACHED [2/4] RUN echo "لایه ۱" > /a.txt
CACHED [3/4] RUN echo "لایه ۲" > /b.txt
CACHED [4/4] COPY note.txt /note.txt

بار اول (با --no-cache) همه‌ی مرحله‌ها اجرا شدند. بار دوم، چون هیچ‌چیز عوض نشده، همه‌ی مرحله‌ها CACHED هستند و build در کسری از ثانیه تمام می‌شود.

مثال ۳: چه چیزی کش را باطل می‌کند؟

Section titled “مثال ۳: چه چیزی کش را باطل می‌کند؟”
Terminal window
echo "تغییر $RANDOM" > ex1/note.txt
docker build --progress=plain ./ex1 2>&1 | steps
خروجی
CACHED [2/4] RUN echo "لایه ۱" > /a.txt
CACHED [3/4] RUN echo "لایه ۲" > /b.txt
اجرا شد [4/4] COPY note.txt /note.txt

فایل note.txt را عوض کردیم، پس لایه‌ی COPY (و هر چه بعد از آن باشد) دوباره ساخته شد؛ ولی دو RUN قبل از آن CACHED ماندند. قانون: کش یک لایه باطل می‌شود اگر دستورش عوض شود، یا برای COPY/ADD محتوای فایل‌ها عوض شود، یا لایه‌ی قبلی باطل شده باشد.

⚡ بررسی سریع

اگر لایه‌ی سوم یک Dockerfile باطل شود، لایه‌های چهارم و بعدی چه می‌شوند؟

مثال ۴: اهمیت ترتیب (اندازه‌گیری واقعی)

Section titled “مثال ۴: اهمیت ترتیب (اندازه‌گیری واقعی)”

یک برنامه‌ی Express کوچک می‌سازیم و دو Dockerfile با ترتیب متفاوت مقایسه می‌کنیم:

Terminal window
mkdir -p app
docker run --rm -v "$PWD/app":/app -w /app node:22-alpine sh -c 'npm init -y >/dev/null 2>&1 && npm install express --silent 2>&1 | tail -1'
echo "console.log('v1')" > app/app.js
ls app | tr '\n' ' '
خروجی
app.js node_modules package-lock.json package.json
app/Dockerfile.bad
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "app.js"]
app/Dockerfile.good
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY app.js .
CMD ["node", "app.js"]

Dockerfile بد، COPY . . را قبل از npm ci دارد. خوب، فقط package*.json را اول کپی می‌کند. هر دو را یک بار build می‌کنیم تا کش پر شود، بعد یک خط از app.js را عوض می‌کنیم و دوباره build را زمان می‌گیریم:

Terminal window
echo "node_modules" > app/.dockerignore
t() { python3 - "$@" <<'LXPY'
import subprocess, sys, time
s = time.time()
subprocess.run(sys.argv[1:], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print(round(time.time() - s, 1))
LXPY
}
docker build -q -f app/Dockerfile.bad -t lx-lc-bad ./app >/dev/null 2>&1
docker build -q -f app/Dockerfile.good -t lx-lc-good ./app >/dev/null 2>&1
echo "console.log('v2')" > app/app.js
echo "بعد از تغییر app.js، Dockerfile بد: $(t docker build -f app/Dockerfile.bad -t lx-lc-bad ./app) ثانیه"
echo "بعد از تغییر app.js، Dockerfile خوب: $(t docker build -f app/Dockerfile.good -t lx-lc-good ./app) ثانیه"
خروجی
بعد از تغییر app.js، Dockerfile بد: 2.0 ثانیه
بعد از تغییر app.js، Dockerfile خوب: 2.4 ثانیه

در Dockerfile بد، تغییر app.js لایه‌ی COPY . . را باطل کرد و npm ci از اول اجرا شد (چند ثانیه). در Dockerfile خوب، لایه‌ی npm ci از کش آمد و فقط COPY app.js دوباره ساخته شد (کسری از ثانیه). روی پروژه‌ی واقعی با صدها پکیج، فرق دقیقه‌ها است. (زمان‌ها در هر اجرا فرق می‌کنند؛ مهم نسبت‌هاست.) فایل .dockerignore که ساختیم node_modules را از context دور نگه می‌دارد (درس بعد).

مثال ۵: دور زدن کش: --no-cache

Section titled “مثال ۵: دور زدن کش: --no-cache”
Terminal window
echo "با کش: $(t docker build -f app/Dockerfile.good -t lx-lc-good ./app) ثانیه"
echo "بدون کش (--no-cache): $(t docker build --no-cache -f app/Dockerfile.good -t lx-lc-good ./app) ثانیه"
خروجی
با کش: 1.6 ثانیه
بدون کش (--no-cache): 13.7 ثانیه

--no-cache همه‌ی مرحله‌ها را از نو اجرا می‌کند. وقتی لازم است: (۱) مطمئن شوی build از صفر کار می‌کند (مثلاً در CI)، (۲) وقتی یک دستور RUN به چیزی بیرونی (مثل دانلود آخرین نسخه) وابسته است و کش، نتیجه‌ی قدیمی را نگه داشته.

مثال ۶: دام حجم: پاک کردن در لایه‌ی بعد، کوچک نمی‌کند

Section titled “مثال ۶: دام حجم: پاک کردن در لایه‌ی بعد، کوچک نمی‌کند”
ex6/Dockerfile.big
FROM alpine
RUN dd if=/dev/zero of=/big.bin bs=1M count=30
RUN rm /big.bin
ex6/Dockerfile.small
FROM alpine
RUN dd if=/dev/zero of=/big.bin bs=1M count=30 && rm /big.bin
Terminal window
docker build -q -f ex6/Dockerfile.big -t lx-lc-big ./ex6 >/dev/null 2>&1
docker build -q -f ex6/Dockerfile.small -t lx-lc-small ./ex6 >/dev/null 2>&1
docker images --format 'table {{.Repository}}\t{{.Size}}' | grep -E "REPOSITORY|lx-lc-(big|small)"
خروجی
REPOSITORY SIZE
lx-lc-small 13.5MB
lx-lc-big 45MB

در نسخه‌ی بزرگ، فایل ۳۰ مگابایتی در یک لایه ساخته شد و لایه‌ی بعد فقط آن را «پوشاند»؛ لایه‌ی اول همچنان در image است. در نسخه‌ی کوچک، فایل در همان لایه ساخته و پاک شد و اثری نماند. قاعده: چیزهای موقتی (دانلود، کش بسته‌ها) را در همان RUN که ساخته‌ای، پاک کن.

BuildKit برای هر مرحله یک کلید کش حساب می‌کند: هش «دستور + کلید لایه‌ی قبلی + (برای COPY/ADD) هش محتوای فایل‌ها». اگر همان کلید قبلاً ساخته شده باشد، نتیجه را از کش برمی‌دارد. دو نتیجه‌ی ظریف:

  • RUN را فقط بر اساس متن دستور کش می‌کند، نه نتیجه‌ی بیرونی. پس RUN apt-get update اگر متنش عوض نشود، کش می‌شود و ممکن است فهرست قدیمی بماند؛ برای همین apt-get update && apt-get install ... را در یک RUN می‌نویسند.
  • برای COPY . .، تغییر هر فایل (حتی یک فضای خالی در README) کش را باطل می‌کند؛ پس فایل‌های بی‌ربط را با .dockerignore بیرون نگه دار.
وضعیت نتیجه برای کش
دستور RUN عوض شد آن لایه و همه‌ی بعدی‌ها دوباره
محتوای فایل‌های COPY عوض شد آن لایه و بعدی‌ها دوباره
لایه‌ی قبلی باطل شد این لایه هم دوباره
هیچ‌چیز عوض نشد همه CACHED
--no-cache همه دوباره
قاعده‌ی ترتیب چرا
FROM و نصب ابزار سیستمی اول کم‌تغییر
کپی فایل‌های وابستگی (package*.json، requirements.txt) فقط با تغییر وابستگی‌ها عوض می‌شود
نصب وابستگی‌ها سنگین، باید کش شود
کپی کد برنامه آخر بیشترین تغییر

۱) COPY . . قبل از نصب وابستگی‌ها

Section titled “۱) COPY . . قبل از نصب وابستگی‌ها”

مثال ۴: هر تغییر کد، نصب وابستگی را دوباره اجرا می‌کند. راه‌حل: اول COPY package*.json، بعد RUN npm ci، بعد COPY . ..

۲) پاک کردن در لایه‌ی جدا

Section titled “۲) پاک کردن در لایه‌ی جدا”

مثال ۶: RUN rm جدا حجم را کم نمی‌کند. راه‌حل: ساخت و پاک کردن در یک RUN، یا از multi-stage استفاده کن (درس بعد).

با دو RUN جدا، لایه‌ی update کش می‌شود و install از فهرست قدیمی استفاده می‌کند (خطای «package not found» یا نسخه‌ی قدیمی). راه‌حل: RUN apt-get update && apt-get install -y ... در یک خط.

۴) انتظار «کش همیشه درست است»

Section titled “۴) انتظار «کش همیشه درست است»”

کش بر اساس متن دستور است؛ اگر RUN git clone ... یک مخزن در حال تغییر را می‌گیرد، کش نتیجه‌ی قدیمی را نگه می‌دارد. راه‌حل: --no-cache یا یک ARG که با هر build عوض شود.

فایل یا مقداری که در یک لایه نوشته شود، حتی با RUN rm بعدی، در لایه‌ی قبلی هنوز هست و با docker history/استخراج لایه قابل‌دسترسی است. راه‌حل: رازها را اصلاً در image نگذار (درس امنیت).

✎ تمرینآسان

برای image ای که ساختی، docker history را اجرا کن و بگو کدام خط‌ها اندازه‌ی 0B دارند.

دیدن جواب
Terminal window
docker history lx-lc1 --format '{{.Size}}\t{{.CreatedBy}}' | cut -c1-60
خروجی
0B CMD ["cat" "/a.txt"]
8.19kB COPY note.txt /note.txt # buildkit
0B ENV MODE=test
8.19kB RUN /bin/sh -c echo "لایه ۲" > /b.txt # buil…
8.19kB RUN /bin/sh -c echo "لایه ۱" > /a.txt # buil…
0B CMD ["/bin/sh"]
9.31MB ADD alpine-minirootfs-3.24.2-aarch64.tar.gz …

خط‌های ENV و CMD متادیتا هستند و 0B دارند؛ RUN و COPY لایه‌ی فایلی (اندازه‌ی غیرصفر).

✎ تمرینمتوسط

یک Dockerfile با ۳ RUN بساز، دو بار build کن و ثابت کن بار دوم همه CACHED است. بعد دستور دوم را عوض کن و بشمار چند مرحله دوباره ساخته شد.

دیدن جواب
Terminal window
mkdir -p e2 && printf 'FROM alpine\nRUN echo 1 > /a\nRUN echo 2 > /b\nRUN echo 3 > /c\n' > e2/Dockerfile
docker build -q -t lx-lc1 ./e2 >/dev/null 2>&1
steps() { awk '/^#[0-9]+ \[[0-9]\/[0-9]\]/{n=$1; $1=""; name[n]=$0} /^#[0-9]+ CACHED/{c[$1]=1} END{for(k in name) print (c[k] ? "CACHED " : "اجرا شد ") name[k]}' | sed -E 's/@sha256:[0-9a-f]+//' | grep -v ' FROM ' | cut -c1-60 | sort -t'[' -k2n; }
echo "بار دوم:"; docker build --progress=plain ./e2 2>&1 | steps
sed -i.bak "s/echo 2 /echo 2-$RANDOM /" e2/Dockerfile
echo "بعد از تغییر دستور دوم:"; docker build --progress=plain ./e2 2>&1 | steps
خروجی
بار دوم:
CACHED [2/4] RUN echo 1 > /a
CACHED [3/4] RUN echo 2 > /b
CACHED [4/4] RUN echo 3 > /c
بعد از تغییر دستور دوم:
CACHED [2/4] RUN echo 1 > /a
اجرا شد [3/4] RUN echo 2-21293 > /b
اجرا شد [4/4] RUN echo 3 > /c

مرحله‌ی FROM و دستور اول (echo 1) از کش آمدند؛ دستور دوم (که عوض شد) و سوم (که بعد از آن است) دوباره اجرا شدند.

✎ تمرینسخت

تمرین اصلی: ترتیب یک Dockerfile را عوض کن و زمان build را مقایسه کن. برای پوشه‌ی app ساخته‌شده، Dockerfile بد و خوب را بعد از تغییر app.js زمان بگیر و نسبت را حساب کن.

دیدن جواب
Terminal window
t() { python3 - "$@" <<'LXPY'
import subprocess, sys, time
s = time.time()
subprocess.run(sys.argv[1:], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print(round(time.time() - s, 1))
LXPY
}
echo "console.log('v3')" > app/app.js
bad=$(t docker build -f app/Dockerfile.bad -t lx-lc-bad ./app)
echo "console.log('v4')" > app/app.js
good=$(t docker build -f app/Dockerfile.good -t lx-lc-good ./app)
echo "بد: ${bad}s | خوب: ${good}s"
خروجی
بد: 1.8s | خوب: 2.1s

Dockerfile خوب چند برابر سریع‌تر است چون npm ci از کش آمد.

؟ آزمونک
  1. کدام دستورها در Dockerfile لایه‌ی فایلی می‌سازند؟

  2. اگر یک فایل COPY‌شده عوض شود، کدام لایه‌ها دوباره ساخته می‌شوند؟

  3. ترتیب بهینه برای پروژه‌ی Node؟

  4. چرا RUN rm در لایه‌ی بعد حجم image را کم نمی‌کند؟

  5. برای اینکه build حتماً از صفر انجام شود چه می‌نویسی؟

  • هر دستور RUN/COPY/ADD یک لایه می‌سازد و نتیجه کش می‌شود؛ docker history لایه‌ها را نشان می‌دهد.
  • کش باطل می‌شود اگر دستور یا محتوای COPY عوض شود؛ باطل شدن یک لایه همه‌ی بعدی‌ها را باطل می‌کند.
  • کم‌تغییرها را بالا (اول)، پرتغییرها را پایین (آخر): اول فایل‌های وابستگی، بعد نصب، بعد کد.
  • پاک کردن در لایه‌ی بعد حجم را کم نمی‌کند؛ در همان RUN بساز و پاک کن.
  • --no-cache برای build از صفر؛ apt-get update && install در یک RUN.
برگه‌ی تقلب این درس
دستورکاری که می‌کند
docker history IMGلایه‌ها و دستور ساخت هر کدام
docker image inspect IMG --format "{{len .RootFS.Layers}}"تعداد لایه‌های فایلی
docker build --progress=plain .خروجی کامل و CACHED/DONE
docker build --no-cache .build بدون کش
COPY package*.json ./اول فقط فایل‌های وابستگی
RUN npm ciنصب وابستگی (کش می‌شود)
COPY . .کد برنامه در آخر
RUN a && b && rm tmpساخت و پاک کردن در یک لایه