توی این درس یاد میگیری چرا بعضی buildها ثانیهای تمام میشوند و بعضی دقیقهها: داکر برای هر دستور یک لایه میسازد و نتیجه را کش میکند. میفهمی کش چه موقع باطل میشود، چرا ترتیب دستورات در Dockerfile مهم است (اول چیزهای کمتغییر، آخر چیزهای پرتغییر)، با docker history لایهها را میبینی، با docker build --no-cache کش را دور میزنی و میبینی چرا پاک کردن یک فایل در لایهی بعدی حجم image را کم نمیکند.
تشبیه: ساختن ساختمان طبقهبهطبقه
Section titled “تشبیه: ساختن ساختمان طبقهبهطبقه”image مثل یک ساختمان است که طبقهبهطبقه ساخته میشود: هر دستور یک طبقه. اگر طبقهی سوم را عوض کنی، باید همهی طبقههای بالاتر (۴، ۵، …) را دوباره بسازی، ولی طبقههای ۱ و ۲ دستنخورده میمانند. پس چیزهایی که کم تغییر میکنند را پایین بگذار (فونداسیون، لولهکشی) و چیزهایی که زیاد تغییر میکنند را بالا (رنگ دیوار). همین قاعده، کلید build سریع است.
لایهها و کش
Section titled “لایهها و کش”مثالهای عملی
Section titled “مثالهای عملی”مثال ۱: هر دستور یک لایه (docker history)
Section titled “مثال ۱: هر دستور یک لایه (docker history)”FROM alpineRUN echo "لایه ۱" > /a.txtRUN echo "لایه ۲" > /b.txtENV MODE=testCOPY note.txt /note.txtCMD ["cat", "/a.txt"]echo "یادداشت" > ex1/note.txtdocker build -t lx-lc1 ./ex1 >/dev/null 2>&1docker history lx-lc1 --format 'table {{.CreatedBy}}\t{{.Size}}' | cut -c1-80docker image inspect lx-lc1 --format 'لایههای فایلی: {{len .RootFS.Layers}}'CREATED BY SIZECMD ["cat" "/a.txt"] 0BCOPY note.txt /note.txt # buildkit 8.19kBENV MODE=test 0BRUN /bin/sh -c echo "لایه ۲" > /b.txt # buil… 8.19kBRUN /bin/sh -c echo "لایه ۱" > /a.txt # buil… 8.19kBCMD ["/bin/sh"] 0BADD alpine-minirootfs-3.24.2-aarch64.tar.gz … 9.31MBلایههای فایلی: 4docker history دستورهای ساخت را از جدید به قدیم و اندازهی هر لایه را نشان میدهد. دستورهای RUN و COPY لایهی فایلی دارند (اندازهی غیرصفر)؛ ENV و CMD فقط متادیتا هستند (0B). لایهی پایهی alpine هم یک لایهی فایلی است؛ جمع لایههای فایلی را inspect میگوید.
مثال ۲: کش هنگام build دوباره
Section titled “مثال ۲: کش هنگام build دوباره”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 | stepsecho "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.txtbuild دوباره بدون هیچ تغییری:CACHED [2/4] RUN echo "لایه ۱" > /a.txtCACHED [3/4] RUN echo "لایه ۲" > /b.txtCACHED [4/4] COPY note.txt /note.txtبار اول (با --no-cache) همهی مرحلهها اجرا شدند. بار دوم، چون هیچچیز عوض نشده، همهی مرحلهها CACHED هستند و build در کسری از ثانیه تمام میشود.
مثال ۳: چه چیزی کش را باطل میکند؟
Section titled “مثال ۳: چه چیزی کش را باطل میکند؟”echo "تغییر $RANDOM" > ex1/note.txtdocker build --progress=plain ./ex1 2>&1 | stepsCACHED [2/4] RUN echo "لایه ۱" > /a.txtCACHED [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 با ترتیب متفاوت مقایسه میکنیم:
mkdir -p appdocker 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.jsls app | tr '\n' ' 'app.js node_modules package-lock.json package.jsonFROM node:22-alpineWORKDIR /appCOPY . .RUN npm ci --omit=devCMD ["node", "app.js"]FROM node:22-alpineWORKDIR /appCOPY package*.json ./RUN npm ci --omit=devCOPY app.js .CMD ["node", "app.js"]Dockerfile بد، COPY . . را قبل از npm ci دارد. خوب، فقط package*.json را اول کپی میکند. هر دو را یک بار build میکنیم تا کش پر شود، بعد یک خط از app.js را عوض میکنیم و دوباره build را زمان میگیریم:
echo "node_modules" > app/.dockerignoret() { python3 - "$@" <<'LXPY'import subprocess, sys, times = 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>&1docker build -q -f app/Dockerfile.good -t lx-lc-good ./app >/dev/null 2>&1echo "console.log('v2')" > app/app.jsecho "بعد از تغییر 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”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 “مثال ۶: دام حجم: پاک کردن در لایهی بعد، کوچک نمیکند”FROM alpineRUN dd if=/dev/zero of=/big.bin bs=1M count=30RUN rm /big.binFROM alpineRUN dd if=/dev/zero of=/big.bin bs=1M count=30 && rm /big.bindocker build -q -f ex6/Dockerfile.big -t lx-lc-big ./ex6 >/dev/null 2>&1docker build -q -f ex6/Dockerfile.small -t lx-lc-small ./ex6 >/dev/null 2>&1docker images --format 'table {{.Repository}}\t{{.Size}}' | grep -E "REPOSITORY|lx-lc-(big|small)"REPOSITORY SIZElx-lc-small 13.5MBlx-lc-big 45MBدر نسخهی بزرگ، فایل ۳۰ مگابایتی در یک لایه ساخته شد و لایهی بعد فقط آن را «پوشاند»؛ لایهی اول همچنان در image است. در نسخهی کوچک، فایل در همان لایه ساخته و پاک شد و اثری نماند. قاعده: چیزهای موقتی (دانلود، کش بستهها) را در همان RUN که ساختهای، پاک کن.
پشت پرده: کلید کش چیست؟
Section titled “پشت پرده: کلید کش چیست؟”BuildKit برای هر مرحله یک کلید کش حساب میکند: هش «دستور + کلید لایهی قبلی + (برای COPY/ADD) هش محتوای فایلها». اگر همان کلید قبلاً ساخته شده باشد، نتیجه را از کش برمیدارد. دو نتیجهی ظریف:
RUNرا فقط بر اساس متن دستور کش میکند، نه نتیجهی بیرونی. پسRUN apt-get updateاگر متنش عوض نشود، کش میشود و ممکن است فهرست قدیمی بماند؛ برای همینapt-get update && apt-get install ...را در یکRUNمینویسند.- برای
COPY . .، تغییر هر فایل (حتی یک فضای خالی در README) کش را باطل میکند؛ پس فایلهای بیربط را با.dockerignoreبیرون نگه دار.
جدولهای مرجع
Section titled “جدولهای مرجع”| وضعیت | نتیجه برای کش |
|---|---|
دستور RUN عوض شد |
آن لایه و همهی بعدیها دوباره |
محتوای فایلهای COPY عوض شد |
آن لایه و بعدیها دوباره |
| لایهی قبلی باطل شد | این لایه هم دوباره |
| هیچچیز عوض نشد | همه CACHED |
--no-cache |
همه دوباره |
| قاعدهی ترتیب | چرا |
|---|---|
FROM و نصب ابزار سیستمی اول |
کمتغییر |
کپی فایلهای وابستگی (package*.json، requirements.txt) |
فقط با تغییر وابستگیها عوض میشود |
| نصب وابستگیها | سنگین، باید کش شود |
| کپی کد برنامه آخر | بیشترین تغییر |
اشتباهات رایج
Section titled “اشتباهات رایج”۱) COPY . . قبل از نصب وابستگیها
Section titled “۱) COPY . . قبل از نصب وابستگیها”مثال ۴: هر تغییر کد، نصب وابستگی را دوباره اجرا میکند. راهحل: اول COPY package*.json، بعد RUN npm ci، بعد COPY . ..
۲) پاک کردن در لایهی جدا
Section titled “۲) پاک کردن در لایهی جدا”مثال ۶: RUN rm جدا حجم را کم نمیکند. راهحل: ساخت و پاک کردن در یک RUN، یا از multi-stage استفاده کن (درس بعد).
۳) apt-get update جدا از install
Section titled “۳) apt-get update جدا از install”با دو RUN جدا، لایهی update کش میشود و install از فهرست قدیمی استفاده میکند (خطای «package not found» یا نسخهی قدیمی). راهحل: RUN apt-get update && apt-get install -y ... در یک خط.
۴) انتظار «کش همیشه درست است»
Section titled “۴) انتظار «کش همیشه درست است»”کش بر اساس متن دستور است؛ اگر RUN git clone ... یک مخزن در حال تغییر را میگیرد، کش نتیجهی قدیمی را نگه میدارد. راهحل: --no-cache یا یک ARG که با هر build عوض شود.
۵) گذاشتن راز در لایه
Section titled “۵) گذاشتن راز در لایه”فایل یا مقداری که در یک لایه نوشته شود، حتی با RUN rm بعدی، در لایهی قبلی هنوز هست و با docker history/استخراج لایه قابلدسترسی است. راهحل: رازها را اصلاً در image نگذار (درس امنیت).
برای image ای که ساختی، docker history را اجرا کن و بگو کدام خطها اندازهی 0B دارند.
دیدن جواب
docker history lx-lc1 --format '{{.Size}}\t{{.CreatedBy}}' | cut -c1-600B CMD ["cat" "/a.txt"]8.19kB COPY note.txt /note.txt # buildkit0B ENV MODE=test8.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 است. بعد دستور دوم را عوض کن و بشمار چند مرحله دوباره ساخته شد.
دیدن جواب
mkdir -p e2 && printf 'FROM alpine\nRUN echo 1 > /a\nRUN echo 2 > /b\nRUN echo 3 > /c\n' > e2/Dockerfiledocker build -q -t lx-lc1 ./e2 >/dev/null 2>&1steps() { 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 | stepssed -i.bak "s/echo 2 /echo 2-$RANDOM /" e2/Dockerfileecho "بعد از تغییر دستور دوم:"; docker build --progress=plain ./e2 2>&1 | stepsبار دوم:CACHED [2/4] RUN echo 1 > /aCACHED [3/4] RUN echo 2 > /bCACHED [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 زمان بگیر و نسبت را حساب کن.
دیدن جواب
t() { python3 - "$@" <<'LXPY'import subprocess, sys, times = 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.jsbad=$(t docker build -f app/Dockerfile.bad -t lx-lc-bad ./app)echo "console.log('v4')" > app/app.jsgood=$(t docker build -f app/Dockerfile.good -t lx-lc-good ./app)echo "بد: ${bad}s | خوب: ${good}s"بد: 1.8s | خوب: 2.1sDockerfile خوب چند برابر سریعتر است چون npm ci از کش آمد.
آزمونک
Section titled “آزمونک”کدام دستورها در Dockerfile لایهی فایلی میسازند؟
بقیه فقط متادیتا هستند.
اگر یک فایل COPYشده عوض شود، کدام لایهها دوباره ساخته میشوند؟
لایههای قبل از آن از کش میآیند.
ترتیب بهینه برای پروژهی Node؟
تا تغییر کد، لایهی نصب وابستگیها را باطل نکند.
چرا RUN rm در لایهی بعد حجم image را کم نمیکند؟
ساخت و پاک کردن را در یک RUN انجام بده.
برای اینکه build حتماً از صفر انجام شود چه مینویسی؟
--no-cache همهی مرحلهها را دوباره اجرا میکند.
جمعبندی
Section titled “جمعبندی”- هر دستور
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 | ساخت و پاک کردن در یک لایه |