সরাসরি পর্বে যান
পর্ব ৩৭ · ৮১-এর মধ্যে · মডিউল ৭

Docker image — layer, Dockerfile আর build cache

Docker Images — Layers, Dockerfile and the Build Cache

Taught in Bangla, this lesson explains an image as stacked layers plus config, writes a first Dockerfile, inspects layers with docker history and image inspect in real output, compares CMD and ENTRYPOINT, covers overlay filesystems, copy-on-write and content digests, shrinks the build context with .dockerignore and BuildKit, orders instructions for the build cache, and shows why deleting in a later layer saves nothing, with simulators.

এই পর্বে যা শিখবেন

  • Dockerfile-এর মূল নির্দেশগুলো লেখা, আর কোনটি layer বানায় তা বলা
  • CMD আর ENTRYPOINT-এর পার্থক্য, shell form-এর signal সমস্যা
  • docker history আর docker image inspect দিয়ে একটি image খুলে পড়া
  • Overlay filesystem, copy-on-write আর digest ব্যবহার করে image-এর আচরণ ব্যাখ্যা করা
  • Build context ছোট রাখা, build cache-এর জন্য নির্দেশ সাজানো, image-এর আকার মাপা

১ · Image আসলে কী — পাতার পর পাতা জোড়া একটি নথি

পর্ব ৩৬-এ দেখেছেন, একটি container image হলো app আর তার দরকারি সব ফাইলের একটি প্যাকেট, আর সেই প্যাকেট থেকে চলমান রূপটি হলো container. এবার প্রশ্ন হলো, প্যাকেটটি ভেতরে কেমন? একটি বড় zip ফাইল হলে সমস্যা হতো: এক লাইন কোড বদলালে পুরো ৭০ MB আবার বানাতে, পাঠাতে আর রাখতে হতো। Docker সেই পথে যায়নি।

সরকারি অফিসের একটি নথির কথা ভাবুন। প্রথম পাতায় মূল আবেদন। পরের প্রতিটি সিদ্ধান্ত যোগ হয় নতুন নোটশিটে। পুরোনো পাতা কখনো ছেঁড়া হয় না। কোনো কথা বাতিল করতে হলেও নতুন পাতায় লেখা হয় “আগের অনুচ্ছেদ বাতিল”, কিন্তু আগের অনুচ্ছেদটি নথিতেই থেকে যায়। নথি শুধু মোটাই হয়। Docker image ঠিক এই রকম। এই একটি ছবি মনে রাখলে এই পর্বের প্রায় সব ফাঁদ নিজেই বুঝবেন।

নথির প্রতিটি পাতাকে বলে layer. একটি layer হলো আগের অবস্থার সাথে ফাইলের পার্থক্য: কোন ফাইল যোগ হলো, কোনটি বদলাল, কোনটি মুছল। Layer read-only, মানে একবার তৈরি হলে আর বদলায় না। Layer-গুলো ছাড়াও image-এ থাকে একটি config, যেখানে লেখা থাকে কোন কমান্ড চলবে আর কোন environment থাকবে। তিনটি শব্দ আলাদা করে নিন:

Dockerfile

Image বানানোর লিখিত নির্দেশ, রান্নাঘরের রেসিপি খাতার মতো। একটি সাধারণ text ফাইল, যা Git-এ রাখা হয়।

নকশা

Image

নির্দেশ চালিয়ে পাওয়া ফল: layer-এর সারি আর config. বদলায় না, তাই হাজারবার হুবহু চালানো যায়।

তৈরি মাল

Container

Image-এর ওপর একটি লেখার স্তর বসিয়ে চালানো process. মুছে ফেললে লেখার স্তরটিও যায়।

চলমান রূপ

পর্ব ১৬-র machine image-এর সাথে চিন্তাটি এক: আগে থেকে প্যাক করে নকল করা। পার্থক্য হলো, container image-এ kernel থাকে না, আর জিনিসটি স্তরে ভাগ করা। এই স্তরই গতি, জায়গা বাঁচানো আর অনেক ফাঁদের উৎস।

Docker ২০১৩ সালে প্রকাশ্যে আসে, আর শুরুতে স্তর জোড়ার কাজটি করত AUFS নামের একটি filesystem. পরে Linux-এর মূল kernel-এ OverlayFS যোগ হয় (version 3.18, ২০১৪)। Docker-এর image-এর ধরন ২০১৫ সালে গঠিত Open Container Initiative (OCI)-এর মানদণ্ডের ভিত্তি হয়। তাই আজ Podman, containerd বা Kubernetes-ও একই ধরনের image চালাতে পারে।

২ · প্রথম Dockerfile — আটটি মূল নির্দেশ

আমরা একটি কাল্পনিক অনলাইন বাজার অ্যাপের ছোট Python service বানাব। এটি শুধু Python-এর standard library ব্যবহার করে, port 8000-এ অনুরোধ শোনে, আর GREETING নামের environment variable পড়ে একটি লাইন ফেরত দেয়। requirements.txt-এ শুধু একটি মন্তব্য আছে, তাই কিছু নামে না। আসল app-এ এখানেই network থেকে package নামত।

নির্দেশকী করেফল
FROMকোন base image-এর ওপর বানাবেনঐ image-এর সব layer
WORKDIRপরের নির্দেশগুলোর কাজের ফোল্ডার ঠিক করে; না থাকলে বানায়ছোট layer + config
ENVBuild-এর পরের ধাপে আর container-এ environment variable দেয়শুধু config
COPYBuild context থেকে ফাইল image-এ আনেনতুন layer
RUNBuild-এর সময় একটি কমান্ড চালিয়ে ফাইলের পরিবর্তন ধরে রাখেনতুন layer
EXPOSEকোন port-এ app শোনে তা লিখে রাখে, port খোলে নাশুধু config
CMDContainer চালুর সময় ডিফল্ট কমান্ড বা argumentশুধু config
ENTRYPOINTContainer চালুর সময় যে প্রোগ্রাম চলবেইশুধু config

RUN চলে build-এর সময়, একবার। CMD আর ENTRYPOINT চলে প্রতিটি container চালুর সময়। এই পার্থক্যই নতুনদের সবচেয়ে বেশি গুলিয়ে দেয়।

FROM python:3.12-alpine
WORKDIR /app
ENV GREETING=salam
COPY requirements.txt .
RUN python -m pip install --no-index -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]

এই পর্বের সব Docker আউটপুট একটি আসল run থেকে নেওয়া: Docker Engine 29, BuildKit সহ। ফোনে পড়ার সুবিধার জন্য কিছু লাইন ও চওড়া কলাম বাদ দেওয়া হয়েছে (লাইনের জায়গায় …), কোনো সংখ্যা বানানো নয়। ID, digest, আকার আর সময় আপনার machine-এ আলাদা হবে। Docker browser-এ চলে না, তাই এগুলো চালানোর ব্লক নয়। Image-এর সব নাম abcl-di- দিয়ে শুরু।

$ docker build --progress=plain -t abcl-di-app:1 .
…
#4 [internal] load build context
#4 transferring context: 508B done
#4 DONE 0.1s

#5 [1/5] FROM docker.io/library/python:3.12-alpine@sha256:e6dae589c1c0…
#5 DONE 0.2s

#6 [2/5] WORKDIR /app
#6 DONE 0.0s

#7 [3/5] COPY requirements.txt .
#7 DONE 0.1s

#8 [4/5] RUN python -m pip install --no-index -r requirements.txt
#8 1.640 WARNING: Running pip as the 'root' user can result in broken …
#8 DONE 1.7s

#9 [5/5] COPY app.py .
#9 DONE 0.1s

#10 exporting to image
#10 exporting layers 0.4s done
…
#10 naming to docker.io/library/abcl-di-app:1 done
#10 DONE 0.7s

খেয়াল করুন, আউটপুটে মাত্র পাঁচটি ধাপ [1/5] থেকে [5/5] আছে, যদিও Dockerfile-এ আটটি লাইন। ENV, EXPOSE আর CMD কোনো ফাইল বদলায় না, শুধু config-এ একটি লাইন লেখে। নিচে পুরো image-টি ধাপে ধাপে গড়ে উঠতে দেখুন। আকারগুলো এই run-এর docker history থেকে নেওয়া।

Image গড়ে উঠছে (আসল run-এর মান)
নির্দেশ
ফল
এই ধাপের আকার
মোট layer
Config-এ নতুন কী
  1. FROM. শুরুতেই base image-এর ৪টি layer এল। মোট প্রায় ৫৬ MB. এগুলো নতুন করে বানানো হয়নি, শুধু ওপরে কাজ শুরু হলো।
  2. WORKDIR. /app ফোল্ডার তৈরি হলো, তাই একটি খুব ছোট layer. Config-এও কাজের ফোল্ডার লেখা হলো।
  3. ENV. কোনো ফাইল বদলায়নি, তাই layer নেই। Layer-এর সংখ্যা ৫-ই থাকল।
  4. COPY requirements.txt. Build context থেকে একটি ফাইল এল। ৪৫ বাইটের ফাইলের জন্যও আকার দেখাচ্ছে ১২.৩ kB, কারণ disk-এ জায়গা ব্লক হিসেবে গোনা হয়।
  5. RUN. কমান্ডটি একটি অস্থায়ী container-এ চলল, আর যত ফাইল বদলাল সব একটি layer হলো। কিছুই নামেনি, তবু ৯.৬৬ MB! কারণটি পরের অংশে দেখব।
  6. COPY app.py. Source কোড এল সবার ওপরে। মোট layer এখন ৮টি।
  7. EXPOSE. শুধু config-এ লেখা হলো। ফাইলও বদলায়নি, port-ও খোলেনি।
  8. CMD. Config-এ ডিফল্ট কমান্ড বসল, base-এর python3-কে সরিয়ে। Build শেষ: ৮টি layer আর একটি config.

চালিয়ে দেখা: EXPOSE আসলে কী করে

EXPOSE লেখা থাকলেও port নিজে থেকে বাইরে আসে না। প্রথমে -p ছাড়া চালালাম, তারপর -p দিয়ে, তারপর -e দিয়ে ENV-এর মান বদলে।

$ docker run -d --name abcl-di-c1 --hostname demo abcl-di-app:1
$ curl -s -m 3 http://127.0.0.1:18371/ ; echo "curl exit=$?"
curl exit=7
$ docker rm -f abcl-di-c1

$ docker run -d --name abcl-di-c2 --hostname demo \
    -p 127.0.0.1:18371:8000 abcl-di-app:1
