iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0

Day 25 把後端與 CLI 打包成一個 Docker 映像檔。但完整的系統有三個部分:

  • 後端:Day 25 的容器
  • 前端:Day 14-17 的 React 應用,目前還要自己 npm run dev
  • Ollama:Day 11 起的 LLM 服務

每個都要記得自己的啟動指令、連接埠、環境變數。今天用 Docker Compose 把它們寫進一個設定檔,docker compose up 一次啟動整個系統。

這一天在系列中的位置

第 4 週:發佈與開源

  • Day 24 → 命令列工具
  • Day 25 → Docker 容器化(後端 + CLI)
  • Day 26 → Docker Compose 部署 ← 今天
  • Day 27 → 大規模性能基準

今日目標

讀完這篇後,你會完成:

  1. 前端映像檔 frontend/Dockerfile:多階段建置,Node 打包、nginx 提供靜態檔案
  2. nginx 設定 frontend/nginx.conf:把 /api/ 轉給後端容器
  3. docker-compose.yml:後端、前端、可選的 Ollama,具名 volume 保存資料
  4. 設定與秘密:SECRET_KEY 必填、.env.example 更新
  5. 端對端驗證:瀏覽器走完註冊 → 登入 → 檢測;down / up 後資料仍在;切換到 Mistral 模式

問題背景:從開發環境到部署

開發時(Day 14),前端由 Vite 開發伺服器提供,它會把 /api/ 代理到 localhost:8000。正式部署不用開發伺服器:

開發(Day 14) 部署(今天)
前端 Vite 開發伺服器,即時編譯 npm run build 產生靜態檔案,nginx 提供
/api/ 代理 vite.config.js nginx.conf
後端位址 localhost:8000 Compose 網路中的服務名稱 api
Ollama 位址 localhost:11434 host.docker.internal:11434 或 ollama:11434

前端程式碼完全不用改:Day 14 起,所有 API 呼叫都是相對路徑 /api/v1/...,交給誰代理都一樣。


實現方法

瀏覽器 → http://localhost:8080
          frontend(nginx:1.27-alpine)
            ├─ /           → 打包好的 React 靜態檔案
            └─ /api/       → http://api:8000
                               api(Day 25 的映像檔)
                                 ├─ volume srs-data → /app/data(SQLite、LLM 緩存)
                                 └─ OLLAMA_URL
                                      ├─ 預設:host.docker.internal:11434(主機上的 Ollama)
                                      └─ --profile ollama:ollama:11434(容器內的 Ollama)
  • 輸入:.env 中的 SECRET_KEY 等設定
  • 輸出:前端 :8080、API :8000
  • 檔案:docker-compose.yml、frontend/Dockerfile、frontend/nginx.conf、frontend/.dockerignore(新增);.env.example(修改)
  • 下游:Day 28 的使用文檔以 Compose 作為建議的安裝方式

專案結構變化:

srs-review-agent/
├── docker-compose.yml        ← 【新增】
├── .env.example              ← 修改:SECRET_KEY、COMPOSE_OLLAMA_URL
├── Dockerfile                ← Day 25
└── frontend/
    ├── Dockerfile            ← 【新增】多階段建置
    ├── nginx.conf            ← 【新增】
    └── .dockerignore         ← 【新增】

代碼示例

1. 前端映像檔

建立 frontend/Dockerfile:

# Day 26:前端映像檔(多階段建置)
# 第 1 階段:用 Node 打包
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
# 網路不穩時重試(實測曾遇到 ECONNRESET)
RUN npm ci --fetch-retries=5 --fetch-retry-mintimeout=20000
COPY . .
# 設為 1 時打包 Day 14 的免登入版(VITE_ 開頭的變數在打包時寫入)
ARG VITE_PUBLIC_MODE=
ENV VITE_PUBLIC_MODE=$VITE_PUBLIC_MODE
RUN npm run build

