iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0

到這裡,我們已經把應用寫完、包好、測過、丟進乾淨的容器。接下來就是你最不想面對但跑不掉的一題:怎麼把它穩定可觀測能回滾地丟上線。

今天做三件事:

  1. 把 Python Web 服務的啟動模型說清楚:Uvicorn vs Gunicorn + UvicornWorker
  2. 雲端兩條常見路線的「最小可用設定」:Cloud RunKubernetes
  3. 把前面幾天的積木扣起來(結構化日誌、健康檢查、秘密管理、CI/CD)

風格與原則延續前文:JSON stdout 日誌、唯讀根檔系統、依賴鎖定、非 root、可回滾。


一、啟動模型:Uvicorn 單兵 vs Gunicorn 指揮官

什麼時候只用 uvicorn

  • 單節點或 Cloud Run 這類「平台幫你管多實例與自動縮放」的環境
  • I/O 密集為主,CPU 不會被吃爆
  • 你希望配置最少,啟動最快

啟動範例:

uvicorn my_project.adapters.web.app:app \
  --host 0.0.0.0 --port 8000 \
  --proxy-headers --forwarded-allow-ips='*' \
  --timeout-keep-alive 5

什麼時候用 gunicornUvicornWorker

  • 你要多進程進階的工作者治理(preload、graceful timeout、存活監控)
  • 要綁「CPU 核心數 × N」的 workers,並交給 OS 調度
  • 裝在 VM、K8s,或你不信任平台的前端代理

啟動範例:

gunicorn my_project.adapters.web.app:app \
  -k uvicorn.workers.UvicornWorker \
  --bind 0.0.0.0:8000 \
  --workers ${WORKERS:-$(python - <<'PY'
import os, multiprocessing as m
print(max(2, m.cpu_count()*2 + 1))
PY
)} \
  --threads ${THREADS:-1} \
  --timeout 60 --graceful-timeout 30 \
  --keep-alive 5 --access-logfile '-' --error-logfile '-'

選擇:

  • Cloud Run:傾向直接 uvicorn,因為「多實例」由平台處理

  • K8s/VM:偏好 gunicorn + UvicornWorker,多進程彈性更大

  • CPU 密集請外接任務系統或把熱點換到 C/Rust;Web 層再怎麼調 workers 也救不了錯刀口


二、容器鏡像回顧與調整

沿用多階段 Dockerfile:builder 解決依賴,runtime 超瘦、非 root、stdout JSON log。

啟動命令抽成環境變數,方便在 Cloud Run 或 K8s 覆寫:

ENV APP_CMD="uvicorn my_project.adapters.web.app:app --host 0.0.0.0 --port 8000"
CMD ["sh", "-c", "$APP_CMD"]

需要 gunicorn 時只要在部署層改:

APP_CMD="gunicorn my_project.adapters.web.app:app -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers 3"


三、Cloud Run:最小可用設定

Cloud Run 幫你處理自動擴縮、TLS、日誌聚合。你要負責的只有可預測的資源正確的併發

推薦基線:

  • CPU/Memory-cpu=1 --memory=512Mi(依框架與流量再拉)
  • Concurrency(併發)-concurrency=40(FastAPI I/O 密集通常能撐,先量測)
  • 最小實例-min-instances=0~1(冷啟動 vs 成本取捨)
  • 超時-timeout=30s(搭配上游重試策略)
  • 環境變數/Secrets:用 Secret Manager,別在映像烤死
  • 健康檢查:提供 /healthz/readyz;啟動期間回 503

部署指令範例:

gcloud run deploy awesome-api \
  --source . \
  --region asia-east1 \
  --platform managed \
  --allow-unauthenticated \
  --cpu=1 --memory=512Mi \
  --concurrency=40 \
  --min-instances=1 --max-instances=50 \
  --timeout=30s \
  --set-env-vars "APP_ENV=prod" \
  --set-secrets "DATABASE_URL=projects/xxx/secrets/db-url:latest" \
  --set-env-vars "APP_CMD=uvicorn my_project.adapters.web.app:app --host 0.0.0.0 --port 8080"

備註:Cloud Run 預設用 8080。把 APP_CMD 的 port 改成 8080,別硬碰 8000。

實務 Tips

  • 想省錢:-cpu=1 --concurrency=80 先試,但壓測要看 95/99 百分位
  • 想少冷啟:-min-instances=1,然後壓測調到剛好不會把單實例打爆
  • 日誌:stdout JSON會被 Cloud Logging 吃進欄位,查問題才不會像考古

四、Kubernetes:Deployment + Service + HPA 最小骨架

你自己養集群,當然就多寫一點 YAML。重點只有三件事:Requests/LimitsProbesAutoscaling

