跳到主要內容

AG Day 39 容器化:Docker 打包 Agent 服務

AG Day 39 容器化:Docker 打包 Agent 服務

執行需求:需 Docker。昨天的 AG Day 38 部署:FastAPI 包裝 Agent(原文連結)讓 research-agent 變成一個 HTTP 服務,但這個服務目前只在開發者機器上能跑得順。今天我們把整個服務連同它的所有依賴——Python 3.13、uv、LangGraph 1.x、FastAPI、Tavily API、Langfuse、AG Day 31 留下的 SQLite 檢查點、AG Day 36 的語意快取資料庫——打包進 Docker image,讓任何裝了 Docker 的機器都能一鍵啟動。我們用多階段建構(multi-stage build)縮小最終 image 體積、用 docker compose 把 Langfuse 觀測服務一起拉起來、並把昨天寫的 /healthz 接到 Docker 的 healthcheck。本篇需要本機有 Docker 與 docker compose 才能完整跑起來,沒有 Docker 的讀者可以純閱讀 manifest 與 Dockerfile 內容。

引言

把一個 Python 服務打包進 Docker 看起來只是「寫個 Dockerfile」這麼簡單,但 research-agent 這種有狀態、有外部依賴的服務會立刻撞到幾個典型問題。第一是 image 體積:直接把整台開發機器的 Python 環境搬進去,image 很容易破 2 GB;用多階段建構可以把 builder 階段的 gcc、header 檔留在 builder layer,最終 image 只留執行期需要的東西。第二是依賴與啟動順序:服務啟動時可能會等外部 Langfuse、SQLite volume、Tavily 連線,沒有 health check 與 retry 機制就會在容器剛啟動時錯。第三是 secrets 管理:API 金鑰不該寫進 Dockerfile,也不該 commit 進 Git;標準做法是透過環境變數或 Docker secrets 注入。

今天的目標分三層。第一層是 research-agent/Dockerfile:用 Python 3.13 slim 作基底、安裝 uv、用多階段建構、把最終 image 縮到 500 MB 以下。第二層是 research-agent/docker-compose.yml:定義 research-agent 服務與其依賴(Langfuse、SQLite volume),並設定 health check 與 restart policy。第三層是 research-agent/.dockerignore:避免把 .venv、__pycache__、.env、本地 SQLite 檔案意外打包進 image。我們也會處理「AG Day 31 的 SQLite 檢查點要怎麼存活於容器重啟」這個昨天留下的伏筆——答案是 named volume mount。

這一篇結束的時候,你應該能在一台全新的 Linux 機器上(裝了 Docker 即可)用 docker compose up 把整個 research-agent 連同 Langfuse 觀測服務一起啟動,並用 curl 打到 /healthz 收到 {"status":"ok"}。這正是系列走到交付階段後,部署工程師接手時最在乎的「一份指令就能跑起來」的體驗。

原理/觀念

多階段建構的核心觀念

Docker 多階段建構的觀念是「把『建構需要的工具』與『執行期需要的函式庫』分開」。研究助理的 build 階段需要:完整的 gcc、Python 開發 header、uv、build-essential(給某些套件編譯 wheels 用)。但執行期只需要:Python 直譯器、uv 執行期、專案依賴的 wheels、以及專案程式碼本身。如果不分階段,這些 gcc 與 build-essential 都會留在最終 image 裡,浪費空間也增加攻擊面。我們今天的 Dockerfile 採用兩個階段:第一階段 builder 編譯並安裝所有依賴,第二階段 runtime 只把第一階段的 site-packages 與專案程式碼複製過來,並用非 root 使用者執行。

Health check 與啟動順序

Docker 的 healthcheck 機制讓容器定期執行一個指令,根據回傳碼判定服務是否健康。我們昨天寫的 /healthz 端點正是這個機制的最佳搭檔:Docker 會每 30 秒對 /healthz 發一次請求,連續失敗就把容器標記為 unhealthy。docker compose 也會根據 healthcheck 決定依賴服務的啟動順序——例如 research-agent 可以宣告 depends_on: langfuse: condition: service_healthy,這樣 Langfuse 還沒準備好之前 research-agent 容器不會啟動。

Named volume vs. bind mount

容器化時最容易忽略的是「有狀態資料」。research-agent 有三類有狀態資料:AG Day 31 的 SQLite 檢查點(data/checkpoints.db)、AG Day 36 的語意快取資料庫(data/cache.db)、AG Day 21 的知識庫(data/knowledge.db 與 data/chroma/)。如果沒特別處理,每次 docker compose down 之後再 up,所有資料都會回到 image 剛 build 完成時的樣子。修法是用 named volume:docker compose 會在 host 機器上建立一個由 Docker 管理的目錄,容器內掛到 /app/data。這樣容器重啟、升級、甚至換到別台機器,只要把 volume 帶過去,狀態就還在。Bind mount 則是把 host 上的某個路徑直接掛到容器內,適合開發階段即時同步程式碼,production 建議用 named volume。

完整實作

今天的程式集中在 research-agent/ 目錄下的四個新檔案。我們先建立骨架:

cd research-agent
touch Dockerfile docker-compose.yml .dockerignore scripts/entrypoint.sh

第一步:寫 .dockerignore,把不該進 image 的東西擋掉。這份清單跟 .gitignore 概念類似,但作用在 Docker build 階段:

.venv/
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
.env
data/*.db
data/chroma/
reports/
node_modules/
*.egg-info/
dist/
build/
docs/

這份清單擋掉了開發階段的 cache、本地 SQLite 與 Chroma 資料、目錄輸出、以及任何 .env 形式的秘密檔案。注意 data/*.db 在開發階段是有用的,但進 image 後就不該被覆蓋;我們之後會用 volume mount 把資料目錄掛回來。

第二步:寫 Dockerfile,用多階段建構。我們把 builder 階段的依賴安裝與 runtime 階段的設定分開:

# syntax=docker/dockerfile:1.7
ARG PYTHON_VERSION=3.13
ARG UV_VERSION=0.5

FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv
FROM python:${PYTHON_VERSION}-slim AS builder

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    UV_LINK_MODE=copy \
    UV_PROJECT_ENVIRONMENT=/app/.venv

COPY --from=uv /uv /uvx /usr/local/bin/
WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential \
        libffi-dev \
    && rm -rf /var/lib/apt/lists/*

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project

COPY src ./src
RUN uv sync --frozen --no-dev


FROM python:${PYTHON_VERSION}-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/app/.venv/bin:$PATH"

RUN apt-get update && apt-get install -y --no-install-recommends \
        tini \
        curl \
    && rm -rf /var/lib/apt/lists/* \
    && groupadd --system app \
    && useradd --system --gid app --home /app app

WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY src /app/src
COPY pyproject.toml uv.lock /app/
COPY scripts/entrypoint.sh /usr/local/bin/entrypoint.sh

RUN chmod +x /usr/local/bin/entrypoint.sh \
    && mkdir -p /app/data /app/reports \
    && chown -R app:app /app

USER app
EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
    CMD curl -fsS http://127.0.0.1:8000/healthz || exit 1

ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
CMD ["uvicorn", "research_agent.server:app", "--host", "0.0.0.0", "--port", "8000"]

第三步:寫 scripts/entrypoint.sh,處理啟動前的環境檢查、data/ 目錄權限、與簡單的 retry。我們用 tini 當 PID 1,確保容器內信號處理乾淨:

#!/usr/bin/env bash
set -euo pipefail

# 確保資料目錄存在且可寫
mkdir -p /app/data /app/reports

# 啟動前印出關鍵設定,協助除錯
echo "[research-agent] starting with model=${RESEARCH_AGENT_MODEL:-not-set}"
echo "[research-agent] langfuse=${LANGFUSE_PUBLIC_KEY:+enabled}${LANGFUSE_PUBLIC_KEY:-disabled}"

# 執行原本的 CMD
exec "$@"

第四步:寫 docker-compose.yml,把 research-agent 與 Langfuse 觀測服務一起拉起來。我們用 named volume 持久保存 SQLite 與 Chroma 資料、用 health check 控制啟動順序:

services:
  research-agent:
    build:
      context: .
      dockerfile: Dockerfile
    image: research-agent:0.1.0
    container_name: research-agent
    restart: unless-stopped
    ports:
      - "8000:8000"
    env_file:
      - .env
    environment:
      RESEARCH_AGENT_MODEL: ${RESEARCH_AGENT_MODEL:-gpt-placeholder}
      RESEARCH_AGENT_MAX_STEPS: ${RESEARCH_AGENT_MAX_STEPS:-8}
      LANGFUSE_HOST: http://langfuse-web:3000
    volumes:
      - agent-data:/app/data
      - agent-reports:/app/reports
    depends_on:
      langfuse-web:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s

  langfuse-web:
    image: langfuse/langfuse:3
    container_name: langfuse-web
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://langfuse:langfuse@langfuse-db:5432/langfuse
      NEXTAUTH_URL: http://localhost:3000
      NEXTAUTH_SECRET: ${LANGFUSE_NEXTAUTH_SECRET:-please-change-me}
      SALT: ${LANGFUSE_SALT:-please-change-me}
    depends_on:
      - langfuse-db
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/api/public/health"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 60s

  langfuse-db:
    image: postgres:16-alpine
    container_name: langfuse-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: langfuse
      POSTGRES_PASSWORD: langfuse
      POSTGRES_DB: langfuse
    volumes:
      - langfuse-db:/var/lib/postgresql/data

volumes:
  agent-data:
  agent-reports:
  langfuse-db:

第五步:建立 .env.example,把所有需要注入的環境變數列出來。這個檔案本身要 commit 進版控(讓其他工程師知道有哪些變數),但實際的 .env 檔(含真實金鑰)要加進 .gitignore 與 .dockerignore:

# research-agent/.env.example
# 複製成 .env 並填入真實值;.env 不進版控
RESEARCH_AGENT_MODEL=gpt-placeholder
RESEARCH_AGENT_MAX_STEPS=8
OPENAI_API_KEY=sk-replace-me
ANTHROPIC_API_KEY=sk-ant-replace-me
TAVILY_API_KEY=tvly-replace-me
LANGFUSE_PUBLIC_KEY=pk-replace-me
LANGFUSE_SECRET_KEY=sk-replace-me
LANGFUSE_NEXTAUTH_SECRET=please-change-me
LANGFUSE_SALT=please-change-me

第六步:用一段小腳本驗證 image 體積與啟動時間。我們不希望最終 image 超過 600 MB,也不希望從 docker compose up 到 /healthz 通過超過 60 秒:

# research-agent/scripts/measure_image.sh
#!/usr/bin/env bash
set -euo pipefail

cd research-agent

echo "[1/2] build image..."
docker build -t research-agent:0.1.0 .

echo "[2/2] measure image size..."
docker images research-agent:0.1.0 --format "{{.Size}}"

echo "[3/3] cold start time test..."
start=$(date +%s)
docker compose up -d research-agent
for i in $(seq 1 60); do
  status=$(docker inspect --format='{{.State.Health.Status}}' research-agent 2>/dev/null || echo "starting")
  if [ "$status" = "healthy" ]; then
    end=$(date +%s)
    echo "research-agent healthy after $((end - start))s"
    exit 0
  fi
  sleep 1
done

echo "research-agent did not become healthy within 60s"
docker compose logs research-agent
exit 1

實際啟動驗證:

cd research-agent
cp .env.example .env  # 填入金鑰
docker compose up -d
sleep 10
curl -s http://127.0.0.1:8000/healthz
# {"status":"ok"}

第七步:把今天的 Dockerfile 與 docker-compose 結構用 Python 寫一段自驗程式,確保 manifest 沒有手誤。我們在 CI 上跑這段,CI 失敗就代表有人改了部署組態但忘了同步驗證腳本:

# research-agent/scripts/verify_docker_manifest.py
"""驗證 Dockerfile / docker-compose.yml 的基本結構;CI 上必跑。"""
from __future__ import annotations
import re
from pathlib import Path


REPO = Path(__file__).resolve().parents[1]
DOCKERFILE = REPO / "Dockerfile"
COMPOSE = REPO / "docker-compose.yml"


def check_multi_stage() -> list[str]:
    text = DOCKERFILE.read_text(encoding="utf-8")
    asgs = re.findall(r"^FROM\s+(\S+)\s+AS\s+(\S+)", text, re.MULTILINE)
    if len(asgs) < 2:
        return [f"預期至少兩個 FROM ... AS ... 階段,找到 {len(asgs)}"]
    stages = {name for _, name in asgs}
    if "builder" not in stages or "runtime" not in stages:
        return ["必須同時存在 builder 與 runtime 兩個階段"]
    return []


def check_non_root() -> list[str]:
    text = DOCKERFILE.read_text(encoding="utf-8")
    if not re.search(r"^USER\s+\S+", text, re.MULTILINE):
        return ["runtime 階段必須設定 USER(非 root 執行)"]
    return []


def check_healthcheck() -> list[str]:
    text = DOCKERFILE.read_text(encoding="utf-8")
    if "HEALTHCHECK" not in text or "/healthz" not in text:
        return ["Dockerfile 必須設定 HEALTHCHECK 並指向 /healthz"]
    return []


def check_depends_on_condition() -> list[str]:
    text = COMPOSE.read_text(encoding="utf-8")
    if "condition: service_healthy" not in text:
        return ["docker-compose.yml 應至少有一個依賴用 condition: service_healthy"]
    return []


def check_named_volumes() -> list[str]:
    text = COMPOSE.read_text(encoding="utf-8")
    if "volumes:" not in text:
        return ["docker-compose.yml 缺少頂層 volumes 區塊(named volume 必填)"]
    return []


def main() -> int:
    errors = []
    for fn in (check_multi_stage, check_non_root, check_healthcheck,
              check_depends_on_condition, check_named_volumes):
        errors.extend(fn())
    if errors:
        for e in errors:
            print(f"[FAIL] {e}")
        return 1
    print("[OK] docker manifest 結構驗證通過")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

離線執行結果:

$ uv run python scripts/verify_docker_manifest.py
[OK] docker manifest 結構驗證通過

第八步:把昨天的 FastAPI healthz 升級成「多層健康檢查」,這樣未來 AG Day 41 壓測時可以更精準地判斷容器是否真的準備好:

# research-agent/src/research_agent/health.py
from __future__ import annotations
import sqlite3
from pathlib import Path

import httpx

from research_agent.env import get_env


CHECK_DB = Path("/app/data/checkpoints.db") if Path("/app/data").exists() else None


def check_sqlite() -> bool:
    if CHECK_DB is None:
        return True
    try:
        with sqlite3.connect(CHECK_DB) as con:
            con.execute("SELECT 1").fetchone()
        return True
    except sqlite3.Error:
        return False


def check_langfuse() -> bool:
    host = get_env("LANGFUSE_HOST", "")
    if not host:
        return True  # 沒設 Langfuse 視為健康(開發者模式)
    try:
        resp = httpx.get(f"{host.rstrip('/')}/api/public/health", timeout=2.0)
        return resp.status_code == 200
    except httpx.HTTPError:
        return False


def report_health() -> dict:
    """回傳多層健康檢查結果;HTTP 200 表示全綠,503 表示至少一項失敗。"""
    sqlite_ok = check_sqlite()
    langfuse_ok = check_langfuse()
    overall = sqlite_ok and langfuse_ok
    return {
        "status": "ok" if overall else "degraded",
        "checks": {
            "sqlite": "ok" if sqlite_ok else "fail",
            "langfuse": "ok" if langfuse_ok else "fail",
        },
    }

把這個模組掛回昨天的 server.py,/healthz 端點改為回傳 report_health() 並用 HTTP 狀態碼區分綠燈與降級:

# research-agent/src/research_agent/server.py(節錄)
from fastapi.responses import JSONResponse
from research_agent.health import report_health


@app.get("/healthz")
async def healthz() -> JSONResponse:
    payload = report_health()
    code = 200 if payload["status"] == "ok" else 503
    return JSONResponse(payload, status_code=code)

第九步:寫一段 pytest 驗證 verify_docker_manifest.py 與 health.py 的行為正確。這些測試不依賴 Docker daemon,可以在 CI 上無腦跑:

# research-agent/tests/test_health.py
from research_agent.health import report_health


def test_report_health_keys():
    payload = report_health()
    assert "status" in payload
    assert "checks" in payload
    assert "sqlite" in payload["checks"]
    assert "langfuse" in payload["checks"]


def test_report_health_ok_when_langfuse_unset(monkeypatch):
    monkeypatch.delenv("LANGFUSE_HOST", raising=False)
    payload = report_health()
    assert payload["status"] == "ok"

第十步:補一段 Python 工具,把 named volume 內容打包成 tar,方便備份與還原。AG Day 44 維運手冊會用這個工具:

# research-agent/scripts/backup_volume.py
"""把 named volume 內容打包成本地 tar,方便備份或遷移。"""
from __future__ import annotations
import argparse
import subprocess
from datetime import datetime


def backup(volume: str, dest: str) -> Path:
    timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    target = Path(dest) / f"{volume}-{timestamp}.tar"
    target.parent.mkdir(parents=True, exist_ok=True)

    cmd = [
        "docker", "run", "--rm",
        "-v", f"{volume}:/source:ro",
        "-v", f"{dest}:/backup",
        "alpine:3.20",
        "tar", "cf", f"/backup/{target.name}", "-C", "/source", ".",
    ]
    subprocess.run(cmd, check=True)
    return target


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--volume", required=True)
    parser.add_argument("--dest", required=True)
    args = parser.parse_args()
    print(backup(args.volume, args.dest))

這支小工具呼叫 Docker 跑一個臨時的 Alpine 容器,把目標 volume 的內容 tar 起來放到本機目錄。它不依賴 research-agent 本身、也不需要 volume 掛載到正在運行的服務,是個獨立的維運小工具。AG Day 44 會把這個腳本整合進維運手冊的「資料遷移」段落。

常見錯誤與踩雷

第一個雷是「image 體積沒有控管」。很多人寫 Dockerfile 時直接用 python:3.13(完整版)當基底,裡面其實包含 pip、apt、一堆範例專案,動輒 1.5 GB 起跳。我們今天刻意用 python:3.13-slim(少 60%)並只安裝必要的 build-essential 與 curl,最終 image 通常落在 350–500 MB。如果未來 image 還是大,可以再把 builder 階段刪掉、或把 wheels 預先 build 好放在自己的 registry。

第二個雷是「沒有設 USER」。預設 Docker 容器以 root 執行,這對安全性是個大漏洞:如果容器被攻陷,攻擊者直接拿到 root。我們今天在 runtime 階段建立 app 使用者並 USER app,讓所有執行都以非 root 進行。注意某些套件(例如 chromadb 的某些擴充)會在啟動時寫暫存檔,若用非 root 需要確保 /tmp 與 /app/data 是可寫的,這也是我們特地做 chown -R app:app /app 的原因。順帶提醒,USER 必須寫在 RUN chown 之後才有效;如果順序顛倒,chown 是在 root 身分下執行的,但接下來的指令卻切換到非 root,會造成檔案擁有者不是 app,引發後續寫入錯誤,這個雷在初學者身上非常常見。

第三個雷是「依賴服務的健康檢查沒寫」。docker compose 預設只等待容器 process 啟動,但 Langfuse 之類的服務從 process 啟動到 HTTP 端可服務還要 30–60 秒。如果 research-agent 不等 Langfuse 就啟動,第一分鐘的 trace 全部會丟失。我們今天用 depends_on: { langfuse-web: { condition: service_healthy } } 讓 research-agent 真的等到 Langfuse 健康才啟動。同樣地,research-agent 自己也要寫 healthcheck,否則 docker compose 不知道什麼時候算「真的 ready」。另外要注意 condition: service_healthy 仰賴依賴服務也設定了 healthcheck;如果 Langfuse 映像的 healthcheck 寫得不夠嚴格(例如只檢查 process 而非 HTTP 端),這個條件就形同虛設。我們今天特地為 Langfuse 自訂了打到 /api/public/health 的 healthcheck,這是 Langfuse 對外公開的健康檢查端點。

第四個雷是「把 .env 檔 commit 進版控」。我們今天用 env_file: .env 讓 docker compose 讀取,這代表 .env 必須存在且不被 commit。建議在 .gitignore 與 .dockerignore 都加 .env,並且 CI 上加一個 secret scanner 確認 .env 沒有被意外 commit。若需要更嚴格的管理,可以用 Docker secrets(Swarm 模式)或外部 secret manager(例如 HashiCorp Vault)。

第五個雷是「沒有處理 SQLite 鎖定」。多個 worker 同時寫 SQLite 時容易撞到鎖。如果你的 production 部署會用多個 uvicorn worker(--workers 4),記得把 SQLite 改成 Postgres 或加連線池。我們今天的 docker compose 預設只跑單一 worker 進程,SQLite 鎖定問題不大;若日後要橫向擴展,AG Day 31 已經示範過改用 Postgres 檢查點的寫法。

第六個雷是「沒有設定 restart policy」。如果 Langfuse Postgres 容器重啟、或 host 機器短暫斷電,沒有 restart policy 的容器就停在那裡。我們用 restart: unless-stopped,讓 docker daemon 在容器崩潰時自動重啟,這對 production 是基本動作。

效能與實務提醒

實務上最容易踩到的效能問題是「image 沒有善用 layer 快取」。Docker build 的每個 COPY、RUN 都會建立一個 layer,layer 一旦有變更就會讓下游 layer 全部失效。我們今天的順序刻意安排:先 COPY pyproject.toml uv.lock 再 uv sync,這樣只有依賴檔變更時才會重跑依賴安裝;接著才 COPY src,平常改程式碼時只會重新打包 src,不會重裝依賴。這個順序對開發階段的迭代速度影響很大。

第二個提醒是「log 輸出要結構化」。AG Day 9 學過 logging,今天進到容器後要特別注意:容器 stdout 預設會被 docker daemon 收集到 /var/lib/docker/containers/<id>/<id>-json.log,這份 log 沒有 rotate 機制,會無限長大。我們建議用 logging 設定把 log 直接寫到 volume mount 的目錄,再搭配 logrotate 或外部 log shipper(Filebeat、Fluentd)送到集中式 log 系統。AG Day 44 的維運手冊會再談這層。

第三個提醒是「time zone」。容器預設用 UTC 時間,但 AG Day 9 的 logging 印出來的時間如果跟台灣時區對不上,除錯時會非常痛苦。我們建議在 Dockerfile 加一行 ENV TZ=Asia/Taipei,並 apt-get install -y tzdata,讓容器內的時間跟開發者一致。

第四個提醒是「健康檢查要包含外部依賴」。我們今天的 /healthz 只檢查 HTTP 服務本身活著,但若 Langfuse 連不上,research-agent 對外是健康的、內部卻完全無法追蹤。建議未來把 /healthz 擴充為同時檢查 Langfuse 連線、SQLite 可寫、Tavily API 可達的多層健康檢查,並回傳 200 / 503 兩種狀態。AG Day 41 壓測時會用這個進階版的 healthz 來衡量容器在壓力下的存活率。

第五個提醒是「image 標籤」。我們今天用 research-agent:0.1.0 這個固定標籤,但 production 應該用 CI 自動產生的 immutable 標籤(例如 commit SHA 或日期),方便追蹤哪個 image 在哪台機器上跑。我們今天刻意保留固定標籤是為了方便教學,實務上 research-agent:git-${COMMIT_SHA} 會比 research-agent:latest 安全得多;後者會在每次 docker compose pull 時自動升級到當下最新版本,導致昨天還能跑的版本今天就消失了,這對需要可重現部署的 production 來說是個大忌。

第六個提醒是「資源限制」。Docker compose 可以用 deploy.resources.limits 設定 CPU 與記憶體上限,避免某個失控的代理把整台機器資源吃光。對於有 LLM 呼叫的研究助理,記憶體通常不是瓶頸(LLM 推理多在 API 端),但偶爾會有 chromadb 或大型 embedding 模型把記憶體撐高。建議從 cpus: "2"、memory: 2G 起跳,觀察幾次高峰後再微調。另外也建議在 docker-compose.yml 把 mem_limit、cpus 這兩個欄位都明確寫出來(即使預設值也寫),這對交接給其他維運工程師很有幫助:

# research-agent/scripts/resource_check.py
"""依據過去一小時的 docker stats 估算建議資源限制。"""
from __future__ import annotations
import json
import subprocess
from statistics import mean


def sample_stats(container: str, samples: int = 6, interval: int = 10) -> dict:
    cpu_pct: list[float] = []
    mem_bytes: list[int] = []
    for _ in range(samples):
        out = subprocess.check_output(
            ["docker", "stats", container, "--no-stream", "--format", "{{json .}}"],
            text=True,
        )
        line = out.strip().splitlines()[-1]
        data = json.loads(line)
        cpu_pct.append(float(data["CPUPerc"].rstrip("%")))
        mem_bytes.append(_parse_mem(data["MemUsage"]))
        if _ != samples - 1:
            subprocess.run(["sleep", str(interval)], check=False)
    return {
        "avg_cpu_pct": round(mean(cpu_pct), 2),
        "peak_cpu_pct": round(max(cpu_pct), 2),
        "avg_mem_mb": round(mean(mem_bytes) / (1024 * 1024), 1),
        "peak_mem_mb": round(max(mem_bytes) / (1024 * 1024), 1),
    }


def _parse_mem(spec: str) -> int:
    value, unit = spec.split()
    factor = {"KiB": 1024, "MiB": 1024 ** 2, "GiB": 1024 ** 3}[unit]
    return int(float(value) * factor)


if __name__ == "__main__":
    print(sample_stats("research-agent"))

這支小工具用 docker stats 採樣六次(每次間隔 10 秒),回傳平均與峰值 CPU / 記憶體,協助你決定 deploy.resources.limits 要設多少。採樣期間對 production 服務幾乎零干擾,可以放在 AG Day 41 壓測後跑一次當成調校依據。

小結

今天我們把 research-agent 連同它的所有外部依賴打包進 Docker image,並用 docker compose 把研究助理服務、Langfuse 觀測服務、Postgres 資料庫一起拉起來。整個系統只需要 docker compose up 一條指令就能在任何裝了 Docker 的機器上啟動,狀態資料透過 named volume 持久保存,秘密透過 .env 與環境變數注入。AG Day 38 的 /healthz 端點正式成為 Docker healthcheck 的標準介面。

今天新增的關鍵詞:多階段建構(multi-stage build)——用 builder/runtime 兩個階段縮小最終 image 體積;named volume——Docker 管理的持久化儲存空間;healthcheck——容器定期檢查自身健康狀態的機制;depends_on condition——docker compose 等待依賴服務健康才啟動的設定;non-root user——容器內以非 root 身分執行的安全最佳實踐。

順帶把今天動過的所有檔案列出來,方便你之後對照:Dockerfile(新增,多階段建構)、docker-compose.yml(新增,三個服務 + 三個 named volume)、.dockerignore(新增,排除開發檔案)、.env.example(新增,列出所有環境變數)、scripts/entrypoint.sh(新增,啟動前檢查)、scripts/verify_docker_manifest.py(新增,CI 自驗)、scripts/measure_image.sh(新增,image 體積與冷啟動時間量測)、scripts/resource_check.py(新增,資源監控)、scripts/backup_volume.py(新增,volume 備份)、src/research_agent/health.py(新增,多層健康檢查)、src/research_agent/server.py(小幅修改 /healthz)。

結語

目前為止我們的部署焦點都在「服務本體」,但 production 還需要讓人類能直接使用這個服務。明天我們會進入「AG Day 40 使用者介面:CLI 與 Streamlit 前端」,把昨天寫的 HTTP 服務再包兩層人類友善的介面:一個是更新版的 CLI(支援串流輸出與互動模式),另一個是 Streamlit 1.5x 寫的聊天介面(讓非技術使用者透過瀏覽器與研究助理對話)。今天建的 Docker image 與 docker compose 環境會直接被 Streamlit 沿用,這個伏筆會在部署章節的最後一篇兌現。我們也會把 AG Day 38 寫的 FastAPI 服務、AG Day 31 寫的 SQLite 檢查點、AG Day 35 接好的 Langfuse trace 觀測,今天打包進 docker compose 的同一個啟動流程,Streamlit 連線時只要指向同一個服務名稱即可,不需要另外處理 secrets。

延伸資源

  • Docker 官方文件:https://docs.docker.com/。本篇 Dockerfile 語法、multi-stage build、healthcheck、depends_on 都以官方文件為準。
  • uv 官方 Docker 指南:https://docs.astral.sh/uv/。本篇採用的 uv sync --frozen 與 UV_PROJECT_ENVIRONMENT 設定詳見該指南。
  • Langfuse 官方文件:https://langfuse.com/docs。Langfuse 3.x 的 self-hosted 啟動方式與 docker compose 範例。
  • Postgres 官方 Docker Hub:https://hub.docker.com/_/postgres。本篇 Langfuse 後端所用的映像以官方維護的 postgres:16-alpine 為準。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...