# 第 2 階段:只把打包結果交給 nginx
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
  • 多階段建置:Node、node_modules、原始碼都只存在於第 1 階段,最後的映像檔只有 nginx 與打包好的檔案
  • npm ci 而不是 npm install:完全依照 package-lock.json 安裝,每次建置的版本都一樣
  • --fetch-retries:第一次建置時,npm ci 在第 20 秒因為網路連線被重設(ECONNRESET)而失敗,整個建置跟著中止。加上重試後順利完成。建置環境的網路不穩是常態,值得預先處理
  • VITE_PUBLIC_MODE 是建置參數:Day 16 的 index.jsx 用 import.meta.env.VITE_PUBLIC_MODE 選擇根元件。Vite 的環境變數在打包時就寫死在 JavaScript 裡,容器執行時再設定是沒有用的,所以必須用 ARG 在建置時傳入

frontend/.dockerignore 排除 node_modules/ 與 dist/:容器會自己 npm ci,不需要主機上那一份。

2. nginx 設定

建立 frontend/nginx.conf:

server {
    listen 80;
    root /usr/share/nginx/html;

    # 與 vite.config.js 相同:結尾斜線讓 /apiClient.js 這類檔案不會被轉走
    location /api/ {
        proxy_pass http://api:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        # 背景任務模式下 POST 會立即回應;這裡放寬是為了慢速的 Ollama 請求
        proxy_read_timeout 300s;
    }

    location / {
        try_files $uri /index.html;
    }
}

location /api/ 的結尾斜線,和 Day 14 在 vite.config.js 踩過的坑是同一個道理:只轉發真正的 API 路徑。

http://api:8000 的 api 是 Compose 裡的服務名稱。Compose 會建立一個內部網路,每個服務都能用名稱找到彼此。

3. docker-compose.yml

在專案根目錄建立 docker-compose.yml,先看後端:

services:
  api:
    build: .
    image: srs-review-agent:latest
    environment:
      SECRET_KEY: ${SECRET_KEY:?請在 .env 設定 SECRET_KEY}
      SRS_USE_OLLAMA: ${SRS_USE_OLLAMA:-1}
      # 不沿用 .env 的 OLLAMA_URL:那是本機開發用的 localhost,在容器裡會指向容器自己
      OLLAMA_URL: ${COMPOSE_OLLAMA_URL:-http://host.docker.internal:11434}
      OLLAMA_MODEL: ${OLLAMA_MODEL:-mistral}
    volumes:
      - srs-data:/app/data                # 資料庫與 LLM 緩存
    extra_hosts:
      - "host.docker.internal:host-gateway"   # Linux 也能連到主機上的 Ollama
    ports:
      - "8000:8000"
    restart: unless-stopped

三個值得說明的地方:

${SECRET_KEY:?訊息}:沒有設定就拒絕啟動

Day 15 提過,SECRET_KEY 有一個公開的預設值,正式環境忘了設定時,任何人都能偽造 token。:? 是 Compose 的必填語法,沒有設定時直接報錯:

Error while interpolating services.api.environment.SECRET_KEY: required variable SECRET_KEY is missing a value: 請在 .env 設定 SECRET_KEY

這個檢查對所有 docker compose 指令都會生效,連 docker compose ps 也一樣,所以 SECRET_KEY 應該寫在 .env 裡,而不是每次手動帶入。

COMPOSE_OLLAMA_URL:不沿用 OLLAMA_URL

Compose 會自動讀取專案根目錄的 .env。但這個專案的 .env(Day 2 起)本來就有 OLLAMA_URL=http://localhost:11434,那是給本機開發用的。如果 Compose 直接用 ${OLLAMA_URL},容器就會去連容器自己的 11434 埠。所以 Compose 改用另一個變數名稱,預設指向主機。

用 docker compose config 確認:即使 .env 裡寫的是 localhost,容器拿到的仍是:

OLLAMA_URL: http://host.docker.internal:11434

extra_hosts: host-gateway

host.docker.internal 是 Docker Desktop 內建的名稱,Linux 的 Docker Engine 沒有。這一行讓 Linux 也有同樣的名稱,Day 25 提到的平台差異在這裡處理掉。

前端與 Ollama:

  frontend:
    build:
      context: ./frontend
      args:
        VITE_PUBLIC_MODE: ${VITE_PUBLIC_MODE:-}
    image: srs-review-agent-frontend:latest
    depends_on:
      api:
        condition: service_healthy        # 等 api 的 HEALTHCHECK 通過再啟動
    ports:
      - "8080:80"
    restart: unless-stopped

  # 可選:容器內的 Ollama(第一次需下載模型,約 4.4GB)
  ollama:
    image: ollama/ollama:latest
    profiles: ["ollama"]
    volumes:
      - ollama-models:/root/.ollama
    ports:
      - "11434:11434"
    restart: unless-stopped

volumes:
  srs-data:
  ollama-models:
  • condition: service_healthy:等 Day 25 的 HEALTHCHECK 通過才啟動前端,避免使用者打開頁面時後端還沒準備好
  • Ollama 放在 profile 裡:預設不啟動,直接用主機上已經下載好模型的 Ollama。要完全容器化時加上 --profile ollama,並把 COMPOSE_OLLAMA_URL 設成 http://ollama:11434
  • 具名 volume:srs-data 存資料庫與緩存,ollama-models 存模型檔。docker compose down 不會刪除它們

4. .env.example

更新 .env.example,把需要的設定寫清楚:

# 本機開發:Ollama 位址與模型(Day 11)
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=mistral

# JWT 簽章金鑰(Day 15)。正式環境務必換成隨機字串,例如:
#   python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# docker compose 會要求這個值(Day 26)
SECRET_KEY=change-me

# docker compose 專用:容器內連 Ollama 的位址(Day 26)
# 預設 http://host.docker.internal:11434(主機上的 Ollama)
# 使用 --profile ollama 時改為 http://ollama:11434
# COMPOSE_OLLAMA_URL=http://host.docker.internal:11434

驗證結果

啟動

cp .env.example .env      # 並把 SECRET_KEY 改成隨機字串
docker compose up -d --build

第一次建置約 5 分鐘,大部分時間花在前端的 npm ci 與下載 node:20-alpine、nginx:1.27-alpine 基礎映像檔。完成後:

 Container srs-review-agent-api-1 Healthy
 Container srs-review-agent-frontend-1 Started

前端映像檔只有 76.4MB,是多階段建置的成果;後端仍是 Day 25 的 414MB。

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/         # 200
curl http://localhost:8080/api/v1/health                                  # 經 nginx 轉給 api
{"status": "healthy", "version": "1.0.0", "components": {"detector": "ready", "llm": "mock", ...}}

這次驗證先用 SRS_USE_OLLAMA=0 啟動(規則模式),因為同一時間主機的 Ollama 正在跑 Day 21 的 A/B 實驗。

瀏覽器端對端測試

用 Playwright 驅動 headless Chromium 打開 http://localhost:8080,走一遍 Day 16 的流程:

200 POST /api/v1/auth/register
200 POST /api/v1/auth/login
200 GET  /api/v1/auth/jobs
200 POST /api/v1/detect
200 GET  /api/v1/auth/jobs
200 GET  /api/v1/auth/jobs/<id>
結果: 發現 1 個衝突:
200 GET  /api/v1/auth/me        ← 重新整理頁面
200 GET  /api/v1/auth/jobs

所有請求都經過 nginx 轉發,重新整理後仍維持登入(Day 17 的 token 存在 localStorage),console 沒有錯誤。

重啟後資料還在嗎

先用 API 註冊一個帳號、提交一個檢測任務,然後:

docker compose down        # 停止並移除容器(不加 -v,volume 保留)
docker compose up -d
檢查 結果 原因
用重啟前的 token 呼叫 /api/v1/auth/me 401 Day 17 的會話存在記憶體,容器重啟就清空
用同一組帳密重新登入 200 users 表在 srs-data volume 裡
查詢任務列表 1 個任務,completed jobs 表在 srs-data volume 裡

帳號與任務都保留了,只有登入狀態需要重來。這正是 Day 17 寫下的限制「伺服器重啟後所有人都要重新登入」;在容器環境,每次更新映像檔都會發生一次。

切換到 Mistral 7B

Day 21 的 A/B 實驗結束後,改用預設的 SRS_USE_OLLAMA=1 重新啟動後端:

docker compose up -d
curl http://localhost:8080/api/v1/health
{"status": "healthy", ..., "components": {"detector": "ready", "llm": "ollama:mistral", ...}}

提交 Day 13 那份電商需求中的 3 條:

curl -X POST http://localhost:8080/api/v1/conflicts/detect -H "Content-Type: application/json" \
  -d '{"constraints": [
        {"id": "REQ-1", "text": "支持明文顯示訂單細節"},
        {"id": "REQ-2", "text": "不需要加密用戶的個人信息"},
        {"id": "REQ-3", "text": "所有支付數據必須加密傳輸"}]}'