$ curl -s http://127.0.0.1:18371/
salam from the shop app
$ docker port abcl-di-c2
8000/tcp -> 127.0.0.1:18371
$ docker rm -f abcl-di-c2

$ docker run -d --name abcl-di-c2 --hostname demo -e GREETING=adab \
    -p 127.0.0.1:18371:8000 abcl-di-app:1
$ curl -s http://127.0.0.1:18371/
adab from the shop app

প্রথমবার curl-এর exit code 7, মানে সংযোগই হয়নি। EXPOSE ছিল, তবু host থেকে কেউ পৌঁছাতে পারেনি। Port প্রকাশ করে docker run -p, আর সেটি পর্ব ৩৯-এর বিষয়। তৃতীয়বার image একই, কিন্তু -e দিয়ে salam-এর বদলে adab এল। মানে config-এর ডিফল্ট চলার সময় বদলানো যায়, image না বানিয়েই।

৩ · Layer নিজের চোখে দেখা — docker history আর image inspect

Image একটি বন্ধ বাক্স নয়। দুটি কমান্ডে ভেতরটি পড়া যায়। docker history দেখায় কোন নির্দেশ কতটা জায়গা নিয়েছে, ওপরের সারিটি সবচেয়ে নতুন।

$ docker history abcl-di-app:1
IMAGE          CREATED BY                                      SIZE
86d7dd680608   CMD ["python" "app.py"]                         0B
<missing>      EXPOSE [8000/tcp]                               0B
<missing>      COPY app.py . # buildkit                        12.3kB
<missing>      RUN /bin/sh -c python -m pip install --no-in…   9.66MB
<missing>      COPY requirements.txt . # buildkit              12.3kB
<missing>      ENV GREETING=salam                              0B
<missing>      WORKDIR /app                                    8.19kB
<missing>      CMD ["python3"]                                 0B
<missing>      RUN /bin/sh -c set -eux;  for src in idle3 p…   16.4kB
<missing>      RUN /bin/sh -c set -eux;   apk add --no-cach…   44.1MB
…
<missing>      RUN /bin/sh -c set -eux;  apk add --no-cache…   2.81MB
…
<missing>      CMD ["/bin/sh"]                                 0B
<missing>      ADD alpine-minirootfs-3.24.3-x86_64.tar.gz /…   9.08MB

$ docker image inspect --format '{{json .RootFS.Layers}}' abcl-di-app:1
["sha256:75c137aedfb2…",   # base image starts here
 "sha256:7bd6d027fc87…",
 "sha256:a26f58fa1b9b…",
 "sha256:6cf2f486e746…",   # base image ends here
 "sha256:1534a38a1c10…",   # WORKDIR
 "sha256:9b6d29dfe79e…",   # COPY requirements.txt
 "sha256:18a7dd77bfe4…",   # RUN pip
 "sha256:e88f27928769…"]   # COPY app.py

docker history-তে ১৮টি সারি ছিল, কিন্তু আসল layer ৮টি। 0B-র সারিগুলো শুধু config-এর ইতিহাস। <missing> কোনো ত্রুটি নয়: BuildKit প্রতিটি ধাপের জন্য আলাদা image ID রাখে না, তাই শুধু ওপরের সারিতে ID আছে। Inspect-এর আউটপুট আসলে এক লাইনে আসে; এখানে লাইন ভেঙে ডান পাশের # WORKDIR-এর মতো মন্তব্য আমরা যোগ করেছি। আর python:3.12-alpine-এর ওপর inspect চালালে প্রথম চারটি মান হুবহু মিলেছে। মানে এই image base-এর layer নকল করেনি, ভাগ করে নিয়েছে।

৯.৬৬ MB কোথা থেকে এল?

কিছুই install হয়নি, তবু RUN layer ৯.৬৬ MB. Layer খুলে গুনে দেখলাম: ভেতরে ৪৬৭টি ফাইল, সব মিলিয়ে ৮১,০৮,১৮৮ বাইট, আর প্রতিটিই .pyc. Python কোনো module প্রথমবার চালানোর সময় তার bytecode __pycache__-এ লিখে রাখে, আর pip চলতে গিয়ে এমন শত শত module খুলেছে। আপনি চাননি, তবু তা image-এ গেছে। একটি RUN চলাকালীন যা কিছু disk-এ বদলায়, সবটাই layer-এ ঢোকে। ENV PYTHONDONTWRITEBYTECODE=1 যোগ করে আবার মাপলে একই RUN হলো মাত্র ৪.১ kB.

Image-এর আকার মাপা

“Image কত বড়” প্রশ্নের একটি উত্তর নেই। এই run-এ docker image ls দুটি সংখ্যা দিল:

$ docker image ls abcl-di-app
IMAGE           ID             DISK USAGE   CONTENT SIZE   EXTRA
abcl-di-app:1   86d7dd680608       87.2MB         21.4MB

$ docker image inspect --format '{{.Size}}' abcl-di-app:1 python:3.12-alpine
21440783
18062363

Content size (২১.৪ MB) হলো সংকুচিত layer-এর আকার, মোটামুটি যা registry থেকে নামাতে হয়। docker history-র সংখ্যাগুলো সংকোচনহীন, তাই তাদের যোগফল অনেক বড়। Disk usage (৮৭.২ MB) এই setup-এ দুটো মিলিয়ে হিসাব করে বলে মনে হয়: সংকুচিত blob আর খুলে রাখা ফাইল। Version আর storage-এর ধরনভেদে এই কলামগুলো আলাদা হতে পারে। তাই তুলনা করার সময় একই সংখ্যা ব্যবহার করুন, আর বলুন কোনটি মাপছেন।

৪ · CMD বনাম ENTRYPOINT — কোনটি চলবে, কোনটি বদলাবে

লঞ্চঘাটের একটি লঞ্চ ভাবুন। লঞ্চ চলবেই, সেটি ঠিক করা (ENTRYPOINT); আর কোন ঘাটে যাবে, সেটি (CMD) যাত্রী বদলাতে পারেন। Docker চালানোর সময় দুটিকে জোড়া দেয়: ENTRYPOINT + CMD. আপনি docker run image …-এ কিছু লিখলে সেটি শুধু CMD-কে সরিয়ে বসে।

# image abcl-di-cmd:1
FROM alpine:3.20
CMD ["echo", "from CMD"]

# image abcl-di-ep:1
FROM alpine:3.20
ENTRYPOINT ["echo", "from ENTRYPOINT:"]
CMD ["default arg"]

# image abcl-di-shellep:1
FROM alpine:3.20
ENTRYPOINT echo fixed text
CMD ["ignored"]
$ docker run --rm abcl-di-cmd:1
from CMD
$ docker run --rm abcl-di-cmd:1 echo replaced
replaced
$ docker run --rm abcl-di-cmd:1 hello
docker: Error response from daemon: … exec: "hello": executable file not found in $PATH
…
exit=127

$ docker run --rm abcl-di-ep:1
from ENTRYPOINT: default arg
$ docker run --rm abcl-di-ep:1 hello
from ENTRYPOINT: hello
$ docker run --rm --entrypoint echo abcl-di-ep:1 replaced entrypoint
replaced entrypoint

$ docker run --rm abcl-di-shellep:1 hello
fixed text
Image-এ আছেdocker run imgdocker run img hello
শুধু CMDCMD চলেhello নামের প্রোগ্রাম খোঁজে; পুরো CMD বাতিল
ENTRYPOINT + CMD (exec form)ENTRYPOINT + CMD-এর argumentENTRYPOINT + hello
ENTRYPOINT (shell form)শুধু ENTRYPOINTএখনো শুধু ENTRYPOINT; hello আর CMD উপেক্ষিত

বাস্তবে সাধারণ app-এর জন্য CMD-ই যথেষ্ট, কারণ তাতে docker run img sh দিয়ে ভেতরে ঢুকে দেখা সহজ। Image-টি একটি নির্দিষ্ট হাতিয়ারের মতো চললে (যেমন একটি CLI) ENTRYPOINT সেই হাতিয়ার, আর CMD তার ডিফল্ট flag. --entrypoint দিয়ে ENTRYPOINT বদলালে image-এর CMD-ও বাদ পড়ে।

Exec form আর shell form

["python", "app.py"] হলো exec form: Docker সরাসরি ঐ প্রোগ্রাম চালায়। python app.py হলো shell form: Docker চালায় /bin/sh -c "…". দেখতে ছোট পার্থক্য, কিন্তু ফল বড়। Container-এর প্রথম process-টি হয় PID 1, আর docker stop signal পাঠায় শুধু তাকেই। নিচের worker.py SIGTERM পেলে কাজ গুছিয়ে বের হয়। দুটি image-এ একই প্রোগ্রাম চালালাম, শুধু CMD-র ধরন আলাদা।

# abcl-di-sigshell:1  CMD python worker.py; echo finished
# abcl-di-sigexec:1   CMD ["python", "worker.py"]

$ docker exec abcl-di-shellform cat /proc/1/cmdline | tr '\0' ' '
/bin/sh -c python worker.py; echo finished
$ docker exec abcl-di-execform cat /proc/1/cmdline | tr '\0' ' '
python worker.py

$ docker stop -t 5 abcl-di-shellform     # took about 5.3 s in this run
$ docker stop -t 5 abcl-di-execform      # took about 0.3 s in this run

$ docker inspect --format '{{.Name}} exit={{.State.ExitCode}}' \
    abcl-di-shellform abcl-di-execform
/abcl-di-shellform exit=137
/abcl-di-execform exit=0

$ docker logs abcl-di-shellform
worker started
$ docker logs abcl-di-execform
worker started
got SIGTERM, saving work

Shell form-এ PID 1 ছিল sh, যে signal-টি Python-এর কাছে পৌঁছে দেয়নি। পাঁচ সেকেন্ড অপেক্ষার পর Docker SIGKILL পাঠাল, exit 137, আর “saving work” লাইনটি ছাপাই হলো না। Exec form-এ Python নিজেই PID 1, তাই সে signal পেয়ে ভদ্রভাবে বের হলো, exit 0. একটি আসল app হলে এই পার্থক্য মানে অর্ধেক লেখা data বা কেটে যাওয়া অনুরোধ। পর্ব ১১-তে systemd-র ক্ষেত্রেও একই চিন্তা দেখেছেন।

