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

ফুল-স্ট্যাক অ্যাপ Docker-এ — multi-stage build আর compose

Containerising a Full-Stack App — Multi-Stage Builds, Prisma and Docker Compose

Taught in Bangla, this lesson puts each process of the Bazarghar stack in its own container, writes a multi-stage Dockerfile for Next.js with standalone output and compares image sizes, uses .dockerignore and layer order for fast rebuilds, handles Prisma generate, OpenSSL and a separate migrate job, explains NEXT_PUBLIC_ values baked at build time, runs the stack with Compose health checks, volumes and seeds, and shuts down cleanly on SIGTERM.

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

  • একটি ফুল-স্ট্যাক অ্যাপকে কয়টি container-এ ভাগ করবেন, আর কোনটি container-এ রাখবেন না
  • Next.js 16-এর জন্য deps, build আর runner ধাপের Dockerfile, non-root user আর আসল মাপের তুলনা
  • .dockerignore, layer cache-এর ক্রম, আর কোড বনাম dependency বদলের পরে rebuild-এর সময়
  • Prisma 7 container-এ: build-এ generate, Alpine বনাম Debian slim-এ OpenSSL, আর migrate একটি আলাদা ধাপ
  • Compose-এ health check, depends_on-এর শর্ত, volume আর seed; SIGTERM, PID 1, scan আর resource limit

১ · কোন অংশ কোন container-এ — বাজারঘরের মানচিত্র

আগের পর্বগুলোতে “বাজারঘর” নামের কাল্পনিক অনলাইন দোকান টুকরো টুকরো তৈরি হয়েছে: Next.js-এর দোকানের পাতা, Express-এর API, পর্ব ৩৯-এর Prisma, পর্ব ৪৪-এর Redis আর পর্ব ৬২-এর BullMQ worker. এখন প্রশ্ন: এই সব একসাথে অন্য কম্পিউটারে, একইভাবে চালাবেন কীভাবে? এখানে Docker আসে।

Container, image, layer আর মূল কমান্ডগুলো এই কোর্সে আবার শেখানো হবে না। সেগুলো Cloud & DevOps কোর্সে বিস্তারিত আছে: Docker image আর layer, ভালো Dockerfile, container চালানো আর Docker Compose. এক লাইনে মনে করিয়ে দিই: image হলো অ্যাপ আর তার সব নির্ভরতার স্থির প্যাকেট, container হলো সেই প্যাকেট থেকে চালানো একটি বিচ্ছিন্ন process. এই পর্বের কাজ সেই জ্ঞান একটি আসল ফুল-স্ট্যাক অ্যাপে প্রয়োগ করা।

একটি বিয়েবাড়ির রান্নার কথা ভাবুন। বাবুর্চি, পরিবেশনের দল আর ভাঁড়ার ঘর আলাদা, প্রত্যেকের নিজের জায়গা আর দায়িত্ব। একজন অসুস্থ হলে শুধু তাঁকেই বদলানো হয়। আবার মসলা পেষার লোক একবার কাজ সেরে চলে যান; তিনি সারা রাত থাকেন না। Container ভাগ করার নিয়মও এমন: প্রতিটি দীর্ঘজীবী কাজের (web, api, worker) নিজের container, আর একবারের কাজ (migrate, seed) চলে, শেষ করে থেমে যায়।

বাজারঘরের container-এর মানচিত্র

One process per container; one-off jobs run and exit; data lives in named volumes

বাজারঘরের container-এর মানচিত্র ব্রাউজার :18080 web · Next.js 16 standalone, :3000 http://api:4000 migrate → seed একবার চলে, exit 0 api Express 5, Prisma :4000 worker BullMQ, পর্ব ৬২ একই image job PostgreSQL 18 health: pg_isready Redis 8 health: ping volume: pgdata volume: redisdata Production-এ এই দুটি প্রায়ই managed সেবা ড্যাশ দেওয়া বাক্স = একবারের কাজ, সারাক্ষণ চলে না
ছবি ৮০.১ — নীল আর বেগুনি বাক্স দীর্ঘজীবী service, ড্যাশ দেওয়া বাক্স একবারের কাজ। ডেটা থাকে named volume-এ, container মুছলেও মোছে না। api আর worker একই image থেকে চলে, শুধু শুরুর কমান্ড আলাদা।
অংশContainer-এ?কেন
web (Next.js 16)হ্যাঁ, নিজের imageNode server, নিজের build আর NEXT_PUBLIC_ মান
api (Express 5)হ্যাঁ, যদি আলাদা থাকেসব logic Next.js-এর route handler-এ (পর্ব ৫৭) থাকলে আলাদা api না-ও লাগতে পারে
workerহ্যাঁ, api-র image, আলাদা কমান্ডআলাদা container মানে আলাদা করে বাড়ানো, থামানো আর memory সীমা
migrate, seedএকবারের containerকাজ শেষে exit; প্রতিটি app container-এর ভেতরে নয়
PostgreSQL, Redislocal-এ হ্যাঁ; production-এ প্রায়ই managedBackup, replica, update অন্যের দায়িত্বে; নিজে চালালে সেগুলো আপনার দায়িত্ব
Reverse proxy, TLSVPS-এ প্রায়ই আলাদাপর্ব ৮২-এ Caddy-সহ
মূল ধারণা

এক container-এ এক ধরনের process. এক container-এ Next.js, Express আর worker একসাথে চালালে একটি ভেঙে পড়লে কে আবার চালু করবে, কোনটির log কোনটি, আর কোনটিকে কতটা memory দেবেন, সব জট পাকায়। Database container-এ চালানো দোষের নয়, বিশেষ করে local-এ। তবে production-এ managed database অনেক দলের সাধারণ পছন্দ, কারণ backup আর recovery তখন সেবাদাতার দায়িত্ব। সিদ্ধান্তটি আপনার দল, খরচ আর দক্ষতার ওপর নির্ভর করে; Cloud কোর্সের managed database পর্বে trade-off আছে।

ল্যাবের version (লেখার সময়ে): Docker Engine 29.4.2 (Docker Desktop), Compose v5.1.3, base image node:26-slim (Debian 13) আর node:26-alpine (Alpine 3.24), Next.js 16.4.4, React 19.3.1, Express 5.3.0, Prisma 7.10.1 (npm-এর latest তখন 8-এর release candidate ছিল, তাই stable 7.10.1), BullMQ 6.5.4, ioredis 6.0.0, postgres:18-alpine, redis:8-alpine আর Trivy 0.76.1. লেখার সময়ে node:lts tag আর node:26 একই digest দেখাচ্ছিল। সব সময় এই পর্বের একটি local run-এর; আপনার মেশিন আর network-এ সংখ্যা আলাদা হবে।

২ · Next.js-এর multi-stage Dockerfile — deps, build আর runner

প্রথমে ভুল পথটি দেখি, যা অনেক tutorial আর AI প্রথমে লেখে। একটি ধাপ, পুরো Node image, সব কিছু কপি:

FROM node:26
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["npm", "start"]

চলে ঠিকই, কিন্তু image-এ থেকে যায় compiler-সহ পুরো Debian, সব devDependency, সোর্স কোড, build cache, আর .dockerignore না থাকলে আপনার .env-ও। এখানে সমাধান multi-stage build: একাধিক FROM, প্রতিটি একটি ধাপ, আর শেষ ধাপে শুধু চালানোর জন্য যা লাগে তা কপি। Next.js-এর standalone output এ কাজে বড় সাহায্য করে। nextjs.org-এর output পাতা অনুযায়ী output: 'standalone' দিলে build একটি .next/standalone ফোল্ডার বানায়, যাতে একটি ছোট server.js আর trace করে বের করা শুধু দরকারি node_modules থাকে; public আর .next/static হাতে কপি করতে হয়, আর PORT ও HOSTNAME environment variable দিয়ে ঠিকানা বদলানো যায়।

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  output: 'standalone',
};

export default nextConfig;
# syntax=docker/dockerfile:1

# ---- ধাপ ১: শুধু dependency ----
FROM node:26-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# ---- ধাপ ২: build ----
FROM node:26-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SHOP_NAME
ENV NEXT_PUBLIC_SHOP_NAME=$NEXT_PUBLIC_SHOP_NAME
RUN npm run build

# ---- ধাপ ৩: runner ----
FROM node:26-slim AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
COPY --from=build --chown=node:node /app/public ./public
USER node
EXPOSE 3000
CMD ["node", "server.js"]

তিনটি জিনিস খেয়াল করুন। HOSTNAME=0.0.0.0 না দিলে server শুধু container-এর ভেতরের localhost-এ শুনতে পারে, বাইরে থেকে পৌঁছানো যায় না। USER node: official Node image-এ node নামের সাধারণ user আগে থেকেই থাকে, তাই non-root চালাতে নতুন user বানাতে হয়নি। আর CMD exec form-এ (JSON array), যাতে node নিজেই process হয়, মাঝে কোনো shell না থাকে; অংশ ৮-এ এর গুরুত্ব দেখবেন। ধাপে ধাপে কী ঘটে:

  1. depsnpm ciশুধু lockfile
  2. buildnext buildসোর্স + ARG
  3. standalone.next/standalonetrace করা ফাইল
  4. runnerUSER nodeনতুন, খালি base
  5. ফল407MBnaive 2.65GB
deps ধাপসত্যিকারের build log
#9 [deps 3/4] COPY package.json package-lock.json ./
#10 [deps 4/4] RUN npm ci
#10 32.07 added 28 packages, and audited 29 packages in 32s
#10 DONE 33.5s
build ধাপসত্যিকারের build log
#11 [build 3/5] COPY --from=deps /app/node_modules ./node_modules
#12 [build 4/5] COPY . .
#13 [build 5/5] RUN npm run build
#13 2.031 ✓ Compiled successfully in 1334ms
#13 4.907 Route (app)
#13 4.907 ┌ ƒ /
#13 4.907 ├ ○ /_not-found
#13 4.907 └ ƒ /api/health
#13 DONE 5.4s
.next/standalonerunner-এ যা যায়
server.js          ← ছোট Node server
package.json
node_modules/      ← 39M, শুধু trace করা অংশ
.next/server/      ← render-এর কোড
(public আর .next/static আলাদা করে কপি)
runner ধাপসত্যিকারের build log
#14 [runner 3/5] COPY --from=build --chown=node:node /app/.next/standalone ./
#15 [runner 4/5] COPY --from=build --chown=node:node /app/.next/static ./.next/static
#16 [runner 5/5] COPY --from=build --chown=node:node /app/public ./public
compiler, devDependency, সোর্স: কিছুই আসেনি
যাচাইসত্যিকারের run
$ docker image ls bz80-web
IMAGE            DISK USAGE   CONTENT SIZE
bz80-web:multi        407MB         97.8MB
bz80-web:naive       2.65GB          669MB
$ docker run --rm bz80-web:multi whoami
node
$ docker run --rm bz80-web:naive whoami
root
  1. deps: শুধু dependency. এই ধাপে সোর্স কোড আসে না, শুধু package.json আর lockfile. npm ci lockfile হুবহু মেনে ২৮টি package বসাল, ৩৩.৫ সেকেন্ডে। পরের অংশে দেখবেন, এই আলাদা থাকাই cache-এর চাবি।
  2. build: সোর্স আর compile. আগের ধাপের node_modules কপি করে তারপর সোর্স। next build route-এর তালিকা দিল: ƒ / dynamic, অর্থাৎ প্রতি অনুরোধে server-এ render হবে। Build ৫.৪ সেকেন্ডে শেষ।
  3. standalone: শুধু দরকারি অংশ। Next.js trace করে দেখে কোন ফাইল আসলে লাগে, আর .next/standalone-এ রাখে। ল্যাবের image-এ সেই node_modules মাত্র ৩৯MB; naive image-এ পুরোটা ৩৭১MB.
  4. runner: নতুন করে শুরু। নতুন FROM মানে আগের ধাপের কিছুই নেই, যতক্ষণ না COPY --from দিয়ে আনা হয়। ফাইলের মালিক node, আর USER node. Build-এর যন্ত্রপাতি পেছনে পড়ে থাকে।
  5. ফল মাপা। একই অ্যাপ: naive 2.65GB disk, নামাতে 669MB; multi-stage 407MB আর 97.8MB. Naive চলে root হিসেবে, multi-stage node হিসেবে। এই মাপ ল্যাবের একটি local run-এর।

“Disk usage” হলো image খোলা অবস্থায় disk-এ কতটা জায়গা নেয়, আর “content size” হলো registry থেকে নামানোর সময়কার সংকুচিত আকার। Server-এ deploy-এর সময় দ্বিতীয়টি network-এ যায়। নিচের block ল্যাবের মাপ থেকে হিসাব করে, ধরে নেওয়া 20 Mbit/s গতিতে কত সেকেন্ড লাগবে:

// এই পর্বের ল্যাবের সত্যিকারের মাপ (docker image ls, একটি local run)
// disk = খোলা অবস্থায় জায়গা, content = registry থেকে নামানোর (সংকুচিত) আকার, MB
const images = [
  ["web naive (node:26, 1 stage)", 2650, 669],
  ["web multi-stage (standalone)", 407, 97.8],
  ["api slim, --omit=dev", 841, 187],
  ["api slim, --omit=dev,optional", 483, 117],
  ["api alpine, same Dockerfile", 382, 93.7],
  ["base node:26-slim only", 353, 85.7],
];
const mbps = 20;      // ধরে নেওয়া: server-এর download গতি 20 Mbit/s
const servers = 3;    // ধরে নেওয়া: একই image তিনটি server-এ নামবে
const naive = images[0][2];
console.log("image".padEnd(30), "disk".padStart(6), "pull".padStart(6), "x naive", " secs@20Mbps", " 3 servers");
for (const [name, disk, content] of images) {
  const secs = (content * 8) / mbps;                   // MB -> Mbit -> সেকেন্ড
  console.log(
    name.padEnd(30),
    String(disk).padStart(6),
    String(content).padStart(6),
    (content / naive).toFixed(2).padStart(7),
    secs.toFixed(1).padStart(11),
    (content * servers).toFixed(0).padStart(9) + " MB"
  );
}
const app = 97.8 - 85.7;
console.log(`web multi-stage minus base = ${app.toFixed(1)} MB of your own layers`);

প্রথম সারি web naive … 669 … 267.6: নামাতে প্রায় সাড়ে চার মিনিট। Multi-stage-এর সারিতে 0.15 আর 39.1: আকার naive-এর ১৫%, সময় ৩৯.১ সেকেন্ড। তিনটি server-এ মোট traffic ২০০৭ MB বনাম ২৯৩ MB. শেষ লাইন আরও গুরুত্বপূর্ণ: 12.1 MB of your own layers. Base image-এর layer server-এ আগে থেকে থাকলে নতুন version deploy করতে মোটামুটি শুধু এই অংশ নামে, কারণ layer content hash দিয়ে চেনা হয় (Docker image পর্ব)। api-র তিনটি সারির গল্প অংশ ৫-এ।

৩ · .dockerignore — ভুল জিনিস image-এ ঢোকার আগেই থামান

docker build .-এর শেষের বিন্দুটি build context: এই ফোল্ডারের সব ফাইল builder-এর কাছে পাঠানো হয়, আর COPY . . সেখান থেকেই কপি করে। .dockerignore ফাইল বলে দেয় কোনগুলো পাঠানো হবে না। ল্যাবে web ফোল্ডারে একটি নকল .env রাখা হয়েছিল, যেমন অনেকের project-এ থাকে:

DATABASE_URL=postgresql://shop:example-secret@db:5432/shop
PAYMENT_SECRET=example-secret

তারপর naive Dockerfile .dockerignore ছাড়া build করা হলো। সত্যিকারের run:

$ docker build -f Dockerfile.naive -t bz80-web:naive .
#4 transferring context: 141.77MB 9.5s
#4 transferring context: 289.79MB 19.8s
#4 transferring context: 375.06MB 25.1s done
…
#7 57.75 added 3 packages, removed 4 packages, and audited 29 packages in 58s

$ docker run --rm bz80-web:naive cat /app/.env
DATABASE_URL=postgresql://shop:example-secret@db:5432/shop
PAYMENT_SECRET=example-secret

দুটি ক্ষতি একসাথে। এক, ৩৭৫MB context, কারণ আপনার কম্পিউটারের node_modules পুরোটা পাঠানো হয়েছে; শুধু পাঠাতেই ২৫ সেকেন্ড। লক্ষ করুন added 3 packages, removed 4 packages: ল্যাবের node_modules Windows-এ install করা, তাতে Windows-এর native binary ছিল; Linux container-এ npm সেগুলো সরিয়ে Linux-এর package বসিয়েছে। সবসময় এমন ভাগ্য হয় না; অন্য মেশিনের node_modules কপি হলে ভুল platform-এর binary নিয়ে crash খুব পরিচিত সমস্যা। দুই, .env image-এর ভেতরে। এই image কোনো registry-তে গেলে যিনিই image টানতে পারেন, তিনিই secret পড়তে পারেন; পরের layer-এ rm .env দিলেও আগের layer-এ তা থেকে যায়। এখন .dockerignore-সহ একই ফোল্ডারে multi-stage build:

node_modules
.next
.env*
!.env.example
Dockerfile*
.dockerignore
npm-debug.log*
.git
#5 [internal] load .dockerignore
#5 transferring context: 125B done
#6 [internal] load build context
#6 transferring context: 36.14kB done

$ docker run --rm bz80-web:multi sh -c "ls -la /app/.env"
ls: cannot access '/app/.env': No such file or directory

৩৭৫MB থেকে ৩৬KB, আর .env নেই। Secret তাহলে container-এ পৌঁছায় কীভাবে? চালানোর সময় environment variable হিসেবে, বা secret manager থেকে; build-এ কোনো secret লাগলে BuildKit-এর --mount=type=secret, যা layer-এ থাকে না। বিস্তারিত পর্ব ৭৮-এ আর Cloud কোর্সের Dockerfile পর্বে। নিয়মগুলো কীভাবে মেলে, একটি ছোট মডেলে দেখুন:

#!/bin/bash
# শিক্ষার মডেল: .dockerignore-এর মতো মেলানো (আসল নিয়মের সরল রূপ)
# নিয়ম: উপর থেকে নিচে পড়া হয়, শেষে যে pattern মেলে সেটিই জেতে; ! মানে আবার রাখা হবে
patterns=("node_modules" ".next" ".env*" "!.env.example" "Dockerfile*" ".git")
files=(app/page.tsx package.json package-lock.json public/robots.txt
       .env .env.local .env.example node_modules/next/package.json
       .next/cache/webpack.pack Dockerfile .git/HEAD)

ignored() {                       # 0 = বাদ যাবে, 1 = পাঠানো হবে
  local f=$1 res=1 p neg
  for p in "${patterns[@]}"; do
    neg=0
    if [[ $p == !* ]]; then neg=1; p=${p#!}; fi
    if [[ $f == $p || $f == $p/* ]]; then res=$neg; fi
  done
  return $res
}

sent=0; skipped=0
for f in "${files[@]}"; do
  if ignored "$f"; then echo "SKIP  $f"; skipped=$((skipped+1))
  else echo "SEND  $f"; sent=$((sent+1)); fi
done
echo "context: $sent sent, $skipped skipped"

Output-এ SKIP .env আর SKIP .env.local, কারণ .env* দুটিকেই মেলায়। কিন্তু SEND .env.example: পরে আসা !.env.example নিয়মটি জিতেছে। ক্রম উল্টালে .env.example-ও বাদ যেত, কারণ শেষের মিলই চূড়ান্ত। node_modules/next/package.json আর .git/HEAD বাদ, কারণ ফোল্ডার বাদ দিলে তার ভেতরের সবও বাদ। শেষ লাইন context: 5 sent, 6 skipped. আসল Docker-এ pattern মেলানোর নিয়ম আরও সূক্ষ্ম (Go-র filepath.Match, **), আর Dockerfile আর .dockerignore তালিকায় থাকলেও builder নিজে সেগুলো পড়ে, শুধু COPY দিয়ে image-এ যায় না।

৪ · Layer cache — কোড বদল বনাম dependency বদল

Dockerfile-এর প্রতিটি নির্দেশ একটি cache-যোগ্য ধাপ। BuildKit দেখে: নির্দেশ একই, আগের ধাপ একই, আর COPY হলে কপি হওয়া ফাইলের বিষয়বস্তু একই কি না। সব মিললে আগের ফল ব্যবহার করে (CACHED); একটি ধাপ বদলালে সেই ধাপ আর তার পরের সব ধাপ আবার চলে। তত্ত্বটি Docker image পর্বে আছে; এখানে বাজারঘরে মাপা যাক।

দর্জির দোকানে মাপ নেওয়া একবারের কাজ। আপনি শুধু বোতামের রং বদলাতে চাইলে দর্জি আবার মাপ নেন না। কিন্তু যদি নিয়ম হয় “যেকোনো বদলে আগে আবার মাপ”, তাহলে বোতামের জন্যও পুরো সময় যাবে। COPY . .-এর পরে npm ci রাখা ঠিক সেই নিয়ম: একটি লেখা বদলালেও সব dependency আবার নামে।

ল্যাবে একই web প্রজেক্টে পাঁচটি build চালানো হলো (একটি local run): কিছু না বদলে, app/page.tsx-এর একটি শব্দ বদলে, zod package যোগ করে (package-lock.json বদল), আর তুলনার জন্য একটি “ভুল ক্রমের” Dockerfile, যেখানে COPY . . আগে, npm ci পরে।

কী বদলালDockerfilenpm cinext buildমোট
কিছুই নাতিন ধাপCACHEDCACHED৩.১ সে
app/page.tsxতিন ধাপCACHED৪.১ সে৮.৩ সে
package-lock.jsonতিন ধাপ৩৩.৩ সে৩.৯ সে৪৩.১ সে
app/page.tsxভুল ক্রম৩৩.২ সে৩.৭ সে৫০.৪ সে

সত্যিকারের log-এর দুটি টুকরো, কোড বদলের পরে:

# তিন ধাপের Dockerfile, app/page.tsx বদলের পরে
#8 [deps 3/4] COPY package.json package-lock.json ./   CACHED
#9 [deps 4/4] RUN npm ci                               CACHED
#12 [build 4/5] COPY . .                               DONE 0.0s
#13 [build 5/5] RUN npm run build                      DONE 4.1s

# ভুল ক্রমের Dockerfile, একই বদলের পরে
#6 [3/5] COPY . .                                      DONE 0.1s
#7 [4/5] RUN npm ci                                    DONE 33.2s
#8 [5/5] RUN npm run build                             DONE 3.7s

দিনে বিশবার build করলে পার্থক্য প্রায় ২০ × ৪২ = ৮৪০ সেকেন্ড, অর্থাৎ ১৪ মিনিট, শুধু অপেক্ষায়। CI-তে প্রতি push-এ এই খরচ আরও বড়, যদি সেখানে cache রাখা হয় (pipeline-এর নকশা)। নিচের মডেলে নিয়মটি কোডে লেখা:

// শিক্ষার মডেল: BuildKit কোন ধাপ আবার চালাবে (আসল BuildKit নয়)
// সেকেন্ডগুলো এই পর্বের ল্যাবের একটি local run থেকে নেওয়া
const good = [
  { st: "deps",   id: "copy-pkg",   reads: ["package.json", "package-lock.json"] },
  { st: "deps",   id: "npm-ci",     sec: 33.3 },
  { st: "build",  id: "from-deps",  from: "deps" },
  { st: "build",  id: "copy-src",   reads: ["*"] },
  { st: "build",  id: "next-build", sec: 4.1 },
  { st: "runner", id: "from-build", from: "build" },
];
const bad = [
  { st: "one", id: "copy-src",   reads: ["*"] },
  { st: "one", id: "npm-ci",     sec: 33.3 },
  { st: "one", id: "next-build", sec: 4.1 },
];
function plan(name, steps, changed) {
  const dirty = {}; let sec = 0; const ran = [];
  for (const s of steps) {
    let d = dirty[s.st] || (s.from ? !!dirty[s.from] : false);
    if (s.reads && s.reads.some((f) => (f === "*" ? changed.length > 0 : changed.includes(f)))) d = true;
    dirty[s.st] = d;
    if (d) { sec += s.sec || 0; ran.push(s.id); }
  }
  const cached = steps.length - ran.length;
  console.log(`${name.padEnd(24)} ~${sec.toFixed(1).padStart(4)}s  cached ${cached}, rerun: ${ran.join(", ") || "-"}`);
}
plan("good: no change", good, []);
plan("good: app/page.tsx", good, ["app/page.tsx"]);
plan("good: package-lock.json", good, ["package-lock.json"]);
plan("bad:  app/page.tsx", bad, ["app/page.tsx"]);

দ্বিতীয় লাইন ~ 4.1s cached 3, rerun: copy-src, next-build, from-build: কোড বদলালে শুধু build ধাপ আর runner-এর কপি আবার চলে। তৃতীয় লাইনে lockfile বদলেছে, তাই cached 0 আর ~37.4s. শেষ লাইনে ভুল ক্রমে একই কোড বদলে ~37.4s, কারণ copy-src বদলানোয় তার পরের npm-ci-ও আবার চলেছে। মডেল শুধু RUN ধাপের সেকেন্ড যোগ করে; আসল মোট সময়ে context পাঠানো, layer export আর base image যাচাইও থাকে, তাই টেবিলের ৮.৩, ৪৩.১ আর ৫০.৪ সেকেন্ড একটু বেশি।

আরও দ্রুত: dependency বদলালেও সব package নতুন করে নামানোর দরকার নেই। RUN --mount=type=cache,target=/root/.npm npm ci npm-এর download cache build-এর মধ্যে রেখে দেয়, তবে সেটি image-এ ঢোকে না। এই ল্যাবে সেটি মাপা হয়নি, তাই কোনো সংখ্যা দিচ্ছি না।

৫ · Prisma container-এ — generate, engine, OpenSSL আর migrate job

api-র Dockerfile-এর গঠন web-এর মতোই, সাথে একটি বাড়তি ধাপ। prisma generate build-এর সময় চলে (npm run build = prisma generate && tsc), আর migrate-এর জন্য আলাদা একটি target:

# syntax=docker/dockerfile:1
FROM node:26-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:26-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:26-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --omit=optional

# one-off job: prisma migrate deploy (has the prisma CLI)
FROM build AS migrate
USER node
CMD ["npx", "prisma", "migrate", "deploy"]

FROM node:26-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 4000
CMD ["node", "dist/server.js"]

runner শেষে রাখা জরুরি: --target না দিলে docker build শেষ ধাপটিই বানায়। এই Dockerfile তিনটি সত্যিকারের সমস্যা পেরিয়ে এখানে পৌঁছেছে।

সমস্যা ১: build-এ DATABASE_URL নেই

পর্ব ৪০-এর prisma.config.ts-এ লেখা ছিল url: env('DATABASE_URL'). Container-এর build-এ কোনো database নেই, থাকা উচিতও নয়। সত্যিকারের build:

#13 3.601 Failed to load config file "/app" as a TypeScript/JavaScript module. Error: PrismaConfigEnvError: Cannot resolve environment variable: DATABASE_URL.
ERROR: failed to build: failed to solve: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1

Prisma-র v7 config reference বলে, env() helper variable না পেলে error দেয়; যেখানে শুধু generate চলে, সেখানে সরাসরি process.env পড়তে বলে। ঠিক করা config:

import { defineConfig } from 'prisma/config';

export default defineConfig({
  schema: 'prisma/schema.prisma',
  migrations: { path: 'prisma/migrations', seed: 'node dist/seed.js' },
  datasource: {
    // build-এ (prisma generate) DATABASE_URL থাকে না; migrate deploy-এর সময় থাকে
    url: process.env.DATABASE_URL ?? '',
  },
});

ভুল সমাধান হতো build-এ ENV DATABASE_URL=... লিখে দেওয়া: তাতে সত্যিকারের password image-এর metadata-য় থেকে যায়।

সমস্যা ২: production image-এ Prisma CLI ঢুকে পড়ে

প্রথম চেষ্টায় prod-deps ধাপে ছিল শুধু npm ci --omit=dev. Image হলো 841MB, যেখানে node_modules একাই ৩৭০M, তার মধ্যে @prisma/* ১৬৭M আর prisma CLI ৪১M. কারণ খুঁজতে lockfile দেখা হলো: @prisma/client prisma-কে optional peer dependency হিসেবে ঘোষণা করে, আর আমাদের package.json-এ prisma devDependency. npm এমন package-কে lockfile-এ devOptional চিহ্ন দেয়, আর তা বাদ যায় শুধু dev আর optional দুটোই বাদ দিলে। --omit=dev --omit=optional দেওয়ার পরে:

$ docker image ls      # কলাম ছোট করা
bz80-api:1   (--omit=dev)            841MB   187MB
bz80-api:3   (--omit=dev,optional)   481MB   116MB
$ docker run --rm bz80-api:3 sh -c "du -sh /app/node_modules; ls /app/node_modules/@prisma"
94M	/app/node_modules
adapter-pg  client  client-runtime-utils  debug  driver-adapter-utils

অংশ ২-এর টেবিলে আর পরের তুলনায় এই image-এর মাপ লেখা আছে 483MB আর 117MB, ওপরের এই run-এ এসেছে 481MB আর 116MB. দুটিই আলাদা build-এর মাপ; একই নির্দেশেও দুই build-এর মাপ কয়েক MB আলাদা হতে পারে, তাই হিসাবে মোটা দাগের তুলনাটিই ধরুন।

লক্ষ করুন, runner-এ @prisma/engines নেই, তবু api database-এ query চালিয়েছে। লেখার সময়ের Prisma 7-এ prisma-client generator আর driver adapter (@prisma/adapter-pg) ব্যবহার করলে client-এর জন্য আলাদা Rust query engine binary লাগে না, query তৈরি হয় JavaScript আর WebAssembly-তে। Engine binary এখন লাগে মূলত migrate-এর schema engine-এর জন্য, আর সেটি আছে migrate image-এ।

--omit=optional সাবধানে। কিছু package সত্যিই optional dependency হিসেবে platform-এর binary আনে। এই ল্যাবেই আরেকটি সম্পর্কিত ফাঁদ ধরা পড়েছে: BullMQ 6 ioredis-কে optional peer ঘোষণা করে, তাই আমরা নিজেরা ioredis না লিখলে সেটি install-ই হয় না, --omit থাকুক বা না থাকুক। ফল অংশ ৭-এ। যা runtime-এ লাগে, তা সরাসরি dependencies-এ লিখুন, আর image বানিয়ে একবার চালিয়ে দেখুন।

সমস্যা ৩: Alpine, Debian slim আর OpenSSL

পুরোনো Prisma version-এ Alpine-এ OpenSSL না পাওয়ার error খুব পরিচিত ছিল, কারণ Prisma-র engine binary নির্দিষ্ট OS, C library আর OpenSSL version-এর জন্য বানানো। একই Dockerfile শুধু node:26-slim-এর জায়গায় node:26-alpine দিয়ে চালানো হলো। সত্যিকারের ফল, একটি নতুন database-এ:

$ docker run --rm --network bz80_default -e DATABASE_URL=… bz80-api-migrate:alpine
Applying migration `20261103090000_init`
All migrations have been successfully applied.

$ ls /app/node_modules/@prisma/engines/          # slim image-এ
schema-engine-debian-openssl-3.0.x
$ ls /app/node_modules/@prisma/engines/          # alpine image-এ
schema-engine-linux-musl-openssl-3.0.x

কোনো error নেই। Prisma build-এর সময় platform চিনে ঠিক binary এনেছে: Debian-এ glibc-র জন্য, Alpine-এ musl-এর জন্য, আর দুটো image-এই libssl.so.3 আছে। কিন্তু base image ছোট করতে গিয়ে কেউ libssl সরিয়ে দিলে কী হয়? ল্যাবে Alpine image থেকে libssl মুছে আরেকটি নতুন database-এ চালানো হলো:

prisma:warn Prisma failed to detect the libssl/openssl version to use, and may not work as expected. Defaulting to "openssl-1.1.x".
Please manually install OpenSSL and try installing Prisma again.
Error: Can't write to /app/node_modules/@prisma/engines please make sure you install "prisma" with the right permissions.

দুটি শিক্ষা এক error-এ। প্রথমত, OpenSSL না পেলে Prisma পুরোনো 1.1.x ধরে নিয়ে অন্য binary নামাতে চায়। দ্বিতীয়ত, সেটি নামাতে চায় চালানোর সময়, কিন্তু container চলছে non-root node user-এ, আর node_modules root-এর, তাই লেখা যায় না। এটি আসলে ভালো খবর: production container চালু হওয়ার সময় internet থেকে binary নামানো উচিতই নয়। নিয়ম: engine আর generate build-এর সময়, যে base image-এ চলবে সেই base image-এই।

Migrate: প্রতিটি start-এ নয়, deploy-এর আগে একবার

অনেক Dockerfile-এ দেখা যায় CMD npx prisma migrate deploy && node server.js. একটি container-এ চলে, কিন্তু চারটি api container একসাথে চালু হলে চারটিই migrate চালাতে চায়; Prisma Migrate PostgreSQL-এ lock নিয়ে একসাথে চলা আটকানোর চেষ্টা করে বলে জানা যায়, তবু প্রতিটি start ধীর হয়, app-এর image-এ CLI রাখতে হয়, আর migrate ব্যর্থ হলে app crash loop-এ পড়ে। ভালো পথ: migrate একটি one-off job. Compose-এ সেটি migrate service, CI/CD-তে deploy-এর আগের একটি ধাপ (পর্ব ৮১), Kubernetes-এ একটি Job. Compose-এ দ্বিতীয়বার up করলে সত্যিকারের log:

migrate-1  | 1 migration found in prisma/migrations
migrate-1  | No pending migrations to apply.

Migrate চালানোর অধিকারও আলাদা হতে পারে: migrate job-এর database user টেবিল বানাতে পারে, app-এর user শুধু ডেটা পড়া-লেখা। ধ্বংসাত্মক বদল কীভাবে কয়েক ধাপে করতে হয় (expand and contract), সেটি পর্ব ৪০-এ আছে।

৬ · NEXT_PUBLIC_ — build-এর সময়েই বসে যায়

Web-এর Dockerfile-এ ARG NEXT_PUBLIC_SHOP_NAME কেন? কারণ NEXT_PUBLIC_ দিয়ে শুরু variable-এর মান Next.js next build-এর সময় JavaScript-এর ভেতরে লিখে দেয়। Container চালানোর সময় অন্য মান দিলে কী হয়, ল্যাবে দেখা হলো। Image build হয়েছিল --build-arg NEXT_PUBLIC_SHOP_NAME="Bazarghar Dhaka" দিয়ে; চালানো হলো অন্য মান দিয়ে:

$ docker run -d --name bz80-envtest -e NEXT_PUBLIC_SHOP_NAME="Bazarghar Chattogram" … bz80-web:dev
$ curl -s http://127.0.0.1:18081/ | grep shop
<p id="shop">Shop: Bazarghar Dhaka</p>
$ docker exec bz80-envtest sh -c 'echo "env in container: $NEXT_PUBLIC_SHOP_NAME"; grep -rl "Bazarghar Dhaka" /app/.next'
env in container: Bazarghar Chattogram
/app/.next/server/chunks/ssr/app_banner_tsx_0iyt50sn7omn_._.js
/app/.next/static/chunks/0oz8u40l6mx_i.js

Container-এর environment-এ “Chattogram”, অথচ পাতায় “Dhaka”, কারণ মানটি server-এর SSR chunk আর browser-এর static chunk দুই জায়গাতেই লেখা হয়ে গেছে। এর দুটি ফল। এক, staging আর production-এ আলাদা NEXT_PUBLIC_ মান লাগলে দুটি আলাদা image build করতে হয়, অথবা মানটি runtime-এ server থেকে পড়তে হয়। দুই, NEXT_PUBLIC_-এ কখনো secret নয়, কারণ সেটি সবার browser-এ যায়। অন্যদিকে API_URL (NEXT_PUBLIC_ ছাড়া) server component চালানোর সময় পড়ে, তাই compose-এ http://api:4000 দিলে সেটিই কাজ করেছে। পুরো আলোচনা, আর “একটি build, অনেক পরিবেশ” কীভাবে করবেন, পর্ব ৭৮-এ।

মূল ধারণা

Build argument image-এর অংশ হয়ে যায়, আর environment variable container চালানোর সময় আসে। Image যত বেশি runtime config-এর ওপর নির্ভর করে, একই image তত বেশি পরিবেশে চলে। তাই NEXT_PUBLIC_ কম রাখুন, আর যা পরিবেশভেদে বদলায় তা server-এ পড়ুন।

৭ · Compose দিয়ে পুরো stack — health check, ক্রম, volume আর seed

সাতটি container হাতে একটি একটি করে চালানো ক্লান্তিকর আর ভুলপ্রবণ। Docker Compose একটি YAML ফাইলে পুরো stack লেখে। Local development আর CI-র জন্য এটি দারুণ; production-এ একটি VPS-এ ছোট অ্যাপের জন্যও অনেকে ব্যবহার করেন। Compose-এর সাধারণ কমান্ড Cloud কোর্সের Compose পর্বে। বাজারঘরের ফাইল (কিছুটা ছোট করা):

name: bz80
x-db-url: &db-url postgresql://shop:example-secret@postgres:5432/bazarghar

services:
  postgres:
    image: postgres:18-alpine
    environment: { POSTGRES_USER: shop, POSTGRES_PASSWORD: example-secret, POSTGRES_DB: bazarghar }
    volumes: [ "pgdata:/var/lib/postgresql" ]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shop -d bazarghar"]
      interval: 3s
      retries: 10
  redis:
    image: redis:8-alpine
    volumes: [ "redisdata:/data" ]
    healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 3s, retries: 10 }
  migrate:
    build: { context: ./api, target: migrate }
    environment: { DATABASE_URL: *db-url }
    depends_on: { postgres: { condition: service_healthy } }
  seed:
    build: ./api
    command: ["node", "dist/seed.js"]
    environment: { DATABASE_URL: *db-url }
    depends_on: { migrate: { condition: service_completed_successfully } }
  api:
    build: ./api
    init: true
    stop_grace_period: 10s
    environment: { DATABASE_URL: *db-url, REDIS_URL: "redis://redis:6379" }
    ports: [ "127.0.0.1:18040:4000" ]
    depends_on:
      seed: { condition: service_completed_successfully }
      redis: { condition: service_healthy }
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 5s
      start_period: 10s
    deploy: { resources: { limits: { cpus: "0.50", memory: 256M } } }
  worker:
    build: ./api
    init: true
    command: ["node", "dist/worker.js"]
    depends_on:
      migrate: { condition: service_completed_successfully }
      redis: { condition: service_healthy }
  web:
    build: { context: ./web, args: { NEXT_PUBLIC_SHOP_NAME: "Bazarghar Dhaka" } }
    init: true
    environment: { API_URL: "http://api:4000" }
    ports: [ "127.0.0.1:18080:3000" ]
    depends_on: { api: { condition: service_healthy } }

volumes:
  pgdata:
  redisdata:

কয়েকটি সিদ্ধান্ত ব্যাখ্যা করি। depends_on-এর সাধারণ রূপ শুধু “আগে চালু করা” বোঝায়, “তৈরি হলে” নয়। PostgreSQL-এর process চালু হওয়া আর সংযোগ নিতে পারার মধ্যে কয়েক সেকেন্ড ফাঁক থাকে; condition: service_healthy সেই ফাঁক ঢাকে, কারণ তখন compose health check সফল হওয়া পর্যন্ত অপেক্ষা করে। service_completed_successfully মানে “ওই container exit 0 দিয়ে শেষ হলে”, যা migrate আর seed-এর জন্য ঠিক। Slim image-এ curl নেই, তাই health check Node-এর নিজের fetch দিয়ে, আর /healthz শুধু “process বেঁচে আছে” নয়, database-এ SELECT 1-ও চালায় (পর্ব ৩১-এর liveness আর readiness ভাবনা)। Port শুধু 127.0.0.1-এ খোলা, যাতে অফিসের network থেকে কেউ dev database বা API-তে না ঢোকেন। আর named volume pgdata: container মুছে আবার বানালেও ডেটা থাকে; docker compose down -v দিলে তবেই মোছে। Password এখানে স্পষ্ট নকল (example-secret); আসল প্রজেক্টে compose ফাইলে নয়, .env বা secret থেকে আসবে।

  1. postgres, redisStartedকোনো শর্ত নেই
  2. Healthypg_isreadymigrate শুরু
  3. migrateexit 0seed, worker শুরু
  4. seedexit 0api শুরু
  5. apiHealthyweb শুরু
  6. webHealthy২৪ সেকেন্ড
docker compose up -d --waitসত্যিকারের output
 Network bz80_default Created
 Volume bz80_pgdata Created
 Volume bz80_redisdata Created
 …
 Container bz80-postgres-1 Started
 Container bz80-redis-1 Started
 Container bz80-postgres-1 Waiting
Health check পাসসত্যিকারের output
 Container bz80-postgres-1 Healthy
 Container bz80-migrate-1 Starting
 Container bz80-migrate-1 Started
 Container bz80-redis-1 Healthy
migrate-এর logসত্যিকারের output
migrate-1 | 1 migration found in prisma/migrations
migrate-1 | Applying migration `20261103090000_init`
migrate-1 | All migrations have been successfully applied.
 Container bz80-migrate-1 Exited
 Container bz80-worker-1 Starting
 Container bz80-seed-1 Starting
seedসত্যিকারের output
seed-1   | seed: 3 products upserted
worker-1 | worker ready, queue=orders
 Container bz80-seed-1 Exited
 Container bz80-api-1 Starting
apiসত্যিকারের output
api-1    | api listening on 4000
 Container bz80-api-1 Waiting
 Container bz80-api-1 Healthy   ← /healthz: SELECT 1
 Container bz80-web-1 Starting
docker compose ps -aসত্যিকারের output
SERVICE    STATUS
api        Up 14 seconds (healthy)
migrate    Exited (0) 15 seconds ago
postgres   Up 23 seconds (healthy)
redis      Up 23 seconds (healthy)
seed       Exited (0) 14 seconds ago
web        Up 5 seconds (healthy)
worker     Up 14 seconds
  1. শর্তহীন service আগে। Network আর দুটি named volume তৈরি হলো। postgres আর redis-এর কোনো depends_on নেই, তাই দুটি একসাথে চালু। বাকি সবাই অপেক্ষায়।
  2. Health check পাস। pg_isready সফল হতেই postgres “Healthy”, আর শুধু তখনই migrate চালু হলো। শর্ত না থাকলে migrate হয়তো আগেই চলে “connection refused” দিয়ে মরত।
  3. Migrate একবার চলে থামে। একটি migration প্রয়োগ হলো, container exit 0. এতে service_completed_successfully শর্ত পূরণ হলো: seed আর worker (যার শর্ত migrate আর redis) চালু।
  4. Seed. তিনটি পণ্য upsert হলো, তাই বারবার চালালেও একই ফল (পর্ব ৪০-এর idempotent seed). Seed exit 0 হতেই api চালু হলো।
  5. api Healthy. শুধু “listening” ছাপা যথেষ্ট নয়; health check /healthz ডেকে database-এ SELECT 1 সফল হলে তবেই “Healthy”, আর তখন web চালু।
  6. পুরো stack তৈরি। --wait সব health check পাস হওয়া পর্যন্ত অপেক্ষা করেছে: একটি local run-এ নতুন volume থেকে ২৪ সেকেন্ড। migrate আর seed “Exited (0)”, যা ভুল নয়, ঠিক এটাই চাওয়া। worker-এর কোনো health check নেই, তাই শুধু “Up”.

এবার stack-এর ভেতর দিয়ে একটি সত্যিকারের অনুরোধ: browser থেকে web, web-এর server component থেকে api, api থেকে PostgreSQL; তারপর একটি order, যা Redis-এর queue হয়ে worker-এ যায়।

$ curl -s http://127.0.0.1:18080/
<p id="shop">Shop: Bazarghar Dhaka</p><ul><li>Miniket Rice 5kg: 420 taka (stock 40)</li><li>Masur Dal 1kg: 135 taka (stock 60)</li><li>Tea 400g: 260 taka (stock 25)</li></ul>

$ curl -s -i -X POST http://127.0.0.1:18040/orders -H 'content-type: application/json' -d '{"productId":2,"qty":3}'
HTTP/1.1 201 Created
{"id":1,"productId":2,"qty":3,"status":"pending","createdAt":"2026-11-03T11:37:44.782Z"}

$ docker compose logs worker
worker-1  | job 1: order 1 confirmed

$ docker compose exec -T postgres psql -U shop -d bazarghar -c 'SELECT id, "productId", qty, status FROM "Order";'
 id | productId | qty |  status
----+-----------+-----+-----------
  1 |         2 |   3 | confirmed

$ curl … -d '{"productId":2,"qty":99}'      # qty-র সীমা 1-10
400

Order pending হিসেবে তৈরি হয়ে 201 ফিরেছে, তারপর worker সেটিকে confirmed করেছে: পর্ব ৬২-এর producer আর worker এখন আলাদা container-এ। কেউ চাইলে docker compose up --scale worker=3 দিয়ে worker বাড়াতে পারেন, api না ছুঁয়েই।

একটি সত্যিকারের ব্যর্থতা: “Healthy” বলল, কিন্তু worker মরে গেল

প্রথম চেষ্টায় সব service “Healthy” দেখাল, অথচ কয়েক সেকেন্ড পরে:

 Container bz80-worker-1 Healthy
SERVICE    STATUS
worker     Exited (137) 1 second ago

worker-1  | Error: BullMQ could not load the optional 'ioredis' package. Install it with `npm install ioredis`, or provide a different Redis client instance (e.g. node-redis) via the connection option. In a native ESM environment, pass an already-constructed client instance instead of connection options.
worker-1  |     at new Worker (/app/node_modules/bullmq/dist/cjs/classes/worker.js:43:9)

$ docker inspect bz80-worker-1 --format "{{.State.OOMKilled}} {{.State.ExitCode}}"
true 137

তিনটি শিক্ষা। এক, BullMQ 6 ioredis-কে optional peer ঘোষণা করে, তাই সেটি dependencies-এ নিজে লিখতে হয়; error বার্তা ঠিক সেটিই বলেছে, আর ESM-এ একটি তৈরি client দিতে বলেছে। সমাধান: ioredis 6.0.0 যোগ করে new Redis(url, { maxRetriesPerRequest: null }) পাঠানো। দুই, error-এর ধারায় memory বেড়ে ২৫৬MB সীমা ছাড়িয়েছে, kernel process মেরেছে (OOMKilled true, exit 137): resource limit কাজ করেছে, পুরো মেশিন বাঁচিয়েছে। তিন, health check ছাড়া service-এর ক্ষেত্রে compose-এর “Healthy” মানে শুধু “চালু হয়েছিল”। Worker-এর জন্য heartbeat ফাইল বা queue-র সংযোগ যাচাই করা health check, আর restart: unless-stopped নীতি ভাবার মতো।

Compose-এর এই শর্তগুলো থেকে কে কখন চালু হবে, তা হিসাব করা যায়। নিচের মডেলে প্রতিটি service কত সেকেন্ডে তৈরি হয়, তা ধরে নেওয়া সংখ্যা:

// শিক্ষার মডেল: compose-এর depends_on আর condition থেকে চালুর সময় (আসল compose নয়)
// ready = চালুর কত সেকেন্ড পরে healthy বা কাজ শেষ (ধরে নেওয়া সংখ্যা)
const svc = {
  postgres: { ready: 4, deps: {} },
  redis:    { ready: 2, deps: {} },
  migrate:  { ready: 3, exit: 0, deps: { postgres: "service_healthy" } },
  seed:     { ready: 2, exit: 0, deps: { migrate: "service_completed_successfully" } },
  worker:   { ready: 1, deps: { migrate: "service_completed_successfully", redis: "service_healthy" } },
  api:      { ready: 5, deps: { seed: "service_completed_successfully", redis: "service_healthy" } },
  web:      { ready: 3, deps: { api: "service_healthy" } },
};
const memo = {};
function startAt(name, path = []) {
  if (path.includes(name)) throw new Error("cycle: " + [...path, name].join(" -> "));
  if (name in memo) return memo[name];
  let t = 0;
  for (const [dep, cond] of Object.entries(svc[name].deps)) {
    const s = startAt(dep, [...path, name]);
    if (s === null) return (memo[name] = null);           // dependency failed
    if (cond === "service_completed_successfully" && svc[dep].exit !== 0) return (memo[name] = null);
    t = Math.max(t, cond === "service_started" ? s : s + svc[dep].ready);
  }
  return (memo[name] = t);
}
function show(title) {
  for (const k in memo) delete memo[k];
  console.log("--", title);
  const rows = Object.keys(svc).map((n) => [n, startAt(n)]);
  rows.sort((a, b) => (a[1] ?? 99) - (b[1] ?? 99));
  for (const [n, t] of rows) console.log(n.padEnd(9), t === null ? "never started (dependency failed)" : `start t=${t}s`);
}
show("all good");
svc.migrate.exit = 1;
show("migrate exits 1");

প্রথম অংশে migrate start t=4s, কারণ postgres ৪ সেকেন্ডে healthy. seed start t=7s = ৪ + migrate-এর ৩. worker start t=7s: দুটি শর্তের বড়টি, migrate শেষ (৭) আর redis healthy (২)। api start t=9s = seed শেষ ৭ + ২, আর web start t=14s = ৯ + api-র ৫. দ্বিতীয় অংশে migrate exit 1, তাই seed, worker, api, web চারটিই never started (dependency failed): ভাঙা migration-এর ওপর app চালু হয় না, যা ঠিক চাওয়া আচরণ। Compose-এও এমন ক্ষেত্রে নির্ভরশীল service চালু হয় না আর error দেখায়। condition-এ চক্র থাকলে মডেল cycle error দেয়; আসল compose-ও চক্র মানে না।

৮ · SIGTERM, PID 1 আর init — থামার সময় অর্ডার না হারানো

নতুন version deploy, docker compose down বা server-এর রক্ষণাবেক্ষণ: সব ক্ষেত্রেই Docker প্রথমে container-এর প্রধান process-কে SIGTERM পাঠায়, তারপর সময়সীমা (Docker-এ সাধারণত ১০ সেকেন্ড) পেরোলে SIGKILL. পর্ব ৩১-এ graceful shutdown-এর কোড শিখেছেন: নতুন সংযোগ বন্ধ, চলমান অনুরোধ শেষ, তারপর exit. Container-এ একটি বাড়তি মোড় আছে: PID 1.

Container-এর ভেতরে প্রথম process-এর PID 1, যেমন একটি পুরো Linux-এ init. Linux kernel PID 1-কে বিশেষ সুরক্ষা দেয়: যে signal-এর জন্য process নিজে কোনো handler বসায়নি, সেটি PID 1-এ পৌঁছালে ডিফল্ট আচরণ (মরে যাওয়া) ঘটে না। Node নিজে থেকে SIGTERM-এর handler বসায় না। ফলে Node যখন PID 1 আর আপনার কোডে handler নেই, SIGTERM কিছুই করে না। Init process (যেমন tini) PID 1 হয়ে বসে, signal Node-এর কাছে পাঠিয়ে দেয়, আর মরে যাওয়া child process-গুলোও পরিষ্কার করে। Docker-এ --init, compose-এ init: true এটি যোগ করে।

ল্যাবে একটি ছোট server বানানো হলো, যার /slow উত্তর দিতে ৩ সেকেন্ড লাগে, যেন একটি order সেভ হচ্ছে। একটি অনুরোধ পাঠিয়ে ১ সেকেন্ড পরে docker stop, চারভাবে। সময়সীমা স্পষ্ট করে --stop-timeout 10 দেওয়া হয়েছিল। সত্যিকারের run:

import http from 'node:http';

const server = http.createServer((req, res) => {
  if (req.url === '/slow') {               // একটি order সেভ হতে ৩ সেকেন্ড লাগে
    setTimeout(() => res.end('order saved\n'), 3000);
    return;
  }
  res.end('ok\n');
});
server.listen(3000, () => console.log(`pid ${process.pid} listening`));

if (process.env.GRACEFUL === '1') {
  process.on('SIGTERM', () => {
    console.log('SIGTERM: no new connections, finishing in-flight');
    server.close(() => { console.log('closed cleanly'); process.exit(0); });
  });
}
  1. পরীক্ষা/slow + stopt=0, t=1
  2. init নেইexit 137১১.৩ সে
  3. init আছেexit 143অনুরোধ কাটা
  4. init + handlerexit 0২.৩ সে
  5. শুধু handlerexit 0২.৪ সে
পরীক্ষার ছকপ্রতিটিতে একই
t=0.0  curl /slow           (উত্তর আসবে t≈3.0-এ)
t=1.0  docker stop          → PID 1-এ SIGTERM
       ১০ সেকেন্ডে না থামলে → SIGKILL
মাপা হয়: client কী পেল, stop কত সময় নিল, exit code
১ · init নেই, handler নেইসত্যিকারের run
log: pid 1 listening
client: rc=0 reply='order saved' after 3.3s
docker stop took 11.3s
exit code: 137
২ · --init, handler নেইসত্যিকারের run
log: pid 7 listening
client: rc=52 reply='curl: (52) Empty reply from server' after 1.2s
docker stop took 0.5s
exit code: 143
৩ · --init + handlerসত্যিকারের run
log: pid 7 listening
log: SIGTERM: no new connections, finishing in-flight
log: closed cleanly
client: rc=0 reply='order saved' after 3.0s
docker stop took 2.3s
exit code: 0
৪ · init নেই, handler আছেসত্যিকারের run
log: pid 1 listening
log: SIGTERM: no new connections, finishing in-flight
log: closed cleanly
client: rc=0 reply='order saved' after 3.0s
docker stop took 2.4s
exit code: 0
  1. পরীক্ষার ছক। একটি ৩ সেকেন্ডের অনুরোধ শুরু, ১ সেকেন্ড পরে docker stop. একটি ভালো shutdown-এ অনুরোধটি শেষ হওয়া উচিত, আর container দ্রুত, exit 0 দিয়ে থামা উচিত।
  2. Node PID 1, handler নেই। Node-এর PID 1. SIGTERM উপেক্ষিত হলো, তাই অনুরোধটি ভাগ্যক্রমে শেষ হলো, কিন্তু container থামল না। ১০ সেকেন্ড পরে SIGKILL: stop নিল ১১.৩ সেকেন্ড, exit 137 (১২৮ + ৯)। প্রতিটি deploy-এ ১০ সেকেন্ড নষ্ট, আর অনুরোধ লম্বা হলে মাঝপথে কাটা।
  3. init আছে, handler নেই। এখন tini PID 1, Node-এর PID 7. Signal Node-এ পৌঁছাল, Node-এর ডিফল্ট আচরণ সাথে সাথে মরা। Stop ০.৫ সেকেন্ডে, কিন্তু client পেল Empty reply: order সেভ হওয়ার মাঝপথে সংযোগ কাটা। Exit 143 (১২৮ + ১৫)। দ্রুত, কিন্তু ক্ষতিকর।
  4. init আর handler দুটোই। Handler server.close ডাকল: নতুন সংযোগ বন্ধ, চলমান অনুরোধ শেষ হতে দেওয়া। Client পেল order saved, stop ২.৩ সেকেন্ডে, exit 0. এটাই লক্ষ্য।
  5. শুধু handler, init নেই। Handler বসানো থাকলে PID 1-ও signal পায়, তাই ফল প্রায় একই (২.৪ সেকেন্ড, exit 0). তাহলে init কেন? Child process থাকলে (যেমন npm start বা shell মাঝে থাকলে) signal সঠিক জায়গায় পৌঁছানো আর zombie পরিষ্কার করার জন্য। নিয়ম: handler বাধ্যতামূলক, init: true সস্তা বীমা।

বাজারঘরের api আর worker-এ handler আছে, আর compose-এ init: true. docker compose stop api worker-এর সত্যিকারের log:

api-1     | SIGTERM received, closing server
api-1     | api stopped cleanly
worker-1  | SIGTERM received, finishing current job
worker-1  | worker stopped cleanly

দুটি ফাঁদ। (১) Shell form CMD npm start বা CMD node server.js (JSON ছাড়া) লিখলে PID 1 হয় /bin/sh, আর shell সাধারণত signal Node-এ পাঠায় না; exec form CMD ["node", "server.js"] লিখুন। (২) সময়সীমা পরিবেশভেদে আলাদা হতে পারে: এই ল্যাবের Docker Desktop-এ container-এ কোনো মান না দিলে StopTimeout ১ সেকেন্ড দেখাচ্ছিল, তাই compose-এ stop_grace_period: 10s স্পষ্ট লেখা হয়েছে। Kubernetes-এ ডিফল্ট grace period সাধারণত ৩০ সেকেন্ড। আপনার সবচেয়ে লম্বা অনুরোধ বা job-এর চেয়ে সময়সীমা বড় রাখুন।

৯ · নিরাপত্তা, সীমা আর পরের গন্তব্য — scan, digest, limit, registry

ছোট base image মানে কম ঝুঁকি

Image-এ যত বেশি package, তত বেশি জানা দুর্বলতা (CVE) থাকার সম্ভাবনা। Vulnerability scanner image-এর package-এর তালিকা মিলিয়ে দেখে কোনগুলোর জানা দুর্বলতা আছে। ল্যাবে Trivy 0.76.1 (Aqua Security-র open-source scanner) চারটি image-এ চালানো হলো; binary-র SHA-256 প্রকাশিত checksum-এর সাথে মিলিয়ে নেওয়া হয়েছিল, কারণ scanner নিজেও supply chain-এর অংশ। Docker Scout-ও একটি পথ, কিন্তু তার পূর্ণ বিশ্লেষণে সাধারণত Docker account-এ login লাগে, আর এই ল্যাবে কোনো account-এ login না করার নিয়ম ছিল, তাই চালানো হয়নি।

ImageOSমোটCRITICALHIGHfix আছে
web naive (node:26)Debian 13.7৩৯৮৯১৬৪৭১১১১৮
web multi-stage (slim)Debian 13.7২০৭০৫০১৯
api (slim)Debian 13.7২০৭০৫০১৯
api (alpine)Alpine 3.24.3১৯০৬১৯

সংখ্যাগুলো পড়তে বিচার লাগে। Slim image-এর ৪৪টি OS-স্তরের HIGH-এর একটিরও লেখার সময় Debian-এ fix ছিল না (util-linux, ncurses, perl-base-এর মতো package; বেশিরভাগই আমাদের app কখনো চালায় না)। বাকি ৬টি HIGH আমাদের কোডে নয়, Node.js runtime-এর ভেতরে বসানো undici আর brace-expansion-এ, যার সমাধান নতুন Node patch-এর base image. সত্যিকারের output-এর একটি অংশ (সময়ের ছাপ বাদ, টেবিলের প্রস্থ ছোট করা):

$ trivy image --scanners vuln --severity HIGH,CRITICAL bz80-web:dev
INFO	Detected OS	family="debian" version="13.7"
INFO	[debian] Detecting vulnerabilities...	os_version="13" pkg_num=80
INFO	[node-pkg] Detecting vulnerabilities...
│ bz80-web:dev (debian 13.7)                    │  debian  │       44        │
│ app/node_modules/next/package.json            │ node-pkg │        0        │
│ app/node_modules/react-dom/package.json       │ node-pkg │        0        │
…

অর্থাৎ নিয়মিত কাজ দুটি: base image নিয়মিত হালনাগাদ করে আবার build, আর scan-এর ফল দেখে সিদ্ধান্ত (fix আছে, আমাদের পথে আসে, তাহলে আগে)। তালিকা থেকে সিদ্ধান্তে যাওয়ার পদ্ধতি Cloud কোর্সের registry আর supply chain পর্বে।

Digest দিয়ে pin, non-root, health check, limit

node:26-slim একটি tag, যা সময়ের সাথে নতুন image-এ সরে যায়। পুনরুৎপাদনযোগ্য build-এর জন্য digest দিয়ে pin করা যায়; ল্যাবের build log-এ যে digest দেখা গেছে:

FROM node:26-slim@sha256:e4db4c914bf8ad241bd24804fc25d272d89bad97e68cdc8a1414862790a02c39 AS deps

Digest pin করলে নিরাপত্তা patch আর নিজে থেকে আসে না, তাই Dependabot বা Renovate-এর মতো টুল দিয়ে pin হালনাগাদের pull request নেওয়া একটি প্রচলিত পথ। বাকি অভ্যাসগুলো এই পর্বেই সত্যিকারে যাচাই হয়েছে: whoami → node, image-এ .env নেই, প্রতিটি service-এর health check. আর deploy.resources.limits দেওয়া container-এর সত্যিকারের docker stats (সীমা ছাড়া সারিগুলো বাদ):

NAME              CPU %     MEM USAGE / LIMIT
bz80-api-1        0.01%     61.55MiB / 256MiB
bz80-web-1        0.01%     48.05MiB / 512MiB
bz80-worker-1     0.00%     34.53MiB / 256MiB

সীমা ঠিক করার সময় মনে রাখুন, Node-এর heap-এর একটি সীমা আছে, আর container-এর memory সীমা তার চেয়ে কম হলে kernel আগেই মেরে ফেলবে (exit 137, অংশ ৭-এর worker-এর মতো)। Memory আর CPU সীমার পুরো আচরণ container চালানোর পর্বে। আরও কঠোর হতে চাইলে read_only: true, cap_drop: [ALL] আর distroless-এর মতো আরও ছোট base image ভাবা যায়; প্রতিটির সাথে debug করার সুবিধা কিছুটা কমে।

Image এরপর কোথায় যায়

এই পর্বে কোনো image কোথাও push করা হয়নি। বাস্তবে পথটি সাধারণত এমন: CI image build আর scan করে, commit-এর hash দিয়ে tag দিয়ে একটি container registry-তে push করে (Docker Hub, GitHub Container Registry, বা cloud সেবাদাতার registry, উদাহরণ হিসেবে), তারপর যেখানে চলবে সেখানে নামায়। জায়গা হতে পারে নিজের VPS-এ docker compose, Dockerfile বুঝতে পারে এমন কোনো PaaS, বা Kubernetes. কোনটি কখন, খরচ আর সীমা কী, সেটি পর্ব ৮২-এ; pipeline-এর ধাপ পর্ব ৮১-এ।

আরও গভীরে: Docker image আর container-এর বিন্যাস এখন Open Container Initiative (OCI)-এর মানদণ্ডে লেখা, তাই Docker-এ বানানো image Podman, containerd বা Kubernetes-এও চলে। PID 1-এর signal আচরণ Linux kernel-এর PID namespace-এর নিয়ম থেকে আসে: namespace-এর init-কে শুধু সেই signal পাঠানো যায় যার handler সে বসিয়েছে (SIGKILL আর SIGSTOP বাইরের namespace থেকে ব্যতিক্রম)। Next.js-এর standalone trace করে Vercel-এর @vercel/nft (Node File Trace), যা import, require আর fs-এর ব্যবহার স্থিরভাবে বিশ্লেষণ করে; গতিশীলভাবে পড়া ফাইল বাদ পড়তে পারে, তখন outputFileTracingIncludes লাগে।

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

  • Multi-stage build কেন? Build-এর যন্ত্রপাতি আর সোর্স চূড়ান্ত image-এ না রেখে ছোট, কম ঝুঁকির image পাওয়া।
  • COPY package*.json কেন COPY . .-এর আগে? যাতে কোড বদলালে npm ci-এর layer cache থেকে আসে।
  • depends_on কি অপেক্ষা করে database তৈরি হওয়া পর্যন্ত? শুধু condition: service_healthy আর একটি health check থাকলে।
  • Exit code 137 আর 143-এর মানে? ১৩৭ = SIGKILL (সময়সীমা পেরোনো বা OOM), ১৪৩ = SIGTERM-এ থামা।
  • NEXT_PUBLIC_ মান container-এর env দিয়ে বদলানো যায়? না, এটি build-এর সময় JavaScript-এ বসে যায়।

১০ · AI-এর যুগে container-এ ফুল-স্ট্যাক

“আমার Next.js আর Express অ্যাপের জন্য Dockerfile লিখে দিন” বললে AI কয়েক সেকেন্ডে কিছু একটা দেবে, আর সেটি প্রায়ই চলবেও। সমস্যা হলো “চলে” আর “ঠিক” এক নয়। এই পর্বের প্রতিটি ফাঁদ AI-এর লেখা Dockerfile-এ নিয়মিত দেখা যায়:

AI প্রায়ই যা লেখেকেন ভুলকীভাবে ধরবেন
FROM node:latest, এক ধাপ, COPY . . আগেবড় image, ধীর rebuild, অনির্দিষ্ট versiondocker image ls, কোড বদলে rebuild-এর সময় মাপা
.dockerignore নেই.env আর node_modules image-এdocker run --rm img ls -la /app
CMD npx prisma migrate deploy && node server.jsপ্রতিটি replica migrate চালায়, CLI production-এmigrate আলাদা job কি না দেখা
পুরোনো binaryTargets বা prisma-client-jsPrisma 7-এর generator আর adapter-এর সাথে মেলে নাprisma.io-র v7 docs আর নিজের run
ENV DATABASE_URL=... Dockerfile-এSecret image-এর metadata-য়docker image inspect, docker history
CMD npm start, init নেই, handler নেইSIGTERM উপেক্ষা, প্রতিবার ১০ সেকেন্ড পরে SIGKILLdocker stop-এর সময় আর exit code মাপা
depends_on: [db] শুধুDatabase তৈরি হওয়ার আগেই app চালুনতুন volume-এ up করে log পড়া

AI-কে version, কাঠামো আর যাচাইয়ের নিয়ম স্পষ্ট বলুন। আসল password, token বা .env-এর বিষয়বস্তু prompt-এ দেবেন না; নকল মান দিন।

আমার monorepo-তে web/ (Next.js 16.4, App Router, TypeScript) আর api/ (Express 5, Prisma 7.10.1 with prisma-client generator + @prisma/adapter-pg, BullMQ 6 + ioredis 6) আছে। Base image node:26-slim. দুটির জন্য Dockerfile আর একটি compose.yaml লিখে দাও। শর্ত: ১. web: deps, build, runner তিন ধাপ; next.config-এ output: 'standalone'; runner-এ public আর .next/static কপি; USER node; CMD exec form-এ। ২. api: prisma generate build-এ; production node_modules-এ prisma CLI থাকবে না; migrate একটি আলাদা target, app-এর CMD-তে migrate নয়। ৩. প্রতিটির .dockerignore, যাতে node_modules, .next, .env* বাদ যায়। ৪. compose: postgres আর redis-এ health check; migrate → seed → api → web ক্রম depends_on condition দিয়ে; init: true; stop_grace_period; port শুধু 127.0.0.1-এ; password নকল মান। ৫. কোনো secret Dockerfile-এ ENV বা ARG হিসেবে নয়। NEXT_PUBLIC_ মান কোনগুলো build-এ বসবে, তালিকা দাও। ৬. তুমি এগুলো চালাওনি, তাই "কাজ করবে" বলবে না; আমি কোন কমান্ডে image-এর আকার, user, .env-এর অনুপস্থিতি আর SIGTERM-এ exit code যাচাই করব, সেটি লিখে দাও।

AI-এর লেখা Dockerfile আর compose মেলানোর চেকলিস্ট

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

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

প্র ০১Production-এ database container-এ চালানো নিয়ে এত বিতর্ক কেন, যখন local-এ সবাই চালায়?

Container-এর মূল প্রতিশ্রুতি হলো “ফেলে দেওয়া যায়”: একটি container ভাঙলে নতুন একটি চালান, আগেরটির কথা ভাবার দরকার নেই। Web, api আর worker-এর ক্ষেত্রে এটি সত্য, কারণ তাদের অবস্থা থাকে database আর queue-তে। Database নিজেই সেই অবস্থা; তাকে ফেলে দেওয়া যায় না। তাই database container-এ চালালে প্রশ্নটি আর container নিয়ে থাকে না, প্রশ্ন হয় ডেটা নিয়ে: volume কোন disk-এ, backup কখন, restore কি কেউ কখনো পরীক্ষা করেছে, version upgrade কীভাবে, replica আর failover কে চালাবে।

Docker-এর প্রথম দিকে (২০১৩ সালে প্রকাশ্যে আসার পরের কয়েক বছর) storage driver আর volume ব্যবস্থা আজকের মতো পরিপক্ব ছিল না, তাই “database container-এ নয়” কথাটি তখন প্রায় নিয়ম ছিল। এখন volume, Kubernetes-এর StatefulSet আর database operator অনেক পরিণত, আর অনেক প্রতিষ্ঠান সফলভাবে নিজেরা চালায়। তবু ছোট দলের কাছে আসল খরচ কারিগরি নয়, মানুষের: রাত তিনটায় disk ভরে গেলে কে উঠবেন?

Managed database (cloud সেবাদাতার PostgreSQL সেবা, উদাহরণ হিসেবে) সেই দায়িত্বের বড় অংশ কিনে নেওয়ার পথ: স্বয়ংক্রিয় backup, point-in-time recovery, patch. বিনিময়ে খরচ বেশি হতে পারে, আর নিয়ন্ত্রণ কম। বাংলাদেশের অনেক ছোট প্রকল্পে একটি VPS-এ compose দিয়ে সব চালানো, সাথে নিয়মিত pg_dump backup অন্য জায়গায় রাখা, একটি বাস্তবসম্মত শুরু; কিন্তু তখন backup আর restore-এর মহড়া আপনার দায়িত্ব। মাঝের পথও আছে: app container-এ, database managed.

মূল উপলব্ধি: প্রশ্নটি “container-এ কি না” নয়, “ডেটার দায়িত্ব কার”। যিনি backup, restore আর upgrade নিশ্চিত করতে পারবেন, তাঁর ওপর ভিত্তি করে সিদ্ধান্ত নিন, আর restore অন্তত একবার সত্যিই করে দেখুন।

প্র ০২Alpine, Debian slim না distroless? সবচেয়ে ছোট image কি সবসময় সবচেয়ে ভালো?

এই পর্বের মাপে Alpine সবচেয়ে ছোট ছিল: api-র image 382MB বনাম slim-এ 483MB, আর Trivy-তে ১৯টি বনাম ২০৭টি জানা দুর্বলতা। প্রথম দেখায় সিদ্ধান্ত সহজ মনে হয়। কিন্তু Alpine-এর ছোট হওয়ার কারণটিই তার বড় পার্থক্য: সেখানে GNU-র glibc নয়, musl নামের আরেকটি C library. বেশিরভাগ Node কোডে এর কোনো প্রভাব নেই, কিন্তু native module (যেমন ছবি প্রক্রিয়ার sharp, bcrypt বা Prisma-র engine) প্রতিটি platform-এর জন্য আলাদা binary চায়। এই পর্বে Prisma 7 নিজে থেকে musl-এর engine এনেছে আর কোনো error হয়নি; কিন্তু libssl সরাতেই বিপদ এসেছে। পুরোনো version-এ OpenSSL নিয়ে Alpine-এর error বহু বছর ধরে Prisma-র সবচেয়ে পরিচিত অভিযোগগুলোর একটি ছিল।

আরও কিছু সূক্ষ্ম পার্থক্যের কথা শোনা যায়: musl-এর DNS resolver-এর আচরণ, memory allocator-এর কর্মক্ষমতা, আর debug-এর সময় চেনা টুল না থাকা। এগুলো সব প্রজেক্টে দেখা দেয় না, তাই মেপে দেখাই নিরাপদ। Debian slim মাঝামাঝি: glibc, প্রায় সব npm binary চলে, আকার মাঝারি। Distroless (Google-এর শুরু করা ধারণা) আরও এক ধাপ: shell নেই, package manager নেই, শুধু runtime. আক্রমণকারী ঢুকলেও হাতে টুল কম, কিন্তু আপনার debug-এর জন্যও sh নেই।

Scanner-এর সংখ্যাও সাবধানে পড়তে হয়। Slim-এর ৪৪টি HIGH-এর একটিরও লেখার সময় fix ছিল না, আর বেশিরভাগ package আমাদের app কখনো চালায় না; তবু সংখ্যাটি নিরাপত্তা দলের রিপোর্টে লাল দেখায়, আর সেটিও একটি বাস্তব খরচ। ছোট image মানে কম package, কম রিপোর্ট, কম patch-এর চাপ।

মূল উপলব্ধি: ছোটত্ব একটি গুণ, একমাত্র গুণ নয়। আপনার native dependency কোন C library-তে চলে, দল কীভাবে debug করবে, আর scan-এর রিপোর্ট কে দেখবে, তিনটি মিলিয়ে বেছে নিন, আর বেছে নেওয়ার পরে সেই image-এ সত্যিকারের test চালান।

প্র ০৩PID 1 সমস্যা কোথা থেকে এলো, আর Docker কেন সরাসরি এটি “ঠিক” করে দেয় না?

Unix-এ বহু দশক ধরে PID 1 হলো init, সিস্টেমের প্রথম process. তার দুটি বিশেষ দায়িত্ব: অনাথ হয়ে যাওয়া process-গুলোর “অভিভাবক” হয়ে তাদের শেষ হওয়ার পরে পরিষ্কার করা (না করলে সেগুলো zombie হয়ে process table-এ থেকে যায়), আর হঠাৎ কোনো signal-এ মরে গিয়ে পুরো সিস্টেম না ফেলা। এই দ্বিতীয় কারণেই kernel PID 1-কে সুরক্ষা দেয়: handler না থাকলে সাধারণ signal-এর ডিফল্ট “মরে যাওয়া” আচরণ PID 1-এ খাটে না।

Linux-এর PID namespace আসার পরে container-এর ভেতরেও একটি নিজস্ব PID 1 হয়। Docker যখন node server.js চালায়, Node নিজেই সেই PID 1. Node একটি সাধারণ অ্যাপ হিসেবে লেখা, init হিসেবে নয়: সে zombie পরিষ্কার করে না, আর SIGTERM-এর handler নিজে বসায় না। ফল আপনি এই পর্বের ল্যাবে দেখেছেন: ১১.৩ সেকেন্ড অপেক্ষা আর exit 137. Node.js-এর Docker নিয়ে প্রকাশিত best practice-এও এই সতর্কতা বহুদিন ধরে আছে।

সমাধান হিসেবে ছোট init program তৈরি হলো। সবচেয়ে পরিচিত tini, যা Thomas Orozco লিখেছিলেন; পরে Docker নিজেই --init option যোগ করে, যা ভেতরে tini-ভিত্তিক একটি init বসায় (Docker 1.13, ২০১৭ সালের শুরুর দিকে বলে জানা যায়)। Yelp-এর dumb-init আরেকটি বিকল্প। তাহলে Docker সবসময় নিজে থেকে init দেয় না কেন? কারণ কিছু image-এর প্রধান process নিজেই ভালো init (যেমন একটি process supervisor), আর কেউ কেউ ঠিক PID 1-এর আচরণই চান। Docker নিয়ন্ত্রণ আপনার হাতে রেখেছে, ডিফল্ট বদলায়নি।

লক্ষ করুন, init একা সমস্যার অর্ধেক সমাধান করে। ল্যাবের দ্বিতীয় পরীক্ষায় init ছিল, কিন্তু handler না থাকায় Node সাথে সাথে মরেছে, আর order মাঝপথে কাটা গেছে। Graceful shutdown আপনার কোডের দায়িত্ব; init শুধু নিশ্চিত করে যে signal কোডে পৌঁছায়।

মূল উপলব্ধি: Container একটি পুরো OS নয়, একটি process; তাই OS-এর init যে কাজগুলো চুপচাপ করত, সেগুলো এখন আপনার দায়িত্ব। Exec form-এর CMD, SIGTERM handler আর init: true একসাথে সেই দায়িত্ব পূরণ করে।

প্র ০৪“একবার build, সব জায়গায় চালানো” নীতির সাথে NEXT_PUBLIC_ মানের build-এ বসে যাওয়া কি সাংঘর্ষিক?

২০১১ সালের দিকে Heroku-র প্রকৌশলী Adam Wiggins “The Twelve-Factor App” নামে কয়েকটি নীতি লেখেন। তার একটি: build, release আর run আলাদা ধাপ, আর config কোডে নয়, environment-এ। এর লক্ষ্য একই build-এর ফল (আজকের ভাষায় একই image) staging-এ পরীক্ষা করে হুবহু production-এ পাঠানো, যাতে “staging-এ তো ঠিক ছিল” সমস্যা কমে। Image-এর digest একই থাকলে আপনি নিশ্চিত, যা পরীক্ষা করেছেন তাই চলছে।

NEXT_PUBLIC_ এখানে সত্যিই একটি টান তৈরি করে। Browser-এ চলা JavaScript-এর কাছে server-এর environment নেই, তাই Next.js build-এর সময় মানটি সরাসরি কোডে লিখে দেয়। এই পর্বের ল্যাবে container-এ “Chattogram” দেওয়ার পরেও পাতায় “Dhaka” এসেছে, কারণ মানটি দুটি chunk-এ লেখা হয়ে গেছে। তাহলে staging আর production-এ আলাদা analytics key বা আলাদা API ঠিকানা লাগলে কী করবেন?

তিনটি প্রচলিত পথ, প্রতিটির দাম আছে। এক, প্রতি পরিবেশে আলাদা image build: সহজ, কিন্তু staging-এ যা পরীক্ষা করলেন আর production-এ যা গেল, সেগুলো কারিগরিভাবে আলাদা build. দুই, মানটি runtime-এ server থেকে পড়া: server component environment পড়ে client component-কে prop হিসেবে দেয়, বা একটি ছোট /api/config endpoint. এক image সব জায়গায় চলে, কিন্তু কোড একটু জটিল আর প্রথম render-এ একটি বাড়তি ধাপ। তিন, container চালুর সময় একটি script দিয়ে build-এর JavaScript-এ placeholder বদলানো: কাজ করে, কিন্তু ভঙ্গুর আর image-কে লেখার যোগ্য রাখতে হয়, যা non-root আর read-only নীতির বিরুদ্ধে যায়।

সম্প্রদায়ে এ নিয়ে একমত নেই; ছোট দল প্রায়ই প্রথম পথে চলে, বড় দল দ্বিতীয়তে যায়। তবে একটি বিষয়ে কোনো বিতর্ক নেই: NEXT_PUBLIC_-এ কখনো secret নয়, কারণ সেটি প্রত্যেক দর্শকের browser-এ পৌঁছায়।

মূল উপলব্ধি: কোন মান build-এ বসে আর কোনটি runtime-এ আসে, সেটি নকশার সিদ্ধান্ত। যত কম মান build-এ বসবে, একই image তত বেশি পরিবেশে নিশ্চিন্তে চলবে।

প্র ০৫App চালু হওয়ার সময় migration চালালে তো সবচেয়ে সহজ; তবু অভিজ্ঞ দল সেটি আলাদা ধাপে রাখে কেন?

একটি container আর একটি database-এ “start-এ migrate” সত্যিই সহজ, আর ছোট প্রজেক্টে অনেকে এভাবেই শুরু করেন। সমস্যা শুরু হয় বড় হলে। ধরুন বাজারঘর ঈদের ভিড়ে চারটি api replica চালায়, আর নতুন version deploy হচ্ছে। চারটি container প্রায় একসাথে চালু হয়, চারটিই migrate চালাতে চায়। ভালো migration tool database-এ lock নিয়ে একসাথে চলা আটকায়, কিন্তু তখন বাকি তিনটি অপেক্ষায় থাকে, health check সময়মতো পাস করে না, আর orchestrator তাদের “অসুস্থ” ভেবে আবার চালু করতে পারে।

দ্বিতীয় সমস্যা ব্যর্থতার রূপ। Migration মাঝপথে ব্যর্থ হলে start-এ migrate করা app crash করে, আবার চালু হয়, আবার migrate চেষ্টা করে, আবার crash: একটি crash loop, যেখানে আসল error খুঁজতে log ঘাঁটতে হয়। আলাদা job হলে ব্যর্থতা একটি স্পষ্ট জায়গায় থামে: “migrate job ব্যর্থ, deploy বন্ধ”, আর পুরোনো app version চলতে থাকে। এই পর্বের compose মডেলে ঠিক এটাই দেখেছেন: migrate exit 1 হলে seed, api, web কেউ চালু হয়নি।

তৃতীয়ত অধিকার আর আকার। Migrate-এর জন্য টেবিল বানানোর অধিকার আর Prisma CLI লাগে; app-এর জন্য দুটোই অপ্রয়োজনীয়। আলাদা job মানে app-এর image ছোট (এই পর্বে 841MB থেকে 483MB-এর গল্পের একটি কারণ), আর app-এর database user-এর ক্ষমতা কম।

শেষে সময়ের প্রশ্ন। Rolling deploy-এ কিছুক্ষণ পুরোনো আর নতুন দুই version একসাথে চলে, তাই migration এমন হতে হয় যাতে পুরোনো কোডও চলে: column আগে যোগ, পুরোনোটি পরে মোছা। এই expand and contract কৌশল পর্ব ৪০-এ আছে। Migration আলাদা ধাপ হলে এই ক্রম পরিকল্পনা করা সহজ হয়।

মূল উপলব্ধি: Migration হলো deploy-এর একটি ঘটনা, প্রতিটি container-এর জীবনের ঘটনা নয়। তাকে একবার, একটি জায়গায়, স্পষ্ট ব্যর্থতার নিয়মসহ চালালে deploy নির্ভরযোগ্য আর app-এর image পরিষ্কার থাকে।

অনুশীলন

হিসাব, কমান্ড, সমালোচনা আর ফল অনুমান, চারটিই আছে।

  1. হিসাব (numerical): একটি দল দিনে ২৪ বার web image build করে; তার ২০ বার শুধু কোড বদলায়, ৪ বার dependency. তিন ধাপের Dockerfile-এ কোড বদলে build ৮.৩ সেকেন্ড আর dependency বদলে ৪৩.১ সেকেন্ড; ভুল ক্রমের Dockerfile-এ দুই ক্ষেত্রেই ৫০.৪ সেকেন্ড ধরুন। (ক) দিনে মোট সময় দুই ক্ষেত্রে কত? (খ) ২২ কর্মদিবসের মাসে কত ঘণ্টা বাঁচে? (গ) Image-এর content size 97.8MB থেকে নিজের layer 12.1MB, server-এ base layer আগে থেকে আছে; 20 Mbit/s-এ নতুন version নামাতে আনুমানিক কত সেকেন্ড?

    (ক) ভালো ক্রম: ২০ × ৮.৩ + ৪ × ৪৩.১ = ১৬৬ + ১৭২.৪ = ৩৩৮.৪ সেকেন্ড. ভুল ক্রম: ২৪ × ৫০.৪ = ১২০৯.৬ সেকেন্ড. (খ) দিনে বাঁচে ১২০৯.৬ − ৩৩৮.৪ = ৮৭১.২ সেকেন্ড; মাসে ৮৭১.২ × ২২ = ১৯১৬৬.৪ সেকেন্ড ≈ ৫.৩২ ঘণ্টা. (গ) 12.1 × 8 / 20 = ৪.৮৪ সেকেন্ড (পুরো 97.8MB হলে ৩৯.১ সেকেন্ড)। সংখ্যাগুলো একটি local run আর ধরে নেওয়া গতির ওপর; ধারণাটি হলো ক্রম আর layer ভাগাভাগির প্রভাব।

  2. কমান্ড দিয়ে যাচাই: একজন সহকর্মী shop-web:1 image বানিয়ে বলছেন সব ঠিক আছে। পাঁচটি কমান্ড লিখুন, যা দিয়ে আপনি দেখবেন: (১) আকার, (২) কোন user-এ চলে, (৩) image-এ .env আছে কি না, (৪) image-এর ইতিহাসে কোনো secret-এর মতো ENV আছে কি না, (৫) চলমান অনুরোধের মাঝে থামালে exit code কত।
    • (১) docker image ls shop-web: disk আর content size.
    • (২) docker run --rm shop-web:1 whoami: node বা অন্য সাধারণ user হওয়া উচিত, root নয়।
    • (৩) docker run --rm shop-web:1 sh -c "ls -la /app; ls -la /app/.env": “No such file” প্রত্যাশিত।
    • (৪) docker history --no-trunc shop-web:1 আর docker image inspect shop-web:1 --format "{{json .Config.Env}}": PASSWORD, SECRET, TOKEN-এর মতো কিছু থাকলে সমস্যা।
    • (৫) container চালিয়ে একটি ধীর অনুরোধ পাঠান, তারপর docker stop, শেষে docker inspect <name> --format "{{.State.ExitCode}}": 0 ভালো, 143 মানে handler নেই, 137 মানে SIGTERM উপেক্ষিত বা OOM.
  3. সমালোচনা (নকশা): অন্তত ছয়টি সমস্যা ধরুন আর সমাধান বলুন। Dockerfile: FROM node:latest / COPY . . / ENV DATABASE_URL=postgresql://admin:Pa55@db/shop / RUN npm install && npx prisma generate && npm run build / CMD npx prisma migrate deploy && npm start. Compose: depends_on: [db], ports: ["5432:5432"] database-এ, কোনো volume নেই।
    • latest: version অনির্দিষ্ট। সমাধান: node:26-slim, প্রয়োজনে digest.
    • এক ধাপ, COPY . . আগে: বড় image, cache নষ্ট। সমাধান: deps, build, runner; আগে package*.json.
    • Password Dockerfile-এ: image-এর metadata-য় থেকে যায়। সমাধান: runtime environment বা secret; ফাঁস হওয়া password বদলানো।
    • npm install: lockfile হুবহু মানে না। সমাধান: npm ci.
    • Start-এ migrate: প্রতিটি replica migrate চালায়, CLI production-এ। সমাধান: আলাদা migrate job.
    • CMD shell form আর npm start: signal Node পর্যন্ত না-ও পৌঁছাতে পারে। সমাধান: CMD ["node", "server.js"], init: true, SIGTERM handler.
    • USER নেই: root. সমাধান: USER node.
    • depends_on: [db]: শুধু চালুর ক্রম। সমাধান: health check আর condition: service_healthy.
    • Database port সব interface-এ খোলা: সমাধান: খোলার দরকার না থাকলে বাদ, local-এ 127.0.0.1:5432:5432.
    • Volume নেই: container মুছলে ডেটা হারায়। সমাধান: named volume.
  4. ফল অনুমান: Compose-এ postgres (health check আছে), migrate (depends_on: postgres: service_healthy), api (depends_on: migrate: service_completed_successfully, init: true, SIGTERM-এ server.close), আর worker (depends_on: migrate: service_started). Migration ফাইলে একটি SQL ভুল আছে, তাই migrate exit 1 দেয়। docker compose up -d-এর পরে প্রতিটি service-এর অবস্থা কী? তারপর ভুল সারিয়ে আবার up, আর একটি ৩ সেকেন্ডের অনুরোধ চলার সময় docker compose stop api (grace period ১০ সেকেন্ড): client কী পাবে, আর api-র exit code কত?

    প্রথম up: postgres চলছে (healthy); migrate Exited (1); api চালুই হয় না, কারণ তার শর্ত exit 0, আর compose নির্ভরতা ব্যর্থ বলে error দেখায়; worker চালু হয়, কারণ service_started শুধু migrate শুরু হওয়া চায়, শেষ বা সফল হওয়া নয়। এটি একটি ফাঁদ: worker এখন হয়তো এমন টেবিল খুঁজবে যা তৈরিই হয়নি। দ্বিতীয় ধাপে api চালু, আর stop-এ SIGTERM init হয়ে Node-এ পৌঁছায়, server.close চলমান অনুরোধ শেষ হতে দেয়: client পূর্ণ উত্তর পায়, api থামে প্রায় ২-৩ সেকেন্ডে, exit code 0 (handler process.exit(0) ডাকে বলে)।

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

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

ক্রমনতুন version-এর একটি container deploy-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Multi-stage Dockerfile থেকে image build
  2. Image scan আর test, তারপর registry-তে push
  3. Database-এ migrate job একবার চালানো
  4. নতুন app container চালু করে health check পাস হওয়ার অপেক্ষা
  5. পুরোনো container-কে SIGTERM দিয়ে চলমান অনুরোধ শেষ করতে দেওয়া

একটি Node.js Dockerfile-এ COPY . . লাইনটি RUN npm ci-এর আগে আছে। শুধু একটি source ফাইল বদলে আবার build করলে কী ঘটবে?

  • সব ধাপ cache থেকে আসবে
  • npm ci আবার চলবে, কারণ তার আগের ধাপ বদলেছে
  • শুধু build ধাপ চলবে, কারণ Docker বোঝে dependency বদলায়নি
  • Base image আবার নামানো হবে

একটি Next.js অ্যাপের image NEXT_PUBLIC_API_BASE=https://staging.example.com দিয়ে build হয়েছে। Production-এ container চালানোর সময় -e NEXT_PUBLIC_API_BASE=https://shop.example.com দেওয়া হলো। Browser-এর JavaScript কোন ঠিকানা ব্যবহার করবে?

  • production-এর ঠিকানা, কারণ runtime-এর মান সবসময় জেতে
  • কোনোটিই নয়, মানটি খালি থাকবে
  • প্রথম অনুরোধে staging, পরে production
  • staging-এর ঠিকানা, কারণ মানটি build-এর সময় বসে গেছে

একটি container-এ Node.js সরাসরি PID 1, কোডে কোনো SIGTERM handler নেই, init নেই। docker stop দিলে (সময়সীমা ১০ সেকেন্ড) সাধারণত কী দেখা যায়?

  • প্রায় ১০ সেকেন্ড পরে container থামে, exit code 137
  • সাথে সাথে থামে, exit code 143
  • চলমান অনুরোধ শেষ করে exit code 0
  • Container কখনোই থামে না

Compose-এ api service-এর জন্য depends_on: { db: { condition: service_healthy } } লেখা, কিন্তু db service-এ কোনো healthcheck নেই। এর সবচেয়ে সম্ভাব্য সমস্যা কী?

  • Compose নিজে PostgreSQL-এর জন্য একটি health check বানিয়ে নেবে
  • api সাথে সাথে চালু হবে, শর্তটি উপেক্ষিত হবে
  • db কখনো “healthy” হবে না, তাই api চালু হবে না
  • db-এর volume মুছে যাবে

Prisma ব্যবহার করা একটি API-র Docker build-এ prisma generate চলার সময় “Cannot resolve environment variable: DATABASE_URL” error আসে। সবচেয়ে ভালো সমাধান কোনটি?

  • Dockerfile-এ আসল production DATABASE_URL ENV হিসেবে লেখা
  • Config-এ process.env.DATABASE_URL ?? '' পড়া, আর আসল মান শুধু migrate বা app চালানোর সময় দেওয়া
  • prisma generate বাদ দিয়ে দেওয়া
  • Build-এর ভেতরে একটি PostgreSQL চালিয়ে সংযোগ দেওয়া

একই Next.js অ্যাপের দুটি image: এক ধাপের node:26-ভিত্তিক image-এর content size 669MB, আর standalone-সহ multi-stage node:26-slim-ভিত্তিক image-এর 97.8MB. Base node:26-slim-এর content size 85.7MB আর server-এ আগে থেকেই আছে। নতুন version deploy করতে multi-stage image-এর ক্ষেত্রে আনুমানিক কত MB নামাতে হবে?

  • 97.8MB
  • 669MB
  • প্রায় 12.1MB
  • 0MB

শব্দকোষ

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

মনে রাখুন

  1. এক container, এক ধরনের process. web, api, worker আলাদা; migrate আর seed একবার চলে থামে; production-এর database প্রায়ই managed, আর সিদ্ধান্তটি ডেটার দায়িত্ব নিয়ে।
  2. Multi-stage আর standalone. deps → build → runner, USER node, exec form-এর CMD; ল্যাবে 2.65GB থেকে 407MB, আর .dockerignore-এ context ৩৭৫MB থেকে ৩৬KB, .env বাইরে।
  3. ক্রমই cache. আগে package*.json আর npm ci, পরে সোর্স: কোড বদলে ৮.৩ সেকেন্ড, ভুল ক্রমে ৫০.৪ সেকেন্ড (একটি local run).
  4. Prisma: generate build-এ, migrate আলাদা job-এ। Build-এ DATABASE_URL নেই, তাই config-এ process.env; engine চলার base image-এই আনা; NEXT_PUBLIC_ build-এ বসে যায়।
  5. Compose আর থামা। Health check + condition, named volume, resource limit; SIGTERM handler আর init: true-এ চলমান অনুরোধ শেষ হয়ে exit 0; scan আর digest দিয়ে image নিরাপদ রাখা।

পরের পর্বে: এই image আর migrate job এবার স্বয়ংক্রিয় পথে: CI/CD. প্রতিটি push-এ lint, test আর build, pull request-এর preview, আর deploy-এর আগে migration নিরাপদে চালানো।

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

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