15.4 秒後完成,5 次 LLM 呼叫(批量驗證 1、補充 1、自我矛盾 3),回報 3 個衝突:

需求 類型 來源
REQ-1 ↔ REQ-3 安全性衝突 規則,LLM 驗證保留
REQ-3 ↔ REQ-2 邏輯矛盾 LLM 補充
REQ-2(自身) 自相矛盾 第 4 層

規則層的誤報「明文顯示訂單 vs 不需要加密個人信息」被批量驗證擋下,和 Day 13 的結果一致。第 4 層把「不需要加密用戶的個人信息」判為自相矛盾,理由與 Day 21 A/B 中的那個誤報一字不差:「可能存在隱私漏洞」。容器化沒有改變任何判斷。


權衡與限制

  • 只有單一後端容器:Day 17 的會話在記憶體,Day 13 的免登入任務也在記憶體,不能直接把 api 擴展成多個副本。要水平擴展,需要先把會話移到 Redis
  • SQLite 在 volume 裡:適合單機部署。多人同時寫入較多時,建議加一個 PostgreSQL 服務,並把 DATABASE_URL 指向它(Day 15)
  • 沒有 HTTPS:nginx 只聽 80 埠。對外提供服務時,要在前面再加一層反向代理(例如 Caddy、Traefik)處理 TLS 憑證
  • 容器內 Ollama 未實測:--profile ollama 需要在容器內下載 4.4GB 的模型,本文只驗證了「連主機上的 Ollama」這條路徑。在 macOS 上,容器內的 Ollama 也無法使用 Apple Silicon 的 GPU,會比主機上慢很多
  • SECRET_KEY 寫在 .env:比寫在 Compose 檔或映像檔安全,但仍是明文檔案。正式環境可以改用 Docker secrets 或雲端平台的秘密管理

提交變更

git add docker-compose.yml .env.example frontend/Dockerfile frontend/nginx.conf frontend/.dockerignore
git commit -m "Day 26: Docker Compose(api、nginx 前端、可選 Ollama、具名 volume)"

.env 本身不要提交,裡面有 SECRET_KEY。

明天預告

系統已經能一行指令部署了。但 Day 24 在一份 58 條需求的規格書上,看到補充層回報了 17 個全部錯誤的衝突,補充 prompt 也已經用掉 2,623 個 token、接近 Mistral 的 4096 上限。明天 Day 27 我們做大規模性能基準:用程式產生不同大小、衝突數量已知的規格書,量測每一層的耗時、LLM 呼叫次數與準確率如何隨規模變化,找出這套系統能處理多大的規格書。


上一篇
Day 25:Docker 容器化
下一篇
Day 27:大規模性能基準
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言