একটি সূক্ষ্ম ব্যাপার। আমাদের আরেকটি পরীক্ষায় CMD sleep 30 দিলে PID 1 দেখা গেল sleep 30, sh নয়। একটিমাত্র কমান্ড হলে Alpine-এর shell নিজেকে সেই কমান্ড দিয়ে বদলে ফেলে। এই সুবিধার ওপর ভরসা করবেন না, কারণ pipe থাকলে বা শেষে আরেকটি কমান্ড থাকলে (যেমন ; echo finished) তা আর খাটে না, আর shell-ভেদে আচরণও আলাদা। সবসময় exec form লিখুন। Linux container-এ docker stop-এর ডিফল্ট অপেক্ষা ১০ সেকেন্ড। এখানে -t 5 দিয়ে আমরা সেটি ৫ সেকেন্ড করেছি।

৫ · Overlay filesystem আর copy-on-write

Layer-গুলো তো আলাদা আলাদা ফোল্ডারে থাকে। তবু container একটিই পুরো filesystem দেখে কীভাবে? কাজটি করে overlay filesystem. এটি কয়েকটি ফোল্ডারকে ওপর-নিচে সাজিয়ে একটি দৃশ্য বানায়। কোনো ফাইল খুঁজতে হলে ওপর থেকে নিচে দেখে, আর যে স্তরে প্রথম পায় সেটিই দেখায়।

Container চালু হলে Docker সবার ওপরে একটি খালি writable layer বসায়। নিচের সব layer আগের মতোই read-only থাকে। তাই একই image থেকে ১০০টি container চললেও image-এর ফাইল ডিস্কে একবারই থাকে।

Layer-এর স্তূপ আর ওপরের writable layer

Two containers share read-only image layers; each has its own writable layer

দুটি container-এর নিজস্ব writable layer, নিচে ভাগ করা read-only image layer Container A writable layer Container B writable layer Image: read-only layer, সবার ভাগে এক COPY app.py · 12.3kB RUN pip install · 9.66MB COPY requirements.txt · 12.3kB WORKDIR /app · 8.19kB python:3.12-alpine (৪টি layer) · 56.0MB ফাইল খোঁজা চলে ওপর থেকে নিচে, প্রথম মিলটিই দেখা যায় লেখা হয় শুধু ওপরের হলুদ স্তরে
ছবি ৩৭.১ — আকারগুলো এই run-এর docker history থেকে নেওয়া। Container মুছলে হলুদ স্তরটি যায়, image থেকে যায়।

এবার লেখার সময় কী হয়। নিচের স্তর বদলানো নিষেধ। তাই নিচের কোনো ফাইল বদলাতে গেলে আগে পুরো ফাইলটি writable layer-এ কপি হয়, তারপর সেই কপিতে লেখা হয়। একে বলে copy-on-write, আর কপি করার কাজটিকে বলে copy-up. নিচের ফাইল মুছলে কিছুই আসলে মোছে না: ওপরে একটি whiteout চিহ্ন বসে, যা নিচের ফাইলটি ঢেকে দেয়। কোন স্তরে কী হয়, তার পুরো ব্যাখ্যা আছে Operating Systems · Container পর্বে।

এবার মেপে দেখা যাক। abcl-di-cow:1 image-এ ২০ MiB-র একটি /big.log আছে। দুটি container চালিয়ে একটিতে ছোট ছোট বদল করলাম।

$ docker exec abcl-di-cow1 sh -c 'echo hi > /tmp/new.txt; rm /etc/motd
    echo more >> /etc/hostname.demo'
$ docker diff abcl-di-cow1
C /tmp
A /tmp/new.txt
C /etc
A /etc/hostname.demo
D /etc/motd
$ docker ps -s --filter name=abcl-di-cow1 --format '{{.Names}} {{.Size}}'
abcl-di-cow1 20.5kB (virtual 29.5MB)

$ docker exec abcl-di-cow1 sh -c 'echo one-line >> /big.log'
$ docker diff abcl-di-cow1
…
C /big.log
…
$ docker ps -s --filter name=abcl-di-cow1 --format '{{.Names}} {{.Size}}'
abcl-di-cow1 21MB (virtual 50.5MB)

$ docker exec abcl-di-cow2 sh -c 'wc -c /big.log; cat /tmp/new.txt'
20971520 /big.log
cat: can't open '/tmp/new.txt': No such file or directory
$ docker ps -s --filter name=abcl-di-cow2 --format '{{.Names}} {{.Size}}'
abcl-di-cow2 4.1kB (virtual 29.5MB)

docker diff-এর A মানে যোগ, C মানে বদল, D মানে মোছা। আসল কথা শেষের দিকে: /big.log-এ মাত্র একটি লাইন যোগ করতেই writable layer ২০.৫ kB থেকে হলো ২১ MB. কারণ পুরো ফাইলটি copy-up হয়েছে। অন্যদিকে abcl-di-cow2 একই image থেকে চলছে, অথচ সে /tmp/new.txt দেখে না, আর তার নিজের স্তর মাত্র ৪.১ kB. স্তর আলাদা, image এক।

এখান থেকে দুটি ব্যবহারিক নিয়ম আসে। Database বা বড় log-এর মতো ঘন ঘন লেখার জিনিস writable layer-এ রাখবেন না; সেগুলো volume-এ যাবে (পর্ব ৩৯)। আর container-এর ভেতরে যা লিখলেন, container মুছলে তা শেষ, তাই স্থায়ী কিছু সেখানে রাখা যায় না।

৬ · Digest — বিষয়বস্তুই যখন ঠিকানা

sha256:e6dae589c1c0…-এর মতো লম্বা মানগুলো হলো digest: কোনো বাইটের ওপর SHA-256 hash চালিয়ে পাওয়া ফল। একটি বাইট বদলালেও ফলটি পুরো বদলে যায়। Registry বা disk-এ জিনিস খোঁজা হয় এই hash দিয়েই। একে বলে content-addressable ব্যবস্থা: নাম দিয়ে নয়, বিষয়বস্তু দিয়ে ঠিকানা। পর্ব ৩০-এর content-hashed filename-এর সাথে চিন্তাটি এক, আর Git-ও একইভাবে কাজ করে।

আঙুলের ছাপের মতো ভাবুন। নাম (tag) বদলানো যায়, দুজনের একই নামও হতে পারে। আঙুলের ছাপ মানুষের সাথেই বাঁধা। নিচের ব্লকে Python দিয়ে সত্যিকারের ছোট layer বানিয়ে দেখুন।

import gzip, hashlib, io, tarfile

def sha(data):
    return "sha256:" + hashlib.sha256(data).hexdigest()

def layer(files, mtime=0):
    """A layer is a tar of the changed files. Same bytes, same digest."""
    buf = io.BytesIO()
    with tarfile.open(fileobj=buf, mode="w", format=tarfile.USTAR_FORMAT) as tar:
        for name in sorted(files):
            body = files[name].encode()
            info = tarfile.TarInfo(name)
            info.size, info.mtime = len(body), mtime
            tar.addfile(info, io.BytesIO(body))
    return buf.getvalue()

def show(label, digest):
    print(f"{label:<26}{digest[7:19]}")

app = {"app/app.py": "print('salam')\n"}
one = layer(app)
show("app layer", sha(one))
show("same files, built again", sha(layer(app)))
show("one letter changed", sha(layer({"app/app.py": "print('Salam')\n"})))
show("same text, new file time", sha(layer(app, mtime=1)))
show("gzip of app layer", sha(gzip.compress(one, mtime=0)))

base_a = sha(layer({"etc/os": "alpine 3.20\n"}))
base_b = sha(layer({"etc/os": "alpine 3.24\n"}))
diff = sha(one)                            # the same top layer on two bases
show("top layer on base A", sha(f"{base_a} {diff}".encode()))
show("top layer on base B", sha(f"{base_b} {diff}".encode()))

প্রথম দুটি লাইন হুবহু এক: 6d6c419d6756, কারণ বাইট এক। এক অক্ষর বদলাতেই 46de1e347bf0, একদম অন্য মান। চার নম্বর লাইনটি খেয়াল করুন: লেখা একই, শুধু tar-এর ভেতরের ফাইলের সময় আলাদা, তাই 18565391e02a. এজন্যই পরে --no-cache-এ digest বদলাবে। সংকুচিত রূপের hash আলাদা (afdcad7fd86c), তাই registry-তে সংকুচিত layer-এর digest থাকে, আর image config-এ সংকোচনহীন tar-এর digest. শেষ দুটি লাইনে একই layer দুটি আলাদা base-এ বসেছে: 6e147527773e আর f701622ed4dc. Layer এক, কিন্তু তার ইতিহাস আলাদা, আর সেই হিসাবকে OCI-তে বলে ChainID.

Image-এর গঠন তাই একটি hash-এর গাছ। প্রতিটি layer-এর digest থাকে config আর manifest-এ, manifest-এর নিজের digest হয়, আর বহু platform-এর image-এ তার ওপরে থাকে একটি index. ফলে ওপরের একটি digest মিলিয়ে নিলেই নিচের সবকিছু মিলে যায়। এই run-এ ধাপগুলো build-এর আউটপুটেই দেখা গেছে:

#10 exporting manifest sha256:d3149cd3ccce377d…  done
#10 exporting config sha256:741582a63fdc9b79…  done
#10 exporting attestation manifest sha256:39a7bf7fd2a9f404…  done
#10 exporting manifest list sha256:86d7dd680608501…  done

$ docker build -q --provenance=false -t abcl-di-np:1 .
sha256:741582a63fdc9b79e7a05c33eac26fa77ff74d6f6c5d46efc51240a33f933a9c
$ docker build -q --provenance=false -t abcl-di-np:2 .
sha256:741582a63fdc9b79e7a05c33eac26fa77ff74d6f6c5d46efc51240a33f933a9c

$ docker build -q --no-cache --provenance=false -t abcl-di-np:3 .
sha256:d1d876031ff9df639a87b28e23ba94ecc5bed0226a30a9119862835e2d4dd8b1

একই উৎস, cache চালু: হুবহু একই digest, মানে image একই। --no-cache দিলে ধাপগুলো আবার চলে, ফাইলের সময় বদলায়, তাই মূলত সেই কারণে COPY আর RUN-এর layer-এর digest বদলে গেল (WORKDIR-এর layer এই run-এ একই ছিল)। Provenance চালু থাকা build-এ সময় লেখা attestation যোগ হয়, তাই সব ধাপ CACHED হলেও ওপরের ID বদলাতে পারে; আমাদের run-এ তাই হয়েছিল। লেখার সময়ে Docker-এর এই ডিফল্ট setup-ভেদে আলাদা।

Pull-এর সময় শুধু যা নেই তা-ই নামে

Layer-এর digest থাকায় registry থেকে নামানোর সময় Docker আগে তালিকা মিলিয়ে নেয়। আমরা আগে python:3.12-alpine3.20 নামিয়েছিলাম, তারপর python:3.13-alpine3.20 নামালাম।

