অধ্যায় 5 · চালু করুন
Docker: আপনার API প্যাকেজ করা
- পৃষ্ঠা 17 / 22
- 21 মিনিট পড়া
আপনার রিভিউ অ্যানালাইজার আপনার ল্যাপটপে ঠিকঠাক চলে। কিন্তু এবার এটাকে চলতে হবে এমন একটা সার্ভারে, যেখানে অপারেটিং সিস্টেম আলাদা, পাইথন আলাদা, আপনার কোনো প্যাকেজই নেই; আর আগামী মাসে আপনার টিমমেটকেও এটা চালাতে হবে। "আমার মেশিনে তো চলে" ডিপ্লয়মেন্টের সবচেয়ে পুরোনো সমস্যা, আর Docker হলো ইন্ডাস্ট্রির দেওয়া সমাধান: আপনি অ্যাপটাকে তার পাইথন, লাইব্রেরি আর স্টার্ট কমান্ডসহ একটা ইমেজে (image) প্যাকেজ করেন, আর সেই ইমেজ আপনার ল্যাপটপে, CI-তে এবং Render, Cloud Run বা Kubernetes ক্লাস্টারে একইভাবে চলে।
AI ব্যাকএন্ডের জন্য এটা আরও জরুরি। মডেল সার্ভ করার কোড NumPy, scikit-learn বা PyTorch-এর নির্দিষ্ট ভার্সনের ওপর নির্ভর করে, আর ভার্সন না মিললে সবসময় ক্র্যাশ হয় না; কখনো কখনো শুধু প্রেডিকশন অন্যরকম আসে। ইমেজ এই সবকিছু অপরিবর্তিত করে রাখে। এই পাতায় আপনি ছোট FastAPI "রিভিউ অ্যানালাইজার" API প্যাকেজ করবেন, চালাবেন, লগ পড়বেন, ঠিকভাবে বন্ধ করবেন, এবং ইমেজটাকে ছোট, নিরাপদ ও পুনরুৎপাদনযোগ্য (reproducible) করবেন।
যা শিখবেন
- কন্টেইনার কী, ভার্চুয়াল মেশিন থেকে কীভাবে আলাদা, এবং শব্দভান্ডার: ইমেজ, লেয়ার, কন্টেইনার, রেজিস্ট্রি, ট্যাগ।
python:3.14-slim-এর ওপর uv দিয়ে FastAPI-র প্রোডাকশন-ধাঁচের Dockerfile (মাল্টি-স্টেজ), এবং সাধারণ pip সংস্করণ।- লেয়ার ক্যাশিং,
.dockerignore, পোর্ট ম্যাপিং ও এনভায়রনমেন্ট ভেরিয়েবলসহ বিল্ড ও রান। - লগ, এক্সিট কোড ও সিগন্যাল:
docker stopকেন মাঝে মাঝে ১০ সেকেন্ড নেয় আর 137 দিয়ে শেষ হয়। - নন-রুট ব্যবহারকারী হিসেবে চালানো, ইমেজের সাইজ, ভালনারেবিলিটি স্ক্যানিং এবং পুনরুৎপাদনযোগ্য বিল্ড।
এক ছবিতে কন্টেইনার
একটা ভার্চুয়াল মেশিন (VM) পুরো একটা কম্পিউটারের নকল বানায় এবং নিজের অপারেটিং সিস্টেম কার্নেল বুট করে। একটা কন্টেইনার আসলে হোস্টের Linux কার্নেলের ওপর চলা একটা সাধারণ প্রসেস, যাকে কার্নেলের কিছু সুবিধা দিয়ে ঘিরে রাখা হয়েছে (namespace তাকে ফাইল, প্রসেস ও নেটওয়ার্কের নিজস্ব দৃশ্য দেয়; cgroup তার CPU ও মেমরি সীমিত করে)। দ্বিতীয় কোনো কার্নেল বুট হয় না, তাই কন্টেইনার এক সেকেন্ডেরও কম সময়ে চালু হয় এবং মেমরির বাড়তি খরচ প্রায় নেই।
Virtual machines Containers
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ app A │ │ app B │ │ app A │ │ app B │
│ libs │ │ libs │ │ Python, │ │ Python, │
│ guest OS │ │ guest OS │ │ libs │ │ libs │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
┌───────────────────────┐ ┌───────────────────────┐
│ hypervisor │ │ Docker Engine │
├───────────────────────┤ ├───────────────────────┤
│ host OS + hardware │ │ host Linux kernel │
└───────────────────────┘ └───────────────────────┘| ভার্চুয়াল মেশিন | কন্টেইনার | |
|---|---|---|
| আইসোলেশন | শক্তিশালী: আলাদা কার্নেল | প্রসেস-স্তরের: হোস্টের কার্নেল ভাগ করে |
| চালু হতে সময় | কয়েক দশ সেকেন্ড | মিলিসেকেন্ড থেকে এক সেকেন্ড |
| সাইজ | গিগাবাইট (পুরো একটা OS) | কয়েক দশ থেকে কয়েক শ MB |
| সাধারণ ব্যবহার | আলাদা OS চালানো, শক্তিশালী মাল্টি-টেন্যান্ট আইসোলেশন | একটা অ্যাপ বা সার্ভিস প্যাকেজ করে পাঠানো |
macOS ও Windows-এ Docker Desktop চুপচাপ একটা ছোট Linux VM চালায় আর আপনার কন্টেইনার সেই VM-এর ভেতরে চলে; এ কারণেই Linux ইমেজ সবখানে চলে। ২০২৬ সালে এর মানে Docker Engine 29 এবং Docker Desktop 4.9x; এই পাতার কমান্ড সবগুলোতে একই।
শব্দভান্ডার
| শব্দ | অর্থ |
|---|---|
| ইমেজ (Image) | শুধু-পড়া একটা টেমপ্লেট: একটা ফাইলসিস্টেম (Debian, Python, আপনার venv, আপনার কোড) এবং মেটাডেটা (স্টার্ট কমান্ড, এনভায়রনমেন্ট, এক্সপোজ করা পোর্ট)। Dockerfile থেকে বানানো হয়। |
| লেয়ার (Layer) | ফাইলসিস্টেম বদলায় এমন প্রতিটা নির্দেশ (COPY, RUN) একটা করে লেয়ার যোগ করে। লেয়ার ক্যাশ হয় এবং ইমেজগুলোর মধ্যে ভাগ হয়। |
| কন্টেইনার (Container) | একটা ইমেজের চলমান (বা থেমে থাকা) ইনস্ট্যান্স, ওপরে লেখা যায় এমন একটা পাতলা লেয়ারসহ। কন্টেইনার মুছলে সেই লেয়ারও চলে যায়। |
| রেজিস্ট্রি (Registry) | যে সার্ভারে ইমেজ জমা থাকে: Docker Hub, GitHub Container Registry (ghcr.io), Google Artifact Registry, AWS ECR। |
| ট্যাগ (Tag) | ইমেজের একটা ভার্সনের মানুষের পড়ার নাম, কোলনের পরে: python:3.14-slim, bitbyte-p17-api:1.0। ট্যাগ সরে যেতে পারে; ডাইজেস্ট (@sha256:…) সরে না। |
এই পাতার ভিত্তি অফিসিয়াল Python ইমেজ। docker run প্রথমবার সেটা ডাউনলোড করে, একটা কন্টেইনার চালু করে, একটা কমান্ড চালায়, আর --rm শেষে কন্টেইনারটা মুছে দেয়:
docker run --rm python:3.14-slim python --version
docker run --rm python:3.14-slim grep PRETTY_NAME /etc/os-releasePython 3.14.8
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"slim হলো Debian 13 "trixie", যাতে শুধু পাইথনের দরকারি জিনিসটুকু আছে। অন্য ধরনগুলো: পূর্ণ python:3.14 (প্রায় ১ GB, কম্পাইলারসহ; যখন কোনো প্যাকেজ সোর্স থেকে বিল্ড করতে হয় তখন কাজে লাগে), আর python:3.14-alpine (খুব ছোট, কিন্তু glibc-র বদলে musl ব্যবহার করে, ফলে অনেক সায়েন্টিফিক wheel ইনস্টল হয় না, কম্পাইল করতে হয়; AI কাজে এড়িয়ে চলুন)। Python 3.15 অক্টোবর ২০২৬-এ বেরিয়েছে, কিন্তু এর ইমেজ আর অনেক wheel পিছিয়ে আছে, তাই এই কোর্স 3.14-তেই থাকছে।
প্যাকেজ করার অ্যাপ
এই চ্যাপ্টারের প্রতিটা পাতা নতুন ফোল্ডারে শুরু হয়, তাই ছোট API-টা এখানে আবার দেওয়া হলো: একটা হেলথ চেক আর একটা "প্রেডিকশন" এন্ডপয়েন্ট। অ্যানালাইজারটা একটা কিওয়ার্ড-নিয়ম, আসল মডেলের জায়গায় বসানো (scikit-learn সংস্করণ একটা ML মডেল সার্ভ করা পাতায়); এখানে আসল বিষয় প্যাকেজিং। লক্ষ করুন, অ্যাপটা তার ভার্সন এনভায়রনমেন্ট ভেরিয়েবল থেকে পড়ে আর লগ করে স্ট্যান্ডার্ড আউটপুটে: অ্যাপকে কন্টেইনার-বান্ধব করে এমন দুটো অভ্যাস।
mkdir -p ~/projects/review-api
cd ~/projects/review-api# file: main.py
"""AI review analyser: a tiny API to package with Docker."""
import logging
import os
from typing import Annotated, Literal
from fastapi import FastAPI
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("review-api")
APP_VERSION = os.environ.get("APP_VERSION", "dev")
POSITIVE = {"good", "great", "excellent", "clear", "helpful", "love"}
NEGATIVE = {"bad", "boring", "confusing", "slow", "hate", "broken"}
class Review(BaseModel):
text: Annotated[str, Field(min_length=3, max_length=2000)]
class Analysis(BaseModel):
sentiment: Literal["positive", "negative", "neutral"]
score: float
model_version: str
app = FastAPI(title="Review Analyser", version=APP_VERSION)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok", "version": APP_VERSION}
@app.post("/v1/analyse")
def analyse(review: Review) -> Analysis:
words = [w.strip(".,!?").lower() for w in review.text.split()]
pos = sum(w in POSITIVE for w in words)
neg = sum(w in NEGATIVE for w in words)
score = round((pos - neg) / max(len(words), 1), 2)
sentiment = "positive" if score > 0 else "negative" if score < 0 else "neutral"
log.info("analysed %d words: %s", len(words), sentiment)
return Analysis(sentiment=sentiment, score=score, model_version=f"rules-{APP_VERSION}")ডিপেন্ডেন্সি ঘোষণা করা আছে pyproject.toml-এ, যেমন আপনার প্রথম FastAPI অ্যাপ পাতায় ছিল:
# file: pyproject.toml
[project]
name = "review-api"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]>=0.143.0",
]
[tool.uv]
# Resolve as of this date, so every reader of this page gets the same versions.
exclude-newer = "2026-10-10T00:00:00Z"exclude-newer uv-কে বলে ওই মুহূর্তের পরে প্রকাশিত কিছু উপেক্ষা করতে। এটা আছে যাতে আপনার লকফাইল এই পাতার সঙ্গে হুবহু মেলে; নিজের প্রজেক্টে এটা বাদ দিতে পারেন, কারণ ভার্সন পিন করার কাজটা লকফাইলই করে।
ইমেজ যে uv ব্যবহার করবে, সেটা দিয়েই লক করা
ইমেজ ঠিক তা-ই ইনস্টল করবে যা uv.lock-এ লেখা। সাধারণত আপনি নিজের মেশিনে uv lock চালান। এখানে একটা সুন্দর বিকল্প আছে, যাতে লোকালি uv বা Python কিছুই লাগে না: uv-র অফিসিয়াল ইমেজ থেকে uv চালান, প্রজেক্ট ফোল্ডার কন্টেইনারে মাউন্ট করে। -v "$PWD":/app বর্তমান ফোল্ডারকে /app-এ মাউন্ট করে, -w /app সেটাকে ওয়ার্কিং ডিরেক্টরি বানায়, আর --user নতুন ফাইলের মালিক করে রুটের বদলে আপনাকে:
docker run --rm -v "$PWD":/app -w /app --user "$(id -u):$(id -g)" \
-e UV_CACHE_DIR=/tmp/uv-cache \
ghcr.io/astral-sh/uv:0.13.0-python3.14-trixie-slim uv lock
lsUsing CPython 3.14.8 interpreter at: /usr/local/bin/python3
Resolved 58 packages in 763ms
main.py
pyproject.toml
uv.lockদুটো uv ভার্সনই 0.13.0-তে পিন করা (এখানকার ইমেজ ট্যাগ, আর নিচের Dockerfile-এর COPY --from লাইন)। uv এখনো ০.x, তাই ভার্সনভেদে লকফাইলের ফরম্যাট বদলাতে পারে; যে টুল লক লেখে আর যে টুল পড়ে, তাদের মিলে থাকা উচিত।
Dockerfile
Dockerfile হলো ইমেজ বানানোর রেসিপি। এটাতে দুটো স্টেজ (stage) আছে: একটা বিল্ডার, যাতে uv আছে এবং যা ভার্চুয়াল এনভায়রনমেন্ট বানায়; আর একটা পরিষ্কার রানটাইম ইমেজ, যা শুধু তৈরি .venv আর কোড পায়। uv নিজে কখনো প্রোডাকশনে যায় না।
# file: Dockerfile
# ---- stage 1: build the virtual environment with uv ----
FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.13.0 /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
WORKDIR /app
# Dependencies first: this layer is rebuilt only when the lock changes.
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project
# ---- stage 2: the runtime image ----
FROM python:3.14-slim
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY main.py ./
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PORT=8000
USER app
EXPOSE 8000
CMD ["fastapi", "run", "main.py"]| নির্দেশ | এখানে কী করে |
|---|---|
FROM python:3.14-slim AS builder | অফিসিয়াল ইমেজ থেকে শুরু করে এই স্টেজের নাম দেয়। |
COPY --from=ghcr.io/astral-sh/uv:0.13.0 /uv /uvx /bin/ | uv-র নিজের ইমেজ থেকে uv বাইনারি কপি করে: uv-র ডকুমেন্টেশনের সুপারিশ করা পদ্ধতি। |
ENV UV_COMPILE_BYTECODE=1 | বিল্ডের সময়ই .pyc ফাইল কম্পাইল করে, ফলে কন্টেইনার দ্রুত চালু হয়। UV_LINK_MODE=copy আলাদা ফাইলসিস্টেমের মধ্যে হার্ড লিংক নিয়ে সতর্কবার্তা এড়ায়; UV_PYTHON_DOWNLOADS=0 uv-কে ইমেজের Python ব্যবহার করতে বাধ্য করে, নতুন ডাউনলোড করতে দেয় না। |
RUN uv sync --locked --no-dev --no-install-project | লক থেকে /app/.venv বানায়। --locked-এ uv.lock পুরোনো হলে চুপচাপ নতুন করে রিজলভ করার বদলে ফেল করে; --no-dev টেস্ট টুল বাদ দেয়। |
RUN useradd … app / USER app | একটা বিশেষ অধিকারহীন ব্যবহারকারী বানিয়ে সেই হিসেবে চালায় (নিচে আরও আছে)। |
COPY --from=builder /app/.venv /app/.venv | স্টেজ ১-এর শুধু ফলাফলটা নেয়। বেস ইমেজ আর পথ একই, তাই venv-এর /usr/local/bin/python-এর লিংক ঠিক থাকে। |
ENV PATH=… | venv-কে PATH-এর সামনে বসায়: এখন fastapi আর python বলতে venv-এরটাই বোঝায়। PYTHONUNBUFFERED=1 লগ সঙ্গে সঙ্গে বের করে দেয়। |
ENV PORT=8000 / EXPOSE 8000 | fastapi run PORT ভেরিয়েবল পড়ে (Render ও Cloud Run-ও তা-ই করে, এবং নিজেরাই সেট করে দেয়)। EXPOSE শুধু ডকুমেন্টেশন; এটা কিছু পাবলিশ করে না। |
CMD ["fastapi", "run", "main.py"] | স্টার্ট কমান্ড, exec ফর্মে (JSON লিস্ট)। fastapi run শোনে 0.0.0.0-এ, যেটা কন্টেইনারের জন্য দরকার: কন্টেইনারের ভেতরের 127.0.0.1 বাইরে থেকে পৌঁছানো যায় না। |
.dockerignore
docker build . ফোল্ডারটাকে (বিল্ড কনটেক্সট) Docker ইঞ্জিনে পাঠায়। .dockerignore না থাকলে তাতে যায় আপনার লোকাল .venv (শত শত MB, আপনার OS-এর জন্য বানানো), .git, আর সবচেয়ে খারাপ, আসল কি-সহ .env, যা অসাবধান একটা COPY . . ইমেজে ঢুকিয়ে দেবে, আর যে-ই ইমেজ পুল করবে সে-ই পাবে:
# file: .dockerignore
.venv/
__pycache__/
*.pyc
.git/
.env
*.log
Dockerfile*বিল্ড ও রান
-t ইমেজকে নাম ও ট্যাগ দেয়; শেষের . হলো বিল্ড কনটেক্সট:
docker build -t bitbyte-p17-api:1.0 .প্রথম বিল্ডে PyPI থেকে প্যাকেজ নামাতে হয়, তাই একটু সময় লাগে। BuildKit-এ (Docker Desktop ও বর্তমান Docker Engine-এর ডিফল্ট বিল্ডার) প্রতিটা ধাপের জন্য এক লাইন দেখা যায়, যেমন => [builder 5/5] RUN uv sync --locked …, আর যে ধাপ আগের ক্যাশ থেকে নেওয়া হয়েছে সেখানে CACHED; হুবহু লগ মেশিন ও রানভেদে আলাদা, তাই এখানে দেখানো হয়নি। ফলাফল পরীক্ষা করুন:
docker run --rm bitbyte-p17-api:1.0 python --version
docker run --rm bitbyte-p17-api:1.0 python -c "import fastapi; print('fastapi', fastapi.__version__)"Python 3.14.8
fastapi 0.143.0এবার API-টাকে দীর্ঘক্ষণ চলা কন্টেইনার হিসেবে চালু করুন। -d ব্যাকগ্রাউন্ডে চালায় এবং নতুন কন্টেইনারের ID ছাপে; --name একটা নাম দেয়, যাতে পরে ID-র বদলে সেটা ব্যবহার করা যায়; -e এনভায়রনমেন্ট ভেরিয়েবল সেট করে; -p 8000:8000 পাবলিশ করে হোস্ট পোর্ট:কন্টেইনার পোর্ট হিসেবে:
docker run -d --name bitbyte-p17-api -e APP_VERSION=1.0.0 -e PORT=8000 -p 8000:8000 bitbyte-p17-api:1.04a2879830e2ba57a2a5efdbb7de8c6471a3f91fc438480165384a595b0a1d422(আপনার ID আলাদা হবে।) এখানে PORT পাঠানো মানে ইমেজের ডিফল্টের পুনরাবৃত্তি, কিন্তু এতে চুক্তিটা চোখে পড়ে: অ্যাপ কন্টেইনারের ভেতরে PORT-এ শোনে, আর -p-র ডান পাশ সেটার সঙ্গে মিলতে হবে। বাঁ পাশে আপনার মেশিনের যেকোনো ফাঁকা পোর্ট দিতে পারেন।
অ্যাপ চালু হতে এক সেকেন্ডের মতো লাগে। ভেতরের অ্যাপ তৈরি হওয়ার আগেই Docker পাবলিশ করা পোর্টে কানেকশন নেয়, তাই খুব আগে পাঠানো রিকোয়েস্ট "connection refused"-এর বদলে ফাঁকা রিপ্লাই পায়; --retry-all-errors সেটাকেও আবার চেষ্টা করে:
curl -s --retry 30 --retry-all-errors --retry-delay 1 http://localhost:8000/health
echo
curl -s -X POST http://localhost:8000/v1/analyse \
-H "Content-Type: application/json" \
-d '{"text": "Great course, very clear and helpful!"}'
echo
docker ps --filter name=bitbyte-p17-api --format "{{.Names}} {{.Image}} {{.State}}"{"status":"ok","version":"1.0.0"}
{"sentiment":"positive","score":0.5,"model_version":"rules-1.0.0"}
bitbyte-p17-api bitbyte-p17-api:1.0 runningলগ
কন্টেইনারের লগ হলো তার মূল প্রসেস stdout ও stderr-এ যা লেখে তা-ই। এ কারণেই অ্যাপ স্ট্যান্ডার্ড আউটপুটে লগ করে এবং PYTHONUNBUFFERED=1 জরুরি: কন্টেইনারের ভেতরে কখনো লগ ফাইল লিখবেন না, কারণ কন্টেইনারের সঙ্গে সেগুলোও হারিয়ে যায়। প্রতিটা প্ল্যাটফর্ম (Docker, Render, Cloud Run, Kubernetes) আপনার হয়ে stdout সংগ্রহ করে।
docker logs --tail 20 bitbyte-p17-api ⚡️ Starting FastAPI in production mode
🐍 Using import string: main:app
🌐 Server started at http://0.0.0.0:8000
Documentation at http://0.0.0.0:8000/docs
Logs:
INFO: Started server process [1]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO: 172.17.0.1:49848 - "GET /health HTTP/1.1" 200 OK
INFO review-api: analysed 6 words: positive
INFO: 172.17.0.1:49860 - "POST /v1/analyse HTTP/1.1" 200 OKঅ্যাক্সেস-লগের লাইনে ক্লায়েন্টের ঠিকানা ও পোর্ট থাকে, যা প্রতি রানে বদলায়। যা দরকার সেটা ফিল্টার করুন; docker logs -f লগ লাইভ অনুসরণ করে, আর --since 10m বা --tail 50 সীমা দেয়:
docker logs bitbyte-p17-api 2>&1 | grep review-apiINFO review-api: analysed 6 words: positiveপূর্ণ লগে Started server process [1] লক্ষ করুন: সার্ভারটা কন্টেইনারের ভেতরে PID ১। এই তথ্যই ঠিক করে কন্টেইনার কীভাবে বন্ধ হবে।
এক্সিট কোড ও সিগন্যাল
কন্টেইনারের মূল প্রসেস শেষ হলে কন্টেইনার থেমে যায়, আর প্রসেসের এক্সিট কোডই কন্টেইনারের এক্সিট কোড হয়। স্ক্রিপ্ট, CI আর অর্কেস্ট্রেটর এটা পড়ে, তাই প্রচলিত মানগুলো জেনে রাখুন:
docker run --rm bitbyte-p17-api:1.0 python -c "import sys; sys.exit(3)"
echo "exit code: $?"exit code: 3| এক্সিট কোড | সাধারণত মানে |
|---|---|
| 0 | পরিষ্কারভাবে শেষ (সার্ভারের ক্ষেত্রে: থামতে বলা হয়েছিল এবং সে সুন্দরভাবে থেমেছে) |
| 1 | অ্যাপ ক্র্যাশ করেছে: আনহ্যান্ডেলড এক্সেপশন, ফেল করা ইমপোর্ট, বা অনুপস্থিত সেটিং |
| 125 / 126 / 127 | Docker নিজে ফেল করেছে / কমান্ড চালানো যায়নি / কমান্ড খুঁজে পাওয়া যায়নি |
| 137 | SIGKILL দিয়ে মারা হয়েছে (128 + 9): মেমরি শেষ, অথবা SIGTERM উপেক্ষা করায় টাইমআউটের পর মারা হয়েছে |
| 143 | SIGTERM (128 + 15) পেয়ে, সামলানো ছাড়াই থেমেছে |
docker stop পাঠায় SIGTERM, অপেক্ষা করে (ডিফল্ট ১০ সেকেন্ড), তারপর পাঠায় SIGKILL। Uvicorn SIGTERM সামলায়: নতুন কানেকশন নেওয়া বন্ধ করে, চলমান রিকোয়েস্ট শেষ করে, আপনার lifespan-এর শাটডাউন অংশ চালায়, এবং ০ দিয়ে বেরিয়ে যায়:
docker stop bitbyte-p17-api
docker logs bitbyte-p17-api 2>&1 | grep -iE "shutting|shutdown|finished"
docker inspect -f "{{.State.ExitCode}}" bitbyte-p17-apibitbyte-p17-api
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [1]
0এটা কাজ করেছে exec-ফর্ম CMD ["fastapi", "run", "main.py"]-এর কারণে: সার্ভার নিজেই PID ১ এবং সিগন্যালটা সে-ই পায়। এবার ক্লাসিক ভুলটা দেখুন। শেল ফর্ম, অর্থাৎ CMD fastapi run main.py, চলে /bin/sh -c "fastapi run main.py" হিসেবে: শেল হয় PID ১, সার্ভার হয় তার সন্তান, আর শেল SIGTERM এগিয়ে দেয় না। PID ১ যে সিগন্যালের হ্যান্ডলার নেই সেটাও উপেক্ষা করে, ফলে Docker ধৈর্য হারানো পর্যন্ত কিছুই ঘটে না। পরের ব্লকে কমান্ড বদলে সেটা নকল করা হয়েছে, আর ১০ সেকেন্ডের বদলে মাত্র ৩ সেকেন্ড অপেক্ষা করা হয়েছে:
docker run -d --name bitbyte-p17-shell bitbyte-p17-api:1.0 sh -c "fastapi run main.py" > /dev/null
docker stop -t 3 bitbyte-p17-shell
docker inspect -f "{{.State.ExitCode}}" bitbyte-p17-shell
docker rm bitbyte-p17-shell bitbyte-p17-apibitbyte-p17-shell
137
bitbyte-p17-shell
bitbyte-p17-api137: সার্ভারকে মাঝপথে মেরে ফেলা হয়েছে। চলমান রিকোয়েস্ট কেটে গেছে, আর শাটডাউন কোড কখনো চলেনি (মডেল ঠিকভাবে আনলোড হয়নি, কিউ খালি হয়নি)। প্রতিটা ডিপ্লয় ও স্কেল-ডাউনে আপনার ব্যবহারকারীরা ঠিক এটাই টের পেত। exec ফর্ম ব্যবহার করুন; যদি এন্ট্রিপয়েন্ট হিসেবে শেল স্ক্রিপ্ট সত্যিই লাগে, তার শেষে exec fastapi run main.py লিখুন, যাতে সার্ভার শেলের জায়গা নিয়ে নেয়।
নন-রুট ব্যবহারকারী হিসেবে চালানো
ডিফল্টভাবে কন্টেইনারের প্রসেস root হিসেবে চলে। কন্টেইনারের রুট সীমিত, কিন্তু আক্রমণকারী যদি আপনার অ্যাপে (বা তার ৫০টা ডিপেন্ডেন্সির একটায়) ফাঁক পায়, ভেতরে রুট থাকলে পরের ধাপ, অর্থাৎ হোস্টে পালানো বা ইমেজের ফাইল বদলানো, অনেক সহজ হয়ে যায়। USER app লাইনটার কোনো খরচ নেই:
docker run --rm bitbyte-p17-api:1.0 id
docker run --rm bitbyte-p17-api:1.0 touch /app/main.py || echo "write refused"uid=10001(app) gid=10001(app) groups=10001(app)
touch: cannot touch '/app/main.py': Permission denied
write refusedকোডের মালিক root, আর অ্যাপ চলে app (UID 10001) হিসেবে, তাই ক্ষতিগ্রস্ত অ্যাপও নিজেকে নতুন করে লিখতে পারে না। একটা নির্দিষ্ট, বড় UID প্ল্যাটফর্মের সঙ্গেও ভালো মানায় (Kubernetes runAsNonRoot বাধ্য করতে পারে)। অ্যাপকে কোথাও লিখতে হলে (আপলোড, মডেল ক্যাশ), শুধু সেই একটা ফোল্ডার বানিয়ে app-কে chown করুন, অথবা আরও ভালো, ভলিউম ব্যবহার করুন। বাড়তি সুরক্ষার জন্য docker run --read-only --tmpfs /tmp পুরো ফাইলসিস্টেমকে শুধু-পড়া করে দেয়।
লেয়ার ক্যাশিং: ক্রম গুরুত্বপূর্ণ
Docker প্রতিটা ধাপ ক্যাশ করে। আবার বিল্ড করার সময় সে ক্যাশ করা লেয়ার ব্যবহার করতে থাকে, যতক্ষণ না প্রথম এমন ধাপ আসে যার ইনপুট বদলেছে; সেই ধাপ এবং তার পরের সব ধাপ আবার চলে। আমাদের Dockerfile main.py কপি করার আগে pyproject.toml আর uv.lock কপি করে ডিপেন্ডেন্সি ইনস্টল করে। তাই শুধু কোড বদলালে ধীর ইনস্টলের ধাপটা ক্যাশ থেকে আসে:
sed -i 's/"dev"/"local"/' main.py
docker build -t bitbyte-p17-api:1.1 .এই রিবিল্ডে এক-দুই সেকেন্ড লাগে: uv sync পর্যন্ত প্রতিটা ধাপ ক্যাশড দেখায়, শুধু COPY main.py আর তার পরেরগুলো আবার চলে। Dockerfile যদি COPY . . দিয়ে শুরু হতো, কোডে এক অক্ষর বদলালেই ৫৮টা প্যাকেজ নতুন করে ইনস্টল হতো। নিয়ম: যা কম বদলায় সেটা আগে কপি করুন, যা ঘন ঘন বদলায় সেটা শেষে।
BuildKit দিয়ে আরও এগোনো যায়: লক বদলালেও বিল্ডের মাঝে uv-র ডাউনলোড ক্যাশ ধরে রাখা যায়। RUN-এর এই রূপের জন্য BuildKit দরকার (Docker Desktop ও বর্তমান Docker Engine-এ ডিফল্টভাবে চালু):
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --no-install-projectpip সংস্করণ
কিছু টিম ও প্ল্যাটফর্ম uv ব্যবহার করে না। একই লক থেকে পিন করা একটা requirements.txt এক্সপোর্ট করা যায়, আর Dockerfile হয়ে যায় একক-স্টেজ ও সাধারণ pip:
uv export --locked --no-dev --no-hashes --no-header --format requirements-txt -q -o requirements.txt
grep -E "^(fastapi|uvicorn|pydantic)==" requirements.txtfastapi==0.143.0
pydantic==2.14.0
uvicorn==0.54.0# file: Dockerfile.pip
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PORT=8000
RUN useradd --create-home --uid 10001 app
WORKDIR /app
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY main.py ./
USER app
EXPOSE 8000
CMD ["fastapi", "run", "main.py"]docker build -f Dockerfile.pip -t bitbyte-p17-api:pip .docker run --rm bitbyte-p17-api:pip python -c "import fastapi; print('fastapi', fastapi.__version__)"
docker run --rm bitbyte-p17-api:pip id -unfastapi 0.143.0
appভার্সন একই, কারণ দুটোই এসেছে একটা লক থেকে। -f অন্য নামের Dockerfile বেছে নেয়। PIP_NO_CACHE_DIR=1 pip-র ডাউনলোড ক্যাশ ইমেজের বাইরে রাখে। ট্রেড-অফ: pip সরাসরি চূড়ান্ত ইমেজের সিস্টেম site-packages-এ ইনস্টল করে, তাই কোনো প্যাকেজের দরকারি বিল্ড টুল ইমেজে থেকে যায়; মাল্টি-স্টেজ uv বিল্ড সেগুলো পেছনে ফেলে আসে।
ইমেজের সাইজ
এই মেশিনে দুটো ইমেজই ডিস্কে প্রায় ৩০০ MB দেখায় (রেজিস্ট্রিতে কমপ্রেসড ৬৯ MB)। এর ১৮০ MB আসে বেস python:3.14-slim থেকে, আর venv প্রায় ৯৬ MB। প্রতিটা লেয়ারের অবদান দেখা যায় docker history দিয়ে:
docker history bitbyte-p17-api:1.0 --format "{{.Size}}\t{{.CreatedBy}}" | head -60B /bin/sh -c #(nop) CMD ["fastapi" "run" "mai…
0B /bin/sh -c #(nop) EXPOSE 8000
0B /bin/sh -c #(nop) USER app
0B /bin/sh -c #(nop) ENV PATH=/app/.venv/bin:/…
12.3kB /bin/sh -c #(nop) COPY file:4c3197cadf436bdd…
95.8MB /bin/sh -c #(nop) COPY dir:18e0cab54c73b870f…এই আউটপুট Docker-এর ক্লাসিক বিল্ডার থেকে; BuildKit-এ দ্বিতীয় কলামে আপনার Dockerfile-এর লাইনের মতো লেখা আসে (COPY /app/.venv /app/.venv # buildkit)। যেভাবেই হোক, venv-এর লেয়ারটাই বড়, বাকিটা বেস ইমেজ।
ইমেজ ছোট করার উপায়, লাভের ক্রমে:
- কম ডিপেন্ডেন্সি।
fastapi[standard]আনে CLI, OpenTelemetry, FastAPI Cloud CLI ও আরও কিছু। যে সার্ভিসের শুধুfastapiআরuvicornলাগে, তার ইমেজ অনেক ছোট। ML-এ CPU-only PyTorch wheel CUDA-র তুলনায় কয়েক ভাগের এক ভাগ। - মাল্টি-স্টেজ বিল্ড, যাতে কম্পাইলার ও ক্যাশ বিল্ডারেই থাকে।
- slim বেস, আর একটা ভালো
.dockerignore। - একই
RUN-এর ভেতরে পরিষ্কার করুন:apt-get install … && rm -rf /var/lib/apt/lists/*। পরের লেয়ারে মুছে ফেলা ফাইলও আগের লেয়ারে জায়গা নিয়ে থাকে।
কয়েক GB-র মডেল ওয়েট না ভেবে ইমেজে ঢোকাবেন না: তাহলে প্রতিটা ডিপ্লয়ে সেগুলো আবার পুল করতে হয়। বড় মডেল সাধারণত অবজেক্ট স্টোরেজ বা মডেল রেজিস্ট্রিতে থাকে এবং স্টার্টআপে একবার ভলিউমে নামানো হয়।
ভালনারেবিলিটির জন্য স্ক্যান
আপনার ইমেজে একটা পুরো Debian ইউজারল্যান্ড আর প্রতিটা পাইথন প্যাকেজ আছে, এবং সেগুলোর জন্য প্রতি সপ্তাহে নতুন ভালনারেবিলিটি (CVE) প্রকাশিত হয়। CI-তে ইমেজ স্ক্যান করুন এবং নিয়মিত রিবিল্ড করুন, যাতে প্যাচ করা বেস ইমেজ পান। দুটো প্রচলিত স্ক্যানার (দুটোরই ভালনারেবিলিটি ডেটাবেস নামাতে নেটওয়ার্ক লাগে, তাই এখানে চালানো হয়নি):
# Docker Scout (built into Docker Desktop; needs a Docker login)
docker scout cves bitbyte-p17-api:1.0
docker scout recommendations bitbyte-p17-api:1.0
# Trivy (open source), run from its own image
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image --severity HIGH,CRITICAL bitbyte-p17-api:1.0রিপোর্ট বিচার-বুদ্ধি দিয়ে পড়ুন: আপনার অ্যাপ যে লাইব্রেরি কখনো ডাকে না তার একটা ক্রিটিক্যাল CVE, আপনার ওয়েব সার্ভারের একটা মাঝারি CVE-র চেয়ে কম জরুরি। বিল্ডের আগেই পাইথন দিকটা দেখে নেয় pip-audit (API নিরাপত্তা ও OWASP API Top 10 পাতা থেকে)।
পুনরুৎপাদনযোগ্য বিল্ড
একই কমিট দুইবার বিল্ড করলে একই সফটওয়্যার পাওয়া গেলে বিল্ডটা পুনরুৎপাদনযোগ্য। পরিবর্তনশীল প্রতিটা অংশ পিন করতে হয়:
| যা বদলাতে পারে | কীভাবে পিন করবেন |
|---|---|
| পাইথন প্যাকেজ | uv.lock + uv sync --locked (অথবা পুরো পিন করা, সম্ভব হলে হ্যাশসহ, requirements ফাইল) |
| uv নিজে | ghcr.io/astral-sh/uv:0.13.0, কখনো :latest নয় |
| বেস ইমেজ | নির্দিষ্ট ট্যাগ (3.14-slim, কখনো latest নয়); পুরোপুরি অপরিবর্তনীয়তার জন্য তার ডাইজেস্ট |
| আপনার কোড | Git কমিট থেকে বিল্ড করুন, আর ইমেজে কমিট SHA বা রিলিজ ভার্সনের ট্যাগ দিন |
python:3.14-slim-এর মতো ট্যাগ প্যাচ রিলিজ বা Debian সিকিউরিটি ফিক্স এলে সরে যায় (সাধারণত সেটাই আপনি চান)। ডাইজেস্ট কখনো সরে না। আপনার কাছে যেটা আছে সেটা বের করুন:
docker image inspect python:3.14-slim --format "{{index .RepoDigests 0}}"python@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170এবং FROM python:3.14-slim@sha256:… দিয়ে পিন করুন। মানুষের জন্য ট্যাগ সামনে রাখুন; Docker ডাইজেস্টটাই ব্যবহার করে। পিন করা ডাইজেস্ট নিজে থেকে আপডেট হয় না, তাই Dependabot বা Renovate-এর সঙ্গে জুড়ে দিন, নতুন ডাইজেস্ট এলে যারা পুল রিকোয়েস্ট খোলে (GitHub Actions দিয়ে CI/CD পাতায় কনফিগার করা হয়েছে)।
বাস্তবে কোথায় কাজে লাগে
- প্রতিটা ডিপ্লয়মেন্ট টার্গেট। Render, Cloud Run, Railway, Kubernetes ও AWS ECS সবাই ইমেজ চালায়। একই ইমেজ CI থেকে স্টেজিং হয়ে প্রোডাকশনে যায়; শুধু এনভায়রনমেন্ট ভেরিয়েবল বদলায়।
- মডেল সার্ভিং। ইনফারেন্স সার্ভার (vLLM, Text Generation Inference, Triton) ইমেজ হিসেবেই আসে; GPU ইমেজ একই ধারণার ওপর NVIDIA রানটাইম যোগ করে।
- পুনরুৎপাদনযোগ্য গবেষণা। এক্সপেরিমেন্টের ফলাফলের পাশে একটা ইমেজ ট্যাগ ঠিক বলে দেয় কোন কোড ও লাইব্রেরি সেগুলো বানিয়েছে।
সাধারণ ভুল
- ডিপেন্ডেন্সি ইনস্টলের আগে
COPY . .। প্রতিটা কোড পরিবর্তনে সবকিছু আবার ইনস্টল হয়, আর.dockerignoreনা থাকলে.venvও.envইমেজে ঢুকে যায়। আগে লকফাইল কপি করুন, ইনস্টল করুন, তারপর কোড। - ইমেজে সিক্রেট।
ENV OPENAI_API_KEY=…বা কপি করা.envইমেজ পুল করতে পারে এমন যে কেউ পড়তে পারে, এবং পরের ধাপে মুছে ফেললেও লেয়ার ইতিহাসে থেকে যায়। সিক্রেট দিন রান টাইমে (-e,--env-file, প্ল্যাটফর্মের সিক্রেট স্টোর); বিল্ড-টাইম সিক্রেটের জন্য BuildKit-এরRUN --mount=type=secret। - কন্টেইনারের ভেতরে 127.0.0.1-এ শোনা। পোর্ট পাবলিশ করা আছে অথচ প্রতিটা রিকোয়েস্ট ফেল করে। কন্টেইনারের সার্ভারকে শুনতে হবে
0.0.0.0-এ;fastapi runডিফল্টভাবে তা-ই করে,uvicorn-এ লাগে--host 0.0.0.0। - শেল-ফর্ম
CMD। SIGTERM কখনো সার্ভারে পৌঁছায় না, তাই প্রতিটা স্টপে পুরো টাইমআউট লাগে এবং শেষ হয় SIGKILL (137) দিয়ে। JSON exec ফর্ম ব্যবহার করুন, অথবা এন্ট্রিপয়েন্ট স্ক্রিপ্টেexec। - সবখানে
:latest। গতকাল যে বিল্ড কাজ করেছিল, আজ সে নতুন Python বা নতুন uv তুলে নেয়। ট্যাগ (আর যেখানে দরকার ডাইজেস্ট) পিন করুন, এবং নিজের ইমেজেlatest-এর বদলে ভার্সন ট্যাগ দিন।
নিজে চেষ্টা করুন
- সহজ: 1.0 ইমেজ থেকে
bitbyte-p17-easyনামে একটা কন্টেইনার চালান,APP_VERSION=2.0.0দিয়ে, হোস্ট পোর্ট 8001-এ পাবলিশ করে (কন্টেইনার এখনো 8000-এ শোনে)। 8001 পোর্ট দিয়ে/healthকল করুন, তারপর কন্টেইনারটা মুছে দিন। - মাঝারি:
app.envফাইলেAPP_VERSION=1.2.0রাখুন এবং-e-র বদলে--env-file app.envদিয়ে কন্টেইনার চালান। দেখান যে/health1.2.0 জানায়, আর ভেরিয়েবলটা ইমেজের অংশ নয় (ইমেজ থেকে নতুন কন্টেইনারেprintenv APP_VERSIONকিছুই ছাপে না)। - কঠিন: অ্যাপে এমন একটা
lifespanদিন, যা স্টার্টআপেmodel loadedআর শাটডাউনেmodel unloadedলগ করে। এটাকেbitbyte-p17-api:1.3নামে বিল্ড করুন, চালান,docker stopদিয়ে থামান, এবং লগ ও এক্সিট কোড থেকে প্রমাণ করুন যে শাটডাউন কোড চলেছে।
উত্তর
১. হোস্ট পোর্ট বদলায়; কন্টেইনার পোর্ট তা-ই থাকে যেখানে অ্যাপ শোনে:
docker run -d --name bitbyte-p17-easy -e APP_VERSION=2.0.0 -e PORT=8000 -p 8001:8000 bitbyte-p17-api:1.0 > /dev/null
curl -s --retry 30 --retry-all-errors --retry-delay 1 http://localhost:8001/health
echo
docker rm -f bitbyte-p17-easy{"status":"ok","version":"2.0.0"}
bitbyte-p17-easy২. env ফাইলে প্রতি লাইনে একটা NAME=value, কোনো কোটেশন বা export নয়। আসল env ফাইল Git-এর বাইরে রাখুন, .env-এর মতো:
echo "APP_VERSION=1.2.0" > app.env
docker run -d --name bitbyte-p17-medium --env-file app.env -e PORT=8000 -p 8000:8000 bitbyte-p17-api:1.0 > /dev/null
curl -s --retry 30 --retry-all-errors --retry-delay 1 http://localhost:8000/health
echo
docker run --rm bitbyte-p17-api:1.0 printenv APP_VERSION || echo "not in the image"
docker rm -f bitbyte-p17-medium{"status":"ok","version":"1.2.0"}
not in the image
bitbyte-p17-medium৩. lifespan যোগ করুন (main.py-এর শুধু বদলানো অংশগুলো নতুন; এন্ডপয়েন্ট অপরিবর্তিত), রিবিল্ড করে থামান:
from pathlib import Path
source = Path("main.py").read_text()
source = source.replace(
"from fastapi import FastAPI\n",
"from contextlib import asynccontextmanager\n\nfrom fastapi import FastAPI\n",
)
source = source.replace(
'app = FastAPI(title="Review Analyser", version=APP_VERSION)',
'''@asynccontextmanager
async def lifespan(app: FastAPI):
log.info("model loaded")
yield
log.info("model unloaded")
app = FastAPI(title="Review Analyser", version=APP_VERSION, lifespan=lifespan)''',
)
Path("main.py").write_text(source)
print("lifespan" in source)Truedocker build -t bitbyte-p17-api:1.3 .docker run -d --name bitbyte-p17-hard -e PORT=8000 -p 8000:8000 bitbyte-p17-api:1.3 > /dev/null
curl -s --retry 30 --retry-all-errors --retry-delay 1 http://localhost:8000/health > /dev/null
docker stop bitbyte-p17-hard > /dev/null
docker logs bitbyte-p17-hard 2>&1 | grep model
docker inspect -f "{{.State.ExitCode}}" bitbyte-p17-hard
docker rm bitbyte-p17-hard > /dev/nullINFO review-api: model loaded
INFO review-api: model unloaded
0সবশেষে, এই পাতা যা যা বানিয়েছে সব পরিষ্কার করুন (আগে কন্টেইনার, তারপর ইমেজ):
docker rm -f bitbyte-p17-api bitbyte-p17-easy bitbyte-p17-medium bitbyte-p17-hard > /dev/null 2>&1
docker image rm bitbyte-p17-api:1.0 bitbyte-p17-api:1.1 bitbyte-p17-api:1.3 bitbyte-p17-api:pip > /dev/null
docker image ls bitbyte-p17-api --format "{{.Repository}}:{{.Tag}}" | wc -l0সারসংক্ষেপ
- কন্টেইনার হলো হোস্ট কার্নেলের ওপর আলাদা করা একটা প্রসেস; ইমেজ হলো তার শুধু-পড়া, স্তরে স্তরে সাজানো টেমপ্লেট, যা রেজিস্ট্রিতে ট্যাগ বা অপরিবর্তনীয় ডাইজেস্টের নামে থাকে।
- ভালো FastAPI Dockerfile:
python:3.14-slim, বিল্ডার স্টেজেuv sync --lockedদিয়েuv.lockথেকে ডিপেন্ডেন্সি, পরিষ্কার রানটাইম স্টেজে venv কপি, নন-রুট ব্যবহারকারী,PORT, এবং exec-ফর্মCMD ["fastapi", "run", …]। pip সংস্করণ এক্সপোর্ট করা, পিন করাrequirements.txtইনস্টল করে। - ভালো ক্যাশিংয়ের জন্য ধাপগুলো কম-বদলানো থেকে বেশি-বদলানোর ক্রমে সাজান, আর
.dockerignoreদিয়ে.venv,.gitও.envবাইরে রাখুন। -p host:containerএবং-e/--env-fileদিয়ে চালান;docker logsপড়ুন; এক্সিট কোড ০, ১, 137 ও 143 চিনুন, আর নিশ্চিত করুন SIGTERM আপনার সার্ভারে পৌঁছাচ্ছে।- ইমেজ ছোট ও স্ক্যান করা রাখুন, সিক্রেট কখনো ভেতরে ঢোকাবেন না, এবং পুনরুৎপাদনযোগ্য বিল্ডের জন্য যা বদলাতে পারে সব পিন করুন।
এরপর: Docker Compose: API, PostgreSQL ও Redis একসাথে এই ধরনের ইমেজ একটা ডেটাবেস ও একটা ক্যাশের পাশে চালায়, হেলথ চেক, ভলিউম ও সিক্রেটসহ, সবই একটা compose.yaml থেকে।