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

ভালো Dockerfile — multi-stage build, ছোট image আর non-root

Dockerfile Best Practices — Multi-stage Builds, Small Images and Non-root

Taught in Bangla, this lesson shrinks images with multi-stage builds, chooses between full, slim, alpine, distroless and scratch bases, pins versions by tag and digest while still taking security updates, runs as non-root, adds HEALTHCHECK, shows real timing differences between shell and exec form for signals and PID 1, keeps secrets out of layers with BuildKit, and ends with a lint-backed checklist, using real output and simulators.

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

  • Multi-stage build দিয়ে build-এর জিনিস আর চালানোর জিনিস আলাদা করা
  • Full, slim, alpine, distroless আর scratch-এর মধ্যে কাজ দেখে base image বাছাই করা
  • Tag আর digest দিয়ে version pin করা, এবং pin করার পরও নিরাপত্তা আপডেট নেওয়া
  • Non-root, HEALTHCHECK, exec form আর PID 1-এর আচরণ বাস্তবে যাচাই করা
  • Secret, .dockerignore, layer-এর ক্রম আর lint নিয়ে একটি পেশাদার চেকলিস্ট বানানো

আউটপুট সম্পর্কে: এই পর্বের প্রতিটি Docker আউটপুট Docker Engine 29 আর BuildKit-এ একটি আসল run থেকে নেওয়া। লম্বা অংশ বাদ দিয়ে … বসানো হয়েছে, আর সহজ রাখতে কমান্ডের অদরকারি flag দেখানো হয়নি। আপনার মেশিনে id, digest, size আর সময় আলাদা হবে। Docker ব্রাউজারে চলে না, তাই এসব উদাহরণ এখানে চালানো যায় না; তবে JS simulator-গুলো চালানো ও বদলানো যায়।

১ · ছোট প্রোগ্রাম, কিন্তু বিশাল image

পর্ব ৩৭-এ দেখা হয়েছে container image আসলে একের ওপর এক বসানো layer, আর Dockerfile সেই layer বানানোর নির্দেশ। এবার প্রশ্ন হলো: যে Dockerfile কাজ করে, সেটিই কি ভালো Dockerfile? উত্তর হলো, না। চলা আর ভালোভাবে চলা আলাদা কথা।

একটি খুব ছোট C প্রোগ্রাম ধরুন। সে একটি লাইন ছাপে, আর কোন user হিসেবে চলছে সেটিও বলে:

#include <stdio.h>
#include <unistd.h>

int main(void) {
    printf("hello from container, uid=%d\n", (int)getuid());
    return 0;
}

সবচেয়ে সরল Dockerfile হলো একই image-এ compiler বসানো, compile করা, আর সেই image-ই চালানো:

FROM alpine:3.20
RUN apk add --no-cache gcc musl-dev
WORKDIR /src
COPY hello.c .
RUN gcc -O2 -o /usr/local/bin/hello hello.c
CMD ["hello"]

এটি চলে। কিন্তু build শেষে docker history দেখুন, কোন layer কত জায়গা নিয়েছে:

$ docker history abcl-dp-fat
IMAGE          CREATED BY                                      SIZE
…              CMD ["hello"]                                   0B
<missing>      RUN /bin/sh -c gcc -O2 -o /usr/local/bin/hel…   36.9kB
<missing>      COPY hello.c . # buildkit                       12.3kB
<missing>      WORKDIR /src                                    8.19kB
<missing>      RUN /bin/sh -c apk add --no-cache gcc musl-d…   158MB
<missing>      CMD ["/bin/sh"]                                 0B
<missing>      ADD alpine-minirootfs-3.20.10-x86_64.tar.gz …   8.47MB

আসল প্রোগ্রাম মাত্র ৩৬.৯kB-এর একটি layer. কিন্তু তাকে ঘিরে বসে আছে ১৫৮MB-এর compiler. Production-এ এই compiler-এর কোনো কাজ নেই, তবু প্রতিটি সার্ভারে সে যাবে।

বিয়েবাড়ির ক্যাটারিং ভাবুন। রান্নার মাঠে বিশাল ডেকচি, চুলা, লাকড়ি আর মশলার বস্তা থাকে। কিন্তু অতিথির টেবিলে যায় শুধু রান্না করা খাবার। কেউ চুলাসহ বিরিয়ানি টেবিলে তোলে না। Compiler হলো চুলা আর ডেকচি, আর তৈরি প্রোগ্রাম হলো খাবারের প্লেট।

মূল ধারণা

ভালো Dockerfile পাঁচটি প্রশ্নের উত্তর দেয়। ছোট কি, অর্থাৎ অদরকারি জিনিস বাদ গেছে? নির্দিষ্ট কি, অর্থাৎ আগামী মাসেও একই জিনিস build হবে? নিরাপদ কি, অর্থাৎ root ছাড়া চলে আর secret নেই? পরিচালনাযোগ্য কি, অর্থাৎ health জানায় আর ঠিকভাবে বন্ধ হয়? দ্রুত কি, অর্থাৎ cache ঠিকমতো কাজে লাগে?

ছোট image শুধু জায়গা বাঁচায় না। এতে pull দ্রুত হয়, ফলে নতুন server বা autoscaling দ্রুত সাড়া দেয়। কম package মানে কম CVE, আর আক্রমণের সুযোগও কম। এই সুযোগের মোট পরিমাণকে বলে attack surface. Container-এ compiler আর package manager না থাকলে কেউ ভেতরে ঢুকলেও তার হাতের কাছে কম হাতিয়ার থাকে।

২ · Multi-stage build — রান্নাঘর আলাদা, টেবিল আলাদা

Multi-stage build মানে একটি Dockerfile-এ একাধিক FROM. প্রতিটি FROM একটি নতুন stage শুরু করে। আগের stage থেকে শুধু বেছে নেওয়া ফাইল পরের stage-এ আনা যায়, COPY --from দিয়ে। Docker 17.05 (২০১৭) থেকে এই সুবিধা আছে।

FROM alpine:3.20 AS build
RUN apk add --no-cache gcc musl-dev
WORKDIR /src
COPY hello.c .
RUN gcc -O2 -static -s -o /hello hello.c

FROM scratch
COPY --from=build /hello /hello
USER 10001:10001
ENTRYPOINT ["/hello"]

এখানে -static প্রোগ্রামের ভেতরেই C library জুড়ে দেয়, আর -s debug তথ্য বাদ দেয়। scratch হলো একদম খালি শুরু।

Multi-stage build-এর প্রবাহ

Only the file you copy reaches the final image

Build stage থেকে শুধু /hello ফাইল final stage-এ কপি হয় Build stage (AS build) alpine:3.20 · 8.47MB gcc + musl-dev · 158MB hello.c (source) /hello · তৈরি প্রোগ্রাম COPY --from=build Final stage (FROM scratch) /hello · 32.8kB USER 10001 · ENTRYPOINT ["/hello"] Registry-তে যায় শুধু final stage Build stage থাকে শুধু builder-এর cache-এ
ছবি ৩৮.১ — ওপরের ১৫৮MB-এর compiler নিচে আসেনি, কারণ কেউ তাকে কপি করেনি।

নিচের ধাপগুলো এই Dockerfile-এর আসল build থেকে নেওয়া। প্রতিটি ধাপের সময়ও সেই run-এর।

  1. Basealpine:3.20build stage শুরু
  2. Compilergcc৩০.৭ সেকেন্ড
  3. Compile/hellosource থেকে binary
  4. খালি শুরুscratchনতুন stage
  5. কপি--from=buildশুধু একটি ফাইল
build logBuild stage-এর ১ম ধাপ
#5 [build 1/5] FROM docker.io/library/alpine:3.20@sha256:d9e853e8…
#5 CACHED
build logCompiler বসানো হলো
#6 [build 2/5] RUN apk add --no-cache gcc musl-dev
#6 …
#6 DONE 30.7s
build logStatic binary তৈরি
#7 [build 3/5] WORKDIR /src
#7 DONE 0.1s
#8 [build 4/5] COPY hello.c .
#8 DONE 0.1s
#9 [build 5/5] RUN gcc -O2 -static -s -o /hello hello.c
#9 DONE 0.2s
Dockerfileদ্বিতীয় FROM
# stage-1 এখনো পুরো খালি:
# no shell, no libc, no gcc, no files
FROM scratch
build log ও historyFinal image-এর একমাত্র layer
#10 [stage-1 1/1] COPY --from=build /hello /hello
#10 DONE 0.0s

$ docker history abcl-dp-multi
ENTRYPOINT ["/hello"]           0B
USER 10001:10001                0B
COPY /hello /hello # buildkit   32.8kB
  1. ধাপ ১ — Build stage শুরু। FROM alpine:3.20 AS build stage-টির নাম দিল build. Base image আগেই মেশিনে ছিল, তাই CACHED দেখাচ্ছে।
  2. ধাপ ২ — রান্নাঘর সাজানো। gcc আর musl-dev বসতে এই run-এ ৩০.৭ সেকেন্ড লেগেছে। আগের হিসাবে এই layer ১৫৮MB.
  3. ধাপ ৩ — রান্না। Source কপি হলো, আর gcc ০.২ সেকেন্ডে /hello বানাল। Log-এ [build 5/5] মানে build stage-এর শেষ ধাপ।
  4. ধাপ ৪ — নতুন খালি টেবিল। দ্বিতীয় FROM আগের সব layer পেছনে ফেলে একটি নতুন stage শুরু করে। এই stage-এর ভেতরে এখনো কোনো ফাইলই নেই।
  5. ধাপ ৫ — শুধু প্লেট টেবিলে। COPY --from=build একটি ফাইল আনল, ৩২.৮kB. Final image-এ ডেটা-বহনকারী layer এই একটিই। gcc আসেনি, কারণ কেউ তাকে কপি করেনি।