$ docker pull python:3.13-alpine3.20
3.13-alpine3.20: Pulling from library/python
cb733b4064f2: Pulling fs layer
74269e1557ed: Pulling fs layer
0648f32ed48b: Pulling fs layer
…
Digest: sha256:40a4559d3d6b2117b1fbe426f17d55b9100fa40609733a1d0c3f39e2151d4b33
Status: Downloaded newer image for python:3.13-alpine3.20

$ docker image inspect --format '{{json .RootFS.Layers}}' python:3.12-alpine3.20
["sha256:994456c4fd7b…", "sha256:742e38c37a3d…", "sha256:7b7e15815a1d…", "sha256:dbb969ba5eaf…"]
$ docker image inspect --format '{{json .RootFS.Layers}}' python:3.13-alpine3.20
["sha256:994456c4fd7b…", "sha256:e1c020a6a204…", "sha256:20f7784406b1…", "sha256:8cfa70d20f39…"]

Image-এ ৪টি layer, কিন্তু নেমেছে ৩টি। প্রথম layer (Alpine-এর base) দুটিতে একই digest, তাই সেটি আবার আনা হয়নি। অনেক Docker setup-এ এমন layer-এর পাশে Already exists ছাপা হয়। এই run-এর setup-এ সেই লাইন একেবারে আসেইনি। ধাপগুলো দেখুন:

python:3.13-alpine3.20 নামানো (আসল run-এর digest)
কাজ
Layer ১: 994456c4
Layer ২: e1c020a6
Layer ৩: 20f77844
Layer ৪: 8cfa70d2
নামানো layer
  1. Tag থেকে digest. Docker registry-কে জিজ্ঞেস করল, 3.13-alpine3.20 এখন কোন জিনিসকে দেখায়। উত্তর এল একটি digest, 40a4559d3d6b…. Tag নিছক একটি চলমান লেবেল।
  2. Platform বাছাই। ঐ digest একটি index-এর, যাতে নানা CPU architecture-এর তালিকা। Docker নিজের platform-এর manifest বেছে নিল।
  3. Manifest পড়া। Manifest জানাল config কোনটি, আর চারটি layer কী কী।
  4. মিলিয়ে দেখা। প্রতিটি layer-এর digest local store-এ খোঁজা হলো। Layer ১ আছে, কারণ 3.12-alpine3.20 একই Alpine base ব্যবহার করে। বাকি তিনটি নেই।
  5. শুধু তিনটি নামল। আউটপুটে Pulling fs layer তিনবারই ছাপা হয়েছে। Layer ১-এর জন্য একটি বাইটও network-এ যায়নি।
  6. যাচাই। নামা বাইটের hash আবার হিসাব করে digest-এর সাথে মেলানো হলো। মাঝপথে একটি বাইট নষ্ট হলে মিলবে না। তারপর layer-গুলো খোলা হলো।
  7. নাম বসানো। Local-এ tag-টি ঐ image-এর দিকে গেল, আর Status: Downloaded newer image ছাপা হলো। মনে রাখবেন, এই tag পরে অন্য digest-কে দেখাতে পারে; পর্ব ৪১-এ এটি নিয়ে বিস্তারিত থাকবে।

৭ · Build context, .dockerignore আর BuildKit

docker build .-এর শেষের বিন্দুটি অনেকেই খেয়াল করেন না। এটি হলো build context: যে ফোল্ডারের ফাইল build-এর সময় নাগালে থাকবে। Docker client আর build-এর কাজ যে করে, সে একই machine-এ না-ও থাকতে পারে। তাই দরকারি ফাইল তার কাছে পাঠাতে হয়। COPY শুধু context-এর ভেতর থেকে আনতে পারে।

ফোল্ডারে যদি বড় data dump, .git বা .env থাকে? .dockerignore ফাইল বলে দেয় কী পাঠানো হবে না। বিদেশে মাল পাঠানোর আগে গুদামঘরে বাছাই করার মতো: ভুল মাল ট্রাকে উঠলে ভাড়াও বেশি, ঝুঁকিও বেশি। মাপার জন্য একটি ফোল্ডারে ৫০ MiB-র data/, ৮ MiB-র .git-demo/ আর একটি নমুনা .env.sample রাখলাম। Dockerfile-এ ছিল COPY . ..

$ docker build --no-cache --progress=plain -t abcl-di-ctx:big .
…
#6 [internal] load build context
#6 transferring context: 60.83MB 4.0s done
…
$ docker run --rm abcl-di-ctx:big ls -a /app
.   ..   .env.sample   .git-demo   Dockerfile   app.py   data   requirements.txt

$ cat .dockerignore
.git-demo
data
.env*
Dockerfile
.dockerignore

$ docker build --no-cache --progress=plain -t abcl-di-ctx:small .
…
#4 [internal] load build context
#4 transferring context: 63B done
…
$ docker run --rm abcl-di-ctx:small ls -a /app
.   ..   app.py   requirements.txt

$ docker image inspect --format '{{.Size}}' abcl-di-ctx:big abcl-di-ctx:small
78890251
18053670
এই run-এ.dockerignore ছাড়া.dockerignore সহ
Context-এ পাঠানো৬০.৮৩ MB, ৪.০ সেকেন্ড৬৩ B
Image (content size)৭৮.৯ MB১৮.১ MB
Image-এ .env.sampleআছেনেই

শেষ সারিটিই সবচেয়ে গুরুত্বপূর্ণ। আকার তো বাড়েই, কিন্তু COPY . . ফোল্ডারের secret-ও image-এ তুলে দেয়। তাই .dockerignore শুধু গতির জন্য নয়, নিরাপত্তারও প্রথম ধাপ। ls-এর আউটপুট এখানে এক লাইনে সাজানো হয়েছে।

BuildKit — আজকের build যন্ত্র

BuildKit হলো Docker-এর build ইঞ্জিন, Linux-এ Docker Engine 23.0 (২০২৩) থেকে ডিফল্ট। আগের ইঞ্জিন Dockerfile-কে ওপর থেকে নিচে একটি একটি করে চালাত। BuildKit Dockerfile-কে কাজের একটি গ্রাফে রূপ দেয়। এতে কয়েকটি সুবিধা আসে:

  • শুধু দরকারি ফাইল: Context-এর যে ফাইল লাগবে সেগুলোই আনে, আর আগের পাঠানো অংশ মনে রাখে।
  • সমান্তরাল কাজ: একে অন্যের ওপর নির্ভর না করলে ধাপগুলো একসাথে চলে, যা multi-stage build-এ কাজে লাগে (পর্ব ৩৮)।
  • Secret mount: Build-এর সময় একটি secret ধার দেওয়া যায়, যা layer-এ থাকে না (পর্ব ৩৮)।
  • Remote cache: Cache একটি registry-তে রেখে অন্য CI machine-এ ব্যবহার করা যায় (পর্ব ৫৭)।

৮ · Build cache — কোন ধাপ আবার চলবে

প্রতিটি ধাপের ফল BuildKit জমিয়ে রাখে। পরের বার একই ধাপ এলে আগের layer-টি নেয়, কমান্ড আর চালায় না। এটিই build cache. কোনো ধাপ পুনরায় ব্যবহার হবে কি না, তা ঠিক হয় একটি cache key দিয়ে। নিয়ম তিনটি:

Cache-এর নিয়ম
  1. ওপরের ধাপ একই হতে হবে। Key-এর ভেতরে আগের ধাপের key থাকে। একটি ধাপ বদলালে নিচের সবগুলো বাতিল।
  2. COPY-র জন্য ফাইলের বিষয়বস্তু দেখা হয়, সময় নয়।
  3. RUN-এর জন্য দেখা হয় শুধু কমান্ডের লেখা। কমান্ড বাইরের জগৎ থেকে কী আনবে, তা Docker জানে না।

ঈদের আগে বাসের টিকিটের লাইনের কথা ভাবুন। সামনের একজন লাইন ভাঙলে পেছনের সবাইকে নতুন করে দাঁড়াতে হয়। Cache-ও তাই: ওপরের একটি ধাপ বদলালে নিচের কেউ আগের জায়গা রাখতে পারে না। তাই যিনি ঘন ঘন লাইন ছাড়েন, তাঁকে পেছনে রাখাই বুদ্ধির কাজ।

আসল পরীক্ষা ১: খারাপ ক্রম

এই Dockerfile-এ COPY . . আগে, pip পরে। app.py-তে একটি শব্দ বদলে আবার build করলাম।

FROM python:3.12-alpine
WORKDIR /app
COPY . .
RUN python -m pip install --no-index -r requirements.txt
EXPOSE 8000
CMD ["python", "app.py"]

$ docker build --progress=plain -t abcl-di-bad:5 .     # only app.py changed
…
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY . .
#7 DONE 0.0s
#8 [4/4] RUN python -m pip install --no-index -r requirements.txt
#8 DONE 1.7s

Source-এর একটি শব্দ বদলাতেই pip আবার চলল, যদিও dependency একই। এখানে ১.৭ সেকেন্ড, কারণ কিছু নামছে না। আসল app-এ এই ধাপ কয়েক মিনিটও নিতে পারে, আর তা প্রতিবার। নিচে ঘটনাটি ধাপে ধাপে দেখুন।

খারাপ ক্রমের Dockerfile, শুধু app.py বদলেছে
FROM
WORKDIR
COPY . .
RUN pip
EXPOSE
CMD
ওপরের key
  1. FROM. Base image-এর digest একই, তাই কিছু করার নেই।
  2. WORKDIR. ওপরের key একই, নির্দেশও একই। আউটপুটে CACHED.
  3. COPY . . হলো প্রথম miss. নির্দেশের লেখা একই, কিন্তু ফাইলের checksum বদলেছে, কারণ app.py এই ধাপের ইনপুট। ধাপটি আবার চলল, আর তৈরি হলো নতুন layer.
  4. RUN pip-ও বাতিল। Pip-এর কমান্ডের একটি অক্ষরও বদলায়নি, requirements.txt-ও একই। তবু ওপরের key বদলে যাওয়ায় Docker ধরে নেয়, ফল আলাদা হতে পারে। ধাপটি চলল।
  5. EXPOSE. ফাইল নেই, কিন্তু ওপরের ধাপ নতুন, তাই এখানেও পুরোনো ফল আর খাটে না। এর কোনো খরচ নেই।
  6. CMD. একই কারণে নতুন করে বসল।
  7. ফল। ছয়টির মধ্যে শুধু ওপরের দুটি বাঁচল। সমস্যাটি pip-এ নয়, ক্রমে: বদলাতে থাকা ফাইলটি ব্যয়বহুল ধাপের ওপরে বসে আছে।