Deployment(節錄):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: awesome-api
spec:
  replicas: 2
  selector:
    matchLabels: { app: awesome-api }
  template:
    metadata:
      labels: { app: awesome-api }
    spec:
      securityContext:
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
      containers:
        - name: web
          image: ghcr.io/you/awesome-api:1.0.0
          imagePullPolicy: IfNotPresent
          env:
            - name: APP_ENV
              value: "prod"
            - name: APP_CMD
              value: >-
                gunicorn my_project.adapters.web.app:app
                -k uvicorn.workers.UvicornWorker
                --bind 0.0.0.0:8000
                --workers=3 --timeout=60 --graceful-timeout=30 --keep-alive=5
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: app-secrets
                  key: database_url
          ports:
            - containerPort: 8000
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "1"
              memory: "512Mi"
          readinessProbe:
            httpGet: { path: /readyz, port: 8000 }
            periodSeconds: 5
            failureThreshold: 6
          livenessProbe:
            httpGet: { path: /healthz, port: 8000 }
            periodSeconds: 10
            failureThreshold: 3
          volumeMounts:
            - name: tmp
              mountPath: /tmp
          securityContext:
            readOnlyRootFilesystem: true
      volumes:
        - name: tmp
          emptyDir: {}

Service:

apiVersion: v1
kind: Service
metadata:
  name: awesome-api
spec:
  selector: { app: awesome-api }
  ports:
    - name: http
      port: 80
      targetPort: 8000
  type: ClusterIP

HPA(CPU 觸發,先求有感):

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: awesome-api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: awesome-api
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

實務 Tips

  • Requests/limits 先保守,壓測後再拉高。別一開始就 2C/2G,把錢燒在等待上
  • Probes 要「快且廉價」:回固定 JSON,嚴肅檢查放 /readyz/healthz 只要證明活著
  • 日誌 JSON + stdout;Sidecar/DaemonSet 收集丟到你習慣的平台
  • 機密:Secret + KMS;別把 .env 打包進映像
  • 滾動更新:maxUnavailable=0maxSurge=1 起步,慢慢調

五、健康檢查與降級:邊界一律要設計

FastAPI 端點:

from fastapi import APIRouter, Response, status

router = APIRouter()

@router.get("/healthz")
def healthz():
    return {"ok": True}

@router.get("/readyz")
def readyz():
    # 只做「快」的檢查:例如 DB 連線池 ping(有 timeout),或關鍵依賴的快取標誌
    ok = True
    return Response(status_code=status.HTTP_200_OK if ok else status.HTTP_503_SERVICE_UNAVAILABLE)

降級策略:外部依賴抖動時回「舊快取或預設值」而非 500;把 retry 次數、退避與 jitter 記進結構化日誌。


六、結構化日誌:雲原生世界的唯一路線

  • 全站統一 structlogJSON → stdout
  • Web 層至少打:eventpathstatusmsrequest_idversion
  • 追效能:把資料庫查詢耗時外部呼叫延遲上報;K8s/Cloud Run 都能用指標拉 HPA 或告警

七、CI/CD 與發佈:不手點,一鍵到底

  • 建置:沿用 GitHub Actions,用 uv sync --locked 確保依賴一致
  • 打包hatch build,產出鏡像用Dockerfile
  • 安全pip-auditSafetyCycloneDX SBOM 進 artifacts
  • 部署
    • Cloud Run:gcloud run deploy(分環境用不同專案或不同 service 名稱)
    • K8s:kubectl apply -k overlays/prod,把 APP_CMD 之類的差異放 Kustomize overlay

八、速查表(Cheatsheet)

場景 啟動方式 併發與擴縮 你要操心的
Cloud Run API uvicorn 平台自動擴縮,設 --concurrency 超時、最小實例、成本、冷啟
K8s 小中型流量 gunicorn + UvicornWorker HPA + Requests/Limits Probes、滾動更新、日誌聚合
重 I/O、少 CPU 任一皆可 拉高單實例併發 外部依賴重試與降級
重 CPU 不要硬撐 Web 把 CPU 料丟背景任務 熱點剖析、快取

九、常見踩雷與解法

  • Pod 活著但請求超時:readinessProbe 太寬鬆;把昂貴檢查移除或放後台 warm-up
  • Cloud Run 高延遲concurrency 太高導致排隊;或冷啟,開 min-instances
  • 記憶體 OOM:Gunicorn workers 太多;或 limits 太小。先壓測再設計 workers
  • JSON 日誌亂:有人偷偷 print()。把 logger 注入中介層並在 CI lint 禁用裸 print
  • 機密外洩:把 .env 打包進映像。移除,改 Secrets 注入,並審 .dockerignore

十、把它收進一鍵工作流(Hatch scripts)

pyproject.toml 片段:

[project.optional-dependencies]
deploy = ["uvicorn>=0.30", "gunicorn>=22.0"]

[tool.hatch.envs.deploy]
features = ["deploy"]

[tool.hatch.envs.deploy.scripts]
serve-uv = "uvicorn my_project.adapters.web.app:app --host 0.0.0.0 --port 8000"
serve-gunicorn = "gunicorn my_project.adapters.web.app:app -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers 3 --timeout 60 --graceful-timeout 30 --keep-alive 5"
health = "curl -sf http://127.0.0.1:8000/healthz && echo ok"
ready = "curl -sf http://127.0.0.1:8000/readyz && echo ready"


結語

部署不該是「神秘學」。選一條你能量測、能回滾、能看見的路;平台要嘛幫你管進程與縮放(Cloud Run),要嘛你自己把進程治理做好(Gunicorn + K8s)。其餘都是細節,細節我們今天都寫給你了。去壓測,把門檻拉到剛好,讓錢花在用戶身上,而不是 CPU 放空的時間裡。


上一篇
Day 26 -安全與授權:pip-audit、Safety、授權掃描
系列文
30 天 Python 專案工坊:環境、結構、測試到部署全打通27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言