এই পর্বে যা শিখবেন
compose.yaml-এর গঠন: services, build, image, ports, environment, volumes, networksup,ps,logs,exec,down,down -vঠিক কী বদলায়- Healthcheck দিয়ে startup ক্রম নিয়ন্ত্রণ, আর ব্যর্থ হলে কারণ খোঁজা
.env, variable, secret, profile, override আরdocker compose config--scaleদিয়ে nginx-এর পেছনে একটি stateless service বাড়ানো- কখন Compose যথেষ্ট, আর কখন Kubernetes-এ যাওয়ার সময় হয়েছে
১ · একটি করে docker run — সমস্যা কোথায়
পর্ব ৩৯-এ আপনি একটি করে container চালিয়েছেন। এবার একটি কাল্পনিক অনলাইন বইয়ের দোকান ধরুন, নাম বইঘর। এতে চারটি অংশ আছে: সামনে nginx (reverse proxy), মাঝে একটি Python API, পেছনে অর্ডার গোছানোর একটি worker, আর ডেটা রাখার জন্য Redis.
শুধু docker কমান্ড দিয়ে এটি চালাতে হলে কী লিখতে হয়, দেখুন:
docker network create shop-front
docker network create shop-back
docker volume create shop-redis-data
docker build -t shop-api ./app
docker build -t shop-worker ./app
docker run -d --name redis --network shop-back \
-v shop-redis-data:/data -e REDIS_PASSWORD=... redis:7.4-alpine ...
# now wait by hand until redis answers, then:
docker run -d --name api --network shop-back \
-e REDIS_HOST=redis -e REDIS_PASSWORD=... shop-api
docker network connect shop-front api
docker run -d --name worker --network shop-back \
-e REDIS_HOST=redis -e REDIS_PASSWORD=... shop-worker \
python -u worker.py
# wait again, then:
docker run -d --name proxy --network shop-front \
-p 127.0.0.1:8080:80 -v "$PWD/proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \
nginx:1.27-alpineদশটি কমান্ড, দুই জায়গায় হাতে অপেক্ষা। এর সমস্যাগুলো ছোট নয়:
- ক্রম মাথায় রাখতে হয়। Redis-এর আগে API চালালে API ভেঙে পড়ে।
- নথি থাকে না। কোন flag দেওয়া হয়েছিল, তা শুধু আপনার shell history জানে। নতুন সহকর্মীকে শেখানোর উপায় নেই।
- বদলানো কঠিন। একটি environment variable বদলাতে container মুছে পুরো লম্বা কমান্ড আবার লিখতে হয়।
- গোছানো কঠিন। কাজ শেষে প্রতিটি জিনিস উল্টো ক্রমে আলাদা করে মুছতে হয়, একটি ভুলে গেলে পড়ে থাকে।
বিয়েবাড়ির ক্যাটারিং ভাবুন। হাতে চালানো মানে প্রতি অনুষ্ঠানে বাবুর্চি, ডেকোরেটর, পানির লোককে আলাদা করে ফোন করে মুখে মুখে বোঝানো। Compose হলো একটি লিখিত চুক্তিপত্র: কে কী করবেন, কার পরে কে আসবেন, সব এক কাগজে। পরের অনুষ্ঠানে কাগজটি দিলেই হয়।
এই চুক্তিপত্রই compose ফাইল। আপনি "কী চাই" লেখেন, "কীভাবে করব" Compose ঠিক করে। ফাইলটি code-এর সাথে Git-এ থাকে, তাই stack-এর গঠনও review করা যায়।
২০১৩-১৪ সালে Orchard নামের একটি ছোট দল Fig নামে একটি হাতিয়ার বানায়, যা YAML ফাইল পড়ে কয়েকটি container চালাত। ২০১৪ সালে Docker, Inc. দলটিকে কিনে নেয়, আর Fig-ই হয় Docker Compose. প্রথম version Python-এ লেখা docker-compose (হাইফেনসহ) কমান্ড ছিল। পরে Go-তে লেখা docker compose (হাইফেন ছাড়া) plugin আসে, আর পুরোনোটির আপডেট ২০২৩ সালে বন্ধ হয়।
২ · compose.yaml-এর গঠন — বইঘরের stack
ফাইলের ফরম্যাট YAML, যেখানে indentation-ই গঠন। Tab নয়, space দিতে হয়। এই পর্বের প্রতিটি আউটপুট নিচের ফাইলটি আসলে চালিয়ে নেওয়া (Docker Engine 29, BuildKit, Compose v5). Container id, image size আর সময় আপনার machine-এ আলাদা হবে। লম্বা আউটপুটের বাদ দেওয়া অংশে … আছে।
# compose.yaml: the fictional bookshop "Boighor"
name: abcl-dc
services:
proxy:
image: nginx:1.27-alpine
ports:
- "127.0.0.1:${PROXY_PORT:-18401}:80"
volumes:
- ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks: [front]
depends_on:
api:
condition: service_healthy
restart: unless-stopped
api:
build: ./app
environment:
REDIS_HOST: redis
REDIS_PASSWORD: ${REDIS_PASSWORD:?put REDIS_PASSWORD in .env}
LOG_LEVEL: ${LOG_LEVEL:-info}
networks: [front, back]
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8000/healthz"]
interval: 2s
timeout: 2s
retries: 5
worker:
build: ./app
command: ["python", "-u", "worker.py"]
environment:
REDIS_HOST: redis
REDIS_PASSWORD: ${REDIS_PASSWORD:?put REDIS_PASSWORD in .env}
networks: [back]
depends_on:
redis:
condition: service_healthy
redis:
image: redis:7.4-alpine
user: redis
# sleep 6 stands in for the slow start of a real database
command: ["sh", "-c", "sleep 6 && exec redis-server --appendonly yes --requirepass \"$$REDIS_PASSWORD\""]
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD:?put REDIS_PASSWORD in .env}
volumes:
- redis-data:/data
networks: [back]
healthcheck:
test: ["CMD-SHELL", "REDISCLI_AUTH=\"$$REDIS_PASSWORD\" redis-cli ping | grep -q PONG"]
interval: 2s
timeout: 2s
retries: 5
start_period: 10s
probe:
image: alpine:3.20
profiles: [test]
command: ["wget", "-q", "-O", "-", "http://proxy/books"]
networks: [front]
volumes:
redis-data:
networks:
front:
back:| চাবি | কী বলে | বইঘরে |
|---|---|---|
name | Project-এর নাম, যা সব জিনিসের নামের আগে বসে | abcl-dc |
services | প্রতিটি service-এর সংজ্ঞা | proxy, api, worker, redis, probe |
build | Dockerfile থেকে image বানানো | ./app ফোল্ডার |
image | তৈরি image চালানো, না থাকলে registry থেকে আনা | nginx, redis, alpine |
ports | Host থেকে ভেতরে আসার পথ (published port) | শুধু proxy, শুধু 127.0.0.1-এ |
environment | Container-এর environment variable | REDIS_HOST: redis |
volumes | Named volume বা bind mount | redis-data, nginx.conf |
networks | কোন network-এ বসবে | front, back |
দুই রকম volume
ফাইলে volumes দুইভাবে এসেছে, আর দুটির উদ্দেশ্য আলাদা। redis-data:/data হলো named volume: Docker নিজে জায়গাটি রাখে, আপনি শুধু নাম জানেন। এটি ডেটার জন্য, যেন গুদামঘর। ./proxy/nginx.conf:… হলো bind mount: host-এর একটি নির্দিষ্ট ফাইল ভেতরে দেখানো। এটি config আর development-এর কোডের জন্য। শেষের :ro মানে read-only, অর্থাৎ container ফাইলটি বদলাতে পারবে না। যেটি বদলানোর দরকার নেই, সেটিকে read-only রাখাই নিরাপদ অভ্যাস।
একটি পার্থক্য মনে রাখুন। Named volume-এর নাম ফাইলের নিচের volumes: অংশে ঘোষণা করতে হয়, আর bind mount-এর পথ ./ বা / দিয়ে শুরু হয়। Compose এই চিহ্ন দেখেই দুটিকে আলাদা করে।
API আর worker-এর image
api আর worker একই ./app ফোল্ডার থেকে build হয়। শুধু command আলাদা, তাই দুটি কাজের জন্য দুটি code-base লাগেনি। এই পরীক্ষায় API মাত্র Python-এর standard library দিয়ে লেখা, Redis-এর সাথে কথা বলে একটি সাধারণ socket-এ। অর্ডার এলে API সেটি Redis-এর তালিকায় রাখে, আর worker সেখান থেকে একে একে তুলে নেয়।
FROM python:3.12-alpine
WORKDIR /app
COPY *.py .
ENV LOG_LEVEL=image-default
USER nobody
CMD ["python", "-u", "api.py"]এই Dockerfile থেকে এই run-এ image হয়েছে ৭৪.১ MB-র। -u দেওয়া হয়েছে যাতে Python-এর লেখা সাথে সাথে log-এ আসে, buffer-এ আটকে না থাকে।
একটি পুরোনো অভ্যাস ছাড়ুন: ফাইলের ওপরে version: "3.8" এখন আর লাগে না। আধুনিক Compose এটি উপেক্ষা করে, আর compose.yaml নামটিই পছন্দের নাম।
পুরো stack-এর ছবিটি দেখুন। খেয়াল করুন, api দুই network-এ বসে আছে, আর host থেকে ঢোকার দরজা মাত্র একটি।
বইঘরের stack: দুটি network, একটি volume
Services, networks and volumes on one host
api front আর back, দুই network-এই আছে। proxy আর redis-এর কোনো সাধারণ network নেই।৩ · up, ps, logs, exec — প্রতিদিনের কমান্ড
প্রথমে চালিয়ে দেখি। -d মানে detached, অর্থাৎ পেছনে চলবে। --progress plain দিলে প্রতিটি ধাপ লাইনে লাইনে দেখা যায়।
$ docker compose --progress plain up -d --build
Image abcl-dc-worker Built
Image abcl-dc-api Built
Network abcl-dc_front Created
Network abcl-dc_back Created
Volume abcl-dc_redis-data Created
Container abcl-dc-redis-1 Created
Container abcl-dc-worker-1 Created
Container abcl-dc-api-1 Created
Container abcl-dc-proxy-1 Created
Container abcl-dc-redis-1 Started
Container abcl-dc-redis-1 Waiting
Container abcl-dc-redis-1 Healthy
Container abcl-dc-worker-1 Started
Container abcl-dc-api-1 Started
Container abcl-dc-api-1 Waiting
Container abcl-dc-api-1 Healthy
Container abcl-dc-proxy-1 Started
$ docker compose ps
NAME IMAGE COMMAND SERVICE … STATUS PORTS
abcl-dc-api-1 abcl-dc-api "python -u api.py" api … Up 3 seconds (healthy)
abcl-dc-proxy-1 nginx:1.27-alpine "/docker-entrypoint.…" proxy … Up Less than a second 127.0.0.1:18401->80/tcp
abcl-dc-redis-1 redis:7.4-alpine "docker-entrypoint.s…" redis … Up … (healthy) 6379/tcp
abcl-dc-worker-1 abcl-dc-worker "python -u worker.py" worker … Up 3 secondsনামগুলো নিয়মমাফিক: project-service-সংখ্যা, আর network ও volume-এর নাম project_নাম। Redis-এর PORTS ঘরে শুধু 6379/tcp আছে, তীর নেই। অর্থাৎ port-টি container-এর ভেতরে খোলা, host থেকে নয়। শুধু proxy-তে 127.0.0.1:18401->80/tcp আছে।
নিচের ধাপগুলো একটি আলাদা run থেকে নেওয়া, যেখানে image আগে থেকেই ছিল। প্রতি মুহূর্তে container-এর অবস্থা মেপে সময় লেখা হয়েছে, তাই সেকেন্ডগুলো আসল।
| সময় | |
| redis | |
| api | |
| worker | |
| proxy |
- ফাইল পড়া। Compose
compose.yamlআর.envপড়ে,${...}পূরণ করে, ফাইল যাচাই করে, আরdepends_onথেকে ক্রম বের করে। এখনো কিছু তৈরি হয়নি। - Network আর volume. আগে থাকা জিনিস আবার বানানো হয় না। না থাকলে তৈরি হয়। তারপর প্রথম container (redis) তৈরি শুরু।
- সবাই created, শুধু redis চলছে। ০.৭ সেকেন্ডে চারটি container-ই তৈরি। কিন্তু api, worker আর proxy এখনো চলছে না, কারণ তাদের শর্ত পূরণ হয়নি। Redis-এর healthcheck-এর অবস্থা
starting. - Redis healthy.
sleep 6শেষে redis উঠল, আর পরের healthcheck পাস করল। এই run-এ ১১.০ সেকেন্ড লাগল। এই অপেক্ষাটিইservice_healthy-এর কাজ। - Api আর worker একসাথে চালু। দুজনেই শুধু redis-এর ওপর নির্ভর করে, তাই একই ধাপে চালু হলো। Worker-এর healthcheck নেই, তাই তার অবস্থা শুধু
running. - Api healthy. ১৩.৫ সেকেন্ডে
/healthzসফল হলো। Compose এই খবরের জন্যই অপেক্ষা করছিল। - Proxy চালু, কাজ শেষ। ১৪.১ সেকেন্ডে শেষ ধাপ।
up -dএবার ফিরে এলো, আর দোকান খোলা। সব container একসাথে চালু হলে nginx হয়তো api-কে পেতই না।
দোকান খোলা, এবার ব্যবহার করে দেখি। তিনটি অর্ডার দিয়ে worker-এর log পড়ব, তারপর একটি container-এর ভেতরে কমান্ড চালাব।
$ curl -s http://127.0.0.1:18401/books
{"served_by": "462c93b441d9", "orders_done": 0}
$ curl -s -X POST http://127.0.0.1:18401/orders
{"queued": "order-1"}
…(two more orders)…
$ docker compose logs --no-color worker
worker-1 | worker started, waiting for orders
worker-1 | packed order-1 total_done=1
worker-1 | packed order-2 total_done=2
worker-1 | packed order-3 total_done=3
$ docker compose exec api sh -c 'id -un; printenv LOG_LEVEL'
nobody
infoserved_by-এ যে ১২ অক্ষরের মান, সেটি container-এর hostname, অর্থাৎ তার ছোট id. Worker অর্ডারগুলো একে একে গুছিয়েছে। exec চলমান container-এ নতুন কমান্ড চালায়, আর দেখা গেল API nobody user হিসেবে চলছে, যা পর্ব ৩৮-এর non-root অভ্যাস।
প্রতিদিনের কমান্ডের তালিকা
| কমান্ড | কাজ |
|---|---|
up -d | ফাইলের সাথে বর্তমান অবস্থা মেলায়। যা বদলেছে শুধু সেটি নতুন করে, বাকিগুলো ছোঁয় না। |
ps, ps -a | এই project-এর container দেখায়। -a দিলে থেমে থাকাগুলোও। |
logs -f worker | একটি বা সব service-এর log, -f দিলে চলতে থাকে। |
exec api sh | চলমান container-এ নতুন কমান্ড চালায়। |
run --rm probe | একই সংজ্ঞা থেকে নতুন একটি container চালায়, কাজ শেষে মুছে দেয়। এককালীন কাজের জন্য। |
stop, start | থামায় আর আবার চালায়, কিছু মোছে না। |
down | Container আর network মোছে। |
down -v | এর সাথে named volume-ও মোছে। |
সব কমান্ড চলে বর্তমান ফোল্ডারের ফাইল ধরে, আর project-এর নাম দিয়ে নিজের জিনিস চেনে। তাই অন্য project-এর container-এ হাত পড়ে না। নাম ফাইলের name: থেকে আসে। -p দিয়েও দেওয়া যায়, আর দুটিই থাকলে -p জেতে। একই ফাইল দুটি আলাদা -p নামে চালালে দুটি আলাদা stack পাবেন, যা একই host-এ পরীক্ষার জন্য খুব কাজের, যদি host port আলাদা হয়।
কোন কমান্ড কী মোছে
এখানেই সবচেয়ে দামি ভুলটি হয়। orders_done এখন 4 (আরেকটি অর্ডার দেওয়া হয়েছে)। এবার ধাপে ধাপে দেখুন কোন কমান্ডে ডেটা বাঁচে।
| Container | |
| Network | |
| redis-data volume | |
| Image | |
| orders_done |
- শুরুর অবস্থা। Stack চলছে। Redis AOF ফাইলে লিখছে, যা volume-এ আছে। ৪টি অর্ডার গোছানো হয়েছে।
stop: শুধু থামানো। Container থাকে, Exited অবস্থায়।startদিলে আবার চলে। এই run-এ api আর workerExited (137)দেখিয়েছে, কারণ SIGTERM না ধরায় ১০ সেকেন্ড পরে SIGKILL পেয়েছে (পর্ব ৩৮-এর PID 1 সমস্যা)।down: container আর network মোছা। Named volume আর image থেকে যায়। দোকান ভেঙে ফেললেন, কিন্তু গুদামঘর অক্ষত।- আবার
up. Network নতুন তৈরি হলো, কিন্তু আউটপুটে volume-এর কোনো লাইন নেই, কারণ সেটি আগে থেকেই ছিল।orders_doneএখনো 4: ডেটা টিকে গেছে। down -v: volume-ও মোছা।-vমানে--volumes. ফাইলে ঘোষণা করা named volume মুছে যায়, আর কোনো নিশ্চিত করার প্রশ্ন আসে না। Image এখনো থাকে।- নতুন শুরু। এবার আউটপুটে
Volume … Createdআছে।orders_doneএখন 0: সব অর্ডারের হিসাব চিরতরে গেছে।
AI বা কোনো blog "সব পরিষ্কার করতে docker compose down -v চালান" বললে থামুন। Local ডেটা মুছলে সমস্যা ছোট, কিন্তু কোনো server-এ এই কমান্ড চালালে সেই host-এর database শেষ। Image-ও মুছতে চাইলে --rmi local আছে, তবে সেটি আলাদা সিদ্ধান্ত।
৪ · নাম দিয়ে খোঁজা আর network-এর দেয়াল
API-র কোডে REDIS_HOST: redis লেখা, কোনো IP নেই। তবু সংযোগ হলো কীভাবে? ব্যবহারকারীর বানানো Docker network-এ একটি embedded DNS থাকে। Container "redis" নামটি জিজ্ঞেস করলে সে ঠিকানা জানিয়ে দেয়। একেই বলে service discovery.
IP হাতে লিখলে চলত না, কারণ container মুছে নতুন করে তৈরি হলে তার IP বদলাতে পারে। নাম কিন্তু একই থাকে।
একটি রেস্তোরাঁ ভাবুন। খাবার ঘরে (front) কাউন্টারের লোক আর ওয়েটার, রান্নাঘরে (back) ওয়েটার আর বাবুর্চি। ওয়েটার (api) দুই ঘরেই যান। কাউন্টারের লোক (proxy) ভাঁড়ারে (redis) ঢুকতে পারেন না, এমনকি ভাঁড়ার কোথায় তাও জানেন না। Network ঠিক এই দেয়াল।
এটি মুখের কথা নয়। প্রতিটি container-এর ভেতর থেকে পরীক্ষা করে দেখি:
$ docker compose exec proxy sh -c \
'getent hosts redis || echo "redis: name not found"'
redis: name not found
$ docker compose exec proxy sh -c \
'getent hosts worker || echo "worker: name not found"'
worker: name not found
$ docker compose exec worker sh -c \
'getent hosts proxy || echo "proxy: not found"'
proxy: not foundproxy থেকে redis নামটিই পাওয়া যায় না, কারণ তাদের কোনো সাধারণ network নেই। Nginx কোনোভাবে অন্যের হাতে চলে গেলেও সেখান থেকে database পর্যন্ত সরাসরি পথ নেই। পর্ব ২৭-এর security group-এর মতো এখানেও ধারণাটি এক: যা দরকার নেই, তা খোলা রাখবেন না।
নিচের সিমুলেটরে নিয়মটি হাতে-কলমে দেখুন। প্রতিটি service-এর nets বদলে চালান। যেমন proxy-কে back-এ যোগ করে দেখুন কী খুলে যায়।
// Two containers can talk only if they share at least one network
const stack = {
proxy: { nets: ["front"], host: "127.0.0.1:18401->80" },
api: { nets: ["front", "back"], host: null },
worker: { nets: ["back"], host: null },
redis: { nets: ["back"], host: null },
};
function shared(a, b) {
return stack[a].nets.filter(n => stack[b].nets.includes(n));
}
const names = Object.keys(stack);
console.log("from/to".padEnd(11) + names.map(n => n.padEnd(8)).join(""));
for (const a of names) {
const cells = names.map(b => (a === b ? "-" : shared(a, b).length ? "yes" : "no"));
console.log(a.padEnd(11) + cells.map(c => c.padEnd(8)).join(""));
}
console.log("\nshared network for each allowed pair:");
names.forEach((a, i) => names.slice(i + 1).forEach(b => {
const s = shared(a, b);
if (s.length) console.log(" ", a, "<->", b, "via", s.join(", "));
}));
console.log("\nfrom the host machine:");
for (const n of names) {
console.log(" ", n.padEnd(7), stack[n].host ? "open at " + stack[n].host : "no published port");
}ছকে proxy-র সারিতে redis-এর নিচে no আছে, ঠিক ওপরের আসল পরীক্ষার মতো। আর api <-> redis via back বলছে পথটি কোন network দিয়ে। Host থেকে শুধু proxy খোলা: ports ছাড়া একটি service বাইরের দিক থেকে অদৃশ্য।
দুটি কথা মনে রাখুন। এক, ports লাগে শুধু host থেকে ঢুকতে। Container থেকে container-এ কথা বলতে ports লাগে না, আর ব্যবহার হয় container-এর ভেতরের port. দুই, "5432:5432" লিখলে সাধারণত host-এর সব interface-এ port খোলে। Server-এ এর মানে হতে পারে পুরো ইন্টারনেট, তাই 127.0.0.1: দিয়ে শুরু করুন। Ubuntu-র মতো সিস্টেমে Docker-এর নিয়ম firewall tool ufw-কে পাশ কাটাতে পারে, তাই শুধু ufw-এর ওপর ভরসা করবেন না।
৫ · Startup ক্রম — depends_on আর healthcheck
ধরুন বিয়েবাড়িতে গ্যাসের সিলিন্ডার এসে পৌঁছেছে। তার মানে কি চুলা জ্বলছে? না। সিলিন্ডার পৌঁছানো মানে শুধু "এসেছে", আর চুলা জ্বলে উঠলে তবেই "প্রস্তুত"। Container-এর ক্ষেত্রেও started আর ready দুটি আলাদা জিনিস।
| শর্ত | কিসের জন্য অপেক্ষা | কখন ব্যবহার |
|---|---|---|
service_started (সংক্ষিপ্ত রূপ depends_on: [db]) | Container চালু হওয়া পর্যন্ত | শুধু ক্রম দরকার, প্রস্তুতি নয় |
service_healthy | Healthcheck পাস করা পর্যন্ত | Database, cache, API |
service_completed_successfully | কাজ শেষ করে exit 0 পর্যন্ত | Database migration-এর মতো এককালীন কাজ |
Compose ভেতরে একটি topological sort করে: যার কোনো নির্ভরতা বাকি নেই, সে আগে চলে। কোনো বৃত্ত থাকলে কেউ আগে শুরু করতে পারে না। একে বলে dependency cycle. আসল Compose এমন ফাইল শুরুতেই ফিরিয়ে দেয়:
# api depends on cache, cache on db, db on api
$ docker compose config -q
dependency cycle detected: api -> cache -> db -> api
$ echo $?
1নিচের কোড একই হিসাব করে। stacks-এ নতুন service যোগ করুন, অথবা cycle-এর একটি তীর কেটে দেখুন বৃত্ত ভাঙে কি না।
// Which containers may start, wave by wave, from the depends_on graph
const stacks = {
bookshop: { proxy: ["api"], api: ["redis"], worker: ["redis"], redis: [] },
cycle: { api: ["cache"], cache: ["db"], db: ["api"] },
};
function findCycle(g, left) {
const path = [];
let s = [...left].sort()[0];
while (!path.includes(s)) {
path.push(s);
s = g[s].find(d => left.has(d));
}
return [...path.slice(path.indexOf(s)), s].join(" -> ");
}
function resolve(g) {
const left = new Set(Object.keys(g)), done = new Set(), waves = [];
while (left.size) {
const ready = [...left].filter(s => g[s].every(d => done.has(d))).sort();
if (!ready.length) return { error: "dependency cycle detected: " + findCycle(g, left) };
ready.forEach(s => { left.delete(s); done.add(s); });
waves.push(ready);
}
return { waves };
}
for (const [name, g] of Object.entries(stacks)) {
const r = resolve(g);
console.log("== " + name);
if (r.error) { console.log(r.error); continue; }
r.waves.forEach((w, i) => console.log("start wave " + (i + 1) + ":", w.join(", ")));
console.log("stop order: " + r.waves.slice().reverse().map(w => w.join(", ")).join(" | "));
}start wave 2: api, worker ঠিক আগের আসল run-এর মতো, যেখানে দুজন একসাথে চালু হয়েছিল। stop order উল্টো, কারণ থামানোর সময় আগে proxy, শেষে redis বন্ধ হয়, নইলে চলমান service নির্ভরতা হারাত। আর cycle ধরা পড়ল, যেমনটি আসল Compose-এর বার্তায় দেখেছেন।
Healthcheck-এর সময়ের হিসাব
Healthcheck-এর চারটি সেটিং আছে। interval হলো কত পরপর পরীক্ষা, timeout হলো একবারের সর্বোচ্চ সময়, retries হলো পরপর কতবার ব্যর্থ হলে unhealthy, আর start_period হলো শুরুর ছাড়ের সময়, যখন ব্যর্থতা গোনা হয় না।
ধরুন start_period: 8s, interval: 2s, retries: 5, আর পরীক্ষাটি প্রায় সাথে সাথে ব্যর্থ হয়। তাহলে সময় ≈ ৮ + ৫ × ২ = ১৮ সেকেন্ড। এবার আমরা ইচ্ছা করে healthcheck-এ ভুল password দিয়ে এই মাপ পরীক্ষা করেছি। আসল run-এ পুরো up ১৯.৭ সেকেন্ডে ব্যর্থ হলো, হিসাবের খুব কাছে।
| Redis | |
| ব্যর্থতার গোনা | |
| api, worker, proxy | |
| up-এর exit code |
- Redis চালু। Compose বাকিদের ধরে রেখে অপেক্ষা শুরু করল। এই পরীক্ষায় healthcheck-এ ইচ্ছা করে ভুল password দেওয়া, আর
start_period৮ সেকেন্ড। - ছাড়ের সময়। পরীক্ষা ব্যর্থ হচ্ছে, কিন্তু
start_period-এর ভেতরে ব্যর্থতা গোনা হয় না। ধীরে ওঠা database-কে এভাবে সময় দেওয়া হয়। - গোনা শুরু। ছাড়ের সময় শেষ। এখন প্রতিটি ব্যর্থতা গোনা হচ্ছে: ১, ২, ৩, ৪। একবার সফল হলেই গোনা শূন্যে ফিরত।
- Unhealthy. পরপর ৫টি ব্যর্থতা,
retries: 5পূর্ণ।docker inspect-এstreak=5দেখা গেছে। Redis-এর অবস্থা এখনunhealthy. - Compose হাল ছেড়ে দিল।
upexit code 1 দিয়ে থামল। Api, worker, proxyCreated-ই রইল, কখনো চালু হয়নি। Redis কিন্তু চলছে, Compose তাকে মোছে না। এই আচরণটিই চাই, কারণ এখন ভেতরে ঢুকে খোঁজা যায়। - কারণ খোঁজা। Log বলছে redis নিজে
Ready to accept connections, অর্থাৎ redis ঠিক আছে। Health log বলছেWRONGPASS. তাই সমস্যা service-এ নয়, healthcheck-এ। Unhealthy দেখলে আগে এই দুটি জায়গাই পড়ুন।
$ docker inspect abcl-dc-redis-1 \
--format '{{.State.Health.Status}} streak={{.State.Health.FailingStreak}}'
unhealthy streak=5
$ docker compose ps -a
NAME STATUS
abcl-dc-api-1 Created
abcl-dc-proxy-1 Created
abcl-dc-redis-1 Up 19 seconds (unhealthy)
abcl-dc-worker-1 Createdservice_healthy শুধু শুরুর ক্রম নিয়ন্ত্রণ করে। Stack চালু হওয়ার পরে redis বন্ধ হলে Compose api-কে থামায় না। তাই API-র কোডে retry আর timeout থাকতেই হবে। Healthcheck হলো শুরুর দরজা, বিমা নয়।
৬ · .env, variable আর secret
একই ফাইল দিয়ে আলাদা port বা password চালাতে চাইলে variable substitution দরকার। ফাইলে তিন রকম রূপ দেখেছেন:
| রূপ | মান না থাকলে | কাজে লাগে |
|---|---|---|
${PROXY_PORT:-18401} | 18401 বসে | ভালো default |
${REDIS_PASSWORD:?message} | Error দিয়ে থামে | আবশ্যক secret, default দেওয়া বিপজ্জনক |
$$REDIS_PASSWORD | Compose ছোঁয় না, $ হয়ে container-এ যায় | Container-এর shell যেন মান পূরণ করে |
মানগুলো আসে সাধারণত প্রকল্পের ফোল্ডারের .env ফাইল থেকে। ফাইলটি সরিয়ে দিলে কী হয়, দেখুন:
$ cat .env.example # committed: names only, no real values
REDIS_PASSWORD=
PROXY_PORT=18401
$ cat .gitignore
.env
$ mv .env .env.off; docker compose config -q; echo "exit=$?"
error while interpolating services.api.environment.REDIS_PASSWORD: required variable REDIS_PASSWORD is missing a value: put REDIS_PASSWORD in .env
exit=1কিছু চালানোর আগেই Compose থেমে গেল, আর আমাদের লেখা বার্তাটিই দেখাল। ভুল password নিয়ে অর্ধেক stack চালু হওয়ার চেয়ে এটি অনেক ভালো। .env.example Git-এ থাকে, যাতে নতুন সহকর্মী জানেন কোন নাম লাগবে। আসল .env থাকে .gitignore-এ।
সবচেয়ে বড় ভুল বোঝাবুঝি
এখানে দুটি আলাদা ধাপ আছে। ধাপ ১: Compose ফাইল পড়ার সময় ${...} পূরণ করে। ধাপ ২: container-এর environment তৈরি করে। .env ফাইল প্রথম ধাপের জিনিস, এটি নিজে থেকে container-এ ঢোকে না।
# .env contains ONLY_IN_DOTENV=hello, compose.yaml never mentions it
$ docker compose exec api sh -c \
'printenv ONLY_IN_DOTENV || echo "ONLY_IN_DOTENV is not set"'
ONLY_IN_DOTENV is not setএকই variable কয়েক জায়গায় থাকলে কে জেতে? নিচের কোড সেই নিয়ম মেনে চলে, আর এর পাঁচটি অবস্থা আমরা আসল Compose-এ চালিয়ে মিলিয়ে নিয়েছি। ফলাফল হুবহু এক। cases বদলে নিজের অবস্থা বানান।
// Step 1 fills ${VAR} in the file. Step 2 builds the container's environment.
const image = { LVL: "image", FROM_ENVIRONMENT: "image", ONLY_FILE: "image", ONLY_IMAGE: "image" };
const envFile = { LVL: "env_file", FROM_ENVIRONMENT: "env_file", ONLY_FILE: "env_file" };
const envAttr = { FROM_ENVIRONMENT: "${LVL:-compose-default}" };
const cases = {
"1 .env only": { shell: {}, dotenv: { LVL: "dotenv" }, runE: {} },
"2 shell LVL": { shell: { LVL: "shell" }, dotenv: { LVL: "dotenv" }, runE: {} },
"3 no .env": { shell: {}, dotenv: {}, runE: {} },
"4 run -e": { shell: {}, dotenv: { LVL: "dotenv" },
runE: { LVL: "cli", FROM_ENVIRONMENT: "cli" } },
"5 --env-file": { shell: {}, dotenv: { LVL: "alt" }, runE: {} }, // replaces .env
};
function interpolate(text, c) { // shell beats .env beats :-default
return text.replace(/\$\{(\w+):-([^}]*)\}/, (_, name, dflt) =>
c.shell[name] ?? c.dotenv[name] ?? dflt);
}
function containerEnv(c) { // later layers win
const fromAttr = {};
for (const k in envAttr) fromAttr[k] = interpolate(envAttr[k], c);
return { ...image, ...envFile, ...fromAttr, ...c.runE };
}
for (const [name, c] of Object.entries(cases)) {
const e = containerEnv(c);
console.log(name.padEnd(13), "LVL=" + e.LVL.padEnd(9),
"FROM_ENVIRONMENT=" + e.FROM_ENVIRONMENT.padEnd(16), "ONLY_IMAGE=" + e.ONLY_IMAGE);
}Container-এর ভেতরে ক্রম হলো run -e > environment > env_file > image-এর ENV. আর ফাইলের ${...} পূরণে ক্রম হলো shell > .env > :- default. লক্ষ করুন LVL=env_file প্রথম তিন সারিতেই একই আছে, কারণ এই নামটি environment-এ নেই, আর shell-এর মান শুধু ${LVL} পূরণে কাজে লেগেছে। Shell-এর variable নিজে থেকে container-এ যায়নি।
তাহলে কোনটি কখন? কয়েকটি service-এ একই অনেকগুলো মান লাগলে env_file সুবিধাজনক, এবং সেই ফাইলের সব মান সরাসরি container-এ যায়। একটি-দুটি নির্দিষ্ট মানের জন্য environment পরিষ্কার, কারণ ফাইল পড়েই বোঝা যায় container কী পাচ্ছে। আর .env রাখুন প্রতিটি machine-এর আলাদা মানের জন্য, যেমন port বা password. একই নামের ফাইল দুই কাজে ব্যবহার করলে প্রায়ই গোলমাল হয়।
Secret-এর জায়গা
.env Git-এর বাইরে রাখা প্রথম ধাপ মাত্র। Environment variable-এর secret আরও কয়েক জায়গায় ফাঁস হতে পারে। যেমন docker compose config পূরণ করা মানসহ সব ছাপায়:
$ docker compose config --no-path-resolution api
…
environment:
…
REDIS_PASSWORD: demo-only-not-a-real-secret
…তাই এই আউটপুট CI log-এ বা AI-এর prompt-এ দেবেন না। docker inspect-এও environment দেখা যায়। একটু ভালো উপায় হলো Compose-এর secrets:, যা মানটি ফাইল হিসেবে দেয়:
# compose.yaml
services:
a:
image: alpine:3.20
secrets:
- redis_password
secrets:
redis_password:
file: ./redis_password.txt
# inside the container (real run)
$ ls /run/secrets
redis_password
$ printenv | grep -c demo-only
0Password এখন /run/secrets/redis_password ফাইলে, environment-এ নেই (গোনা 0). তবে local Compose-এ এটি সাধারণত একটি ফাইল মাউন্ট মাত্র, encryption নয়। Production-এর secret-এর জন্য পর্ব ৩৩-এর secret manager লাগে। আর secret একবার Git-এ চলে গেলে ফাইল মুছলেই শেষ হয় না: সেটি বদলাতে (rotate করতে) হয়, এবং secret scanning চালু রাখা ভালো।
৭ · Profile, override আর compose config
Profile: দরকার হলে তবেই
ঈদের আগে দোকানে বাড়তি লোক লাগে, সারা বছর লাগে না। probe service-এ profiles: [test] আছে, তাই সাধারণ up তাকে চালু করে না। Debug tool, load test বা এককালীন কাজের জন্য profile ভালো।
$ docker compose config --services
redis
worker
api
proxy
$ docker compose --profile test config --services
redis
worker
api
probe
proxy
$ docker compose run --rm probe
{"served_by": "462c93b441d9", "orders_done": 3}Profile ছাড়া তালিকায় probe নেই। run দিয়ে নাম ধরে ডাকলে তার profile নিজে থেকেই চালু হয়। probe নাম দিয়ে proxy-কে খুঁজে পেল, কারণ সেও front network-এ।
Override: পরিবেশভেদে শুধু পার্থক্যটুকু
Developer-এর ল্যাপটপে কোড সরাসরি মাউন্ট করা আর debug log দরকার, CI-তে নয়। পুরো ফাইল কপি না করে শুধু পার্থক্যটুকু আলাদা override ফাইলে রাখুন। compose.override.yaml নামের ফাইল থাকলে Compose নিজে থেকে মিলিয়ে নেয়।
# compose.override.yaml: merged automatically, for a developer's laptop
services:
api:
command: ["python", "-u", "api.py"]
volumes:
- ./app:/app:ro
environment:
LOG_LEVEL: debug
ports:
- "127.0.0.1:18402:8000"# compose.ci.yaml: named with -f, for a CI run
services:
api:
environment:
LOG_LEVEL: warn
proxy:
restart: "no"-f দিলে override আর নিজে থেকে মেলে না, শুধু নাম ধরে দেওয়া ফাইলগুলো মেলে।
compose config: মেলানোর পরের সত্য
কয়েকটি ফাইল, variable আর default মিলে শেষে কী দাঁড়াল, তা চোখে অনুমান করবেন না। docker compose config চূড়ান্ত ফলটি ছাপায়, কিছু চালায় না। এটি Terraform-এর plan-এর মতো "আগে দেখে নিন" অভ্যাস।
$ docker compose config --no-path-resolution api
name: abcl-dc
services:
api:
build:
context: ./app
dockerfile: Dockerfile
command:
- python
- -u
- api.py
depends_on:
redis:
condition: service_healthy
required: true
environment:
LOG_LEVEL: debug
REDIS_HOST: redis
REDIS_PASSWORD: demo-only-not-a-real-secret
…
ports:
- mode: ingress
host_ip: 127.0.0.1
target: 8000
published: "18402"
protocol: tcp
volumes:
- type: bind
source: ./app
target: /app
read_only: true
…
$ docker compose -f compose.yaml -f compose.ci.yaml config … api
…
LOG_LEVEL: warn
…LOG_LEVEL ছিল ${LOG_LEVEL:-info}, override তাকে debug করেছে, আর CI ফাইলে সেটি warn. environment-এর মতো map মিশে যায়, একই চাবি পরের ফাইলের মান নেয়। command-এর মতো একক মান পুরোপুরি বদলে যায়। আর ports, volumes-এর মতো তালিকা সাধারণত যোগ হয়, তাই সাধারণ override দিয়ে কোনো port বন্ধ করা যায় না (এর জন্য Compose-এ !reset নামের আলাদা tag আছে)। --no-path-resolution দিলে path আপেক্ষিক থাকে।
৮ · --scale আর nginx-এর round robin
api stateless: নিজের ভেতরে কিছু রাখে না, সব redis-এ। তাই একে বাড়ানো নিরাপদ। redis stateful, একে এভাবে বাড়ালে তিনটি আলাদা ডেটা-ভান্ডার হবে, তিনটি কপি নয়। --scale দিয়ে বানানো প্রতিটি কপি হলো একটি replica.
Scale করার একটি শর্ত আছে: সেই service-এ নির্দিষ্ট container_name বা host port থাকা চলে না, কারণ দুটি container একই নাম বা একই host port নিতে পারে না। proxy-কে ২ করলে আসল আউটপুটে Docker-এর network error এলো আর দ্বিতীয় proxy উঠল না। api-তে host port নেই, তাই সমস্যা নেই।
Nginx-এর ফাইলটি পর্ব ১৪-এর upstream-ই। একটি লাইন নতুন, পরে কারণটি দেখবেন।
upstream api_pool {
zone api_pool 64k; # one round-robin counter for all nginx workers
server api:8000; # name is resolved when nginx loads this file
}
server {
listen 80;
location / {
proxy_pass http://api_pool;
proxy_connect_timeout 2s;
}
}$ docker compose --progress plain up -d --scale api=3
Container abcl-dc-api-3 Created
Container abcl-dc-api-2 Created
…
Container abcl-dc-api-2 Healthy
Container abcl-dc-api-3 Healthy
$ for i in 1 2 3 4 5 6; do curl -s http://127.0.0.1:18401/books; done
{"served_by": "462c93b441d9", "orders_done": 3}
{"served_by": "462c93b441d9", "orders_done": 3}
…(all six the same)…
$ docker compose exec proxy nginx -s reload
…
$ for i in 1 2 3 4 5 6; do curl -s http://127.0.0.1:18401/books; done
{"served_by": "462c93b441d9", "orders_done": 3}
{"served_by": "653033f77df5", "orders_done": 3}
{"served_by": "4399686adfdf", "orders_done": 3}
{"served_by": "462c93b441d9", "orders_done": 3}
{"served_by": "653033f77df5", "orders_done": 3}
{"served_by": "4399686adfdf", "orders_done": 3}প্রথম ছয়টি অনুরোধ একই replica-তে গেল, যদিও তিনটি চলছিল। কারণ nginx server api:8000-এর নাম শুধু config লোড করার সময় খোঁজে। Reload-এর পরে তিনটি ঠিকানাই পেল, আর একে একে round robin শুরু হলো। DNS-এ নতুন replica আগেই ছিল, nginx শুধু আবার জিজ্ঞেস করেনি।
zone লাইনটি কেন? এই লাইন ছাড়া আমাদের প্রথম চেষ্টায় reload-এর পরেও সব অনুরোধ একটি replica-তেই গিয়েছিল, যদিও DNS তিনটি ঠিকানা দিচ্ছিল। Nginx CPU core অনুযায়ী কয়েকটি worker process চালায়, আর zone ছাড়া প্রতিটি worker নিজের আলাদা হিসাব রাখে। প্রতিটি নতুন সংযোগ আলাদা worker ধরলে সবাই তালিকার প্রথমটিই বেছে নিতে পারে। zone হিসাবটি shared memory-তে রাখে, আর এটি যোগ করার পরে ওপরের সমান ভাগ দেখা গেছে।
একটি replica হঠাৎ থেমে গেলে কী হয়? docker stop abcl-dc-api-2 চালিয়ে ছয়টি অনুরোধ পাঠানো হলো:
$ docker stop abcl-dc-api-2
$ for i in 1 2 3 4 5 6; do
curl -s -o /dev/null -w '%{http_code} %{time_total}\n' http://127.0.0.1:18401/books
done
200 0.003040
200 0.003320
200 2.005331
200 0.003158
200 0.003002
200 0.003243সবগুলোই 200, কিন্তু তৃতীয়টি ২ সেকেন্ড বেশি নিয়েছে: nginx বন্ধ replica-র জন্য proxy_connect_timeout 2s পর্যন্ত অপেক্ষা করে পরেরটিতে গেছে। এই সেটিং ছাড়া আগের run-এ একটি অনুরোধ প্রায় ৬১ সেকেন্ড আটকে ছিল, কারণ default সময়সীমা ৬০ সেকেন্ড। Compose নিজে থেকে এখানে কিছু সারায় না: থামানো replica থেমেই থাকে।
নাম বদল ধরতে চাইলে: Nginx-এ resolver 127.0.0.11 valid=5s; আর proxy_pass-এ variable দিলে সে নিয়মিত DNS জিজ্ঞেস করে। 127.0.0.11 হলো Docker-এর embedded DNS-এর ঠিকানা। তবে তখন upstream-এর সুবিধা বদলায়, তাই পড়ে নিয়ে ব্যবহার করুন। Kubernetes-এ এই সমস্যা অন্যভাবে মেটে, পর্ব ৪৫-এ দেখবেন।
৯ · Compose-এর সীমা — কখন Kubernetes
Compose একটি host-এর হাতিয়ার। Laptop, CI runner, ছোট VM-এ এটি চমৎকার। কিন্তু সেই host-এর disk নষ্ট হলে বা সে বন্ধ হলে পুরো stack চলে যায়। restart: unless-stopped একই machine-এ container আবার চালায়, অন্য machine-এ নিয়ে যায় না।
| প্রয়োজন | Docker Compose | Orchestrator (Kubernetes) |
|---|---|---|
| কয়টি machine | একটি host | অনেক node |
| Machine নষ্ট হলে | Stack বন্ধ, হাতে ফেরাতে হয় | অন্য node-এ নিজে থেকে বসায় |
| নতুন version | Container বদলের সময় ছোট বিরতি হতে পারে | Rolling update আর rollback |
| Replica মরে গেলে | Restart policy, বাকিটা আপনার | কাঙ্ক্ষিত সংখ্যায় নিজে থেকে ফেরে |
| লোড অনুযায়ী বাড়া | হাতে --scale | Autoscaler |
| শেখা ও চালানোর খরচ | খুব কম | বেশি, cluster নিজেই একটি system |
কখন সরবেন? উত্তর গোনা যায় এমন কয়েকটি প্রশ্নে। একটি machine নষ্ট হলে যে সময় বন্ধ থাকবে, তা কি মেনে নেওয়া যায়? দিনে কতবার deploy হয়, আর প্রতিবার বিরতি চলে কি না? কয়টি দল, কয়টি service? লোক কি এক machine-এর বাইরে চলে গেছে? ছোট একটি অভ্যন্তরীণ tool-এর জন্য একটি VM-এ Compose প্রায়ই যথেষ্ট, আর backup ঠিক থাকলে সেটি সৎ সিদ্ধান্ত। Kubernetes চালানোর নিজস্ব খরচ আছে, তাই শুধু ফ্যাশনের জন্য যাবেন না।
Compose থেকে শেখা জিনিস নষ্ট হয় না। Service, image, environment, volume, healthcheck, replica, নাম দিয়ে খোঁজা: এই ধারণাগুলো Kubernetes-এও আছে, শুধু নাম আর গভীরতা আলাদা। পরের মডিউলে healthcheck হবে probe, --scale হবে Deployment-এর replicas, আর network-এর দেয়াল হবে NetworkPolicy. আর দৈনন্দিন কাজে অনেক দল এখনো দুটোই রাখে: ল্যাপটপে Compose, production-এ Kubernetes.
আরও গভীরে: Compose কোনো আলাদা database রাখে না। সে প্রতিটি container-এ com.docker.compose.project, com.docker.compose.service আর config-এর hash-এর মতো label বসায়, আর পরের up-এ বর্তমান container-এর label ফাইলের সাথে মিলিয়ে দেখে। Config না বদলালে container অক্ষত থাকে, বদলালে শুধু সেটিই নতুন হয়। এই "কাঙ্ক্ষিত বনাম বর্তমান" মেলানোই পরের মডিউলের reconciliation loop-এর ছোট রূপ, তবে Compose শুধু কমান্ড দিলে চলে, Kubernetes সারাক্ষণ চলে।
পরীক্ষা ও ইন্টারভিউতে:
downআরdown -v-এর পার্থক্য? প্রথমটি container আর network মোছে, দ্বিতীয়টি named volume-ও মোছে।depends_onকি ready-র নিশ্চয়তা দেয়? শর্ত ছাড়া না, শুধু শুরুর ক্রম দেয়।service_healthyলাগে, আর app-এ retry-ও লাগে।- Container কীভাবে অন্য service খোঁজে? একই network-এর embedded DNS-এ service-এর নাম দিয়ে।
.envকি container-এ যায়? নিজে থেকে না। এটি ফাইলের variable পূরণ করে। Container-এ দিতেenvironmentবাenv_fileলাগে।- Compose আর Kubernetes-এর মূল পার্থক্য? Compose এক host, কমান্ডে চলে। Kubernetes অনেক node, আর সারাক্ষণ কাঙ্ক্ষিত অবস্থা ধরে রাখে।
১০ · AI-এর যুগে Compose ফাইল
AI খুব দ্রুত একটি পূর্ণ compose.yaml লিখে দেয়, আর সেটি চলেও যায়। সমস্যা হলো, "চলছে" মানে "নিরাপদ" নয়। Compose ফাইলের ভুল সাধারণত প্রথম দিন ধরা পড়ে না।
| AI প্রায়ই যা লেখে | ঝুঁকি | ভালো রূপ |
|---|---|---|
ports: "5432:5432" | Database সব interface-এ খোলা | ports বাদ, বা 127.0.0.1: দিয়ে |
POSTGRES_PASSWORD: admin123 | Secret Git-এ চলে যায় | ${VAR:?message}, .env Git-এর বাইরে |
image: postgres:latest | কোনদিন কোন version আসবে জানা নেই | নির্দিষ্ট tag, পর্ব ৪১-এ digest |
depends_on: [db] | Started-এই ছেড়ে দেয়, ready নয় | service_healthy আর healthcheck |
version: "3.8", links:, docker-compose | পুরোনো নিয়ম | আধুনিক docker compose আর network |
privileged: true, docker.sock মাউন্ট | Host-এর প্রায় পুরো নিয়ন্ত্রণ | ঠিক কোন capability দরকার তা খোঁজা |
"clean করতে down -v" | ডেটা চিরতরে মুছে যায় | সতর্কতা ছাড়া কখনো নয় |
| বানানো flag বা চাবি | চুপচাপ কাজ না করা, বা error | docker compose config আর official docs |
AI-কে শুধু "compose ফাইল বানাও" না বলে নিরাপত্তার শর্তগুলো আগেই দিন, আর উত্তরের সাথে তাকে নিজের ঝুঁকিও লিখতে বলুন।
AI-এর লেখা compose ফাইল চালানোর আগে
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর উত্তর দেখুন।
প্র ০১Bash script দিয়েই তো কয়েকটি docker run চালানো যায়। Compose-এর মতো declarative ফাইল আলাদা কী দেয়?
Script বলে "কীভাবে করতে হবে", declarative ফাইল বলে "কী চাই"। পার্থক্যটি প্রথমবার চালানোর সময় ধরা পড়ে না, ধরা পড়ে দ্বিতীয়বারে।
- দ্বিতীয়বার চালালে: Script-এর
docker run --name redisদ্বিতীয়বার ব্যর্থ হয়, কারণ নামটি ব্যস্ত। প্রতিটি লাইনে "আছে কি না" যাচাই লিখতে হয়। Compose-এরupবর্তমান অবস্থা মিলিয়ে দেখে, কিছু না বদলালে কিছুই করে না। একে বলে idempotent আচরণ। - একটি জিনিস বদলালে: Script-এ কোন container পুরোনো config-এ চলছে, তা জানার উপায় নেই। Compose label-এ রাখা হিসাব মিলিয়ে শুধু বদলানোটিকে নতুন করে।
- গোছাতে হলে: Script-এর জন্য উল্টো ক্রমের আরেকটি script লিখতে হয়।
downএকাই সেটি জানে, কারণ project-এর সব জিনিস label দিয়ে চিহ্নিত। - অপেক্ষা করতে হলে: আগে মানুষ
wait-for-it.shআরsleep 30দিয়ে চালাত, যা ধীর মেশিনে ব্যর্থ, দ্রুত মেশিনে সময় নষ্ট।
Fig-এর জন্ম ২০১৩-১৪ সালে ঠিক এই ব্যথা থেকেই। Docker তখন একটি container ভালোভাবে চালাত, কিন্তু আসল app মানে web, database আর cache একসাথে। ২০১৪ সালে Docker, Inc. Orchard-কে কিনে নেয়, আর ২০২০ সালে ফাইলের নিয়ম খোলা Compose Specification হিসেবে আলাদা করে প্রকাশ করা হয়, যাতে অন্য tool-ও ব্যবহার করতে পারে। একই বিবর্তন Terraform-এও দেখবেন, পর্ব ৫১-এ।
Script-এর জায়গা তবু আছে: একবারের কাজ, বা যেখানে ধাপের ক্রমটিই আসল বিষয়। আর declarative হলেই সব মিটে যায় না, ফাইল বদলের ফল না বুঝলে up চুপচাপ container বদলে দিতে পারে।
মূল উপলব্ধি: Declarative ফাইলের আসল মূল্য প্রথম run-এ নয়, বরং পরের শততম বদলে, যখন কেউ মনে রাখতে পারছে না আগে কী চলছিল।
প্র ০২service_healthy থাকলে API-তে retry লেখার দরকার কী? দুটি কি একই কাজ নয়?
না। এ দুটি ভিন্ন সময়ের সমস্যা মেটায়। service_healthy একবারের প্রশ্নের উত্তর: "শুরুর মুহূর্তে সে প্রস্তুত ছিল কি?" Retry সারাজীবনের প্রশ্নের উত্তর: "এখন সে নেই, আমি কী করব?"
আসল জীবনে নির্ভরতা শুরুর পরেও হারায়। Database restart হয়, network ক্ষণিকের জন্য কেটে যায়, connection pool পুরোনো সংযোগ ধরে রাখে, memory শেষ হয়ে container মরে যায়। Compose এসব সময় আপনার API-কে থামায় না, আবার সাজায়ও না। ১৯৯০-এর দশকে L. Peter Deutsch ও Sun Microsystems-এর সহকর্মীদের লেখা "Fallacies of Distributed Computing"-এর প্রথম ভুল ধারণাই হলো "network নির্ভরযোগ্য"। ত্রিশ বছর পরেও এই ভুল নতুন করে হয়।
Compose-এর ইতিহাসেও এই তর্কের ছাপ আছে। ফাইল ফরম্যাটের version 3-এ depends_on-এর condition বাদ দেওয়া হয়েছিল, আর docs-এর যুক্তি ছিল: app-কে নিজেই পুনরায় চেষ্টা করতে শিখতে হবে, কারণ Swarm-এর মতো ব্যবস্থায় startup ক্রমের কোনো অর্থ নেই। পরে Compose Specification-এ condition ফিরে আসে, কারণ local development-এ এটি সত্যিই কাজের। দুই পক্ষই ঠিক ছিল: ক্রম আরাম দেয়, resilience বাঁচায়।
আমাদের run-এ দেখুন: API-র /healthz প্রতিবার redis-কে PING করে। Redis এক মিনিট বন্ধ থাকলে সব API replica একসাথে unhealthy হবে, অথচ API নিজে সুস্থ। এটি একটি সত্যিকারের নকশার প্রশ্ন, যাকে Kubernetes liveness আর readiness দুই ভাগে ভেঙেছে (পর্ব ৪৩)। আর exponential backoff ছাড়া retry করলে ফিরে আসা database-এর ওপর সবাই একসাথে ঝাঁপিয়ে পড়ে।
মূল উপলব্ধি: Startup ক্রম হলো সুবিধা, আর ব্যর্থতা সামলানো হলো দায়িত্ব। প্রথমটি অবকাঠামো দিতে পারে, দ্বিতীয়টি আপনার কোডকেই করতে হবে।
প্র ০৩Twelve-Factor App বলে config environment-এ রাখতে। তাহলে environment variable-এ password রাখা কি ভুল?
পুরোপুরি ভুল নয়, তবে সীমাটি জানা দরকার। ২০১১ সালে Heroku-র Adam Wiggins ও সহকর্মীদের লেখা Twelve-Factor App বলেছিল: config code-এর বাইরে, environment-এ রাখুন। তখনকার সমস্যা ছিল code-এর ভেতরে password লেখা, আর সেই তুলনায় এটি বিরাট উন্নতি। একই image dev, staging আর production-এ চলে, শুধু environment বদলায়।
কিন্তু environment গোপন রাখার জন্য বানানো হয়নি। ফাঁসের পথগুলো দেখুন:
docker compose configসরাসরি দেখায়, যা এই পর্বেই আসল আউটপুটে দেখেছেন।docker inspect-ও দেখায়।- Crash report আর debug page অনেক সময় পুরো environment ছাপে।
- Child process নিজে থেকে environment পেয়ে যায়।
- Linux-এ যথেষ্ট অনুমতি থাকলে process-এর environment পড়া যায়।
তাই ধাপে ধাপে ভাবুন। Local development-এ .env ঠিক আছে, যদি Git-এর বাইরে থাকে আর মানগুলো ভুয়া হয়। একটু ভালো হলো ফাইলভিত্তিক secret: আমাদের run-এ /run/secrets-এ ফাইল ছিল, environment-এ গোনা 0. ফাইল হলে log-এ ফাঁসের পথ কমে, আর অনুমতি দিয়ে পড়া সীমিত করা যায়। Production-এ আরও ভালো হলো secret manager থেকে স্বল্পমেয়াদি মান আনা, পর্ব ৩৩-এ যা দেখেছেন।
আর .env Git-এ চলে গেলে ফাইল মুছে দেওয়া যথেষ্ট নয়। Git-এর ইতিহাসে সেটি থেকে যায়, আর public repository-তে স্বয়ংক্রিয় bot খুব দ্রুত key খুঁজে নিতে পারে। প্রথম কাজ সেই secret বদলানো, তারপর ইতিহাস পরিষ্কার।
মূল উপলব্ধি: Config আর secret এক জিনিস নয়। Config ফাঁস হলে অসুবিধা, secret ফাঁস হলে ঘটনা। তাই দুটির জায়গাও আলাদা হওয়া উচিত।
প্র ০৪Compose দিয়ে production চালানো কি ভুল? একটি ছোট দল কীভাবে সিদ্ধান্ত নেবে?
"Compose production-এর জন্য নয়" কথাটি বেশি সরল। সঠিক প্রশ্ন হলো: একটি machine নষ্ট হলে আপনার ব্যবসা কতক্ষণ বন্ধ থাকতে পারে?
একটি host-এ Compose আসলে কী দেয় না, তা স্পষ্ট করে দেখি। Host বন্ধ হলে সব বন্ধ: restart policy অন্য machine-এ যায় না। up দিয়ে নতুন image বসানোর সময় container বদলে ক্ষণিকের বিরতি হতে পারে। আমাদের run-এ replica থেমে গেলে সে থেমেই রইল, নিজে থেকে কেউ ফেরায়নি। আর volume একটি disk-এই আছে, তাই backup ছাড়া সেই disk-ই একক ব্যর্থতার জায়গা।
অন্যদিকে Kubernetes-এর দাম আছে: control plane, network plugin, ingress, version আপগ্রেড, আর সেগুলো বোঝার মানুষ। Managed service দাম কমায়, শূন্য করে না। দুজনের একটি দলের জন্য একটি VM, Compose, ভালো backup আর monitoring হতে পারে একদম ঠিক সিদ্ধান্ত। ২০১৬ সালের দিকে Docker তাদের Swarm mode এই ফাঁক পূরণের জন্য আনে, যা সহজ ছিল, তবে বাজার শেষে Kubernetes-এর দিকেই ঝুঁকেছে। মাঝের পথও আছে: Nomad, বা managed container service.
সিদ্ধান্তের কয়েকটি ইঙ্গিত, যা গোনা যায়:
- Availability লক্ষ্য একটি machine-এর সামর্থ্যের বেশি হলে সরুন।
- লোড এক machine ছাড়িয়ে গেলে, বা ঘনঘন ওঠানামা করলে।
- দিনে অনেকবার deploy হলে আর প্রতিবার বিরতি মানা না গেলে।
- কয়েকটি দল আলাদাভাবে deploy করতে চাইলে।
আর সবচেয়ে ভালো খবর: image একই থাকে। সরার সময় container আবার বানাতে হয় না, শুধু চালানোর বর্ণনা বদলায়।
মূল উপলব্ধি: Tool বাছাই হয় ব্যর্থতার খরচ দিয়ে, ফ্যাশন দিয়ে নয়। সবচেয়ে সরল ব্যবস্থা যা আপনার ঝুঁকি মেটায়, সেটিই সঠিক ব্যবস্থা।
প্র ০৫DNS-এ তিনটি ঠিকানা থাকলেও nginx কেন একটিতেই পাঠাল? DNS দিয়ে load balancing করার সমস্যা কী?
আমাদের run-এ আসলে দুটি আলাদা কারণ ধরা পড়েছে, আর দুটিই শিক্ষণীয়। প্রথমটি নাম খোঁজার সময়। Nginx-এর server api:8000 নামটি config লোডের সময় একবার খোঁজা হয়। তখন একটি replica ছিল, তাই তালিকায় একটি ঠিকানা। পরে DNS-এ তিনটি হলেও nginx জানল না, যতক্ষণ না reload হলো।
দ্বিতীয়টি হিসাব কোথায় থাকে। Reload-এর পরেও প্রথম চেষ্টায় একটিতেই গিয়েছিল। Nginx-এর কয়েকটি worker process আলাদা হিসাব রাখলে প্রত্যেকে "প্রথমটি" বেছে নিতে পারে। zone দিয়ে হিসাব ভাগ করে নেওয়ার পরে সমান ভাগ হলো। ঠিক এই ধরনের জিনিস ল্যাপটপে ধরা পড়ে, ডেমো-র স্লাইডে নয়।
এর পেছনে বড় ছবিটি হলো: DNS load balancer হিসেবে বানানো হয়নি। সমস্যাগুলো পুরোনো ও পরিচিত:
- Cache: Client, library আর resolver উত্তর ধরে রাখে। কোনো কোনো runtime দীর্ঘ সময় DNS cache করে, তাই মরে যাওয়া ঠিকানাতেই পাঠাতে থাকে।
- স্বাস্থ্যের খবর নেই: DNS জানে না কে সুস্থ। আমাদের run-এ বন্ধ replica-র জন্য একটি অনুরোধ ২ সেকেন্ড আটকে ছিল।
- ভাগ নিয়ন্ত্রণ নেই: কয়েকটি ঠিকানা দিলে কে কোনটি নেবে, তা client ঠিক করে।
তাই আসল ব্যবস্থায় DNS খোঁজার কাজ করে, আর ভাগ করার কাজ করে একটি সত্যিকারের load balancer, যা পর্ব ১৮-এ দেখেছেন। Kubernetes-এর Service একটি স্থির virtual IP দেয়, ফলে পেছনের pod বদলালেও client-এর DNS উত্তর বদলায় না। এটি ঠিক এই সমস্যারই একটি সমাধান।
মূল উপলব্ধি: "নাম খুঁজে পাওয়া" আর "ঠিক জায়গায় পাঠানো" দুটি আলাদা কাজ। দুটিকে এক ভাবলেই scale করার দিনে অদ্ভুত আচরণ দেখা দেয়।
অনুশীলন
Docker না থাকলেও এগুলো কাগজে করা যায়। নিজের কম্পিউটারে চালালে শেষে docker compose down -v দিয়ে গুছিয়ে নিন, আর দেখে নিন সেটি আপনারই পরীক্ষার ফোল্ডার।
-
হিসাব: একটি database-এর healthcheck:
start_period: 30s,interval: 5s,timeout: 3s,retries: 6. (ক) পরীক্ষা সাথে সাথে ব্যর্থ হলে unhealthy হতে আনুমানিক কত সময়? (খ) প্রতিবার timeout হলে? (গ)--scale api=3-এর পরে ১২টি অনুরোধ round robin-এ গেলে প্রতিটি replica কয়টি পাবে?(ক) ৩০ + ৬ × ৫ = ৬০ সেকেন্ড। (খ) একবারের সময় ৫ + ৩ = ৮ সেকেন্ড, তাই ৩০ + ৬ × ৮ = ৭৮ সেকেন্ড। (গ) ১২ ÷ ৩ = ৪টি করে, যদি nginx তিনটি ঠিকানাই জানে আর সব worker একই হিসাব ভাগ করে। শেষ শর্তটিই এই পর্বের আসল শিক্ষা। এগুলো আনুমানিক, কারণ Docker-এর আসল সময় সামান্য বেশি হতে পারে।
-
সমালোচনা করুন: একজন সহকর্মী এই ফাইল দিয়েছেন:
dbservice-এimage: postgres:latest,ports: "5432:5432",POSTGRES_PASSWORD: admin123;webservice-এcontainer_name: web,depends_on: [db]; কোনো volume নেই। অন্তত পাঁচটি সমস্যা ধরুন, আর একটি নতুন নকশা বলুন।latest: version অনিশ্চিত, নির্দিষ্ট major-minor tag দিন।5432:5432: সব interface-এ খোলা। Port বাদ দিন, কারণwebnetwork দিয়েই পায়।- হাতে লেখা password:
${POSTGRES_PASSWORD:?...}আর Git-এর বাইরের.envব্যবহার করুন। container_name:--scaleআটকে দেয়, বাদ দিন।depends_on: [db]: শুধু started-এর জন্য অপেক্ষা করে।pg_isreadyদিয়ে healthcheck আরservice_healthyদিন।- Volume নেই: Container মুছলে ডেটা শেষ। Named volume যোগ করুন।
- Network:
frontআরbackভাগ করুন, যাতে শুধু দরকারি service database পায়।
-
ফল অনুমান করুন: বইঘরের stack-এ পরপর এই কাজগুলো হলো:
up -d, ৩টি অর্ডার,down,up -d, ২টি অর্ডার,stop,start,down -v,up -d, ১টি অর্ডার। প্রতিটিup-এর ঠিক পরেorders_doneকত, আর শেষে কত? শেষে volume কয়টি? সব অর্ডার গোছানো হয়েছে ধরে নিন।প্রথম
up-এর পরে 0. ৩টি অর্ডারে 3.downvolume রাখে, তাই দ্বিতীয়up-এর পরে 3. ২টি অর্ডারে 5.stopআরstartকিছু মোছে না, তাই 5-ই থাকে।down -vvolume মুছে দেয়, তাই তৃতীয়up-এর পরে 0. ১টি অর্ডারে শেষ ফল 1. Volume ১টি, তবে সেটি নতুন। -
আসল পরীক্ষা (ঐচ্ছিক, নিজের কম্পিউটারে): Shell-এ
export PROXY_PORT=19000,.env-এPROXY_PORT=18401, ফাইলে${PROXY_PORT:-8080}। আগে অনুমান করুনdocker compose config-এpublishedকত হবে, তারপরunset PROXY_PORTকরে আবার দেখুন। শেষে.env-এর লাইনটিও মুছে দেখুন। কিছু খরচ হয় না,configকোনো container চালায় না।প্রথমে 19000, কারণ shell
.env-কে হারায়।unset-এর পরে 18401,.envথেকে।.env-এর লাইন মুছলে 8080,:-default থেকে। এই তিন ধাপ মনে থাকলে "আমার ফাইলে তো ঠিক লেখা, তবু অন্য মান আসছে কেন" সমস্যাটি পাঁচ মিনিটে ধরা পড়বে। প্রায়ই কারণ হলো shell-এ অনেক আগে export করা একটি পুরোনো variable.
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমredis → api → proxy নির্ভরতার একটি stack-এ docker compose up -d-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- compose ফাইল আর
.envপড়ে variable পূরণ - network আর named volume তৈরি
- container তৈরি, redis চালু
- redis-এর healthcheck পাস করা পর্যন্ত অপেক্ষা
- api চালু, api healthy হওয়া পর্যন্ত অপেক্ষা
- proxy চালু
docker compose down (কোনো flag ছাড়া) সাধারণত কী মোছে?
- Container, network, named volume আর image, সব
- Project-এর container আর network
- কিছুই মোছে না, শুধু container থামায়
- শুধু named volume
একটি compose ফাইলে api-এর depends_on: [db] লেখা, কোনো condition নেই। db-তে ৩০ সেকেন্ড সময় লাগে connection নিতে। কী হবে?
- Compose ৩০ সেকেন্ড অপেক্ষা করবে, কারণ সে db-র প্রস্তুতি বোঝে
- api আগে চালু হবে, db পরে
- db চালু হওয়ার পরেই api শুরু হবে, কিন্তু তখন db প্রস্তুত নাও থাকতে পারে
- ফাইলটি dependency cycle হিসেবে বাতিল হবে
proxy আছে শুধু front network-এ, redis শুধু back-এ, api দুটিতেই। proxy-র ভেতর থেকে getent hosts redis চালালে কী হবে?
- redis-এর ঠিকানা পাবে, কারণ দুজন একই project-এ
- api-র মাধ্যমে redis-এর ঠিকানা পাবে
- পাবে, যদি redis-এ ports লেখা থাকে
- নাম পাওয়া যাবে না
.env ফাইলে SECRET_X=abc আছে। compose ফাইলের কোথাও SECRET_X নেই। Container-এর ভেতরে printenv SECRET_X কী দেবে?
- কিছু দেবে না, variable-টি সেট নেই
- abc
- Compose শুরুতেই error দেবে
- তারকাচিহ্ন দিয়ে লুকানো মান
nginx-এর upstream-এ server api:8000; লেখা, nginx চালু আছে। এবার docker compose up -d --scale api=3 চালানো হলো, কিন্তু reload হয়নি। নতুন দুটি replica কি অনুরোধ পাবে?
- হ্যাঁ, nginx নিজে থেকেই নতুন ঠিকানা জেনে যায়
- না, nginx-এর কাছে config লোডের সময়ের ঠিকানাই আছে
- হ্যাঁ, Compose nginx-এর config ফাইল লিখে দেয়
- না, আর পুরোনো replica-ও অনুরোধ পাওয়া বন্ধ করবে
একটি দলের অ্যাপ এখন একটি VM-এ Docker Compose দিয়ে চলছে। কোন পরিবর্তনটি Kubernetes-এর মতো orchestrator-এর দিকে যাওয়ার সবচেয়ে শক্ত কারণ?
- অ্যাপে ৪টি service আছে
- Developer-রা একটি পরীক্ষার service আলাদা রাখতে চান
- Machine নষ্ট হলেও অ্যাপ চালু রাখতে হবে, আর লোড এক machine ছাড়িয়ে যাচ্ছে
- কয়েকটি environment variable বদলাতে হবে
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- একটি ফাইল পুরো stack বর্ণনা করে, আর
up -dসেটিকে বাস্তব করে। Git-এ রাখুন, review করুন। - Service একে অপরকে নাম দিয়ে পায়, কিন্তু শুধু একই network-এ।
portsশুধু host থেকে ঢোকার পথ। - Started ≠ ready.
service_healthyশুরুর ক্রম দেয়, আর app-এর retry দেয় সারাজীবনের সহনশীলতা। downডেটা রাখে,down -vমোছে।.envশুধু ফাইলের variable পূরণ করে, আর Git-এ যায় না।docker compose configদিয়ে মেলানো ফল দেখুন। Scale করলে nginx reload আরzoneমনে রাখুন। Compose এক host-এর হাতিয়ার।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪১ · Registry আর supply chain — tag, digest, scanning, SBOM আর signingপরবর্তী পর্বLocal stack থেকে বিতরণে: image কোথায় রাখবেন, আর কীভাবে বিশ্বাস করবেন।
- ক্লাউড কম্পিউটিং ও DevOps · কোর্স হোম৮১টি পর্বপরের পর্ব ৪১: Registry আর supply chain. পুরো রোডম্যাপ এখানে।
- পর্ব ১৪ · Nginx আর reverse proxyআগের ভিত্তিupstream, proxy_pass আর timeout-এর পুরো ব্যাখ্যা।
- Cloud ও container networkingগভীরে যানBridge, veth আর container network-এর ভেতরের কাজ।
- Operating Systems · Containersগভীরে যানNamespace আর cgroup দিয়ে container কীভাবে তৈরি হয়।
- System Design · Load balancingপাশের ট্র্যাকRound robin ছাড়া আর কোন কৌশল আছে, কখন কোনটি।