আসল পরীক্ষা ২: ক্রম ঠিক করে

এবার আগে শুধু requirements.txt, তারপর pip, সবার শেষে source. একই বদল করলাম, তারপর dependency ফাইলটিও বদলালাম, আর শেষে শুধু touch চালিয়ে ফাইলের সময় বদলালাম।

FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt .
RUN python -m pip install --no-index -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]

$ docker build --progress=plain -t abcl-di-good:6 .    # only app.py changed
…
#6 [2/5] WORKDIR /app
#6 CACHED
#7 [3/5] COPY requirements.txt .
#7 CACHED
#8 [4/5] RUN python -m pip install --no-index -r requirements.txt
#8 CACHED
#9 [5/5] COPY app.py .
#9 DONE 0.0s

$ docker build --progress=plain -t abcl-di-good:7 .    # requirements.txt changed
…
#6 [2/5] WORKDIR /app
#6 CACHED
#7 [3/5] COPY requirements.txt .
#7 DONE 0.0s
#8 [4/5] RUN python -m pip install --no-index -r requirements.txt
#8 DONE 1.5s
#9 [5/5] COPY app.py .
#9 DONE 0.1s

$ touch app.py requirements.txt                        # new time, same bytes
$ docker build --progress=plain -t abcl-di-good:5 .
…
#7 [3/5] COPY requirements.txt .
#7 CACHED
#8 [4/5] RUN python -m pip install --no-index -r requirements.txt
#8 CACHED
#9 [5/5] COPY app.py .
#9 CACHED

Source বদলালে pip CACHED, শুধু শেষের COPY চলল। Dependency ফাইল বদলালে pip আবার চলল, এটিই ঠিক আচরণ, কারণ তখন সত্যিই নতুন কিছু দরকার। আর touch-এর পর সব CACHED, কারণ COPY ফাইলের বিষয়বস্তু দেখে, সময় নয়। মূল নীতি: যা কম বদলায় তা ওপরে, যা বেশি বদলায় তা নিচে।

এবার নিজে ক্রম বদলে দেখুন। নিচের সিমুলেটরে আসল নিয়মটিই আছে: একটি ধাপের key-এর ভেতরে ওপরের key আর তার ইনপুট ফাইলের version. সেকেন্ডগুলো ধরে নেওয়া। bad বা good তালিকার ক্রম বদলান, অথবা শেষের লাইনগুলোতে ফাইলের নাম বদলে আবার Run চাপুন।

// Build-cache simulator. Seconds are assumed. Reorder a list, then press Run.
const STEP = {                 // name: [input files, assumed seconds]
  'FROM python':     [[], 0], 'WORKDIR /app': [[], 0],
  'COPY reqs':       [['requirements.txt'], 1],
  'COPY app.py':     [['app.py'], 1],
  'COPY . .':        [['requirements.txt', 'app.py', 'README.md'], 1],
  'RUN pip install': [[], 90],
  'CMD python':      [[], 0],
};
const bad  = ['FROM python', 'WORKDIR /app', 'COPY . .', 'RUN pip install', 'CMD python'];
const good = ['FROM python', 'WORKDIR /app', 'COPY reqs', 'RUN pip install', 'COPY app.py', 'CMD python'];
function keys(order, version) {            // each key contains the key above it
  let above = '';
  return order.map(name => above += ' > ' + name + ' '
    + STEP[name][0].map(f => f + '=' + (version[f] || 1)));
}
function rebuild(title, order, changed) {
  const cache = new Set(keys(order, {}));  // the first build fills the cache
  const version = {};
  changed.forEach(f => version[f] = 2);
  let total = 0, broken = false;
  console.log('== ' + title + ' | changed: ' + (changed.join(', ') || 'nothing'));
  keys(order, version).forEach((key, i) => {
    const hit = cache.has(key), secs = hit ? 0 : STEP[order[i]][1];
    total += secs;
    const why = hit ? '' : broken ? '(a step above changed)' : '(input changed)';
    console.log(order[i].padEnd(16), hit ? 'CACHED' : 'RUN ' + secs + 's', why);
    broken = broken || !hit;
  });
  console.log('total: ' + total + 's');
}
rebuild('bad order', bad, ['app.py']);
rebuild('good order', good, ['app.py']);
rebuild('good order', good, ['requirements.txt']);
rebuild('good order', good, ['README.md']);

খারাপ ক্রমে app.py বদলালে total: 91s, ভালো ক্রমে total: 1s, যদিও বদলটি একই। ভালো ক্রমে requirements.txt বদলালে total: 92s, কারণ তখন pip-এর সত্যিই চলা দরকার। শেষের README.md-র বেলায় total: 0s, কারণ ভালো ক্রমে কোনো COPY ঐ ফাইলটি পড়েই না। bad তালিকায় ঐ বদল করে দেখুন: COPY . . সব ফাইল পড়ে, তাই README-র একটি শব্দেও পুরো ৯১ সেকেন্ড লাগবে। এই কারণেই .dockerignore-এ অপ্রয়োজনীয় ফাইল বাদ দিলে cache-ও ভালো থাকে।

Cache আপনাকে কখন ঠকায়

তৃতীয় নিয়মটি মনে করুন: RUN-এর cache শুধু কমান্ডের লেখা দেখে। তাই RUN apk add curl মাসের পর মাস CACHED থাকতে পারে, ততদিনে package-এর নিরাপত্তা patch এসে গেছে। FROM-এর tag-ও একই রকম: local-এ যা আছে, BuildKit তা-ই নিতে পারে। প্রতিকার হলো নির্দিষ্ট সময়ে docker build --pull --no-cache চালিয়ে নতুন করে বানানো, যেমন প্রতি সপ্তাহে। পর্ব ১৬-র image patch-এর ছন্দের সাথে এটি মেলে। কখন নতুন করে বানাবেন তা নিজে ঠিক করে দেওয়াই এখানে cache invalidation-এর বাস্তব রূপ।

৯ · পরের layer-এ মুছলে image ছোট হয় না

নথির কথা মনে আছে? “আগের অনুচ্ছেদ বাতিল” লিখলে আগের পাতাটি থেকেই যায়। দুটি image বানালাম, দুটিই ৩০ MiB-র একটি random ফাইল বানিয়ে মুছে ফেলে। পার্থক্য শুধু একটি: মোছা হচ্ছে পরের RUN-এ, না একই RUN-এ।

# Dockerfile.trap
FROM alpine:3.20
RUN dd if=/dev/urandom of=/big.bin bs=1M count=30
RUN rm /big.bin

# Dockerfile.oneline
FROM alpine:3.20
RUN dd if=/dev/urandom of=/big.bin bs=1M count=30 && rm /big.bin

$ docker history abcl-di-trap:1
IMAGE          CREATED BY                                      SIZE
c13ce53a6a88   RUN /bin/sh -c rm /big.bin # buildkit           4.1kB
<missing>      RUN /bin/sh -c dd if=/dev/urandom of=/big.bi…   31.5MB
…
$ docker history abcl-di-oneline:1
IMAGE          CREATED BY                                      SIZE
20ccf9fa62ea   RUN /bin/sh -c dd if=/dev/urandom of=/big.bi…   4.1kB
…
$ docker image inspect --format '{{.Size}}' abcl-di-trap:1 abcl-di-oneline:1
35099407
3631926
$ docker run --rm abcl-di-trap:1 ls -la /big.bin
ls: /big.bin: No such file or directory

Container-এর ভেতরে দুটিতেই ফাইলটি নেই। তবু একটি image ৩৫.১ MB, অন্যটি ৩.৬৩ MB, প্রায় দশ গুণ! ফাইলটি কি সত্যিই আছে? docker save দিয়ে image একটি tar-এ নামিয়ে layer-গুলো খুলে দেখলাম। নিচের সারাংশ আমাদের একটি ছোট script-এর:

layer 1 (alpine)     3,630,321 bytes   517 entries
layer 2 (RUN dd)    31,467,054 bytes   big.bin      31,457,280 bytes
layer 3 (RUN rm)            77 bytes   .wh.big.bin           0 bytes

এই তিন লাইনই পুরো গল্প। দ্বিতীয় layer-এ big.bin পুরো আছে। তৃতীয় layer-এ শুধু .wh.big.bin, মানে whiteout চিহ্ন: “নিচের big.bin আর দেখানো হবে না”। ফাইল কেবল চোখের আড়ালে গেছে, image-এর ভেতর থেকে যায়নি।

এর একটি নিরাপত্তার দিক আছে। Password, private key বা .env একটি layer-এ ঢুকে পরের layer-এ মুছে গেলেও সেটি image-এ থেকে যায়। Image যে নামাতে পারেন, তিনি layer-ও পড়তে পারেন। তাই secret ভুলে ঢুকে গেলে শুধু image মুছলে চলবে না: secret-টি বদলে ফেলুন (পর্ব ৩৩), তারপর নতুন করে build করুন। Secret build-এ না ঢোকানোর পথ পর্ব ৩৮-এ।

একটি আরও সূক্ষ্ম ফাঁদ আছে। পরের RUN-এ শুধু chmod করলেও পুরো ফাইল আবার layer-এ যায়, কারণ layer ফাইল ধরে রাখে, ফাইলের টুকরো নয়। আসল মাপে: ২০ MiB-র ফাইলের পর RUN chmod 600 /data.db দিতেই docker history-তে দুটি ধাপই 21MB দেখাল, আর image হলো ৪৫.৬ MB.

নিচের ক্যালকুলেটরে একেকটি { } মানে একটি layer. add ফাইল যোগ করে, del মোছে, touch ফাইলটি বদলায়। প্রথম তিনটি পরিকল্পনার আকার আসল run-এর docker history থেকে নেওয়া, পরের দুটি ধরে নেওয়া। নিজের পরিকল্পনা যোগ করে Run চাপুন।