এবার তিনটি পদ্ধতি পাশাপাশি। মাঝেরটিতে build stage একই, কিন্তু final image হলো alpine:3.20, আর প্রোগ্রাম dynamic linking-এ তৈরি:

$ docker image ls --format '{{.Repository}} {{.Size}}'
abcl-dp-fat 229MB
abcl-dp-alp 12.1MB
abcl-dp-multi 47kB

$ docker run --rm abcl-dp-fat
hello from container, uid=0
$ docker run --rm abcl-dp-multi
hello from container, uid=10001
পদ্ধতিএই run-এ SIZEভেতরে shellচলে যে user হিসেবে
এক stage, compiler সহ229MBআছেroot (uid 0)
Multi-stage, final alpine12.1MBআছেroot (uid 0)
Multi-stage, final scratch47kBনেই10001

একই SIZE-কে সবখানে এক ভাববেন না। docker image ls, docker history আর registry-র compressed মাপ আলাদা হিসাব করে, আর image store-ভেদে এই সংখ্যাও বদলাতে পারে। তাই তুলনা সবসময় একই টুল দিয়ে করুন।

Stage-এর আরও কিছু ব্যবহার আছে। docker build --target build . শুধু নামের stage পর্যন্ত build করে, তাই একই Dockerfile থেকে test চালানোর image আর ship করার image দুটোই পাওয়া যায়। COPY --from অন্য stage ছাড়াও অন্য image থেকে ফাইল আনতে পারে। আর শুধু C বা Go-র জন্য নয়: Python-এ একটি stage-এ dependency বসিয়ে পরের stage-এ কপি করা যায়, Node-এ TypeScript build-এর টুল বাদ দেওয়া যায়, Java-তে JDK build stage-এ রেখে final-এ শুধু JRE রাখা যায়। এই পর্বের শেষে এমন একটি Python উদাহরণ আছে।

যে error ভোগায়: “no such file or directory”

এবার একটি ফাঁদ। ওই Dockerfile থেকে -static বাদ দিলে build সফল হয়, কিন্তু চালাতে গেলে:

$ docker run --name abcl-dp-c1 abcl-dp-broken
exec /hello: no such file or directory
$ echo $?
255

ফাইলটি কিন্তু আছে! আসলে খুঁজে পাওয়া যাচ্ছে না তার loader. Dynamic প্রোগ্রাম চালাতে library আর loader লাগে। Alpine-এ চালিয়ে দেখলে সেটি ধরা পড়ে:

$ docker run --rm abcl-dp-alp ldd /usr/local/bin/hello
	/lib/ld-musl-x86_64.so.1 (0x…)
	libc.musl-x86_64.so.1 => /lib/ld-musl-x86_64.so.1 (0x…)

scratch-এ /lib/ld-musl-x86_64.so.1 নেই, তাই kernel প্রোগ্রাম চালাতে পারে না। এজন্য scratch-এ পাঠানোর প্রোগ্রাম হতে হয় static, অথবা library-গুলো হাতে কপি করতে হয়। Linking-এর ধারণা আরও জানতে দেখুন C-এর টুলচেইন পর্ব।

নিচের simulator আগের docker history-এর সংখ্যা নেয়, আর একটি size budget-এর সাথে মেলায়। Link speed, compression আর deploy-এর সংখ্যা ধরে নেওয়া মান। এগুলো বদলে চালিয়ে দেখুন।

// Image size budget. Layer sizes (MB) from one real "docker history" run.
const BUDGET_MB = 20, LINK_MBPS = 20, COMPRESS = 0.4;  // assumed link and gzip ratio
const DEPLOYS = 30, NODES = 10;                         // per month, assumed
const base = 8.47, compiler = 158, source = 0.012, binary = 0.033;
const plans = [
  ["one stage", [base, compiler, source, binary]],
  ["multi, final alpine", [base, binary]],
  ["multi, final scratch", [binary]],
];
console.log("plan                  size_MB  pull_s  GB/month  budget");
for (const [name, layers] of plans) {
  const mb = layers.reduce((a, b) => a + b, 0);
  const wireMB = mb * COMPRESS;                 // bytes on the network
  const pull = wireMB * 8 / LINK_MBPS;          // MB -> megabits -> seconds
  const gb = wireMB * DEPLOYS * NODES / 1000;   // if no layer is cached yet
  console.log(
    name.padEnd(21) + mb.toFixed(2).padStart(7) + pull.toFixed(2).padStart(8) +
    gb.toFixed(2).padStart(10) + (mb <= BUDGET_MB ? "  OK" : "  OVER by " + (mb - BUDGET_MB).toFixed(2))
  );
}
const one = base + compiler + source + binary;
console.log("final scratch is " + Math.round(one / binary) + " times smaller than one stage");

আউটপুট:

plan                  size_MB  pull_s  GB/month  budget
one stage             166.51   26.64     19.98  OVER by 146.51
multi, final alpine     8.50    1.36      1.02  OK
multi, final scratch    0.03    0.01      0.00  OK
final scratch is 5046 times smaller than one stage

এক stage-এ মোট 8.47 + 158 + 0.012 + 0.033 = 166.51MB. ধরে নেওয়া compression ০.৪ হলে network-এ যায় 66.6MB. ২০ Mbps লাইনে 66.6 × 8 ÷ 20 = প্রায় ২৬.৬ সেকেন্ড। ১০টি সার্ভারে মাসে ৩০ বার deploy হলে 66.6 × 300 ÷ 1000 = প্রায় ২০ GB. তবে এটি সর্বোচ্চ হিসাব, কারণ বাস্তবে আগে থেকে থাকা layer আর আসে না। এখানে docker history-র হিসাব ব্যবহার হয়েছে, তাই সংখ্যাটি docker image ls-এর 229MB-এর সাথে মিলবে না।

৩ · Base image বাছাই — full, slim, alpine, distroless, scratch

FROM লাইনের base image হলো আপনার বাড়ির ভিত। ছোট ভিত সবসময় ভালো নয়। আপনার app কী চায়, সেটিই আসল প্রশ্ন।

ধরনউদাহরণমোটামুটি মাপভেতরে কীকখন
Fullpython:3.12প্রায় ১ GBDebian, compiler, অনেক টুলBuild stage, শেখা, debug
Slimpython:3.12-slim১০০ MB-এর ঘরেছাঁটা Debian, glibcবেশিরভাগ app-এর ভালো শুরু
Alpinepython:3.12-alpineএই run-এ 74.7MBBusyBox, musl, apkছোট, musl-এ পরীক্ষিত app
DistrolessGoogle-এর distrolessকয়েক MB থেকে শুরুRuntime ও library, shell নেইProduction-এর কঠোর image
Scratchscratch০কিছুই নেইStatic binary (Go, Rust, C)

মাপগুলো মোটামুটি, লেখার সময়ের ধারণা। Version আর platform-ভেদে বদলায়, তাই নিজে মেপে নেবেন।

Alpine-এর musl: ছোট হওয়ার দাম

Debian আর Ubuntu ব্যবহার করে glibc, আর Alpine ব্যবহার করে musl. দুটিই C library, কিন্তু হুবহু এক নয়। বাস্তবে যা ঘটতে পারে:

  • Binary মেলে না: glibc-র জন্য build করা প্রোগ্রাম Alpine-এ সাধারণত সরাসরি চলে না। আগের “no such file” error-ই তখন দেখা যেতে পারে।
  • Prebuilt package: Python-এর কিছু C extension-এর musl-এর wheel না থাকলে source থেকে build হয়, তাই build ধীর হয়।
  • DNS আর locale: musl-এর resolver-এর আচরণ glibc-র থেকে আলাদা। পুরোনো version-এ TCP fallback ছিল না; musl 1.2.4 (২০২৩) থেকে সেটি যোগ হয়েছে।
  • গতি: Memory allocator ভিন্ন, তাই কিছু কাজে কর্মক্ষমতা আলাদা হতে পারে। সিদ্ধান্ত নেওয়ার আগে নিজের app মেপে দেখুন।

Distroless image-এ shell নেই, তাই docker exec -it … sh কাজ করে না। এটি নিরাপত্তায় সুবিধা, কিন্তু debug-এ অসুবিধা। সমাধান হিসেবে অনেকে আলাদা debug image রাখেন, অথবা পাশে সাময়িক debug container চালান। পর্ব ৪৯-এ Kubernetes-এর দিক থেকে এই নিরাপত্তা আবার আসবে।

সিদ্ধান্তের সহজ নিয়ম: Static binary হলে scratch বা distroless static. Python, Node বা Java হলে আগে slim, তারপর দরকার ও সময় থাকলে distroless. Alpine তখনই নিন, যখন আপনার সব dependency musl-এ পরীক্ষিত। Build stage-এ যত খুশি বড় image নিতে পারেন, কারণ সেটি ship হয় না।

৪ · Version pin — latest, tag আর digest

আগে পরিষ্কার করে নিই: latest মানে “সবচেয়ে নতুন” নয়। এটি শুধু একটি ডিফল্ট tag-এর নাম। Image প্রকাশক যা push করেছেন, সেটিই latest. FROM python লিখলেও Docker python:latest ধরে নেয়। তাই আজকের build আর আগামী মাসের build-এ ভিন্ন Python আসতে পারে, অথচ আপনার Dockerfile এক অক্ষরও বদলায়নি।

রেস্তোরাঁয় “আজকের স্পেশাল” চাওয়া হলো latest. আজ এক খাবার, কাল আরেক। 3.12-alpine হলো মেনুর নির্দিষ্ট নাম, তবে রাঁধুনি রেসিপি একটু বদলাতে পারেন। আর digest হলো সেই রান্নার রাসায়নিক ছাপ, যা এক অণু বদলালেও মিলবে না।

