অধ্যায় 5 · চালু করুন
ডিপ্লয়মেন্ট ও প্রোডাকশনের প্রস্তুতি
- পৃষ্ঠা 20 / 22
- 31 মিনিট পড়া
আপনার API-র টেস্ট পাস করছে, Docker-এ চলছে, আর GitHub Actions সেটা বিল্ড করছে। তবু এটা এখনো শুধু আপনার কাজে লাগে। ডিপ্লয়মেন্ট (deployment) হলো সেই ধাপ, যেখানে অ্যাপটা এমন একটা কম্পিউটারে ওঠে যা সবসময় চালু থাকে, যার একটা পাবলিক HTTPS ঠিকানা আছে, আসল সিক্রেট আছে, আসল ডেটাবেস আছে, আর কিছু ভেঙে গেলে সেটা জানার উপায় আছে। AI প্রোডাক্টে এখানেই সব চমক লুকিয়ে থাকে: ফ্রি প্ল্যানের চেয়ে বেশি মেমরি লাগে এমন মডেল, ঘুমন্ত সার্ভারের জন্য অপেক্ষা করা প্রথম রিকোয়েস্ট, লগে ফাঁস হয়ে যাওয়া LLM কি, কিংবা এমন একটা খারাপ রিলিজ যা ফেরানো যায় না।
এই পাতা দুটো কাজ করে। প্রথমে আপনার অ্যাপকে নিজের মেশিনেই ডিপ্লয়যোগ্য করা হবে, আর সবকিছু এখানে যাচাই করা: এনভায়রনমেন্ট থেকে সেটিংস, PORT, হেলথ ও রেডিনেস চেক, JSON লগ, স্টার্টে মাইগ্রেশন, গ্রেসফুল শাটডাউন, স্মোক টেস্ট আর রোলব্যাক। তারপর দেখানো হবে এই অংশগুলো Render, Google Cloud Run, Railway আর VPS-এ কীভাবে বসে। ক্লাউডের ধাপগুলো শুধু বর্ণনা করা হয়েছে, চালানো হয়নি, কারণ সেগুলোর জন্য আপনার অ্যাকাউন্ট লাগে। যেসব অংশ যেকোনো হোস্টে চলে, সেগুলোই আপনি সত্যিই পরীক্ষা করতে পারেন।
যা শিখবেন
- লোকাল, স্টেজিং আর প্রোডাকশনের পার্থক্য, এবং ২০২৬ সালে হোস্ট বাছাই (ফ্রি টিয়ার আগের চেয়ে ছোট হয়ে গেছে)।
- যেকোনো প্ল্যাটফর্মের জন্য অ্যাপ তৈরি করা: এনভায়রনমেন্ট থেকে সেটিংস ও সিক্রেট,
PORT, ওয়ার্কার, হেলথ ও রেডিনেস, JSON লগ, ডিপ্লয়ের সময় মাইগ্রেশন। - গ্রেসফুল শাটডাউন:
SIGTERMএলে কী হয়, একটা সত্যিকারের চলমান রিকোয়েস্টসহ দেখানো। - ডিপ্লয়ের পরের স্মোক টেস্ট, GitHub থেকে Render-এ ডিপ্লয়, Cloud Run-এ ইমেজ, আর Actions থেকে ডিপ্লয়।
- Git দিয়ে রোলব্যাক, ব্যাকআপ, মনিটরিং, খরচ নিয়ন্ত্রণ আর প্রোডাকশন-প্রস্তুতির চেকলিস্ট।
লোকাল, স্টেজিং আর প্রোডাকশন
এনভায়রনমেন্ট (environment) হলো আপনার সিস্টেমের একটা পূর্ণ কপি: কোড, কনফিগ, ডেটাবেস আর সিক্রেট। টিমগুলো অন্তত তিনটা রাখে, আর যে নিয়মটা সবচেয়ে জরুরি তা হলো, এগুলো একই কোড, আলাদা কনফিগারেশন।
| লোকাল | স্টেজিং | প্রোডাকশন | |
|---|---|---|---|
| উদ্দেশ্য | লেখা ও ডিবাগ | ঠিক এই রিলিজটার রিহার্সাল | আসল ব্যবহারকারীকে সেবা দেওয়া |
| ডেটা | নমুনা বা নকল | নকল, বা পরিচয় মুছে ফেলা কপি | আসল; ব্যাকআপ নেওয়া |
| সিক্রেট | .env-এ ডেভ কি | আলাদা টেস্ট কি, কম খরচের সীমাসহ | হোস্টের সিক্রেট স্টোরে আসল কি |
| কে ডিপ্লয় করে | আপনি | CI, প্রতিটা মার্জে | CI, ট্যাগ দিলে, প্রায়ই অনুমোদনসহ |
আপনার যদি একটাই এনভায়রনমেন্ট থাকে, সেটাকে স্টেজিং বানান। কাউকে না জানিয়ে ভেঙে ফেলা যায় এমন একটা স্টেজিং কপিই শেখায় আপনার ডিপ্লয় আসলে কী করে।
২০২৬ সালে হোস্ট বাছাই
ফ্রি হোস্টিং ছোট হয়ে গেছে। কয়েক বছর আগের বেশিরভাগ "সবসময় ফ্রি" প্ল্যান এখন ট্রায়াল, ক্রেডিট বা শুধু পেইড, আর যেগুলো টিকে আছে তাদের শর্ত কড়া। প্ল্যান ঘন ঘন বদলায়, তাই সিদ্ধান্তের আগে প্রতিটা প্রাইসিং পাতায় বর্তমান সীমা দেখে নিন (check current limits)। লেখার সময়ের অবস্থা:
| বিকল্প | কীসের জন্য ভালো | ২০২৬-এ ফ্রি? | সতর্ক থাকুন |
|---|---|---|---|
| Render | GitHub রিপো থেকে ওয়েব সার্ভিস, সঙ্গে ম্যানেজড Postgres | ফ্রি ওয়েব সার্ভিস ১৫ মিনিট ট্রাফিক না পেলে ঘুমিয়ে পড়ে। ফ্রি Postgres ৩০ দিন পর মেয়াদ শেষ হয় (সঙ্গে ১৪ দিনের গ্রেস পিরিয়ড) | ঘুমের পর প্রথম রিকোয়েস্ট ধীর; ফ্রি প্ল্যানে স্থায়ী ডিস্ক নেই |
| Google Cloud Run | কন্টেইনার, যা শূন্যে নেমে যায় আর বসে থাকলে প্রায় কোনো খরচ নেই | মাসিক ফ্রি টিয়ার; তবু বিলিং অ্যাকাউন্ট লাগে | স্টেটলেস; কোল্ড স্টার্ট; পোর্ট আসে PORT থেকে (8080) |
| Railway | "রিপো আর ডেটাবেস" সবচেয়ে দ্রুত চালু করা | ৫ ডলারের ট্রায়াল, তারপর মাসে ১ ডলারের ক্রেডিট | ডেটাবেস ছোট ক্রেডিট দ্রুত খেয়ে ফেলে |
| Fly.io | ব্যবহারকারীর কাছাকাছি কন্টেইনার | ফ্রি টিয়ার নেই; যত ব্যবহার তত দাম | ভলিউম আর রিজিয়ন আপনাকে সামলাতে হয় |
| Koyeb | সুন্দর UI-সহ Docker ডিপ্লয় | নতুন ব্যবহারকারীর জন্য ফ্রি প্ল্যান বন্ধ করেছে | ভরসা করার আগে যাচাই করুন |
| Hugging Face Spaces | ডেমো, বিশেষ করে Gradio ও Streamlit | Docker Spaces-এর জন্য PRO অ্যাকাউন্ট লাগে | সাধারণ ব্যাকএন্ড হোস্ট নয় |
| VPS | পূর্ণ নিয়ন্ত্রণ, স্থির খরচ, এক মেশিনে কয়েকটা অ্যাপ | না; মাসে কয়েক ডলার | প্যাচ, নিরাপত্তা, মনিটরিং, ব্যাকআপ সবই আপনার |
শেখার জন্য Render-এর ফ্রি প্ল্যান বা Cloud Run-ই সাধারণত প্রথম ঠিকানা। পোর্টফোলিও ডেমোর ক্ষেত্রে মনে রাখুন, ঘুমন্ত সার্ভিসে প্রথম দর্শককে এক মিনিট পর্যন্ত অপেক্ষা করতে হয়, তাই শেয়ার করার আগে নিজে লিংকটা খুলে নিন। আসল ব্যবহারকারী থাকলে ফ্রি টিয়ারের ওপর ভরসা না করে অল্প, অনুমানযোগ্য টাকা খরচ ধরে নিন। বাংলাদেশের শিক্ষার্থীদের জন্য আন্তর্জাতিক কার্ডে পেমেন্টই অনেক সময় আসল বাধা, তাই কিছু বানানোর আগে দেখে নিন প্রতিটা প্ল্যাটফর্ম কী গ্রহণ করে।
অ্যাপ তৈরি করা: টুয়েলভ-ফ্যাক্টর অভ্যাস
প্রতিটা প্ল্যাটফর্ম আপনার অ্যাপের কাছে একই কয়েকটা জিনিস চায়, আর সেগুলো পুরোনো, সাদামাটা অভ্যাস ("টুয়েলভ-ফ্যাক্টর অ্যাপ" পদ্ধতি থেকে আসা)। Docker পাতার রিভিউ API এখন একটা ডেটাবেসসহ এগুলো মেনে চলে:
| প্ল্যাটফর্ম যা আশা করে | অ্যাপ যা করে |
|---|---|
| কনফিগ থাকে এনভায়রনমেন্টে | settings.py APP_* ভেরিয়েবল পড়ে; সিক্রেটের কোনো ডিফল্ট নেই, তাই কি-টা না থাকলে স্টার্টই হয় না |
| যে পোর্ট দেওয়া হয়েছে সেটায় শুনুন | স্টার্ট স্ক্রিপ্ট $PORT ব্যবহার করে; Render ও Cloud Run সেটা নিজেই সেট করে |
| লগ যায় স্ট্যান্ডার্ড আউটপুটে | প্রতি লাইনে একটা JSON অবজেক্ট; প্ল্যাটফর্ম সেগুলো জমা করে ও খোঁজে |
| জীবিত আর প্রস্তুত কি না জানান | /health সস্তা; /ready ডেটাবেস যাচাই করে |
SIGTERM-এ পরিষ্কারভাবে থামুন | চলমান রিকোয়েস্ট শেষ হলে lifespan-এর শাটডাউন রিসোর্স বন্ধ করে |
| স্কিমার পরিবর্তন কোডের সঙ্গে যায় | সার্ভার চালুর আগে Alembic চলে |
একটা প্রজেক্ট ফোল্ডার আর ভার্চুয়াল এনভায়রনমেন্ট দিয়ে শুরু করুন। ডিপেন্ডেন্সি যায় requirements.txt-এ, কারণ প্রতিটা প্ল্যাটফর্ম ওই ফাইল বোঝে (uv হলে আগের পাতাগুলোর মতোই uv add আর লকফাইল ব্যবহার করবেন)।
mkdir -p ~/projects/review-api
cd ~/projects/review-api# file: requirements.txt
fastapi[standard]>=0.143
pydantic-settings>=2.10
sqlalchemy>=2.0
alembic>=1.16python -m venv .venv
source .venv/bin/activate
pip install -q -r requirements.txtসেটিংস ও সিক্রেট
সব কনফিগারেশন থাকে pydantic-settings-এর একটা Settings ক্লাসে, যা ডিপেন্ডেন্সি, লাইফস্প্যান, মিডলওয়্যার ও সেটিংস পাতার প্যাটার্ন। ডিপ্লয়মেন্টের জন্য তিনটা জিনিস জরুরি। API কি-র কোনো ডিফল্ট নেই, তাই সিক্রেট ভুলে গেলে ডিপ্লয় অরক্ষিত অবস্থায় না চলে স্টার্টেই ব্যর্থ হয়। port প্রিফিক্সবিহীন PORT পড়ে, কারণ প্ল্যাটফর্মগুলো সেটাই ঢুকিয়ে দেয়। আর একটা ভ্যালিডেটর একটা ফাঁদ থেকে বাঁচায়: ম্যানেজড Postgres হোস্ট যে URL দেয় তা শুরু হয় postgres:// বা postgresql:// দিয়ে, কিন্তু SQLAlchemy 2-এর দরকার ড্রাইভারের নাম, postgresql+psycopg://।
# file: settings.py
"""Settings: every value that changes between laptop, staging and production."""
from functools import lru_cache
from typing import Literal
from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env", extra="ignore")
env: Literal["local", "staging", "production"] = "local"
version: str = "dev"
database_url: str = "sqlite:///./reviews.db"
api_key: SecretStr # no default: the app refuses to start without it
log_level: str = "INFO"
port: int = Field(8000, validation_alias="PORT") # platforms inject PORT, no prefix
@field_validator("database_url")
@classmethod
def use_psycopg_driver(cls, url: str) -> str:
# Hosts hand out postgres:// or postgresql://; SQLAlchemy 2 needs a driver name.
for old in ("postgres://", "postgresql://"):
if url.startswith(old):
return "postgresql+psycopg://" + url[len(old):]
return url
@lru_cache
def get_settings() -> Settings:
return Settings()দুটো আচরণই যাচাই করুন। কি ছাড়া সেটিংস লোডই হয় না, আর এরর মিসিং ফিল্ডের নাম বলে (শুধু loc আর msg ছাপলে ভার্সন-নির্ভর লিংক আসে না)। কি থাকলে প্ল্যাটফর্ম-স্টাইলের ডেটাবেস URL বদলে যায়:
python -c "
from pydantic import ValidationError
from settings import Settings
try:
Settings()
except ValidationError as exc:
print([(e['loc'], e['msg']) for e in exc.errors()])
"
APP_API_KEY=x python -c "
from settings import Settings
print(Settings(database_url='postgres://user:pw@db.example.com/reviews').database_url)
"[(('api_key',), 'Field required')]
postgresql+psycopg://user:pw@db.example.com/reviewsআসল মান কখনো কমিট করবেন না। লোকাল কাজের জন্য একটা
.envরাখুন (এটা.gitignore-এ, নিচে দেখবেন), সিক্রেট ছাড়া শুধু নামগুলোর একটা.env.exampleকমিট করুন, আর প্রোডাকশনের মান রাখুন হোস্টের সিক্রেট স্টোরে। যে কি কখনো Git ইতিহাসে ছিল, তাকে ফাঁস হয়ে গেছে ধরে বদলাতে হবে, যেমনটা AI প্রজেক্টে Git: ডেটা, মডেল, LFS, DVC ও ফাঁস হওয়া সিক্রেট পাতায় আছে।
JSON লগ, হেলথ ও রেডিনেস
দুটো ছোট ফাইলে অপারেশনের আচরণ থাকে। logs.py প্রতিটা লগ রেকর্ডকে স্ট্যান্ডার্ড আউটপুটে এক লাইনের JSON করে। Render, Cloud Run ও Railway-র মতো প্ল্যাটফর্ম স্ট্যান্ডার্ড আউটপুট জমা করে, আর JSON লাইনে সাধারণ টেক্সট grep করার বদলে level বা status দিয়ে ফিল্টার করা যায়। কন্টেইনারের ভেতরে কখনো ফাইলে লগ লিখবেন না: ডিস্ক ক্ষণস্থায়ী, আর কেউ ফাইলটা পড়বে না।
# file: logs.py
"""One JSON object per log line, so a platform can search and filter the fields."""
import json
import logging
import sys
import time
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
data = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
**getattr(record, "fields", {}),
}
if record.exc_info:
data["exc"] = self.formatException(record.exc_info)
return json.dumps(data)
def setup_logging(level: str = "INFO") -> None:
handler = logging.StreamHandler(sys.stdout) # containers log to stdout, never to files
handler.setFormatter(JsonFormatter())
logging.getLogger().handlers[:] = [handler]
logging.getLogger().setLevel(level)এবার main.py। চারটা জিনিস লক্ষ করে পড়ুন। lifespan স্টার্টে ডেটাবেস ইঞ্জিন খোলে আর finally-তে অ্যাপকে "প্রস্তুত নয়" চিহ্নিত করে, ইঞ্জিন বন্ধ করে ও shutdown complete লগ করে। /health (লাইভনেস, "প্রসেসটা কি জীবিত?") কোনো I/O করে না। /ready (রেডিনেস, "এখন কি ট্রাফিক সামলাতে পারব?") SELECT 1 চালায় এবং না পারলে 503 ফেরত দেয়, যা লোড ব্যালান্সারকে বলে রিকোয়েস্ট পাঠানো বন্ধ করতে, কিন্তু আপনাকে রিস্টার্ট না করতে। মিডলওয়্যার প্রতি রিকোয়েস্টে এক লাইন লগ করে, তবে প্রোবগুলো বাদ দেয়, নইলে লগ ডুবে যেত। /slow এন্ডপয়েন্ট ধীর মডেল কলের বদলি, যাতে একটা রিকোয়েস্ট চলা অবস্থায় শাটডাউন দেখা যায়।
# file: main.py
"""Review API, ready for production: health, readiness, JSON logs, graceful shutdown."""
import asyncio
import hmac
import logging
import time
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response
from pydantic import BaseModel, Field
from sqlalchemy import create_engine, text
from logs import setup_logging
from settings import Settings, get_settings
log = logging.getLogger("review-api")
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
setup_logging(settings.log_level)
app.state.engine = create_engine(settings.database_url, pool_pre_ping=True)
app.state.ready = True
log.info("startup", extra={"fields": {"env": settings.env, "version": settings.version, "port": settings.port}})
try:
yield
finally:
app.state.ready = False # a load balancer should stop sending traffic first
app.state.engine.dispose()
log.info("shutdown complete", extra={"fields": {"version": settings.version}})
app = FastAPI(title="Review API", lifespan=lifespan)
@app.middleware("http")
async def access_log(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
if request.url.path not in ("/health", "/ready"): # probes would drown the log
ms = round((time.perf_counter() - start) * 1000)
log.info("request", extra={"fields": {"method": request.method, "path": request.url.path,
"status": response.status_code, "ms": ms}})
return response
SettingsDep = Annotated[Settings, Depends(get_settings)]
def require_key(settings: SettingsDep, x_api_key: Annotated[str | None, Header()] = None) -> None:
if x_api_key is None or not hmac.compare_digest(x_api_key, settings.api_key.get_secret_value()):
raise HTTPException(401, "Missing or wrong API key")
class ReviewIn(BaseModel):
text: str = Field(min_length=3, max_length=2000)
@app.get("/health")
async def health(settings: SettingsDep) -> dict[str, str]:
"""Liveness: the process is up. Cheap, no dependencies."""
return {"status": "ok", "version": settings.version}
@app.get("/ready")
def ready(request: Request, response: Response) -> dict[str, str]:
"""Readiness: can this instance serve real traffic right now?"""
try:
if not request.app.state.ready:
raise RuntimeError("shutting down")
with request.app.state.engine.connect() as conn:
conn.execute(text("SELECT 1"))
except Exception as exc:
response.status_code = 503
return {"status": "unavailable", "reason": type(exc).__name__}
return {"status": "ready"}
@app.post("/v1/reviews", status_code=201, dependencies=[Depends(require_key)])
def create_review(review: ReviewIn, request: Request) -> dict[str, int]:
with request.app.state.engine.begin() as conn:
result = conn.execute(text("INSERT INTO reviews (text) VALUES (:t)"), {"t": review.text})
return {"id": result.lastrowid}
@app.get("/v1/reviews/count")
def count_reviews(request: Request) -> dict[str, int]:
with request.app.state.engine.connect() as conn:
return {"count": conn.execute(text("SELECT COUNT(*) FROM reviews")).scalar_one()}
@app.get("/slow")
async def slow(seconds: float = 1.0) -> dict[str, str]:
await asyncio.sleep(min(seconds, 10)) # a stand-in for a slow model call
return {"status": "done"}ডিপ্লয়ের সময় মাইগ্রেশন
নতুন কোডের প্রায়ই নতুন টেবিল বা কলাম লাগে। SQLAlchemy ও Alembic দিয়ে ডেটাবেস পাতার নিয়ম হলো প্রতিটা ডিপ্লয়ে alembic upgrade head দিয়ে মাইগ্রেশন চালানো, নতুন কোড ট্রাফিক নেওয়ার আগে। এখানে প্রথম মাইগ্রেশন reviews টেবিল বানায়। env.py ডেটাবেস URL পড়ে অ্যাপের মতোই একই এনভায়রনমেন্ট ভেরিয়েবল থেকে, ফলে স্টেজিং আর প্রোডাকশন নিজ নিজ ডেটাবেস মাইগ্রেট করে।
# file: alembic.ini
[alembic]
script_location = migrations# file: migrations/env.py
import os
from alembic import context
from sqlalchemy import create_engine
url = os.environ.get("APP_DATABASE_URL", "sqlite:///./reviews.db")
with create_engine(url).connect() as connection:
context.configure(connection=connection)
with context.begin_transaction():
context.run_migrations()# file: migrations/versions/0001_create_reviews.py
"""create reviews"""
import sqlalchemy as sa
from alembic import op
revision = "0001"
down_revision = None
def upgrade() -> None:
op.create_table("reviews", sa.Column("id", sa.Integer, primary_key=True), sa.Column("text", sa.Text, nullable=False))
def downgrade() -> None:
op.drop_table("reviews")স্টার্ট স্ক্রিপ্ট সবকিছু জোড়া দেয়: মাইগ্রেট করে, তারপর সার্ভারকে exec করে, যাতে সার্ভারই মূল প্রসেস হয় আর SIGTERM সরাসরি পায়। (ফাইলটা একটা কমেন্ট লাইন দিয়ে শুরু হয়েছে, কারণ পাতার টুলিং ওটাকে ফাইলের নাম হিসেবে ব্যবহার করে; আপনার মেশিনে ওপরে #!/usr/bin/env bash যোগ করবেন।) --workers আসে WEB_CONCURRENCY থেকে, যে ভেরিয়েবল অনেক প্ল্যাটফর্ম নিজেই সেট করে, আর --no-access-log আমাদের মিডলওয়্যারের লেখা প্রতি-রিকোয়েস্ট লাইনের পুনরাবৃত্তি এড়ায়।
স্টার্ট কমান্ডের ভেতরেই মাইগ্রেট করা সবচেয়ে সহজ উপায়, আর একটা ইনস্ট্যান্সের জন্য এটা ঠিক আছে। একসঙ্গে কয়েকটা ইনস্ট্যান্স চালু হলে সবাই মাইগ্রেট করতে চাইবে, তাই বড় সিস্টেম
alembic upgrade headচালায় আলাদা রিলিজ ধাপে (পেইড প্ল্যানে Render-এর pre-deploy কমান্ড, Cloud Run job, বা CI ধাপ), এবং প্রতিটা মাইগ্রেশনকে ব্যাকওয়ার্ড কম্প্যাটিবল রাখে, যাতে নতুন কোড ছড়ানোর সময়ও পুরোনো কোড চলে: আগে কলাম যোগ করুন, ডিপ্লয় করুন, আর পুরোনোটা সরান অনেক পরে।
# file: start.sh
set -euo pipefail
alembic upgrade head
exec uvicorn main:app --host 0.0.0.0 --port "${PORT:-8000}" --workers "${WEB_CONCURRENCY:-1}" --no-access-logপ্ল্যাটফর্ম যেভাবে চালায়, সেভাবে চালান
কনফিগারেশন এক্সপোর্ট করুন, স্ক্রিপ্টটা ব্যাকগ্রাউন্ডে চালান আউটপুট ফাইলে রেখে, আর হেলথ চেক পর্যন্ত অপেক্ষা করুন। এক্সপোর্ট করা SERVER_PID দিয়েই পরে সার্ভার থামাব; প্ল্যাটফর্ম একই কাজ করে তার নিজের প্রসেস ম্যানেজার দিয়ে।
export APP_ENV=staging APP_VERSION=1.0.0 APP_API_KEY=dev-key-123 PORT=8000
bash start.sh > server.log 2>&1 &
export SERVER_PID=$!
echo $SERVER_PID > server.pid
curl -s --retry 30 --retry-connrefused --retry-delay 0 http://localhost:8000/health{"status":"ok","version":"1.0.0"}সার্ভার চালুর আগেই মাইগ্রেশন চলেছে (reviews.db এখন আছে কি না দেখে নিতে পারেন)। এবার রেডিনেস, API কি দিয়ে একটা লেখা, একটা পড়া, আর কি ছাড়া একটা লেখা:
curl -s http://localhost:8000/ready; echo
curl -s -X POST http://localhost:8000/v1/reviews -H "X-API-Key: $APP_API_KEY" -H "Content-Type: application/json" -d '{"text": "Great course"}'; echo
curl -s http://localhost:8000/v1/reviews/count; echo
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8000/v1/reviews -H "Content-Type: application/json" -d '{"text": "no key"}'{"status":"ready"}
{"id":1}
{"count":1}
401লগের প্রতিটা লাইন JSON, শুধু সার্ভার নিজে যেসব লাইন ছাপে সেগুলো বাদে। নিচের কমান্ড JSON লাইনগুলো রাখে, টাইমস্ট্যাম্প বাদ দেয় আর টাইমিং ঢেকে দেয়, যাতে প্রতিবার একই আউটপুট আসে:
grep '^{' server.log | sed -E 's/"ts": "[^"]+", //; s/"ms": [0-9]+/"ms": N/'{"level": "INFO", "logger": "review-api", "msg": "startup", "env": "staging", "version": "1.0.0", "port": 8000}
{"level": "INFO", "logger": "review-api", "msg": "request", "method": "POST", "path": "/v1/reviews", "status": 201, "ms": N}
{"level": "INFO", "logger": "review-api", "msg": "request", "method": "GET", "path": "/v1/reviews/count", "status": 200, "ms": N}
{"level": "INFO", "logger": "review-api", "msg": "request", "method": "POST", "path": "/v1/reviews", "status": 401, "ms": N}লক্ষ্য করুন কী নেই: /health আর /ready কল লগে নেই, কোথাও কি বা রিভিউয়ের লেখা নেই, আর ব্যর্থ লেখাটা "status": 401 হয়ে এসেছে, ঠিক যেটা গুনবে একটা অ্যালার্ট রুল।
ডিপ্লয়ের পরের স্মোক টেস্ট
ডিপ্লয়ের আগের টেস্ট প্রমাণ করে কোড কাজ করে। ডিপ্লয়ের পরের স্মোক টেস্ট (smoke test) প্রমাণ করে এই চলমান সার্ভিসটা কাজ করছে: সঠিক ভার্সন উত্তর দিচ্ছে, ডেটাবেস নাগালে, অথ চালু, ভ্যালিডেশন চালু। smoke_test.py সার্ভিসের জন্য এক মিনিট পর্যন্ত অপেক্ষা করে (ঘুমন্ত ফ্রি ইনস্ট্যান্স বা চলমান রোলআউটের সময় লাগে), প্রত্যাশিত ভার্সনের ওপর জোর দেয় যাতে ভুল করে পুরোনো রিলিজ টেস্ট না হয়, আর শুধু এমন রিকোয়েস্ট করে যা ডেটা বদলায় না: কি ছাড়া লেখায় 401 আর কি-সহ কিন্তু ফাঁকা বডিতে 422 আসতে হবে।
# file: smoke_test.py
"""Post-deploy smoke test: python smoke_test.py BASE_URL [EXPECTED_VERSION]. Reads SMOKE_API_KEY."""
import os
import sys
import time
import httpx
def main(base_url: str, expected_version: str | None) -> int:
key = os.environ["SMOKE_API_KEY"]
with httpx.Client(base_url=base_url, timeout=10) as client:
deadline = time.monotonic() + 60 # a sleeping or still-deploying service needs time
while True:
try:
health = client.get("/health")
if health.status_code == 200 and expected_version in (None, health.json()["version"]):
break
except httpx.TransportError:
pass
if time.monotonic() > deadline:
print("FAIL service did not become healthy with the expected version")
return 1
time.sleep(1)
checks = {
"health is ok": health.json()["status"] == "ok",
"ready (database reachable)": client.get("/ready").status_code == 200,
"no key is rejected": client.post("/v1/reviews", json={"text": "hello"}).status_code == 401,
"bad body is rejected": client.post("/v1/reviews", json={}, headers={"X-API-Key": key}).status_code == 422,
"read path works": client.get("/v1/reviews/count").status_code == 200,
}
for name, ok in checks.items():
print(("PASS " if ok else "FAIL ") + name)
return 0 if all(checks.values()) else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None))export SMOKE_API_KEY=$APP_API_KEY
python smoke_test.py http://localhost:8000 1.0.0
echo "exit code: $?"PASS health is ok
PASS ready (database reachable)
PASS no key is rejected
PASS bad body is rejected
PASS read path works
exit code: 0এক্সিট কোডই অটোমেশন পড়ে: ০ হলে পাইপলাইন এগোয়, অন্য কিছু হলে জব ব্যর্থ হয় আর যত্নশীল সেটআপে রোলব্যাক শুরু হয়।
গ্রেসফুল শাটডাউন
প্ল্যাটফর্ম প্রতিটা ডিপ্লয়, স্কেল-ডাউন ও রিস্টার্টে আপনার অ্যাপ থামায়। সে পাঠায় SIGTERM ("দয়া করে থামুন"), কিছুক্ষণ অপেক্ষা করে (গ্রেস পিরিয়ড), তারপরই পাঠায় SIGKILL, যা ধরা যায় না। Docker আর Cloud Run ডিফল্টে ১০ সেকেন্ড অপেক্ষা করে; Render আরও বেশি (লেখার সময় ৩০ সেকেন্ড, বর্তমান সংখ্যা দেখে নিন)। ভদ্র সার্ভার এই সময়টা কাজে লাগায়: নতুন কানেকশন নেওয়া বন্ধ করে, চলমান রিকোয়েস্ট শেষ হতে দেয়, পুল বন্ধ ও বাফার ফ্লাশ করতে lifespan-এর শাটডাউন চালায়, আর কোড ০ দিয়ে বের হয়। AI সার্ভিসে এটা জরুরি, কারণ একটা রিকোয়েস্ট ২০ সেকেন্ডের মডেল কল হতে পারে, যা ডিপ্লয়ের সময় মাঝপথে কেটে যাওয়া উচিত নয়।
নিজে দেখুন। ২ সেকেন্ডের একটা রিকোয়েস্ট শুরু করুন, সেটা চলার সময় এক্সপোর্ট করা PID-তে SIGTERM পাঠান, প্রসেস শেষ হওয়া পর্যন্ত অপেক্ষা করুন, তারপর প্রাসঙ্গিক লগ লাইনগুলো পড়ুন:
curl -s "http://localhost:8000/slow?seconds=2" > slow.out &
sleep 0.5
kill -TERM $SERVER_PID
for i in $(seq 100); do grep -q "Finished server process" server.log && break; sleep 0.1; done
wait
cat slow.out; echo
grep -E 'Shutting down|Waiting for (connections|application shutdown)|"/slow"|shutdown complete' server.log | sed -E 's/"ts": "[^"]+", //; s/"ms": [0-9]+/"ms": N/'{"status":"done"}
INFO: Shutting down
INFO: Waiting for connections to close. (CTRL+C to force quit)
{"level": "INFO", "logger": "review-api", "msg": "request", "method": "GET", "path": "/slow", "status": 200, "ms": N}
INFO: Waiting for application shutdown.
{"level": "INFO", "logger": "review-api", "msg": "shutdown complete", "version": "1.0.0"}
INFO: Application shutdown complete.ক্রমটা পড়ুন। সার্ভার জানায় সে শাটডাউন করছে আর কানেকশন বন্ধ হওয়ার জন্য অপেক্ষা করছে। ধীর রিকোয়েস্টটা কাটা পড়ে না: সেটা শেষ হয়, ক্লায়েন্ট তার উত্তর পায়, আর তার লগ লাইন আসে। তারপরই অ্যাপ্লিকেশন শাটডাউন চলে, আর আমাদের shutdown complete লাইন প্রমাণ করে finally ব্লক চলেছে। যে সার্ভারকে মেরে ফেলা হতো, তার ক্লায়েন্ট ভাঙা কানেকশন পেত আর ওই লাইনটা কখনো ছাপা হতো না।
কোনো রিকোয়েস্ট প্ল্যাটফর্মের গ্রেস পিরিয়ডের চেয়ে বেশি চলতে পারলে দুটো উপায় আছে: গ্রেস পিরিয়ড বাড়ানো, অথবা ওই কাজকে স্ট্যাটাস URL-সহ ব্যাকগ্রাউন্ড জবে সরানো (মজবুত API ডিজাইন: পেজিনেশন, রেট লিমিট, রিট্রাই ও ভার্সনিং)। uvicorn-এর --timeout-graceful-shutdown দিয়ে অপেক্ষার সীমা নিজেও বেঁধে দিতে পারেন (অনুশীলনে চেষ্টা করবেন)।
ওয়ার্কার আর fastapi run
একটা Python প্রসেস Python কোডের জন্য একটা CPU কোর ব্যবহার করে। এক মেশিনে বেশি কোর কাজে লাগাতে কয়েকটা ওয়ার্কার প্রসেস চালান। fastapi run main.py --workers 2 (বা uvicorn --workers 2, যা start.sh WEB_CONCURRENCY দিয়ে করে) একটা সুপারভাইজার চালু করে, যা ওয়ার্কারগুলো সামলায়; প্রতিটা ওয়ার্কার নিজের lifespan চালায়, তাই প্রত্যেকে নিজের ডেটাবেস পুল খোলে। মোটা নিয়ম: ছোট হোস্টে এক-দুটো ওয়ার্কারই যথেষ্ট; Kubernetes বা Cloud Run-এ কন্টেইনার প্রতি একটা প্রসেস রাখুন আর কন্টেইনার বাড়ানো প্ল্যাটফর্মের হাতে ছাড়ুন; আর ML মডেল লোড হয় ওয়ার্কার প্রতি একবার, তাই মেমরি ব্যবহার ওয়ার্কারের সংখ্যা দিয়ে গুণ হয়। (Gunicorn-এর পুরোনো uvicorn ওয়ার্কার ক্লাস বাতিল হয়ে গেছে; uvicorn-এর নিজের --workers ব্যবহার করুন।)
দুটো ওয়ার্কার চালিয়ে দেখুন প্রত্যেকে কী লগ করে। শাটডাউন একই: সুপারভাইজার SIGTERM পায়, এগিয়ে দেয়, আর প্রতিটা ওয়ার্কার নিজের শাটডাউন চালায়:
fastapi run main.py --port 8000 --workers 2 > server.log 2>&1 &
export SERVER_PID=$!
curl -s --retry 30 --retry-connrefused --retry-delay 0 http://localhost:8000/health > /dev/null
for i in $(seq 100); do [ "$(grep -c 'Application startup complete' server.log)" = 2 ] && break; sleep 0.1; done
echo "workers started: $(grep -c 'Started server process' server.log)"
kill -TERM $SERVER_PID
for i in $(seq 100); do grep -q "Stopping parent process" server.log && break; sleep 0.1; done
echo "workers shut down cleanly: $(grep -c '"shutdown complete"' server.log)"workers started: 2
workers shut down cleanly: 2GitHub থেকে Render-এ ডিপ্লয়
Render প্রতিটা পুশে আপনার রিপোজিটরি বিল্ড করে চালায়। কনফিগারেশন রিপোজিটরিতেই থাকতে পারে ব্লুপ্রিন্ট (Blueprint) ফাইল render.yaml হিসেবে, ফলে ডিপ্লয়মেন্টও কোডের মতো রিভিউ ও ভার্সন করা যায়। এটা একটা ওয়েব সার্ভিস আর একটা Postgres ডেটাবেস বর্ণনা করে, এবং কোনো পাসওয়ার্ড কপি না করেই ডেটাবেস URL সার্ভিসে জুড়ে দেয়:
# file: render.yaml
services:
- type: web
name: review-api
runtime: python
plan: free # sleeps after 15 minutes without traffic
buildCommand: pip install -r requirements.txt
startCommand: bash start.sh # runs the migrations, then the server
healthCheckPath: /health
autoDeployTrigger: checksPass # deploy only when the GitHub checks are green
envVars:
- key: PYTHON_VERSION
value: "3.14.0"
- key: APP_ENV
value: production
- key: APP_DATABASE_URL
fromDatabase:
name: review-db
property: connectionString
- key: APP_API_KEY
sync: false # you type the value once in the dashboard; it is not in Git
databases:
- name: review-db
plan: free # expires after 30 days (+14 days grace); paid plans do notধাপগুলো আপনি ব্রাউজারে করবেন:
- রিপোজিটরি GitHub-এ পুশ করুন (GitHub: রিমোট, push, pull ও clone পাতার লোকাল বেয়ার রিপোজিটরি প্রবাহটাই Git-এর চোখে GitHub)।
- Render-এ New, Blueprint বেছে নিন, GitHub অ্যাকাউন্ট যুক্ত করুন আর রিপোজিটরি বাছুন। Render
render.yamlপড়ে কোন সার্ভিস আর ডেটাবেস বানাবে তা দেখায়। sync: false-এর কারণে RenderAPP_API_KEYচায়। একটা লম্বা র্যান্ডম মান বানান (python -c "import secrets; print(secrets.token_urlsafe(32))") আর পেস্ট করুন। এটা কখনো Git-এ ঢোকে না।- Apply করুন। Render রিকোয়ারমেন্ট ইনস্টল করে,
start.shচালায় (আগে মাইগ্রেশন),/healthসাড়া দেওয়া পর্যন্ত অপেক্ষা করে, তারপর ট্রাফিক সরায়। আপনি.onrender.com-এ শেষ হওয়া একটা HTTPS ঠিকানা পান। - ল্যাপটপ থেকে ওই ঠিকানার বিরুদ্ধে স্মোক টেস্ট চালান। এরপর
main-এ প্রতিটা পুশে আবার ডিপ্লয় হবে;autoDeployTrigger: checksPassআগে আপনার GitHub Actions চেক সবুজ হওয়া পর্যন্ত অপেক্ষা করে।
একটা সন্ধ্যা বাঁচানোর নোট: Render আপনার অ্যাপকে PORT দিয়ে জানায় কোন পোর্ট ব্যবহার করতে হবে (ডিফল্ট 10000), তাই কখনো 8000 হার্ড-কোড করবেন না; Python ভার্সন বাঁধুন PYTHON_VERSION ভেরিয়েবলে; আর মাইগ্রেশনের pre-deploy কমান্ড পেইড প্ল্যানের ফিচার, তাই এই ফাইল সেটা চালায় start.sh-এর ভেতরে। ফ্রি ডেটাবেস শুধু অনুশীলনের জন্য, কারণ ৩০ দিন পর (সঙ্গে ১৪ দিনের গ্রেস পিরিয়ড) তার মেয়াদ শেষ হয়, আর ফ্রি ওয়েব সার্ভিস ১৫ মিনিট ট্রাফিক না পেলে ঘুমিয়ে পড়ে, ফলে প্রথম দর্শককে জাগার জন্য অপেক্ষা করতে হয়।
ম্যানেজড Postgres, HTTPS আর ডোমেইন
ম্যানেজড ডেটাবেস মানে সার্ভার চালানো, প্যাচ করা ও ব্যাকআপ নেওয়া প্রোভাইডারের কাজ; আপনি পান একটা কানেকশন URL। একই রিজিয়নের সার্ভিসের জন্য ইন্টারনাল URL ব্যবহার করুন (দ্রুত, প্রাইভেট), আর পারলে ডেটাবেসকে পাবলিক ইন্টারনেটের বাইরে রাখুন। এসব প্ল্যাটফর্মে HTTPS স্বয়ংক্রিয়: প্ল্যাটফর্ম TLS শেষ করে সাধারণ HTTP আপনার অ্যাপে পাঠায়, ফলে অ্যাপ ক্লায়েন্ট হিসেবে প্রক্সির ঠিকানা দেখে। রেট লিমিটের জন্য আসল ক্লায়েন্ট IP পেতে uvicorn-কে FORWARDED_ALLOW_IPS দিয়ে বলুন কোন প্রক্সিকে বিশ্বাস করবে, আর প্ল্যাটফর্মের প্রক্সিই কন্টেইনারে পৌঁছানোর একমাত্র পথ না হলে এটা * দেবেন না। কাস্টম ডোমেইন দুই ধাপ: ড্যাশবোর্ডে ডোমেইন যোগ করুন, তারপর যে DNS রেকর্ড দেখায় সেটা বানান (api.yourname.com-এর মতো সাবডোমেইনের জন্য CNAME); সার্টিফিকেট প্ল্যাটফর্মই ইস্যু ও নবায়ন করে।
Google Cloud Run-এ ইমেজ ডিপ্লয়
Cloud Run রেজিস্ট্রি থেকে একটা কন্টেইনার চালায়, বসে থাকলে সেটাকে শূন্যে নামিয়ে দেয় আর ব্যবহার অনুযায়ী বিল করে। আপনি ডিপ্লয় করেন Docker: আপনার API প্যাকেজ করা পাতার ইমেজ, তাই একটা ভালো Dockerfile যেকোনো কন্টেইনার হোস্টকেই সহজ করে। Render থেকে দুটো জিনিস আলাদা: প্ল্যাটফর্ম PORT সেট করে (8080), আর ডিস্কের কিছুই টেকে না, তাই ডেটাবেস বাইরের হতে হবে (Cloud SQL, বা অন্য প্রোভাইডারের হোস্টেড Postgres)। এই কমান্ডগুলোর জন্য বিলিং চালু করা একটা Google Cloud প্রজেক্ট লাগে, তাই এখানে দেখানো হলো, চালানো হয়নি:
docker build -t asia-south1-docker.pkg.dev/my-project/apps/review-api:1.0.0 .
docker push asia-south1-docker.pkg.dev/my-project/apps/review-api:1.0.0gcloud run deploy review-api \
--image asia-south1-docker.pkg.dev/my-project/apps/review-api:1.0.0 \
--region asia-south1 \
--allow-unauthenticated \
--max-instances 3 \
--set-env-vars APP_ENV=production,APP_VERSION=1.0.0 \
--set-secrets APP_API_KEY=review-api-key:latest,APP_DATABASE_URL=review-db-url:latestফ্ল্যাগগুলোতে এই পাতার শিক্ষা আছে: সিক্রেট আসে Secret Manager থেকে, কমান্ড লাইন থেকে নয়; ট্রাফিক বা বাগ বিস্ফোরিত হলে --max-instances বিল আটকে রাখে; আর ইমেজ ট্যাগ একটা ভার্সন, latest নয়। ট্রাফিক সরানোর আগে মাইগ্রেশন চালান Cloud Run job বা CI ধাপে। প্রতিটা ডিপ্লয় একটা অপরিবর্তনীয় রিভিশন বানায়, ফলে রোলব্যাক হয় রিবিল্ড নয়, ট্রাফিক সুইচ:
gcloud run services update-traffic review-api --region asia-south1 --to-revisions review-api-00001-abc=100Railway, VPS, আর কোনটা বাছবেন
Railway সবচেয়ে ছোট পথ: রিপোজিটরি যুক্ত করুন, ক্যাটালগ থেকে একটা Postgres সার্ভিস যোগ করুন, ভেরিয়েবল সেট করুন (অন্য সার্ভিসের ডেটাবেস URL রেফারেন্স করা যায়), আর প্রতিটা পুশ ডিপ্লয় হয়। ক্রেডিটের দিকে নজর রাখুন: একটা ডেটাবেস আর একটা সার্ভিস মিলে মাসে ১ ডলারের ক্রেডিট দ্রুত শেষ করে ফেলে। VPS হলো ভাড়া নেওয়া একটা সাধারণ Linux মেশিন। পরিচিত গঠন: Docker (বা systemd) অ্যাপ চালায় localhost:8000-এ, আর সামনে একটা রিভার্স প্রক্সি HTTPS দেয়। Caddy নিজেই Let's Encrypt সার্টিফিকেট নেয় ও নবায়ন করে:
# file: Caddyfile
api.example.com {
reverse_proxy localhost:8000
}VPS-এ নিরাপত্তার কাজ আপনার: শুধু SSH কি-ওয়ালা নন-রুট ব্যবহারকারী, যে ফায়ারওয়াল শুধু 22, 80 ও 443 পোর্ট খোলে, স্বয়ংক্রিয় সিকিউরিটি আপডেট, ইন্টারনেটে খোলা না থাকা ডেটাবেস, আর অন্তত একবার সত্যিই রিস্টোর করে দেখা ব্যাকআপ। কোন হোস্ট বাছবেন? শেখা ও ডেমো: Render ফ্রি বা Cloud Run। ছোট আসল প্রোডাক্ট: Render বা Railway-র পেইড প্ল্যান, কিংবা একটা VPS। ওঠানামা করা ট্রাফিকের জন্য কন্টেইনার: Cloud Run। আপনার অ্যাপ যত বেশি "এনভায়রনমেন্ট থেকে সেটিংস পড়া একটা কন্টেইনার"-এর মতো, পরে সরানো তত সহজ, আর ওপরের সবকিছুর আসল লক্ষ্য সেটাই।
GitHub Actions থেকে ডিপ্লয়
হোস্ট আপনার রিপোজিটরি পাহারা দেওয়ার বদলে আপনার পাইপলাইনই ঠিক করতে পারে কখন ডিপ্লয় হবে। এই ওয়ার্কফ্লো ভার্সন ট্যাগ পুশ করলে চলে, production এনভায়রনমেন্টে ডিপ্লয় করে (যেখানে অনুমোদন বাধ্যতামূলক করা যায়, যেমন GitHub Actions দিয়ে CI/CD পাতায়), সিক্রেট হিসেবে রাখা একটা ডিপ্লয়-হুক URL দিয়ে হোস্টকে ডিপ্লয় করতে বলে, তারপর লাইভ সার্ভিসের স্মোক টেস্ট চালায়। স্মোক টেস্ট ব্যর্থ হলে জব ব্যর্থ হয়, আর এক মিনিটের মধ্যেই আপনি জানেন। আসল প্রজেক্টে থার্ড-পার্টি অ্যাকশন কমিট SHA-তে পিন করুন আর permissions সর্বনিম্ন রাখুন, যেমন ওই পাতা শিখিয়েছে।
# file: .github/workflows/deploy.yml
name: Deploy
on:
push:
tags: ["v*"]
permissions:
contents: read
concurrency: deploy-production # never two deploys at once
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # secrets and approvals live on the environment
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.14"
- run: pip install httpx
- name: Ask the host to deploy this tag
run: curl -fsS -X POST "$DEPLOY_HOOK_URL"
env:
DEPLOY_HOOK_URL: ${{ secrets.DEPLOY_HOOK_URL }}
- name: Smoke test the live service
run: python smoke_test.py "$SERVICE_URL"
env:
SERVICE_URL: ${{ vars.SERVICE_URL }}
SMOKE_API_KEY: ${{ secrets.SMOKE_API_KEY }}রোলব্যাক: রিলিজ খারাপ হলে ফেরা
প্রতিটা রিলিজ একদিন খারাপ হবেই। আসল কথা হলো কত দ্রুত ফেরানো যায়, তাই রোলব্যাক ঠিক করুন ডিপ্লয়ের আগেই। তিনটা স্তর আছে। প্ল্যাটফর্মের নিজের রোলব্যাক (আগের Render ডিপ্লয়, আগের Cloud Run রিভিশন) সবচেয়ে দ্রুত। আগের একটা Git ট্যাগ আবার ডিপ্লয় করা তার পরেরটা। আর main নিজে ঠিক করার নিরাপদ উপায় git revert: শেয়ার করা ইতিহাস নতুন করে না লিখে এটা একটা নতুন কমিট যোগ করে যা খারাপ কমিটকে উল্টে দেয় আর ইতিহাস রেখে দেয় (ভুল শোধরানো: restore, reset ও revert)। ডেটাবেস মাইগ্রেশনই কঠিন অংশ: কোড রোলব্যাক করলে ফেলে দেওয়া কলাম ফেরানো যায় না, তাই মাইগ্রেশন ব্যাকওয়ার্ড কম্প্যাটিবল করে লেখা হয়।
চলুন সত্যিকারের রিহার্সাল করি, GitHub-এর জায়গায় একটা লোকাল বেয়ার রিপোজিটরি দিয়ে। প্রথমে যা কখনো কমিট হওয়া উচিত নয় তা উপেক্ষা করুন, আর প্ল্যাটফর্ম ডিপ্লয়ের বদলি ছোট স্ক্রিপ্টটা যোগ করুন: সেটা পুরোনো প্রসেস গ্রেসফুলি থামায়, নতুন কোড চালায় আর স্মোক টেস্ট চালায়।
# file: .gitignore
.venv/
__pycache__/
.env
*.db
*.log
*.out
*.pid# file: redeploy.sh
# Local stand-in for a platform deploy: stop the old process gracefully, start the new code, smoke test it.
# usage: bash redeploy.sh EXPECTED_VERSION
set -u
export APP_VERSION="$1"
if [ -f server.pid ] && kill -0 "$(cat server.pid)" 2>/dev/null; then
kill -TERM "$(cat server.pid)"
for i in $(seq 100); do grep -q "Finished server process" server.log && break; sleep 0.1; done
fi
bash start.sh > server.log 2>&1 &
echo $! > server.pid
python smoke_test.py "http://localhost:${PORT:-8000}" "$1"git init -q
git add .
git commit -q -m "Review API 1.0.0"
git tag -a v1.0.0 -m "Release 1.0.0"
git init -q --bare ~/remotes/review-api.git
git remote add origin ~/remotes/review-api.git
git push -q origin main --tagsএবার একটা "নিরীহ রিফ্যাক্টর" পাঠান যা একটা রুটের নাম বদলায়: ট্যাগ করুন, পুশ করুন আর স্ক্রিপ্ট দিয়ে ডিপ্লয় করুন। স্মোক টেস্ট সমস্যাটা ধরে ফেলে, কারণ রিড পাথটা আর নেই:
sed -i 's#/v1/reviews/count#/v1/review-count#' main.py
git commit -q -am "Rename the count route"
git tag -a v1.1.0 -m "Release 1.1.0"
git push -q origin main --tags
bash redeploy.sh 1.1.0
echo "exit code: $?"PASS health is ok
PASS ready (database reachable)
PASS no key is rejected
PASS bad body is rejected
FAIL read path works
exit code: 1ডিপ্লয় খারাপ, আর পাইপলাইন সেটা জানে। git revert দিয়ে রোলব্যাক করুন, ফলাফলকে নতুন ভার্সন হিসেবে ট্যাগ করুন (পুরোনো ট্যাগ কখনো পুনর্ব্যবহার নয়), পুশ করুন আর আবার ডিপ্লয় করুন। সার্ভিস আবার সুস্থ, আর git diff প্রমাণ করে কোড শেষ ভালো রিলিজের হুবহু একই:
git revert --no-edit HEAD
git tag -a v1.1.1 -m "Release 1.1.1 (rolls back 1.1.0)"
git push -q origin main --tags
bash redeploy.sh 1.1.1
echo "exit code: $?"
git log --oneline --decorate
echo "same code as v1.0.0: $(git diff --quiet v1.0.0 v1.1.1 && echo yes || echo no)"[main 2b92962] Revert "Rename the count route"
Date: Thu Jan 15 10:00:00 2026 +0600
1 file changed, 1 insertion(+), 1 deletion(-)
PASS health is ok
PASS ready (database reachable)
PASS no key is rejected
PASS bad body is rejected
PASS read path works
exit code: 0
2b92962 (HEAD -> main, tag: v1.1.1, origin/main) Revert "Rename the count route"
06c1673 (tag: v1.1.0) Rename the count route
daa943a (tag: v1.0.0) Review API 1.0.0
same code as v1.0.0: yesইতিহাস পুরো গল্প ধরে রাখে (রিলিজ, ভুল, আর তা ফেরানো), পোস্ট-মর্টেমে ঠিক এটাই দরকার। কোডের পাশাপাশি ডেটার কথাও ভাবুন: ঝুঁকিপূর্ণ মাইগ্রেশনের আগে ব্যাকআপ নিন, আর রিলিজ নোট এমনভাবে লিখুন যাতে অন-কল যে কেউ রোলব্যাকের ধাপ জানে।
ডিপ্লয়ের পর: মনিটরিং, স্টোরেজ, ব্যাকআপ ও খরচ
| ক্ষেত্র | কী করবেন |
|---|---|
| মনিটরিং | প্রতি এন্ডপয়েন্টে মৌলিক জিনিস দেখুন: রিকোয়েস্ট রেট, এরর রেট (5xx) আর লেটেন্সি পার্সেন্টাইল (p95, p99)। বাইরে থেকে একটা আপটাইম পিং (UptimeRobot, Better Stack-এর মতো টুলের ফ্রি প্ল্যান আছে) আর Sentry-র মতো একটা এরর ট্র্যাকার যোগ করুন। ব্যবহারকারী যা টের পায় সেই লক্ষণ নিয়ে অ্যালার্ট দিন, এমন চ্যানেলে যা কেউ পড়ে। |
| AI-র নিজস্ব সংকেত | মডেল লেটেন্সি, কিউয়ের গভীরতা, প্রতি রিকোয়েস্ট ও প্রতি ব্যবহারকারীর টোকেন ও খরচ, প্রোভাইডারের এরর রেট, আর প্রেডিকশন ড্রিফট। আগের পাতাগুলোর prompt_version আর model_version লগে থাকা উচিত। |
| স্থায়ী স্টোরেজ | প্রতিটা ডিপ্লয়ে কন্টেইনারের ডিস্ক উধাও হয়। রেকর্ড রাখুন ডেটাবেসে, আপলোড ও মডেল ফাইল অবজেক্ট স্টোরেজে (S3, Cloudflare R2, Google Cloud Storage)। VPS-এ মাউন্ট করা ভলিউম ব্যবহার করুন আর তার ব্যাকআপ নিন। |
| ব্যাকআপ | প্রোভাইডারের স্বয়ংক্রিয় ব্যাকআপ ব্যবহার করুন, একটা কপি অন্য জায়গায় রাখুন (যেসব প্ল্যানের মেয়াদ শেষ হয় সেখানে আপনার নিয়ন্ত্রণের স্টোরেজে সাপ্তাহিক pg_dump সবচেয়ে জরুরি), আর একটা স্ক্র্যাচ ডেটাবেসে রিস্টোর অনুশীলন করুন। যে ব্যাকআপ কখনো রিস্টোর করা হয়নি তা আশা, ব্যাকআপ নয়। দেখুন AI-এর জন্য SQL ও ডেটা হ্যান্ডলিং: অ্যাডভান্সড। |
| খরচ | ক্লাউড অ্যাকাউন্টে বাজেট অ্যালার্ট দিন, ইনস্ট্যান্স সীমিত করুন (--max-instances), LLM প্রোভাইডারে খরচের সীমা বাঁধুন, প্রতি রিকোয়েস্টের খরচ লগ করুন, টেস্ট রিসোর্স মুছুন, আর প্রতি মাসে বিল দেখুন। পেইড API কল করা একটা লুপ এক রাতে যা খরচ করে, একটা সার্ভার তা এক বছরে করে না। |
AI সার্ভিস: ডিপ্লয়ের বাড়তি বিষয়
| বিষয় | কী করবেন |
|---|---|
| মডেল ফাইল | ছোট মডেল ইমেজে ঢুকিয়ে দিন (পুনরুৎপাদনযোগ্য), অথবা স্টার্টে অবজেক্ট স্টোরেজ থেকে ভার্সন করা ফাইল নামান। বড় ফাইল কখনো Git-এ কমিট করবেন না (AI প্রজেক্টে Git: ডেটা, মডেল, LFS, DVC ও ফাঁস হওয়া সিক্রেট)। |
| মেমরি | মডেল লোড হয় ওয়ার্কার প্রতি একবার। ৫১২ MB-এর ফ্রি ইনস্ট্যান্স বড় মডেল ধরে না; প্ল্যানের আকার ঠিক করুন মাপজোখ থেকে, আন্দাজ থেকে নয়। |
| কোল্ড স্টার্ট | মডেল লোড হতে কয়েক সেকেন্ড লাগে। lifespan-এ ওয়ার্ম-আপ করুন, মডেল লোড না হওয়া পর্যন্ত /ready ব্যর্থ রাখুন, আর লেটেন্সি-সংবেদনশীল এন্ডপয়েন্টের জন্য ন্যূনতম একটা ইনস্ট্যান্স চালু রাখুন। |
| পুনরুৎপাদনযোগ্যতা | ইমেজ ট্যাগ, ডিপেন্ডেন্সি লকফাইল আর মডেল ভার্সন বেঁধে দিন। রেসপন্সে model_version ফেরত দিন, যাতে খারাপ প্রেডিকশন একটা রিলিজ পর্যন্ত খুঁজে পাওয়া যায়। |
ডিপ্লয়মেন্টের নিরাপত্তা চেকলিস্ট
| যাচাই | কেন |
|---|---|
| শুধু HTTPS, HTTP রিডাইরেক্ট করা | টোকেন ও কি হেডারে যাতায়াত করে |
| হোস্টের সিক্রেট স্টোরে সিক্রেট, প্রতি এনভায়রনমেন্টে আলাদা সেট | স্টেজিংয়ের ফাঁস যেন প্রোডাকশনে না পৌঁছায় |
| ন্যূনতম অধিকারের ডেটাবেস ব্যবহারকারী; ডেটাবেস পাবলিক নয় | SQL ইনজেকশন বা চুরি যাওয়া URL কী করতে পারে তা সীমিত করে |
| নন-রুট কন্টেইনার, স্ক্যান করা ইমেজ, লক করা ডিপেন্ডেন্সি | আক্রমণের জায়গা ছোট (Docker: আপনার API প্যাকেজ করা) |
ডিবাগ বন্ধ; API প্রাইভেট হলে /docs সুরক্ষিত বা বন্ধ; CORS-এ সঠিক অরিজিন | ভেতরের তথ্য বাইরে প্রচার করবেন না |
| ডিপ্লয় শুধু CI থেকে, ট্যাগ থেকে, প্রোডাকশনে অনুমোদনসহ | রাত ২টায় ল্যাপটপ থেকে কেউ শিপ করবে না |
প্রোডাকশন-প্রস্তুতির রিভিউ
কাউকে সার্ভিস লাইভ বলার আগে এই তালিকা ধরে এগোন। প্রতিটা সারি বলে দেয় এই টিউটোরিয়ালের কোন পাতা অংশটা বানিয়েছে।
| প্রশ্ন | হ্যাঁ মানে |
|---|---|
| আচরণ কি CI-তে চলা টেস্টে ঢাকা? | পেশাদার টেস্টিং, GitHub Actions |
| ডিপ্লয় কি মাইগ্রেশন চালায়, আর পুরোনো কোড কি নতুন স্কিমায় চলে? | Alembic, expand-and-contract |
| হেলথ, রেডিনেস, স্মোক টেস্ট আর গ্রেসফুল শাটডাউন আছে? | এই পাতা |
| সীমা বাঁধা আছে: রেট, সাইজ, টাইমআউট, খরচ? | Redis রেট লিমিট, LLM ব্যাকএন্ড, নিরাপত্তা |
| মনিটরিং অ্যালার্ট আছে, আর কেউ কি তা দেখবে? | আপটাইম পিং, এরর ট্র্যাকার |
| ব্যাকআপ রিস্টোর করেছেন আর রোলব্যাকের রিহার্সাল দিয়েছেন? | দুটোই ওপরে চলেছে |
| রাতে ভেঙে পড়লে কী করবেন তা এক অনুচ্ছেদে বলতে পারেন? | একটা রানবুক, ছোট হলেও |
বাস্তবে কোথায় লাগে
একটা মডেল-সার্ভিং টিম প্রতি সপ্তাহে নতুন মডেল ভার্সন পাঠায়: পাইপলাইন স্টেজিংয়ে ডিপ্লয় করে, স্মোক টেস্ট চালায়, প্রোডাকশন ট্রাফিকের ছোট একটা ভাগ পাঠায় (Cloud Run রিভিশনে ৫ শতাংশ), এরর রেট ও লেটেন্সি তুলনা করে, আর তারপরই সবকিছু সরায়। রোলব্যাক একটা ট্রাফিক সুইচ। LLM গেটওয়ে টিম ঘণ্টা প্রতি খরচের অ্যালার্ট যোগ করে, কারণ কেউ জেগে ওঠার আগেই প্রম্পটের বাগ বিল দ্বিগুণ করে দিতে পারে। একা কাজ করা ডেভেলপার একই চক্রের ছোট সংস্করণ চালান: ব্লুপ্রিন্ট ফাইল, হেলথ চেক, স্মোক টেস্ট, প্রতি রিলিজে একটা ট্যাগ।
সাধারণ ভুল
- পোর্ট হার্ড-কোড করা বা
127.0.0.1-এ বাইন্ড করা। প্ল্যাটফর্ম অ্যাপে পৌঁছাতে পারে না, আর টাইমআউট বা "no open ports" জানায়।0.0.0.0-এ বাইন্ড করুন আর$PORTসেট থাকলে সেটা ব্যবহার করুন। - Git, ইমেজ বা লগে সিক্রেট। যা একবার কমিট হয়েছে তা অ্যাক্সেস থাকা সবার কাছে চিরকাল প্রকাশ্য। মান রাখুন হোস্টের সিক্রেট স্টোরে, সিক্রেট সেটিং বাধ্যতামূলক করুন, আর ফাঁস হওয়া যেকোনো কি বদলে ফেলুন।
- হেলথ চেক অতি অগভীর বা অতি গভীর। সবসময় "ok" বলা
/healthভাঙা ডেটাবেস লুকিয়ে রাখে; ডেটাবেস কল করা লাইভনেস চেক ডেটাবেসের সাময়িক ঝামেলায় সুস্থ অ্যাপকেও প্ল্যাটফর্ম রিস্টার্ট করে দেয়। লাইভনেস সস্তা রাখুন আর ডিপেন্ডেন্সির জন্য রেডিনেস ব্যবহার করুন। - কন্টেইনারের ডিস্ক বা SQLite-কে স্টোরেজ ভাবা। পরের ডিপ্লয় সেটা মুছে দেয়। ম্যানেজড ডেটাবেস আর অবজেক্ট স্টোরেজ ব্যবহার করুন; SQLite রাখুন লোকাল রান ও টেস্টের জন্য।
- রোলব্যাক পরিকল্পনা নেই, আর ব্রেকিং মাইগ্রেশন। যে রিলিজ কলাম ফেলে দেয় তাকে কোড দিয়ে রোলব্যাক করা যায় না। মাইগ্রেশন ব্যাকওয়ার্ড কম্প্যাটিবল রাখুন, আগে ব্যাকআপ নিন, আর দরকার পড়ার আগেই রোলব্যাকের রিহার্সাল দিন।
নিজে চেষ্টা করুন
- সহজ। অ্যাপ যেসব সেটিং পড়ে (
APP_ENV,APP_VERSION,APP_DATABASE_URL,APP_API_KEY,APP_LOG_LEVELওPORT) সবকটা তালিকাভুক্ত করে একটা.env.exampleবানান, সিক্রেটের মান ফাঁকা রেখে। - মাঝারি। প্রমাণ করুন ডেটাবেসে পৌঁছানো না গেলে
/ready503 ফেরত দেয়: যে ডেটাবেস পাথ নেই তা দিয়ে 8001 পোর্টে দ্বিতীয় একটা ইনস্ট্যান্স চালান, আর স্ট্যাটাস কোড ও বডি দেখান। - কঠিন। শাটডাউন সীমিত করুন। 8001 পোর্টে এমন একটা ইনস্ট্যান্স চালান যা
SIGTERM-এর পর চলমান রিকোয়েস্টকে সর্বোচ্চ ৩ সেকেন্ড দেয়, তাতে ১০ সেকেন্ডের একটা/slowরিকোয়েস্ট পাঠান, থামান, আর দেখান রিকোয়েস্ট কাটা পড়েছে কিন্তুlifespan-এর শাটডাউন তবু চলেছে।
উত্তর
সহজ। ফাইলটা ডকুমেন্টেশন আর টেমপ্লেট: এটা কমিট হয়, আসল .env হয় না।
# file: .env.example
APP_ENV=local
APP_VERSION=dev
APP_DATABASE_URL=sqlite:///./reviews.db
APP_API_KEY=
APP_LOG_LEVEL=INFO
PORT=8000মাঝারি। start.sh নয়, সরাসরি uvicorn দিয়ে শুরু করুন, কারণ পাথ না থাকায় মাইগ্রেশন ব্যর্থ হতো। ইঞ্জিন লেজি কানেক্ট করে, তাই প্রসেস চালু হয় আর শুধু /ready সেটা টের পায়। লোড ব্যালান্সার ঠিক এটাই দেখত:
APP_DATABASE_URL=sqlite:////nonexistent/reviews.db uvicorn main:app --port 8001 --no-access-log > second.log 2>&1 &
export SECOND_PID=$!
curl -s --retry 30 --retry-connrefused --retry-delay 0 http://localhost:8001/health > /dev/null
curl -s -w "\n%{http_code}\n" http://localhost:8001/ready
kill -TERM $SECOND_PID
for i in $(seq 100); do grep -q "Finished server process" second.log && break; sleep 0.1; done{"status":"unavailable","reason":"OperationalError"}
503কঠিন। --timeout-graceful-shutdown 3 uvicorn-কে বলে খোলা রিকোয়েস্টের জন্য তিন সেকেন্ড অপেক্ষা করে তারপর সেগুলো বাতিল করতে। দুটো ব্যাকগ্রাউন্ড জবই, সার্ভার আর ক্লায়েন্ট, শেষ হলে wait ফেরে:
uvicorn main:app --port 8001 --no-access-log --timeout-graceful-shutdown 3 > second.log 2>&1 &
export SECOND_PID=$!
curl -s --retry 30 --retry-connrefused --retry-delay 0 http://localhost:8001/health > /dev/null
curl -s "http://localhost:8001/slow?seconds=10" > slow.out &
sleep 0.5
kill -TERM $SECOND_PID
wait
[ -s slow.out ] && echo "response received" || echo "request was cut off"
grep -E 'Cancel|shutdown complete' second.log | sed -E 's/"ts": "[^"]+", //'request was cut off
ERROR: Cancel 1 running task(s), timeout graceful shutdown exceeded
{"level": "INFO", "logger": "review-api", "msg": "shutdown complete", "version": "1.0.0"}
INFO: Application shutdown complete.প্ল্যাটফর্মের গ্রেস পিরিয়ড আর এই ফ্ল্যাগের মধ্যে মিল থাকা উচিত: প্ল্যাটফর্ম ১০ সেকেন্ডে মেরে ফেললে ফ্ল্যাগ একটু কম রাখুন, যাতে আপনার নিজের শাটডাউন কোড চলার সুযোগ পায়। শেষে রোলব্যাক অংশে চালু রেখে আসা ইনস্ট্যান্সটা থামান:
kill -TERM $(cat server.pid)
for i in $(seq 100); do grep -q "Finished server process" server.log && break; sleep 0.1; doneসারসংক্ষেপ
- এনভায়রনমেন্ট মানে একই কোড, আলাদা কনফিগারেশন। সেটিংস ও সিক্রেট এনভায়রনমেন্ট থেকে পড়ুন, সিক্রেট বাধ্যতামূলক করুন, আর পোর্ট নিন
PORTথেকে। - অ্যাপকে দিন স্ট্যান্ডার্ড আউটপুটে JSON লগ, সস্তা
/health, নির্ভরতা যাচাই করা/ready, আর সার্ভার ট্রাফিক নেওয়ার আগে চলা মাইগ্রেশন। SIGTERMপেলে ভালো সার্ভার চলমান রিকোয়েস্ট শেষ করে আরlifespan-এর শাটডাউন চালায়; আপনার সবচেয়ে ধীর রিকোয়েস্টের সঙ্গে প্ল্যাটফর্মের গ্রেস পিরিয়ড মিলিয়ে দেখুন।- প্রতিটা ডিপ্লয় বাইরে থেকে স্মোক-টেস্ট করুন, ভার্সন যাচাই করুন, আর এক্সিট কোডকে সিদ্ধান্ত নিতে দিন। ট্যাগে CI থেকে ডিপ্লয় করুন, আর রোলব্যাক করুন প্ল্যাটফর্ম, আগের ট্যাগ বা
git revertদিয়ে। - ২০২৬ সালে ফ্রি হোস্টিং ছোট (Render ঘুমায় আর তার ফ্রি ডেটাবেসের মেয়াদ শেষ হয়, Railway ক্রেডিটভিত্তিক, Fly.io-র ফ্রি টিয়ার নেই, Koyeb ফ্রি প্ল্যান বন্ধ করেছে, Docker Spaces-এ PRO লাগে), তাই বর্তমান সীমা দেখুন, ছোট বিলের জন্য তৈরি থাকুন, আর বিশেষ করে LLM কলের জন্য বাজেট ও ক্যাপ বাঁধুন।
এরপর: অনুশীলন ও ইন্টারভিউ ল্যাব এ পর্যন্ত সবকিছুকে গ্রেড করা চ্যালেঞ্জে পরিণত করে: Git উদ্ধার, HTTP ও API ধাঁধা, FastAPI ঠিক করা, নিরাপত্তা যাচাই আর সিস্টেম-ডিজাইনের প্রশ্ন, প্রতিটার সঙ্গে ইঙ্গিত আর চালানো যায় এমন সমাধান।