// Layer-size calculator. Sizes in MB, uncompressed. Edit a plan and press Run.
const BASE = 8.47;                         // alpine:3.20, from a real run
const plans = {                            // one { } = one RUN = one layer
  'dd, then rm in the next RUN': [{ add: { 'big.bin': 31.5 } }, { del: ['big.bin'] }],
  'dd && rm in the same RUN':    [{ add: { 'big.bin': 31.5 }, del: ['big.bin'] }],
  'chmod in a later RUN':        [{ add: { 'data.db': 21 } }, { touch: ['data.db'] }],
  'install + clean, 2 RUNs':     [{ add: { 'tools': 120, 'cache': 60 } }, { del: ['cache'] }],
  'install + clean, 1 RUN':      [{ add: { 'tools': 120, 'cache': 60 }, del: ['cache'] }],
};
for (const [name, layers] of Object.entries(plans)) {
  const seen = {};                         // what a container can see
  let stored = BASE;                       // what the image must carry
  for (const layer of layers) {
    const here = { ...layer.add };
    for (const f of layer.touch || []) here[f] = seen[f];   // whole file again
    for (const f of layer.del || []) { delete here[f]; delete seen[f]; }
    Object.assign(seen, here);
    stored += Object.values(here).reduce((a, b) => a + b, 0);
  }
  const visible = BASE + Object.values(seen).reduce((a, b) => a + b, 0);
  console.log(name.padEnd(28), 'stored', stored.toFixed(2).padStart(7),
    '| visible', visible.toFixed(2).padStart(7),
    '| wasted', (stored - visible).toFixed(2).padStart(7));
}

প্রথম সারি: stored 39.97, কিন্তু visible 8.47, মানে wasted 31.50 MB. দ্বিতীয় সারিতে একই কাজ একই RUN-এ: নষ্ট 0.00. তৃতীয় সারি: chmod-এ 21.00 MB নষ্ট, কারণ পুরো ফাইল আবার গেছে। শেষ দুটি সারি বাস্তব অভ্যাস: package বসিয়ে cache পরিষ্কার করা। আলাদা RUN-এ wasted 60.00, একই RUN-এ 0.00. সংখ্যাগুলো সংকোচনহীন, আর whiteout-এর কয়েক বাইট এখানে ধরা হয়নি।

তাই বসানো আর পরিষ্কার করা একই RUN-এ রাখুন, && দিয়ে জুড়ে। কিন্তু সব কিছু এক RUN-এ ঠেসে দিলে cache-এর সুবিধা কমে; ভারসাম্য নিয়ে ভাবনার প্রশ্নে কথা হবে। পরিষ্কার সমাধান হলো multi-stage build, যা পর্ব ৩৮-এ আসছে।

আরও গভীরে: OCI image spec-এ একটি layer হলো একটি tar ফাইল, আর মোছার চিহ্ন হলো .wh. দিয়ে শুরু নামের ফাইল; এই রীতি AUFS থেকে এসেছে। চলার সময় Linux overlayfs নিজে অন্য ধরনের whiteout ব্যবহার করে (০/০ নম্বরের character device), তাই layer খোলার সময় একটি থেকে অন্যটিতে রূপান্তর হয়। DiffID হলো সংকোচনহীন tar-এর hash, আর registry-র digest হলো সংকুচিত tar-এর hash, তাই একই layer-এর দুটি hash থাকে। Layer-এর পরম্পরা বোঝাতে ChainID ব্যবহার হয়। লেখার সময়ে নতুন Docker install-এ containerd-এর image store ডিফল্ট হচ্ছে, যা docker image ls-এর কলাম আর pull-এর আউটপুটের কিছু পার্থক্যের কারণ।

পরীক্ষা ও ইন্টারভিউতে:

  • RUN আর CMD-এর পার্থক্য? RUN build-এর সময় চলে আর layer বানায়; CMD container চালুর সময় চলে আর শুধু config-এ থাকে।
  • CMD আর ENTRYPOINT? ENTRYPOINT মূল প্রোগ্রাম, CMD তার ডিফল্ট argument; docker run-এর শেষের লেখা শুধু CMD-কে বদলায়।
  • Image আর container-এর পার্থক্য? Image read-only layer-এর সারি; container তার ওপর একটি writable layer আর একটি চলমান process.
  • Build দ্রুত করবেন কীভাবে? কম বদলানো ধাপ ওপরে, dependency ফাইল আগে COPY, .dockerignore, আর CI-তে remote cache.
  • পরের RUN-এ মুছলে আকার কমে না কেন? আগের layer read-only; নতুন layer-এ শুধু whiteout বসে, আসল বাইট আগের layer-এই থাকে।

১০ · AI-এর যুগে Dockerfile — দ্রুত লেখা, সাবধানে যাচাই

AI কয়েক সেকেন্ডে একটি Dockerfile লিখে দেয়, আর তা প্রায়ই চলেও। কিন্তু “চলে” মানে “ভালো” নয়। Cache-এর ক্রম, secret আর আকারের ভুল কোনো error দেখায় না, চুপচাপ খরচ আর ঝুঁকি বাড়ায়। নিচের ভুলগুলো খুব পরিচিত।

AI-এর ভুলফলযাচাই
FROM python:latestকখন কী আসবে জানা নেই; build বদলাতে থাকেনির্দিষ্ট version, প্রয়োজনে digest
শুরুতেই COPY . .প্রতিবার dependency আবার; .env image-এক্রম বদলান, .dockerignore লিখুন
ENV API_KEY=…, বা ARG-এ secretdocker history আর inspect-এ দেখা যায়Secret image-এ নয়, চলার সময় দিন
পরের RUN-এ rmআকার কমে নাdocker history দেখুন
CMD npm start বা shell formSignal পৌঁছায় না, exit 137Exec form; docker stop করে exit code দেখুন
অচেনা flag বা নির্দেশBuild ব্যর্থ, বা ভুল ধারণাOfficial Dockerfile reference মিলিয়ে নিন
পরিষ্কারের জন্য docker system prune -aঅন্যের image আর cache-ও মুছতে পারেআগে দেখুন কী মুছবে; নাম ধরে মুছুন

AI-কে কাজ দেওয়ার সময় সীমা আগেই বলে দিন, আর ফল আপনার নিজের machine-এ মেপে নিন। Prompt-এ কোনো আসল key, password বা ঠিকানা দেবেন না।

তুমি একজন সতর্ক DevOps প্রকৌশলী। একটি Python web service-এর জন্য Dockerfile আর .dockerignore লেখো। শর্ত: ১. Base image-এ নির্দিষ্ট version দেবে, latest নয়। ২. requirements.txt আগে COPY করে dependency বসাবে, source পরে, যাতে build cache কাজে লাগে। ৩. কোনো secret ENV, ARG বা COPY-তে থাকবে না। .env, .git আর data ফোল্ডার .dockerignore-এ থাকবে। ৪. বসানো আর পরিষ্কার একই RUN-এ হবে। ৫. CMD exec form-এ হবে। ৬. প্রতিটি লাইন কেন, এক বাক্যে বলো। কোনো flag নিয়ে নিশ্চিত না হলে বলে দেবে। ৭. শেষে বলো, কোন কমান্ডে layer-এর আকার আর cache যাচাই করব। কোনো মোছার কমান্ড প্রস্তাব করলে আগে সতর্ক করবে।

AI-এর লেখা Dockerfile হাতে পেলে — চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১সব ধাপ CACHED, build সবুজ। তবু image-এ মাসখানেক পুরোনো একটি নিরাপত্তা-ত্রুটি রয়ে গেল। এটি কীভাবে সম্ভব?

Build cache একটি প্রতিশ্রুতি দেয় না: “ফল আজকের দুনিয়ার সাথে মেলে”। সে শুধু বলে, “এই ধাপের ইনপুট আগের মতোই, তাই আগের ফল নিলাম।” কিন্তু RUN apk add curl-এর আসল ইনপুট শুধু কমান্ডের লেখা নয়। এর আসল ইনপুট হলো সেই মুহূর্তে package repository-তে কোন version আছে। সেটি Docker দেখতে পায় না। কমান্ডের লেখা একই থাকায় cache key-ও একই।

একই রকম একটি পুরোনো ফাঁদ Docker-এর best practice ডকুমেন্টে বলা আছে: apt-get update আলাদা RUN-এ রাখলে সেটি cache হয়ে যায়। পরে apt-get install-এর লাইন বদলালে পুরোনো তালিকা দিয়েই install চলে, আর package না পাওয়ার ঝুঁকিও থাকে। তাই দুটিকে একই RUN-এ রাখার পরামর্শ দেওয়া হয়।

আরেকটি উৎস হলো base image. FROM python:3.12-alpine লিখলে local-এ যা আছে BuildKit তা-ই নিতে পারে। অথচ registry-তে এই tag হয়তো কয়েকবার নতুন হয়েছে। এই পর্বের run-এ docker history-র CREATED কলাম দেখাচ্ছিল base মাত্র তিন দিনের পুরোনো: tag-গুলো নিয়মিত নতুন করে বানানো হয়।

সমাধান দুটি বিপরীত দিকের মধ্যে ভারসাম্য। একদিকে build পুনরুৎপাদনযোগ্য রাখতে চান, তাই version আর digest pin করেন। অন্যদিকে patch চান, তাই নিয়মিত নতুন করে build করেন। বাস্তব অভ্যাস হলো: সাধারণ build-এ cache চালু, আর একটি নির্ধারিত pipeline-এ সপ্তাহে একবার --pull --no-cache. সাথে একটি image scanner (পর্ব ৪১), যা চলমান image-এ পরে ধরা পড়া দুর্বলতা জানাবে। Renovate বা Dependabot-এর মতো হাতিয়ার pin করা version বদলের প্রস্তাবও পাঠাতে পারে।

মূল উপলব্ধি: Cache জানে আপনি কী লিখেছেন, দুনিয়া কী বদলেছে তা জানে না। তাই “build সফল” আর “image আজ নিরাপদ” দুটি আলাদা প্রশ্ন, আর দ্বিতীয়টির জন্য আলাদা, নির্ধারিত ব্যবস্থা লাগে।

প্র ০২একটি বড় ফাইল হলেই তো সহজ হতো। Image-কে স্তরে ভাগ করার ঝামেলা কেন নেওয়া হলো, আর এর দামই বা কী?

Docker-এর আগে VM image প্রায়ই ছিল একটি বিশাল disk ফাইল। এক লাইন বদলালেও গিগাবাইট পাঠাতে হতো। Layer এই সমস্যাকে তিন দিক থেকে কমায়। প্রথমত, build: শুধু বদলানো ধাপ চলে। দ্বিতীয়ত, বিতরণ: registry থেকে শুধু অনুপস্থিত layer নামে, যেমন এই পর্বে Alpine-এর base আবার নামেনি। তৃতীয়ত, চলার সময়: একই layer শত container ভাগ করে, আর memory-র page cache-ও ভাগ হয়।

এর অর্থনৈতিক ফল বড়। একটি প্রতিষ্ঠানের শত service একই base থেকে এলে সেই base registry-তে একবারই থাকে। Git-এর object store আর Nix-এর store-ও এই একই content-addressed চিন্তা ব্যবহার করে।

