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為準。
留言
張貼留言