লেখাবদলাতে পারে?ফল
python বা python:latestহ্যাঁ, major version-ওহঠাৎ ভাঙতে পারে
python:3.12-alpineহ্যাঁ, patch আসেSecurity patch পায়, তবে পুরো নির্দিষ্ট নয়
python:3.12.x-alpine3.20 ধরনের tagকম, তবে tag নিজে বদলানো সম্ভবভালো ভারসাম্য
name@sha256:…নাহুবহু এক image; আপডেট নিজের দায়িত্ব

Digest হলো image-এর আধেয়ের hash, তাই একে চুপচাপ বদলানো যায় না। আসল run-এ digest বের করে pin করে দেখা হলো:

$ docker image inspect alpine:3.20 --format '{{json .RepoDigests}}'
["alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc"]

$ cat Dockerfile.pin
FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc
CMD ["cat", "/etc/alpine-release"]

$ docker run --rm abcl-dp-pin
3.20.10

Digest pin-এর একটি দাম আছে। Base image-এ নিরাপত্তা-সমাধান এলেও আপনার build পুরোনোটিই নেবে। তাই ভালো অভ্যাস হলো digest pin করা, আর Dependabot বা Renovate-এর মতো টুল দিয়ে আপডেটের pull request পাওয়া। পর্ব ৪১-এ registry, tag বদলানো আর signing নিয়ে বিস্তারিত আলোচনা আছে। আর এখানে alpine:3.20 শুধু উদাহরণ; নতুন কাজের আগে দেখে নেবেন কোন version এখনো সমর্থিত।

৫ · Non-root — root হিসেবে চালাবেন না

Dockerfile-এ USER না থাকলে container-এর প্রক্রিয়া ডিফল্টভাবে root হিসেবে চলে। দুটি ছোট image-এ দেখুন:

FROM alpine:3.20
RUN addgroup -S -g 10001 app && adduser -S -D -H -u 10001 -G app app
USER app
CMD ["id"]
# USER ছাড়া
$ docker run --rm abcl-dp-root
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),…

# USER app সহ
$ docker run --rm abcl-dp-user
uid=10001(app) gid=10001(app) groups=10001(app),10001(app)

# non-root প্রক্রিয়া সিস্টেমের ফোল্ডারে লিখতে পারে না
$ docker run --rm abcl-dp-user sh -c 'touch /etc/x; echo "exit=$?"; touch /tmp/x; echo "tmp exit=$?"'
touch: /etc/x: Permission denied
exit=1
tmp exit=0

এই আউটপুট container-এর ভেতরের, এবং ওপরের উদাহরণের জন্য বানানো user-এর। Root চালিয়ে একই touch /etc/x করলে exit=0 আসে। অর্থাৎ root হলে কোনো হামলাকারী app-এর দুর্বলতা দিয়ে ঢুকে সিস্টেমের ফাইল বদলাতে পারে, non-root হলে এই পথ অনেকটা বন্ধ। এটি least privilege-এর সরল প্রয়োগ।

গুদামঘরের ম্যানেজারের কাছে সব তালার চাবি থাকে। কিন্তু দিনমজুরের হাতে শুধু নিজের ঘরের চাবি দিলেই কাজ চলে। সেই চাবি হারালেও পুরো গুদাম ঝুঁকিতে পড়ে না।

কিছু বাস্তব খুঁটিনাটি মনে রাখুন:

  • সংখ্যায় লিখুন: USER 10001:10001 নাম ছাড়াই কাজ করে। scratch-এ /etc/passwd নেই, তাই নাম কাজ করবে না; আগের uid=10001 এভাবেই এসেছে। Kubernetes-এর runAsNonRoot যাচাইও সংখ্যা দেখে সহজে করতে পারে।
  • ক্রম: USER-এর পরের RUN ওই user হিসেবে চলে। তাই package বসানো শেষ হলে তবেই USER.
  • মালিকানা: COPY ডিফল্টভাবে root-এর মালিকানায় ফাইল রাখে। App-কে কোনো ফোল্ডারে লিখতে হলে COPY --chown=app:app বা RUN chown লাগবে। Code নিজে read-only থাকলেই ভালো।
  • Port: ঐতিহ্যগতভাবে non-root ১০২৪-এর নিচের port খুলতে পারে না। তবে নতুন Docker-এ container-এ এই বাধা ডিফল্টভাবে তোলা থাকে; এই run-এ non-root user 80-তে bind করতে পেরেছে। এর ওপর নির্ভর না করে 8080-এর মতো port নেওয়াই সহজ।

৬ · HEALTHCHECK — process চলছে মানেই app ঠিক নয়

একটি app deadlock-এ আটকে থাকতে পারে, process চলছে অথচ কোনো অনুরোধের উত্তর দিচ্ছে না। Docker-এর চোখে তখনও container Up. HEALTHCHECK instruction Docker-কে একটি পরীক্ষা শেখায়, যা নিয়মিত container-এর ভেতরে চলে। Exit code 0 মানে ঠিক, 1 মানে সমস্যা। ধারণাটি health check-এর মতোই, যা পর্ব ১৭ আর পর্ব ১৮-এ autoscaling ও load balancer-এ দেখেছেন।

FROM python:3.12-alpine
COPY server.py /server.py
HEALTHCHECK --interval=2s --timeout=2s --start-period=3s --retries=3 \
  CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1
CMD ["python", "-u", "/server.py"]

এই ছোট server-এর /tmp/ok ফাইল থাকলে সে 200 দেয়, না থাকলে 503. আসল run-এ অবস্থা বদলাল এভাবে (মাঝের লাইনগুলো ছাঁটা):

$ docker inspect abcl-dp-h1 --format '{{.State.Health.Status}} streak={{.State.Health.FailingStreak}}'
starting streak=0
healthy streak=0

$ docker ps --filter name=abcl-dp-h1 --format '{{.Names}}  {{.Status}}'
abcl-dp-h1  Up 9 seconds (healthy)

$ docker exec abcl-dp-h1 rm /tmp/ok     # app-কে অসুস্থ করা হলো
# একই inspect প্রতি ২ সেকেন্ডে
healthy streak=1
healthy streak=2
unhealthy streak=3
unhealthy streak=4

$ docker ps --filter name=abcl-dp-h1 --format '{{.Names}}  {{.Status}}'
abcl-dp-h1  Up 22 seconds (unhealthy)

$ docker inspect abcl-dp-h1 --format '{{json .State.Health.Log}}'
…"ExitCode":1,"Output":"wget: server returned error: HTTP/1.0 503 Service Unavailable\n"}…

$ docker inspect abcl-dp-h1 --format 'running={{.State.Running}} restarts={{.RestartCount}}'
running=true restarts=0

দুটি জিনিস খেয়াল করুন। --retries=3 থাকায় পরপর তিনবার ব্যর্থ হওয়ার পরেই unhealthy হয়েছে, তাই একবারের সাময়িক সমস্যায় ভুল সংকেত আসে না। আর শেষ লাইনটি গুরুত্বপূর্ণ: container unhealthy হলেও restarts=0.

শুধু Docker Engine unhealthy container-কে নিজে থেকে restart করে না; এটি একটি অবস্থা মাত্র। কেউ সেটি দেখে ব্যবস্থা নিলে তবেই কাজে লাগে, যেমন Docker Swarm বা Compose-এর depends_on শর্ত (পর্ব ৪০)। আর Kubernetes Dockerfile-এর HEALTHCHECK উপেক্ষা করে; সেখানে নিজের probe লেখা হয় (পর্ব ৪৩)।

Distroless বা scratch-এ wget নেই। সেখানে app-এর ভেতরে একটি ছোট health subcommand রাখা যায়, যেমন HEALTHCHECK CMD ["/app", "healthcheck"]. পরীক্ষাটি হালকা রাখুন, আর এমন কিছু করবেন না যা ডেটাবেস ধীর হলেই সব container-কে অসুস্থ দেখায়।

৭ · Signal আর PID 1 — shell form বনাম exec form

docker stop প্রথমে container-এর PID 1-কে SIGTERM signal পাঠায়, অর্থাৎ ভদ্রভাবে বন্ধ হওয়ার অনুরোধ জানায়। নির্দিষ্ট সময় পরও না থামলে পাঠায় SIGKILL, যা ধরা বা আটকানো যায় না। Docker-এর নথি অনুযায়ী Linux container-এ ডিফল্ট অপেক্ষা ১০ সেকেন্ড (Windows container-এ ৩০ সেকেন্ড), আর -t দিয়ে বদলানো যায়। এখানে পরিমাপের জন্য একই মান -t 10 স্পষ্ট করে লেখা হয়েছে।

CMD লেখার দুটি রূপ আছে, এবং পার্থক্য শুধু চেহারায় নয়:

# exec form: Docker runs python directly, python becomes PID 1
CMD ["python", "-u", "/app.py"]

# shell form: Docker runs /bin/sh -c "..."
CMD python -u /app.py | tee /tmp/app.log

Exec form হলো JSON array. Shell form হলো সাধারণ লেখা, যা /bin/sh -c-এর ভেতরে চলে। App নিচের মতো SIGTERM ধরে “গুছিয়ে বন্ধ” হওয়ার চেষ্টা করে:

import signal, sys, time

def on_term(signum, frame):
    print("got SIGTERM, closing cleanly", flush=True)
    sys.exit(0)

signal.signal(signal.SIGTERM, on_term)
print("server started", flush=True)
while True:
    time.sleep(1)

এবার একই app, দুইভাবে চালিয়ে একই সময়ে বন্ধ করা হলো। ধাপগুলো দেখুন:

একই app, দুই রকম শুরু
সময়
Shell form
Exec form
  1. দুটোই চলছে। Shell form-এ PID 1 হলো sh, আর python তার child. Exec form-এ python নিজেই PID 1.
  2. Docker signal পাঠাল। docker stop সবসময় PID 1-কে SIGTERM দেয়, ভেতরের সব প্রক্রিয়াকে নয়।
  3. আসল পার্থক্য। Exec form-এ python-এর handler সাথে সাথে চলল। Shell form-এ sh signal পেলেও এগিয়ে দেয় না। Kernel-ও handler ছাড়া PID 1-এর জন্য SIGTERM-এর ডিফল্ট “মৃত্যু” চালায় না।
  4. মাত্র ০.৩ সেকেন্ড পরে। Exec form-এর container বন্ধ। Shell form-এর python তখনও চলছে, আর লেনদেন গুছিয়ে নেওয়ার কোনো সুযোগ সে পায়নি।
  5. ১০ সেকেন্ড শেষ। Docker অপেক্ষা ফুরিয়ে SIGKILL পাঠাল। Handler চলতেই পারল না, যেমন বিদ্যুৎ চলে গেলে হঠাৎ কম্পিউটার বন্ধ হয়।
  6. ফল। 10.32s ও exit 137 (128 + 9) বনাম 0.29s ও exit 0. Rolling update-এ ৫০টি container থাকলে এই পার্থক্যই deploy-কে ধীর করে, আর অসম্পূর্ণ অনুরোধ নষ্ট করে।

এবার আসল মাপ। প্রতিটি সারি একই docker stop -t 10 দিয়ে নেওয়া:

$ docker exec abcl-dp-s10 …        # shell form + pipe: প্রক্রিয়ার তালিকা
1 /bin/sh -c python -u /app.py | tee /tmp/app.log
7 python -u /app.py
8 tee /tmp/app.log
$ docker stop -t 10 abcl-dp-s10     # চারপাশে সময় মাপা হয়েছে
stop took 10.32s
$ docker inspect abcl-dp-s10 --format 'exit={{.State.ExitCode}}'
exit=137
$ docker logs abcl-dp-s10
server started
শুরুর ধরনPID 1এই run-এ সময়ExitHandler চলল?
Shell form + pipe/bin/sh -c …10.32s137না
Exec form + handlerpython0.29s0হ্যাঁ
Script, exec ছাড়া/bin/sh /start.sh10.90s137না
Script, exec python …python0.30s0হ্যাঁ
Exec form, কিন্তু handler নেইpython10.45s137নেই
--init, handler নেইdocker-init0.33s143নেই

সারিগুলো থেকে তিনটি সত্য বেরিয়ে আসে, যা অনেক টিউটোরিয়ালে থাকে না:

  • এক লাইনের shell form সবসময় ফাঁদ নয়। এই run-এ Alpine-এর sh CMD python -u /app.py-কে, এমনকি cd / && python …-কেও, নিজের জায়গায় exec করেছে; python-ই PID 1 হয়েছে আর 0.3s-এ থেমেছে। কিন্তু pipe বা পরে আরেকটি কমান্ড থাকলেই shell থেকে যায়। Shell-ভেদে এই আচরণ আলাদা, তাই এর ওপর নির্ভর করবেন না। Exec form লিখুন।
  • Exec form একা যথেষ্ট নয়। Handler না থাকলে Python PID 1 হিসেবেও SIGTERM উপেক্ষিত হয়, কারণ kernel handler-হীন PID 1-কে বাড়তি সুরক্ষা দেয়। তাই সময় লেগেছে 10.45s.
  • Script-এর শেষ লাইনে exec. ENTRYPOINT script-এ exec python … লিখলে shell নিজের জায়গা app-কে ছেড়ে দেয়। দেখুন, পরিবর্তন মাত্র এক শব্দের।

--init একটি ছোট init process (tini) PID 1-এ বসায়, যা signal এগিয়ে দেয় আর মরে যাওয়া child গুছিয়ে নেয়। তখন python আর PID 1 নয়, তাই ডিফল্ট আচরণেই থেমে যায়, exit 143 (128 + 15). তবে handler না থাকলে এটি গুছিয়ে বন্ধ হওয়া নয়, শুধু দ্রুত বন্ধ হওয়া। কিছু app আবার আলাদা signal চায়; STOPSIGNAL instruction সেটি বলে দেয়, যেমন nginx-এর official image SIGQUIT ব্যবহার করে।

// Predict "docker stop -t 10" from how the container starts.
function stop(c, grace = 10) {
  const pid1 = c.init ? "init" : c.shellStays ? "sh" : "app";
  let how;
  if (pid1 === "sh") how = "killed";            // sh has no trap, PID 1 ignores TERM
  else if (pid1 === "app") how = c.handler ? "clean" : "killed"; // no handler: ignored
  else how = c.handler ? "clean" : "term";      // init forwards; app is not PID 1
  if (how === "clean") return { pid1, sec: 0, code: 0 };
  if (how === "term") return { pid1, sec: 0, code: 128 + 15 }; // SIGTERM default action
  return { pid1, sec: grace, code: 128 + 9 };   // SIGKILL after the grace period
}
const cases = [
  ["shell form with pipe", { shellStays: true, handler: true }],
  ["exec form + handler", { handler: true }],
  ["script without exec", { shellStays: true, handler: true }],
  ["script with exec", { handler: true }],
  ["exec form, no handler", { handler: false }],
  ["--init, no handler", { init: true, handler: false }],
];
console.log("case                    PID1  wait_s  exit");
for (const [name, c] of cases) {
  const r = stop(c);
  console.log(name.padEnd(24) + r.pid1.padEnd(6) + String(r.sec).padStart(6) + String(r.code).padStart(6));
}

আউটপুট:

case                    PID1  wait_s  exit
shell form with pipe    sh        10   137
exec form + handler     app        0     0
script without exec     sh        10   137
script with exec        app        0     0
exec form, no handler   app       10   137
--init, no handler      init       0   143

এই মডেলটি ওপরের আসল সারির সাথে মেলে। শুধু সময় আলাদা, কারণ মডেলে Docker-এর নিজের প্রায় ০.৩ সেকেন্ড গোনা হয়নি। নিয়মটি ছোট: PID 1 যদি signal ধরতে বা এগিয়ে দিতে না পারে, তবে পুরো grace period নষ্ট হবে, তারপর SIGKILL. grace বদলে চালিয়ে দেখুন।

৮ · Secret layer-এ রাখবেন না

পর্ব ৩৩-এ secret নিয়ে মূল নীতি দেখেছেন। Image-এর ক্ষেত্রে একটি বাড়তি বিপদ আছে: layer মুছে ফেলা যায় না। যে image পাবে, সে সব layer আর সব metadata-ও পাবে। আর image প্রায়ই অনেক হাতে যায়: registry, CI, সহকর্মী, এমনকি public repository-তে।

Dockerfile.leakভুল: ENV-এ secret (মানটি নকল)
FROM alpine:3.20
ENV API_KEY=fake-demo-key-000
RUN echo "app configured"
CMD ["sh"]
docker buildBuildKit নিজেই সতর্ক করে
1 warning found (use docker --debug to expand):
 - SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions
   for sensitive data (ENV "API_KEY") (line 2)
docker historyContainer না চালিয়েই দেখা গেল
$ docker history --no-trunc --format '{{.Size}}  {{.CreatedBy}}' abcl-dp-leak
0B  CMD ["sh"]
4.1kB  RUN /bin/sh -c echo "app configured" # buildkit
0B  ENV API_KEY=fake-demo-key-000
0B  CMD ["/bin/sh"]

$ docker image inspect abcl-dp-leak --format '{{json .Config.Env}}'
["PATH=…","API_KEY=fake-demo-key-000"]
docker historyARG আর RUN rm-ও বাঁচায় না
# docker build --build-arg DB_PASS=fake-pass-000 …
RUN |1 DB_PASS=fake-pass-000 /bin/sh -c echo "connecting" > /note.txt
ARG DB_PASS=fake-pass-000

# COPY api_key.txt, তারপর RUN rm:
8.19kB  RUN /bin/sh -c rm /tmp/api_key.txt # buildkit
12.3kB  COPY api_key.txt /tmp/api_key.txt # buildkit
Dockerfile.safeসঠিক: BuildKit --secret
$ docker build --secret id=api_key,src=api_key.txt …
# Dockerfile:
RUN --mount=type=secret,id=api_key \
    sh -c 'wc -c < /run/secrets/api_key > /key-bytes.txt'

$ docker history --no-trunc --format '{{.Size}}  {{.CreatedBy}}' abcl-dp-safe
0B  CMD ["sh"]
8.19kB  RUN /bin/sh -c sh -c 'wc -c < /run/secrets/api_key > /key-bytes.txt' # buildkit

$ docker run --rm abcl-dp-safe sh -c 'cat /key-bytes.txt; ls -A /run/secrets'
17
ls: /run/secrets: No such file or directory
  1. ধাপ ১ — ভুল শুরু। ENV-এ key বসানো হলো। এখানে মানটি নকল, কিন্তু আসল key-তেও ঘটনা হুবহু একই হতো।
  2. ধাপ ২ — সতর্কবার্তা। BuildKit নামটি দেখে আন্দাজ করে SecretsUsedInArgOrEnv warning দেয়। এটি শুধু warning, তাই build তবুও সফল হয়। CI-তে কেউ না পড়লে এটি হারিয়ে যায়।
  3. ধাপ ৩ — ফাঁস। docker history আর docker image inspect-এ মানটি খোলা লেখা। Container চালানোর দরকারও পড়েনি। 0B দেখে ভুলবেন না; মানটি ফাইলে নয়, metadata-তে আছে।
  4. ধাপ ৪ — ভুল সমাধান। ARG-এর মানও history-তে আসে। আর COPY-এর পর RUN rm করলে ফাইল শুধু উপরের layer থেকে আড়াল হয়, নিচের ১২.৩kB-এর layer-এ সে থেকেই যায়।
  5. ধাপ ৫ — সঠিক পথ। --mount=type=secret শুধু ওই একটি RUN-এর সময় /run/secrets/api_key-এ ফাইলটি দেখায়। Build ১৭ বাইট পড়তে পেরেছে, কিন্তু history-তে মান নেই, আর image-এ /run/secrets-ই নেই।

Build secret লাগে build-এর সময়, যেমন private package registry-র token-এর জন্য। আর app চলার সময়ের secret image-এ নয়, runtime-এ আসবে: orchestrator-এর secret, secret manager, বা পর্ব ৩৩-এর পদ্ধতিতে। Multi-stage-ও এখানে একা ভরসা নয়; final image নিরাপদ হলেও build stage-এর cache-এ secret থেকে যেতে পারে, যদি তা ENV বা COPY দিয়ে আসে।

২০২১ সালের এপ্রিলে code-coverage টুল Codecov জানায়, হামলাকারীরা তাদের Bash Uploader script বদলে ফেলেছিল। প্রতিষ্ঠানটির প্রকাশিত ব্যাখ্যা অনুযায়ী, Docker image তৈরির প্রক্রিয়ার একটি ভুল থেকে একটি credential বের করে নেওয়া সম্ভব হয়েছিল। বিস্তারিত তাদের নিজস্ব post-mortem-এ আছে। শিক্ষাটি সরল: image-এ যা ঢোকে, ধরে নিন তা একদিন বাইরের কেউ পড়বে।

Key একবার image-এ গেলে শুধু Dockerfile ঠিক করলে হবে না। Registry-তে পুরোনো image, CI cache আর সহকর্মীর মেশিনে সে রয়ে গেছে। আসল সমাধান হলো সেই key বাতিল করে নতুন key তৈরি করা (rotate), পর্ব ৩৩-এ যেমন দেখেছেন।

৯ · ছোট অভ্যাস, বড় ফল — ignore, ক্রম, পরিষ্কার, label আর lint

.dockerignore: বাক্সে কী যাবে

docker build শুরুতেই ফোল্ডারের ফাইলগুলো builder-এর কাছে পাঠায়। একে বলে build context. COPY . . লিখলে ওই ফোল্ডারের সব কিছু image-এ ঢোকে, এমনকি .env আর .git-ও। .dockerignore ফাইল এটি আটকায়। একটি ফোল্ডারে ২০MB .git, ৩০MB node_modules, একটি নকল .env আর ছোট app.py রেখে দেখা হলো:

# .dockerignore ছাড়া
#4 transferring context: 50.01MB 3.8s done
$ docker run --rm abcl-dp-ctx1
.
..
.env
.git
Dockerfile
app.py
node_modules
abcl-dp-ctx1 62.2MB

# .dockerignore দেওয়ার পর
$ cat .dockerignore
.git
node_modules
.env
*.log
Dockerfile
.dockerignore

#4 transferring context: 27B done
$ docker run --rm abcl-dp-ctx2
.
..
app.py
abcl-dp-ctx2 12.1MB

Context ৫০.০১MB থেকে ২৭ বাইটে নেমেছে, আর .env image-এ ঢোকেনি। অর্থাৎ এটি শুধু গতির নয়, নিরাপত্তারও বিষয়। আরেকটি ভালো অভ্যাস হলো COPY . .-এর বদলে দরকারি ফাইলের নাম লেখা।

Layer-এর ক্রম: কম বদলায় যা, তা আগে

পর্ব ৩৭-এর build cache-এর নিয়ম মনে করুন: একটি ধাপের input বদলালে সেই ধাপ আর তার পরের সব ধাপ আবার চলে। তাই যা কম বদলায় (dependency), তা আগে; যা ঘন ঘন বদলায় (আপনার code), তা পরে। একটি Python app-এ app.py-র একটি লাইন বদলে দুই Dockerfile আবার build করা হলো:

# খারাপ ক্রম: COPY . . আগে, pip পরে
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY . .
#7 DONE 0.1s
#8 [4/4] RUN pip install --no-cache-dir -r requirements.txt
#8 DONE 2.6s

# ভালো ক্রম: requirements.txt আগে, pip, তারপর COPY . .
#6 [2/5] WORKDIR /app
#6 CACHED
#7 [3/5] COPY requirements.txt .
#7 CACHED
#8 [4/5] RUN pip install --no-cache-dir -r requirements.txt
#8 CACHED
#9 [5/5] COPY . .
#9 DONE 0.0s

এখানে dependency মাত্র Flask, তাই ক্ষতি ২.৬ সেকেন্ড। বড় প্রজেক্টে এই ধাপ কয়েক মিনিটও হতে পারে, আর দিনে বহুবার চলে।

Package cache: একই layer-এ পরিষ্কার

আগের layer বদলানো যায় না। তাই পরের RUN-এ মুছলে ফাইল শুধু আড়াল হয়, image ছোট হয় না। আসল মাপ:

# তিনটি আলাদা RUN
16.4kB  RUN /bin/sh -c rm -rf /var/cache/apk/* # bui…
6.15MB  RUN /bin/sh -c apk add curl # buildkit
2.34MB  RUN /bin/sh -c apk update # buildkit

# একটি RUN
6.15MB  RUN /bin/sh -c apk add --no-cache curl # bui…

abcl-dp-split 25.5MB
abcl-dp-one 20.8MB

apk update-এর ২.৩৪MB layer-এ রয়ে গেছে, পরের rm তা কমাতে পারেনি। Alpine-এ সহজ পথ apk add --no-cache. Debian বা Ubuntu-তে একই নিয়ম এক RUN-এ লিখতে হয়:

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

apt-get update আর install একই RUN-এ রাখার আরেকটি কারণ আছে: আলাদা হলে পুরোনো cache-করা তালিকা দিয়ে পরে ভুল version খোঁজা হতে পারে।

ADD নয়, COPY

COPY শুধু build context থেকে ফাইল কপি করে। ADD বাড়তি দুটি কাজ করে: স্থানীয় tar ফাইল নিজে থেকে খুলে দেয়, আর URL থেকে ফাইল নামাতে পারে। এই লুকানো আচরণের জন্য সাধারণ ফাইলে COPY-ই ভালো, কারণ পড়লেই বোঝা যায় কী ঘটছে। সত্যিই tar খোলার দরকার হলে ADD ঠিক আছে। URL থেকে কিছু নামালে তার checksum যাচাই করা জরুরি, নইলে অন্যের ফাইল বদলালে আপনার image-ও চুপচাপ বদলে যাবে।

LABEL: image-এর পরিচয়পত্র

Label image-এ key-value তথ্য জোড়ে, যেমন version, source repository বা দলের নাম। OCI-র প্রমিত নাম org.opencontainers.image.* ব্যবহার করলে অন্য টুলও এটি বোঝে।

LABEL org.opencontainers.image.title="abcl-demo" \
      org.opencontainers.image.version="1.0.0"
$ docker image inspect abcl-dp-hc --format '{{json .Config.Labels}}'
{"org.opencontainers.image.title":"abcl-demo","org.opencontainers.image.version":"1.0.0"}

Lint: মেশিন আগে দেখুক

এত নিয়ম মনে রাখা কঠিন, তাই linter ব্যবহার করুন। নিচের ছোট simulator পাঁচটি নিয়ম দেখে। Dockerfile বদলে চালিয়ে দেখুন।

// Tiny Dockerfile linter. Edit the Dockerfiles below and run again.
const bad = `FROM python:latest
ENV API_KEY=fake-demo-key-000
ADD app.py /app/app.py
CMD python /app/app.py`;
const good = `FROM python:3.12-alpine
ARG APP_VERSION=1.0.0
RUN adduser -D -u 10001 app
COPY app.py /app/app.py
USER 10001
CMD ["python", "/app/app.py"]`;
const SECRET = /(KEY|TOKEN|SECRET|PASS(WORD)?)(=|\s|$)/i;
function lint(text) {
  const out = []; let user = "root";       // default user of a stage
  text.split("\n").forEach((line, i) => {
    const [op, ...rest] = line.trim().split(/\s+/);
    const arg = rest.join(" "), n = "line " + (i + 1) + ": ";
    if (op === "FROM") {
      user = "root";                       // USER resets in every stage
      const noPin = !/[:@]/.test(rest[0]) || /:latest$/.test(rest[0]);
      if (noPin && rest[0] !== "scratch") out.push(n + "ERROR latest or no tag");
    }
    if (op === "ADD" && !/\.tar|\.tgz/.test(arg)) out.push(n + "WARN  use COPY, not ADD");
    if ((op === "ENV" || op === "ARG") && SECRET.test(arg)) out.push(n + "ERROR secret in " + op);
    if ((op === "CMD" || op === "ENTRYPOINT") && arg[0] !== "[") out.push(n + "WARN  shell form " + op);
    if (op === "USER") user = rest[0];
  });
  if (user === "root" || user === "0") out.push("end: ERROR final stage runs as root");
  return out;
}
for (const [name, text] of [["bad", bad], ["good", good]]) {
  const found = lint(text);
  console.log(name + ": " + found.length + " finding(s)");
  found.forEach(f => console.log("  " + f));
}

আউটপুট:

bad: 5 finding(s)
  line 1: ERROR latest or no tag
  line 2: ERROR secret in ENV
  line 3: WARN  use COPY, not ADD
  line 4: WARN  shell form CMD
  end: ERROR final stage runs as root
good: 0 finding(s)

খারাপ Dockerfile-এর চার লাইনে পাঁচটি সমস্যা। শেষেরটি কোনো লাইনে নেই, কারণ সমস্যাটি হলো USER লাইনের অনুপস্থিতি। FROM-এ user আবার root হয়, কারণ প্রতিটি stage নতুন করে শুরু হয়। Secret-এর নিয়মটি শুধু নাম দেখে, তাই MONKEY=1-কেও ধরবে। আসল linter-ও এমন ভুল সংকেত দিতে পারে।

Docker-এর নিজেরও একটি পরীক্ষা আছে, docker build --check, যা image না বানিয়ে শুধু নিয়ম দেখে। ওই একই খারাপ Dockerfile-এ আসল ফল:

$ docker build --check .
…
Check complete, 2 warnings have been found!

WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
Do not use ARG or ENV instructions for sensitive data (ENV "API_KEY")
…
WARNING: JSONArgsRecommended - https://docs.docker.com/go/dockerfile/rule/json-args-recommended/
JSON arguments recommended for CMD to prevent unintended behavior related to OS signals
…

লক্ষ করুন, এটি দুটি ধরেছে, পাঁচটি নয়; latest, ADD বা root নিয়ে এটি কিছু বলেনি। তাই বাড়তি টুল হিসেবে hadolint ব্যবহার করতে পারেন। এটি Dockerfile-এর নিয়মের পাশাপাশি RUN-এর ভেতরের shell কোড ShellCheck দিয়ে দেখে। লেখার সময়ে এর পরিচিত নিয়মের মধ্যে আছে DL3007 (latest), DL3002 (শেষ USER root), DL3020 (ADD-এর বদলে COPY) আর DL3025 (CMD-এ JSON). এটি এই পর্বে চালানো হয়নি, তাই কোনো আউটপুট দেখানো হলো না। নিয়মের নম্বর মেলাতে অফিসিয়াল তালিকা দেখে নেবেন। পর্ব ৫৭ আর পর্ব ৬১-এ pipeline-এ এমন পরীক্ষা বসানো আসবে।

সব একসাথে

নিচের Dockerfile-এ এই পর্বের বেশিরভাগ অভ্যাস একসাথে আছে। এটি আসলেই build করে চালানো হয়েছে: docker build --check বলেছে Check complete, no warnings found., আর ভেতরে Flask পাওয়া গেছে uid 10001 হিসেবে। Image-এর মাপ এসেছে 81.4MB, যেখানে এক stage-এর ভালো ক্রমের image ছিল 94.4MB.

# ---- stage 1: install dependencies ----
FROM python:3.12-alpine AS deps
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# ---- stage 2: what actually ships ----
FROM python:3.12-alpine
LABEL org.opencontainers.image.title="bazar-api" \
      org.opencontainers.image.version="1.0.0"
RUN addgroup -S -g 10001 app && adduser -S -D -H -u 10001 -G app app
WORKDIR /app
COPY --from=deps /install /usr/local
COPY app.py .
USER 10001:10001
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["python", "-u", "app.py"]

লাইন ধরে মিলিয়ে দেখুন: dependency আলাদা stage-এ, তাই pip-এর জিনিসপত্র final-এ আসে না। requirements.txt আগে, তাই cache কাজে লাগে। USER সংখ্যায়, তাই root নয়। ENTRYPOINT JSON-এ, তাই python নিজেই PID 1. কোনো secret নেই, আর app-কে SIGTERM ধরতে হবে নিজের কোডে। Production-এ FROM-এ digest যোগ করে নিন; এখানে পড়ার সুবিধার জন্য বাদ রাখা হয়েছে।

আরও গভীরে: Linux kernel একটি PID namespace-এর init-কে (PID 1) বিশেষ সুরক্ষা দেয়: handler না থাকলে সেখানে পাঠানো signal-এর ডিফল্ট কাজ চলে না। বাইরের namespace থেকে শুধু SIGKILL আর SIGSTOP জোর করে পৌঁছায়। Image-এর ফরম্যাট আর layer-এর নিয়ম Open Container Initiative (OCI)-এর Image Specification-এ লেখা, আর RUN --mount-এর মতো সুবিধা BuildKit-এর, যা Docker Engine 23.0 থেকে ডিফল্ট builder.

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

  • Multi-stage build কেন? Build-এর টুল ship না করে image ছোট ও নিরাপদ রাখতে।
  • CMD আর ENTRYPOINT-এর exec form কেন ভালো? Shell মাঝে থাকে না, তাই app নিজেই PID 1 হয়ে signal পায়।
  • Exit code 137 আর 143 মানে কী? ১৩৭ = ১২৮ + ৯ (SIGKILL), ১৪৩ = ১২৮ + ১৫ (SIGTERM).
  • RUN rm secret কেন কাজ করে না? ফাইল আগের layer-এ থেকে যায়; layer বদলানো যায় না।
  • Tag আর digest-এর পার্থক্য? Tag নামের মতো, বদলানো যায়; digest আধেয়ের hash, বদলায় না।

১০ · AI-এর যুগে — Dockerfile যাচাই

AI কয়েক সেকেন্ডে Dockerfile লিখে দেয়, আর সেটি সাধারণত build-ও হয়। কিন্তু আগেই দেখেছেন, build হওয়া মানেই ভালো নয়। AI প্রায়ই latest লেখে, root-এ চালায়, ENV-এ key বসায়, COPY . . ব্যবহার করে, .dockerignore ভুলে যায়, আর শেষ হওয়া base image বা নেই এমন flag সুপারিশ করে।

AI-কে কাজ দিন, কিন্তু শর্ত স্পষ্ট করে। সবচেয়ে গুরুত্বপূর্ণ অংশ হলো শেষের অনুরোধ: কোন দাবি কীভাবে যাচাই করবেন।

আমার একটি [ভাষা ও framework] app-এর জন্য Dockerfile লিখে দাও। শর্ত: ১. Multi-stage build, final image-এ compiler বা dev dependency থাকবে না। ২. FROM-এ নির্দিষ্ট version, latest নয়। ৩. Non-root, সংখ্যায় USER. ৪. CMD বা ENTRYPOINT exec form-এ। ৫. কোনো secret ENV, ARG বা COPY দিয়ে আসবে না; build-এ লাগলে BuildKit --secret. ৬. Dependency-র layer আগে, code পরে; একটি .dockerignore-ও দাও। উত্তরের শেষে বলো: - কোন base image কেন বেছে নিলে, আর তার ঝুঁকি কী (যেমন musl). - কোন flag বা instruction অফিসিয়াল ডকুমেন্টে দেখে মেলানো দরকার। - আমি কোন কমান্ড দিয়ে নিজে যাচাই করব। আমি কোনো আসল key বা password এখানে দেব না।

AI-এর উত্তর পাওয়ার পর এই চেকলিস্ট মেলান:

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

উত্তর দেখার আগে নিজে একটু ভাবুন। এগুলোর অনেকগুলোর একটিমাত্র সঠিক উত্তর নেই।

প্র ০১Image যত ছোট, তত ভালো — এই কথাটি কি সবসময় সত্য?

পুরোপুরি নয়। ছোট image-এর সুবিধা বাস্তব: pull দ্রুত, autoscaling দ্রুত, storage ও network খরচ কম, আর vulnerability scanner-এ কম সতর্কতা। কিন্তু সংখ্যাকে লক্ষ্য বানালে কয়েকটি দিক হারিয়ে যায়।

  • Layer ভাগাভাগি: একই base image-এর ওপর ২০টি service থাকলে সার্ভারে base-এর layer একবারই নামে। তাই সব service-এর একই ৮০MB base, প্রতিটির আলাদা ৩০MB base-এর চেয়ে বাস্তবে ভালো হতে পারে। মাপার জিনিস হলো মোট নামানো bytes, একটি image-এর সংখ্যা নয়।
  • Debug-এর খরচ: রাত ২টায় incident-এ container-এ shell নেই, curl নেই। Distroless নিরাপদ, কিন্তু দল প্রস্তুত না থাকলে সমস্যা বের করতে সময় বাড়ে। তাই debug container-এর পথ আগে থেকে ঠিক করা দরকার।
  • Compatibility: কয়েক MB বাঁচাতে Alpine নিয়ে যদি কোনো library-তে অদ্ভুত crash আসে, তবে সেই সঞ্চয়ের কোনো মানে নেই।
  • Compile করা ভাষা বনাম interpreter: Go-র binary scratch-এ যায়, কিন্তু Python app-এর সাথে interpreter আর library লাগবেই। ভিন্ন ভাষার সংখ্যা তুলনা করে লাভ নেই।

আসল প্রশ্ন হলো, “এই জিনিসটি production-এ কেন আছে?” উত্তর না থাকলে বাদ দিন। উত্তর থাকলে এক MB বেশি হলেও সমস্যা নেই। পরিমাপ করে একটি budget ঠিক করুন, আর CI-তে সেটি পরীক্ষা করুন, যাতে অজান্তে image বড় না হয়।

মূল উপলব্ধি: লক্ষ্য সবচেয়ে ছোট image নয়; লক্ষ্য এমন image, যার প্রতিটি জিনিসের থাকার কারণ আছে, আর দল যাকে চালাতে ও debug করতে পারে।

প্র ০২Python app-এর জন্য Alpine না slim — কোনটি বেছে নেবেন, আর কেন এ নিয়ে এত বিতর্ক?

এই বিতর্কের মূল কারণ হলো C library. Python-এর অনেক জনপ্রিয় package (যেমন numpy, cryptography) ভেতরে C বা Rust কোড ব্যবহার করে, আর আগে থেকে compile করা ফাইল হিসেবে আসে, যাকে wheel বলে।

দীর্ঘদিন PyPI-তে প্রধানত manylinux wheel ছিল, যা glibc-র জন্য তৈরি। Alpine-এ pip সেগুলো ব্যবহার করতে পারত না, তাই source থেকে compile করত। ফলে build অনেক ধীর হতো, আর compiler বসানোর দরকার হতো, যা ছোট image-এর উদ্দেশ্যকেই নষ্ট করত। ২০২১ সালে PEP 656 musllinux wheel-এর পথ খুলে দেয়। এখন অনেক জনপ্রিয় package-এর musl-এর wheel আছে, তাই সমস্যা আগের চেয়ে কমেছে, তবে সব package-এর নয়।

দ্বিতীয় বিষয় DNS. musl-এর resolver অনেকদিন TCP-তে ফিরে যেত না, তাই বড় DNS উত্তরে সমস্যা হতে পারত। Kubernetes-এ ndots সেটিংয়ের সাথে মিলে এটি অদ্ভুত সমস্যা তৈরি করেছে বলেও অনেকে জানিয়েছেন। musl 1.2.4 (২০২৩) TCP fallback যোগ করেছে, তবে পুরোনো image-এ তা নেই। তৃতীয় বিষয় কর্মক্ষমতা; কিছু কাজে musl-এর allocator ভিন্ন আচরণ করে।

অন্যদিকে Alpine-এ package কম, attack surface ছোট, আর apk খুব দ্রুত। Dependency সরল হলে এটি চমৎকার। সবচেয়ে ভালো উপায় হলো অনুমান না করে দুটোই build করে তুলনা করা: মাপ, build-এর সময়, CVE সংখ্যা, আর আসল traffic-এর মতো লোডে পরীক্ষা। দলের অভিজ্ঞতাও গুরুত্বপূর্ণ; Debian-এ অভ্যস্ত দলের জন্য slim সহজ।

মূল উপলব্ধি: Python-এ slim সাধারণত নিরাপদ শুরু। Alpine ভালো, যখন আপনার পুরো dependency তালিকা musl-এ পরীক্ষিত। এই সিদ্ধান্ত মেপে নিতে হয়, গুজব শুনে নয়।

প্র ০৩Container-এ PID 1 এত বিশেষ কেন? এই সমস্যার ইতিহাস কী?

Unix-এ PID 1 হলো init, সব প্রক্রিয়ার পূর্বপুরুষ। এর দুটি বিশেষ দায়িত্ব। প্রথমত, কোনো প্রক্রিয়ার parent মারা গেলে সে অনাথ হয়, আর init তাকে দত্তক নেয়। শেষ হওয়া child-এর অবস্থা পড়ে নেওয়াও (wait) তার কাজ; না হলে সে zombie হয়ে process table-এ বসে থাকে। দ্বিতীয়ত, kernel init-কে হঠাৎ মৃত্যু থেকে রক্ষা করে, কারণ PID 1 মারা গেলে পুরো সিস্টেম বিপদে পড়ে। তাই handler না থাকলে signal-এর ডিফল্ট কাজ তার ওপর চলে না।

Container-এ PID namespace তৈরি হলে প্রথম প্রক্রিয়াটি ওই namespace-এর PID 1 হয়। এটি সাধারণত একটি web server বা Python script, যে init-এর দায়িত্ব জানেই না। ফলে দুটি সমস্যা হয়: signal উপেক্ষিত হয়, আর child প্রক্রিয়া বেশি তৈরি করা app-এ zombie জমতে পারে।

এই সমস্যার সমাধানে ছোট init প্রোগ্রাম এসেছে। Yelp ২০১৬ সালে dumb-init প্রকাশ করে, আর Thomas Orozco-র tini পরিচিত হয়। Docker পরে --init flag যোগ করে (১.১৩, ২০১৭ সালের শুরুতে), যা একটি ছোট init ব্যবহার করে; এই পর্বের run-এ PID 1-এ সেটি /sbin/docker-init নামে দেখা গেছে।

Kubernetes-এ ধারণাটি একই: pod বন্ধ করতে SIGTERM পাঠানো হয়, আর grace period (ডিফল্ট সাধারণত ৩০ সেকেন্ড) পরে SIGKILL. Rolling update-এ প্রতিটি pod বাড়তি সময় নিলে deploy দীর্ঘ হয়, আর অনুরোধ মাঝপথে কেটে যেতে পারে।

মনে রাখুন, PID 1 শুধু কারিগরি খুঁটিনাটি নয়। গুছিয়ে বন্ধ হওয়া মানে চলমান অনুরোধ শেষ করা, database connection বন্ধ করা, queue-র কাজ ফেরত দেওয়া।

মূল উপলব্ধি: Container-এ আপনার app অজান্তেই init-এর দায়িত্ব পায়। Exec form, signal handler আর প্রয়োজনে একটি ছোট init মিলে সেই দায়িত্ব পূরণ হয়।

প্র ০৪Digest pin করলে build নির্দিষ্ট হয়, কিন্তু নিরাপত্তা আপডেট আসে না। এই টানাপোড়েন কীভাবে মেটাবেন?

এখানে দুটি ভালো লক্ষ্য মুখোমুখি। Pin করলে একই Dockerfile আজ আর ছয় মাস পরে একই base দেয়, rollback নির্ভরযোগ্য হয়, আর কেউ একটি tag চুপিচুপি বদলালে আপনার build-এ সেটি ঢোকে না। কিন্তু pin করে ভুলে গেলে পুরোনো base-এ জানা CVE জমতে থাকে। অন্যদিকে শুধু tag-এ থাকলে patch আসে, কিন্তু কোন build-এ ঠিক কী ঢুকল, তা অস্পষ্ট হয়, আর একটি হঠাৎ আপস্ট্রিম পরিবর্তন production ভেঙে দিতে পারে।

আধুনিক সমাধান হলো দুটিকে আলাদা করা: build নির্দিষ্ট, আপডেট স্বয়ংক্রিয়। Dockerfile-এ digest থাকে, যেন যেকোনো সময়ের build পুনরুৎপাদন করা যায়। Renovate বা Dependabot-এর মতো টুল নতুন digest পেলে একটি pull request খোলে। CI build করে, পরীক্ষা চালায়, scan করে, মানুষ দেখে merge করেন। ফলে আপডেট ঘটনা হয়ে থাকে, git history-তে দেখা যায়, আর প্রয়োজনে revert করা যায়।

আরও কিছু দিক ভাবার আছে। Base image pin করলেই পুরো build নির্দিষ্ট হয় না; apk add curl বা pip install flask সেদিনের version আনে। তাই lockfile, নির্দিষ্ট package version, এবং বিচ্ছিন্ন পরিবেশে build-এর কথাও ভাবতে হয়। Multi-arch image-এ tag একটি index নির্দেশ করে, যার ভেতরে platform-ভিত্তিক আলাদা image থাকে; ওই index-এর digest pin করলে সব platform-ই নির্দিষ্ট থাকে।

আবার ঝুঁকির মাত্রাও দেখুন। ছোট অভ্যন্তরীণ টুলে হয়তো নির্দিষ্ট tag আর সাপ্তাহিক rebuild যথেষ্ট। পেমেন্টের মতো সংবেদনশীল service-এ digest pin, signing যাচাই আর SBOM (পর্ব ৪১) যুক্তিসঙ্গত। Image বানিয়েই কাজ শেষ নয়; base আপডেট হলে আগে build করা image-গুলো আবার build করতে হয়।

মূল উপলব্ধি: Pin করা মানে আপডেট বন্ধ করা নয়। এর মানে আপডেটকে একটি দেখা-যায়, পরীক্ষা-করা, ফেরানো-যায় এমন পরিবর্তনে পরিণত করা।

প্র ০৫Non-root দিলেই কি container নিরাপদ? এটি আসলে কী থেকে বাঁচায়, আর কী থেকে বাঁচায় না?

Non-root একটি শক্তিশালী স্তর, কিন্তু একা যথেষ্ট নয়। প্রথমে বোঝা দরকার, container VM নয়। সব container host-এর একই kernel ব্যবহার করে (দেখুন Operating Systems-এর container পর্ব)। ডিফল্টভাবে container-এর uid 0 আর host-এর uid 0 সংখ্যায় একই, যদিও capability আর namespace তাকে সীমিত রাখে।

Non-root কী থেকে বাঁচায়? App-এর দুর্বলতা দিয়ে কেউ ঢুকলে সে সিস্টেমের ফাইল বদলাতে, package বসাতে বা অনেক privileged কাজ করতে পারে না। Container থেকে বেরোনোর কিছু দুর্বলতায় root লাগে। উদাহরণ হিসেবে ২০১৯ সালের runc-এর CVE-2019-5736-এ container-এর ভেতরের root host-এর runc ফাইল বদলে দিতে পারত; এই ঝুঁকি কমানোর একটি উপায় ছিল container-এ root না চালানো, বা user namespace ব্যবহার। তাই non-root বিপদের একটি বড় অংশ কমায়।

কী থেকে বাঁচায় না? App-এর নিজের অধিকার দিয়ে যা সম্ভব, তা থেকে নয়। App-এর database password চুরি হলে uid কত, তাতে কিছু আসে যায় না। Volume-এ mount করা ফাইল, cloud-এর metadata endpoint থেকে পাওয়া credential, আর নেটওয়ার্কে অন্য service-এ যাওয়া সবই খোলা থাকতে পারে। Kernel-এর দুর্বলতাও non-root থেকে কাজে লাগানো সম্ভব হতে পারে।

তাই নিরাপত্তা হয় স্তরে স্তরে: non-root, read-only root filesystem, অদরকারি capability বাদ, resource limit, seccomp, network সীমাবদ্ধতা, ঠিকমতো IAM role, আর আপডেট রাখা। Rootless Docker বা user namespace আরেকটি স্তর যোগ করে, যেখানে container-এর root আসলে host-এর সাধারণ user. পর্ব ৩৯ আর পর্ব ৪৯-এ এগুলোর কিছু চালানোর দিক আসবে। এটিই defense in depth, যা পর্ব ৩৪-এ দেখেছেন।

মূল উপলব্ধি: Non-root হলো কম খরচে বড় লাভের একটি ডিফল্ট, তবে শেষ কথা নয়। এটি হামলার পরের ক্ষতি কমায়, হামলা ঠেকায় না।

অনুশীলন

প্রথমে নিজে চেষ্টা করুন, তারপর উত্তর মেলান।

  1. Size budget-এর হিসাব: এক stage-এর image-এ layer আছে base 80MB, compiler 200MB, source 2MB, binary 8MB. Multi-stage করলে final-এ থাকে base 10MB আর binary 8MB. Compression 0.5 আর লাইন 40 Mbps ধরুন। দুটির pull time কত? ১২টি সার্ভারে মাসে ২০ বার deploy হলে (কোনো layer cache নেই ধরে) মাসে কত GB বাঁচবে? (1 GB = 1000 MB)

    এক stage: 80 + 200 + 2 + 8 = 290MB. Network-এ 145MB. 145 × 8 ÷ 40 = 29 সেকেন্ড।

    Multi-stage: 10 + 8 = 18MB. Network-এ 9MB. 9 × 8 ÷ 40 = 1.8 সেকেন্ড।

    মাসে: 12 × 20 = 240টি pull. (145 − 9) × 240 = 32,640MB = 32.64 GB সাশ্রয়। বাস্তবে layer আগে থাকলে সংখ্যাটি কম হবে।

  2. সমালোচনা ও নতুন নকশা: একজন সহকর্মীর Dockerfile: FROM node:latest, COPY . ., ENV NPM_TOKEN=fake-token, RUN npm install, RUN npm run build, CMD npm start. কী কী সমস্যা আছে? আপনি কীভাবে নতুন করে সাজাবেন?

    সমস্যা: latest-এ version অনির্দিষ্ট। COPY . . আগে থাকায় যেকোনো বদলে install আবার চলে, আর .env বা .git ঢুকতে পারে। Token docker history-তে দেখা যাবে। Dev dependency আর source final image-এ থাকবে। USER নেই, তাই root. CMD npm start shell form, আর npm মাঝে থাকলে signal app পর্যন্ত না-ও পৌঁছাতে পারে।

    নতুন নকশা: নির্দিষ্ট version-এর Node-এর দুটি stage. Build stage-এ আগে package*.json কপি, RUN --mount=type=secret,id=npm … দিয়ে npm ci, তারপর source কপি ও build. Final stage-এ production dependency আর build-এর ফল কপি, USER সংখ্যায়, CMD ["node", "dist/server.js"]. সাথে .dockerignore. আর সবচেয়ে জরুরি: ফাঁস হওয়া token বাতিল করা।

  3. ফল আন্দাজ করুন: Dockerfile: FROM alpine:3.20, COPY secret.txt /tmp/secret.txt (১০০ বাইট), RUN rm /tmp/secret.txt, CMD ["ls", "/tmp"]. Build শেষে (ক) docker run কী দেখাবে? (খ) docker history-তে COPY-এর সারি থাকবে কি, আর সেটি 0B হবে কি? (গ) ফাইলটি কি আসলেই image থেকে চলে গেছে?

    (ক) কিছুই ছাপবে না, কারণ উপরের দৃশ্যে /tmp খালি।

    (খ) হ্যাঁ, COPY-এর সারি থাকবে, আর তার size 0B নয়। এই পর্বের আসল run-এ এমন সারি 12.3kB দেখিয়েছিল। rm-এর জন্য আলাদা ছোট layer যোগ হয়।

    (গ) না। ফাইলটি COPY-এর layer-এ রয়ে গেছে, শুধু উপরের layer তাকে আড়াল করেছে। Image পাওয়া যে কেউ সেই layer পড়তে পারবে। তাই secret ধরে নিতে হবে ফাঁস হয়েছে, এবং rotate করতে হবে।

  4. নিজের মেশিনে (ঐচ্ছিক): Docker বসানো থাকলে একটি shell form (| tee সহ) আর একটি exec form-এর image বানান, দুটির docker stop-এর সময় মাপুন। শেষে নিজের বানানো image আর container পরিষ্কার করুন। এতে cloud-এর খরচ নেই। কোন কোন কমান্ড লাগবে?

    একটি ধারা: docker build -t demo-shell -f Dockerfile.shell ., docker run -d --name d1 demo-shell, কয়েক সেকেন্ড পরে time docker stop -t 10 d1, তারপর docker inspect d1 --format '{{.State.ExitCode}}' আর docker logs d1. Exec form-এর জন্য একইভাবে করুন।

    পরিষ্কার: docker rm d1 এবং docker rmi demo-shell. ঢালাও prune চালানোর আগে ভাবুন, কারণ তাতে অন্য কাজের জিনিসও মুছতে পারে। প্রত্যাশিত ফল: shell form-এ প্রায় ১০ সেকেন্ড ও 137, exec form-এ এক সেকেন্ডের কম ও 0, যদি app-এ handler থাকে।

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

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

  1. FROM alpine:3.20 AS build দিয়ে build stage শুরু
  2. Compiler বসানো
  3. Source কপি করে static binary তৈরি
  4. FROM scratch দিয়ে final stage শুরু
  5. COPY --from=build দিয়ে শুধু binary আনা
  6. USER আর ENTRYPOINT ঠিক করা

Multi-stage build-এ build stage-এর compiler final image-এ যায় না কেন?

  • Docker শেষে compiler খুঁজে মুছে দেয়
  • Final image শুধু শেষ stage-এর layer নিয়ে তৈরি হয়
  • Build শেষে সব layer compress হয়ে যায়
  • gcc কাজ শেষে নিজেকে uninstall করে

একটি C প্রোগ্রাম dynamic linking-এ Alpine-এ build করে FROM scratch image-এ কপি করা হলো। চালাতে গেলে exec /hello: no such file or directory এলো, যদিও ফাইলটি আছে। সবচেয়ে সম্ভাব্য কারণ কী?

  • ফাইলের execute permission নেই
  • USER instruction লেখা হয়নি
  • প্রোগ্রামের দরকারি dynamic loader আর library image-এ নেই
  • scratch-এ shell নেই, তাই কোনো প্রোগ্রামই চলে না

একটি Dockerfile-এর শেষ লাইন CMD python app.py | tee app.log, আর app-এ SIGTERM handler আছে। docker stop -t 10 চালালে সবচেয়ে সম্ভাব্য ফল কোনটি?

  • প্রায় সাথে সাথে বন্ধ, exit code 0
  • প্রায় সাথে সাথে বন্ধ, exit code 143
  • Container কখনো বন্ধ হবে না
  • প্রায় ১০ সেকেন্ড পরে বন্ধ, exit code 137

Dockerfile-এ COPY key.txt /tmp/key.txt-এর পরে RUN rm /tmp/key.txt লেখা হলো। এই পদ্ধতি নিয়ে কোন কথাটি ঠিক?

  • ফাইলটি আগের layer-এ রয়ে যায়, তাই secret ফাঁস হতে পারে
  • rm চালালে ফাইল সব layer থেকে মুছে যায়, তাই নিরাপদ
  • Image-এর মাপ ফাইলের সমান কমে যায়
  • BuildKit নিজে থেকে এই ফাইল গোপন রাখে

একটি container-এর HEALTHCHECK পরপর ব্যর্থ হয়ে docker ps-এ (unhealthy) দেখাচ্ছে। শুধু Docker Engine-এ (কোনো orchestrator ছাড়া) পরের ঘটনা কী?

  • Docker container-টি সাথে সাথে restart করবে
  • Container চলতেই থাকবে; অবস্থাটি শুধু জানানো হয়
  • Docker container-টি মুছে ফেলবে
  • Docker একটি নতুন container চালু করবে

কোন কথাটি latest tag সম্পর্কে ঠিক?

  • এটি সবসময় সবচেয়ে নতুন version নির্দেশ করে
  • এটি একবার তৈরি হলে আর বদলায় না
  • এটি শুধু একটি ডিফল্ট tag-এর নাম, যা সময়ের সাথে ভিন্ন image নির্দেশ করতে পারে
  • এটি vulnerability scan করা নিরাপদ image বোঝায়

শব্দকোষ

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

মনে রাখুন

  1. Multi-stage build: রান্নাঘর আলাদা, টেবিল আলাদা। Final image-এ যা COPY করেননি, তা সেখানে নেই।
  2. নির্দিষ্ট আর ছোট: কাজ দেখে base image বাছুন; latest নয়, নির্দিষ্ট version বা digest, সাথে স্বয়ংক্রিয় আপডেট।
  3. Non-root আর exec form: সংখ্যায় USER, JSON-এ CMD, script-এ exec, আর app-এ SIGTERM handler.
  4. Secret layer-এ নয়: ENV, ARG, COPY + rm সবই ফাঁস করে। Build-এ --secret, চলার সময়ে runtime secret, ফাঁস হলে rotate.
  5. অভ্যাস: .dockerignore, dependency আগে, পরিষ্কার একই RUN-এ, LABEL, আর lint; শেষে id, docker history, docker stop দিয়ে নিজে যাচাই।

পরের পর্বে: ভালো image তৈরি হলো। এবার সেটি চালানোর পালা: container-এর lifecycle, port, volume, network আর resource limit.

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

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