দামও আছে। প্রথমত, মোছার ফাঁদ, যা এই পর্বে দেখেছেন: স্তর শুধু বাড়তে পারে। দ্বিতীয়ত, layer ভাগ হয় পুরো ফাইল ধরে, ফাইলের টুকরো ধরে নয়। একটি বড় jar বা binary-তে এক বাইট বদলালেও পুরোটা নতুন। তৃতীয়ত, overlay-তে বড় ফাইলের প্রথম লেখা ধীর, কারণ copy-up লাগে।

চতুর্থ দামটি নিয়ে গবেষণা আছে। Harter ও সহলেখকদের Slacker গবেষণাপত্র (USENIX FAST ২০১৬) জানিয়েছিল, তাদের মাপে container চালুর সময়ের বড় অংশ, প্রায় তিন-চতুর্থাংশ, যায় image নামাতে, অথচ চালুর সময় পড়া হয় তার খুব ছোট অংশ। এই পর্যবেক্ষণ থেকে lazy pulling-এর ধারণা জোরালো হয়: আগে শুধু দরকারি ফাইল আনা, বাকিটা পরে। eStargz বা SOCI-র মতো প্রকল্পগুলো এই পথে কাজ করে। সংখ্যাগুলো তখনকার workload-এর, আজকের সাথে হুবহু না-ও মিলতে পারে।

মূল উপলব্ধি: Layer হলো একটি নকশার সিদ্ধান্ত, বিনামূল্যের সুবিধা নয়। এটি ভাগাভাগিকে সস্তা করে, আর ইতিহাস মোছাকে অসম্ভব করে। ভালো Dockerfile লেখা মানে এই বিনিময়টি নিজের পক্ষে কাজে লাগানো।

প্র ০৩একজন ডেভেলপার ভুলে .env COPY করে পরের RUN-এ মুছে দিলেন, image public registry-তে গেল। এখন কী করা উচিত, আর এমন ঘটা ঠেকাবেন কীভাবে?

প্রথম কথা: ধরে নিন secret ফাঁস হয়ে গেছে। আগের অংশে নিজের চোখে দেখেছেন, পরের layer-এ মোছা ফাইল image-এর ভেতরেই থাকে। Image public হওয়া মানে যে কেউ সব layer পেতে পারেন। কেউ সত্যিই দেখেছেন কি না, তা প্রায়ই জানা যায় না। তাই দায়িত্বশীল ধরে নেওয়া হলো “ফাঁস হয়েছে”।

করণীয়ের ক্রম গুরুত্বপূর্ণ। আগে secret বাতিল করে নতুন বানান (rotation, পর্ব ৩৩)। Image মোছা পরে, কারণ মুছলেও কেউ আগে নামিয়ে থাকতে পারেন, আর registry-র mirror বা cache-এও কপি থাকতে পারে। তারপর audit log দেখুন, ঐ secret দিয়ে অস্বাভাবিক কিছু হয়েছে কি না (পর্ব ৩৪)। শেষে Dockerfile ঠিক করে নতুন image বানান, আর ঘটনাটি দোষারোপ ছাড়া লিখে রাখুন (পর্ব ৬৭)।

সমস্যাটি বিরল নয়। ২০২৩ সালে Dahlmanns ও সহলেখকদের একটি গবেষণা (ACM ASIA CCS) public container image-এ private key আর API secret-এর ব্যাপক উপস্থিতি জানিয়েছিল, যার অনেকগুলো সত্যিই ব্যবহার হচ্ছিল। সঠিক সংখ্যার জন্য মূল গবেষণাপত্রটি দেখুন।

ঠেকানোর উপায় কয়েক স্তরে। প্রথম স্তর, .dockerignore এমনভাবে লিখুন যাতে .env* কখনো context-এ না যায়। দ্বিতীয়, COPY . . এড়িয়ে নির্দিষ্ট ফাইল আনুন। তৃতীয়, build-এ secret লাগলে BuildKit-এর secret mount ব্যবহার করুন (পর্ব ৩৮)। চতুর্থ, CI-তে secret scanning চালান, যা শেষ image নয়, প্রতিটি layer দেখে। পঞ্চম, app-কে চলার সময় secret manager বা environment থেকে secret দিন।

মূল উপলব্ধি: Layer-এর জগতে “মুছে ফেলা” বলে কিছু নেই, শুধু “ঢেকে রাখা” আছে। তাই নিরাপত্তার আসল কাজ দুটি: secret-কে build-এ ঢুকতেই না দেওয়া, আর ঢুকলে তাকে অকেজো করে দেওয়া।

প্র ০৪Tag দিয়েই তো কাজ চলে। Digest-এর এত গুরুত্ব কেন, আর একই source থেকে কি হুবহু একই digest পাওয়া সম্ভব?

Tag একটি চলমান লেবেল, রেলের “ঢাকা মেইল” নামের মতো: নাম একই, আজকের বগি অন্য। python:3.12-alpine আজ এক digest-কে দেখায়, কাল অন্যটিকে। তাই দুটি machine একই tag দিয়ে আলাদা জিনিস চালাতে পারে। Digest বদলায় না, কারণ সেটি বিষয়বস্তু থেকেই হিসাব হয়।

ইতিহাসেও বদলটি এসেছে ধীরে। Docker-এর শুরুর দিকে layer-এর ID ছিল random, বিষয়বস্তু থেকে হিসাব করা নয়। Docker 1.10 (২০১৬) content-addressable ID-তে যায়, যাতে একই বিষয়বস্তু সব জায়গায় একই নাম পায় আর যাচাই করা যায়। পেছনের ধারণাটি পুরোনো: Ralph Merkle-এর hash-এর গাছ, ১৯৭০-এর দশকের শেষের কাজ। Git-ও এই গাছ ব্যবহার করে।

Digest-এ pin করলে আপনি জানেন ঠিক কী চলছে, আর registry বদলে দিলেও ধরা পড়বে। কিন্তু একটি দামও আছে: pin করা digest নিজে থেকে patch পায় না। তাই pin-এর সাথে নিয়মিত বদলের স্বয়ংক্রিয় ব্যবস্থা লাগে।

দ্বিতীয় প্রশ্নের উত্তর: সম্ভব, কিন্তু নিজে থেকে হয় না। এই পর্বের run-এ --no-cache দিতেই একই source থেকে আলাদা digest এল। Python ব্লকেও দেখেছেন, লেখা এক হলেও ফাইলের সময় বদলালে hash বদলায়। Layer-এর tar-এ সময়, মালিক আর ফাইলের ক্রম থাকে। তার সাথে RUN-এ নামা package-এর version, build-এর সময় আর তৈরি হওয়া bytecode-ও যুক্ত হয়।

Reproducible builds আন্দোলন এই সমস্যা নিয়েই কাজ করে। SOURCE_DATE_EPOCH নামের একটি প্রচলিত রীতিতে সময় স্থির করা হয়, আর সাম্প্রতিক BuildKit-এ layer-এর সময় সেই মানে বেঁধে দেওয়ার সুবিধা এসেছে। এতে অন্য কেউ একই source থেকে build করে মিলিয়ে দেখতে পারেন, supply chain-এর জন্য যা খুব মূল্যবান (পর্ব ৪১, ৬১)।

মূল উপলব্ধি: Tag মানুষের জন্য, digest machine-এর জন্য। Production-এ যা চলছে, তা digest ধরে জানুন। আর “একই source” মানে “একই বাইট” নয়, যতক্ষণ না আপনি সেটি প্রকৌশল করে নিশ্চিত করছেন।

প্র ০৫পরের layer-এ মোছা যখন ফাঁদ, তখন সব কিছু একটি RUN-এ লিখলে বা শেষে সব layer এক করে দিলেই কি সেরা হয়?

না। Layer-এর সংখ্যা কমানো নিজে কোনো লক্ষ্য নয়। লক্ষ্য হলো মোট পাঠানো বাইট আর build-এর সময় কমানো, আর সেখানে layer দুই দিকেই কাজ করে।

ধরুন একটি RUN-এ OS package, Python dependency আর source সবই আছে। আকার হয়তো একটু কমল। কিন্তু এখন source-এর এক লাইন বদলালে পুরো ধাপটি আবার চলবে। প্রতি deploy-এ পুরো layer আবার registry-তে যাবে, আর প্রতিটি server পুরোটা নতুন করে নামাবে। আলাদা layer থাকলে নামত শুধু কয়েক kB.

একই যুক্তি squash-এর ক্ষেত্রেও। Layer গুঁড়িয়ে এক করলে পরের layer-এ মোছা জিনিস সত্যিই বাদ পড়ে। কিন্তু base-এর সাথে ভাগাভাগি শেষ। ধরুন ৫০টি service একই ১০০ MB base ব্যবহার করে। আলাদা layer-এ একটি node-এ সেটি একবার থাকে; squash করলে হতে পারে ৫০ × ১০০ MB = ৫ GB. তাছাড়া secret ফাঁসের প্রতিকার হিসেবে squash ঠিক পথ নয়, কারণ build-এর cache-এ আর পুরোনো image-এ সেটি থেকে যেতে পারে। Docker-এর classic builder-এ একটি পরীক্ষামূলক --squash ছিল, যা সব setup-এ চলে না।

ভালো কৌশল হলো বদলের হার অনুযায়ী layer সাজানো। Base সবচেয়ে কম বদলায়, তারপর OS package, তারপর dependency, সবার শেষে source. প্রতিটি শ্রেণির জন্য একটি করে স্তর, আর প্রতিটির ভেতরে বসানো ও পরিষ্কার একসাথে।

Build-এর হাতিয়ার (compiler, header) শেষ image-এ রাখার দরকার নেই। সেখানে multi-stage build সবচেয়ে পরিষ্কার: একটি ধাপে বানিয়ে, শুধু ফলটুকু একটি পরিষ্কার base-এ আনা (পর্ব ৩৮)। তাতে মোছার দরকারই পড়ে না।

মূল উপলব্ধি: প্রশ্ন “কয়টি layer” নয়। প্রশ্ন হলো “একটি সাধারণ বদলে কত বাইট আবার বানাতে আর পাঠাতে হয়”। সেই সংখ্যা মাপুন, layer গুনবেন না।

অনুশীলন

প্রথম তিনটি কাগজ-কলমেই হবে। চতুর্থটি চাইলে নিজের Docker-এ করতে পারেন; এটি ঐচ্ছিক, cloud account লাগবে না।

  1. হিসাব করুন: একটি CI-তে দিনে ৪০টি build হয়, প্রতিটিতে শুধু source বদলায়। ধরে নিই, dependency-র ধাপে ৯০ সেকেন্ড, source COPY-তে ১ সেকেন্ড লাগে, আর dependency layer ১৫০ MB. খারাপ ক্রমে প্রতিবার সব চলে ও নতুন dependency layer registry-তে যায়। ভালো ক্রমে dependency শুধু মাসে ৪ বার বদলায়। ৩০ দিনের মাসে হিসাব করুন: (ক) কত CI সময় বাঁচে? (খ) মিনিটপ্রতি খরচ ধরে নেওয়া $০.০১ হলে কত? (গ) Registry-তে নতুন dependency layer কত GB যায়, দুই ক্রমে? (৪ বারের বাড়তি CI সময় উপেক্ষা করুন।)

    (ক) প্রতি build-এ বাঁচে ৯১ − ১ = ৯০ সেকেন্ড। ৪০ × ৩০ = ১,২০০ build. ১,২০০ × ৯০ = ১,০৮,০০০ সেকেন্ড = ৩০ ঘণ্টা = ১,৮০০ মিনিট।

    (খ) ১,৮০০ × ০.০১ = মাসে $১৮. অঙ্কটি ছোট, কিন্তু ডেভেলপারের অপেক্ষার সময় এর চেয়ে অনেক দামী।

    (গ) খারাপ ক্রমে ১,২০০ × ১৫০ MB = ১,৮০,০০০ MB = ১৮০ GB. ভালো ক্রমে ৪ × ১৫০ MB = ৬০০ MB = ০.৬ GB. পার্থক্য ৩০০ গুণ। সংখ্যাগুলো ধরে নেওয়া; registry-র আসল দাম provider-এর পাতায় দেখুন।

  2. ফলাফল অনুমান করুন: Image X-এর Dockerfile: FROM alpine:3.20, ENTRYPOINT ["echo", "A"], CMD ["B"]. (ক) docker run --rm X, (খ) docker run --rm X C, (গ) docker run --rm --entrypoint echo X D, (ঘ) docker run --rm --entrypoint echo X কী ছাপবে? তারপর image Y: ৫ MB-র একটি base (১টি layer), এরপর পরপর চারটি RUN: ১০০ MB-র a.bin বানানো; chmod 600 a.bin; rm a.bin; ১ MB-র b.txt বানিয়ে একই RUN-এ মুছে ফেলা। Y-তে কয়টি layer, মোট সংকোচনহীন আকার প্রায় কত, আর container কত দেখে? (ছোট চিহ্নের আকার উপেক্ষা করুন।)

    (ক) A B. (খ) A C, কারণ argument শুধু CMD-কে সরায়। (গ) D, কারণ ENTRYPOINT বদলে গেছে। (ঘ) একটি ফাঁকা লাইন, কারণ --entrypoint দিলে image-এর CMD-ও বাদ পড়ে, তাই B ছাপা হয় না। একই ধরনের image-এ এই আচরণ আসল run-এ মিলিয়ে দেখা হয়েছে।

    Y: layer ১ + ৪ = ৫টি। আকার ৫ + ১০০ + ১০০ + ০ + ০ = প্রায় ২০৫ MB. কারণ chmod-এ পুরো ফাইল আবার গেছে, rm শুধু চিহ্ন, আর একই RUN-এর b.txt layer-এ ওঠেইনি। Container দেখে মাত্র ৫ MB, মানে ২০০ MB নষ্ট।

  3. সমালোচনা করুন: নিচের Dockerfile-এ অন্তত ৬টি সমস্যা ধরুন, আর ভালো রূপটি লিখুন।
    FROM python:latest
    COPY . .
    ENV API_KEY=sample-not-real
    RUN apt-get update
    RUN apt-get install -y gcc
    RUN pip install -r requirements.txt
    RUN rm -rf /root/.cache
    CMD python app.py
    • latest: কী আসবে তা অনিশ্চিত। নির্দিষ্ট version দিন।
    • COPY . . শুরুতে: প্রতিটি source বদলে dependency আবার। .dockerignore-ও নেই, তাই .env বা .git ঢুকতে পারে।
    • ENV API_KEY: docker history আর inspect-এ সবাই দেখবেন। Secret চলার সময় দিন।
    • apt-get update আলাদা RUN-এ: পুরোনো তালিকা cache হয়ে থাকবে। update আর install একসাথে দিন।
    • পরের RUN-এ rm: আকার কমে না। পরিষ্কার একই RUN-এ।
    • gcc শেষ image-এ: বাড়তি আকার আর আক্রমণের জায়গা। দরকার হলে multi-stage (পর্ব ৩৮)।
    • Shell form CMD: Exec form দিন, যাতে signal পৌঁছায়।
    FROM python:3.12-slim
    WORKDIR /app
    COPY requirements.txt .
    RUN pip install --no-cache-dir -r requirements.txt
    COPY app.py .
    CMD ["python", "app.py"]

    এই রূপটি নমুনা, চালিয়ে দেখা হয়নি; ধরে নেওয়া হয়েছে dependency-গুলোর compiler লাগে না। Non-root user পর্ব ৩৮-এ যোগ হবে।

  4. কমান্ড লিখুন: নিজের Docker-এ এই পর্বের delete-এর ফাঁদটি পুনরায় তৈরি করতে চান। ছোট Dockerfile-সহ কমান্ড লিখুন, যা (ক) image বানাবে, (খ) সবচেয়ে বড় ধাপ দেখাবে, (গ) আকার মাপবে, (ঘ) শুধু নিজের image-টি নাম ধরে মুছবে। Local Docker-এ cloud-এর খরচ নেই, তবে disk-এর জায়গা লাগবে।
    cat > Dockerfile <<'EOF'
    FROM alpine:3.20
    RUN dd if=/dev/urandom of=/big.bin bs=1M count=30
    RUN rm /big.bin
    EOF
    docker build -t my-trap:1 .
    docker history my-trap:1
    docker image inspect --format '{{.Size}}' my-trap:1
    docker rmi my-trap:1

    docker rmi-তে নাম দেওয়া হয়েছে, তাই শুধু এই image-টিই যাবে। docker system prune এখানে দরকার নেই, কারণ তা অন্য জিনিসও মুছতে পারে। Build-এর cache কিছু জায়গা ধরে রাখবে; docker system df-এর Build Cache সারিতে সেটি দেখা যায়।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমdocker pull-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Registry থেকে tag-এর digest জানা
  2. Index থেকে নিজের platform-এর manifest বাছাই
  3. Manifest থেকে config আর layer-এর তালিকা পড়া
  4. Local store-এ প্রতিটি layer-এর digest খোঁজা
  5. শুধু অনুপস্থিত layer নামানো ও hash যাচাই
  6. Layer খুলে local tag বসানো

একটি Dockerfile-এর ক্রম: FROM, COPY . ., RUN pip install -r requirements.txt, CMD. শুধু app.py-র একটি লাইন বদলে আবার build করলে কী হবে?

  • শুধু COPY আবার চলবে, pip CACHED থাকবে
  • COPY আর তার নিচের সব ধাপ আবার চলবে
  • Base image সহ সব কিছু নতুন করে নামবে
  • কিছুই আবার চলবে না, সব CACHED

একটি Dockerfile-এ RUN দিয়ে ৫০০ MB-র ফাইল বানানো হলো, আর ঠিক পরের RUN-এ ফাইলটি মোছা হলো। Image-এর আকারে কী হবে?

  • কিছুই বাড়বে না, কারণ ফাইলটি মোছা হয়েছে
  • প্রায় ২৫০ MB বাড়বে
  • প্রায় ৫০০ MB বাড়বে, যদিও container-এ ফাইলটি দেখা যাবে না
  • প্রায় ১,০০০ MB বাড়বে

একটি image-এ আছে ENTRYPOINT ["echo", "A"] আর CMD ["B"]. docker run --rm img C কী ছাপবে?

  • A C
  • A B C
  • C
  • A B

একটি Dockerfile-এ EXPOSE 8000 আছে। docker run -d img চালানো হলো, কোনো -p ছাড়া। Host থেকে 127.0.0.1:8000-এ কী হবে?

  • সংযোগ হবে, কারণ EXPOSE port খুলে দেয়
  • একটি random host port-এ সংযোগ হবে, 8000-এ নয়
  • Build-এই ব্যর্থ হবে, তাই container চলবে না
  • Host থেকে সংযোগ হবে না; port প্রকাশ করতে -p লাগবে

একই image থেকে container A আর B চলছে। A /etc/motd ফাইলটি মুছে দিল, যা image-এর একটি layer-এ আছে। এখন কী সত্য?

  • Image থেকে ফাইলটি মুছে গেছে, B-ও আর দেখবে না
  • A ফাইলটি দেখে না, B এখনো দেখে; image অপরিবর্তিত
  • A আর B দুটিই ফাইলটি হারিয়েছে, শুধু image-এ আছে
  • Read-only layer হওয়ায় A-এর rm ব্যর্থ হবে

Build-এর আগে শুধু touch app.py চালিয়ে ফাইলের সময় বদলানো হলো, লেখা একই। পরের docker build-এ COPY app.py . ধাপের কী হবে?

  • সময় বদলেছে, তাই ধাপটি আবার চলবে
  • Build ব্যর্থ হবে, কারণ checksum মেলেনি
  • CACHED থাকবে, কারণ বিষয়বস্তু একই
  • COPY CACHED, কিন্তু নিচের RUN আবার চলবে

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Image হলো read-only layer-এর সারি + config. RUN, COPY ফাইল বদলায়; ENV, EXPOSE, CMD শুধু config বদলায়।
  2. RUN build-এ, CMD চালুর সময়। ENTRYPOINT + CMD জোড়া লাগে, আর exec form লিখলে signal পৌঁছায়।
  3. Container মানে image + writable layer. নিচের ফাইলে লিখলে পুরো ফাইল copy-up হয়; মুছলে শুধু whiteout বসে।
  4. Digest বিষয়বস্তুর hash: একই বাইট, একই digest. তাই শুধু অনুপস্থিত layer নামে। Tag একটি চলমান লেবেল।
  5. একটি ধাপ বাতিল হলে নিচের সব বাতিল। যা কম বদলায় তা ওপরে, .dockerignore রাখুন, আর মুছুন একই RUN-এ।

পরের পর্বে: এখন আপনি জানেন image কেন বড় হয়। পর্ব ৩৮-এ multi-stage build, ছোট base image আর non-root user দিয়ে একটি সত্যিকারের ভালো Dockerfile লিখব।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান