在共享 Docker 伺服器上部署
使用時機
- 伺服器非專用:已運行其他人或其他專案的部署。
- 應用程式有原生相依性(
better-sqlite3、sharp、canvas)或瀏覽器(Chromium)。 - 已有反向代理(Caddy 或 nginx)發布多個網域。
- 任何
EADDRINUSE、OOM 或磁碟空間不足都可能影響其他堆疊。
黃金法則:新應用程式不能成為其他服務崩潰的原因。 因此所有步驟都需預先規劃與測量。
0. 建構前:測量並釋放磁碟空間
使用 Chromium 建構會下載約 2 GB 的映像檔。如果磁碟使用率達 78%,建構會失敗或使伺服器處於危險邊緣,下一次的 prune 可能會刪除生產環境的映像檔。
ssh SERVIDOR 'docker system df; df -h /'
通常可以安全釋放的項目:
docker builder prune -af # 建構快取(通常佔用最大空間)
docker image prune -af # 未使用的映像檔
docker container prune -f # 已停止的容器
前後都要計算容器數量,以確認沒有任何服務中斷:
docker ps -q | wc -l # 記錄數字,之後進行比較
根據實際案例:磁碟使用率從 78% 降至 32%,且 41 個生產容器完好如初。
1. 原生模組與 Node 版本
為不同 major 版本的 Node 編譯的 .node 檔案不會給出明確的錯誤訊息:它會因內部 assert 而導致程序崩潰,並產生無用的原生堆疊追蹤。
典型症狀:
# node.exe[61084]: void __cdecl node::RemoveEnvironmentCleanupHook(Isolate *, CleanupHook, void *)
# Assertion failed: (env) != nullptr
----- Native stack trace -----
原因:better-sqlite3@11 搭配 Node 24。支援在 better-sqlite3@12 中引入。
規則:
- 在整合前,先確認原生模組版本與 Node 的 major 版本相容。
- 在建構用的
Dockerfile中安裝工具鏈(python3 make g++),以便在不提供預編譯二進位檔時能即時編譯。 - 執行環境中,不要假設需要工具鏈:最好使用較小的最終映像檔。
- 如果模組被 workspace 中的多個套件引用,請在一個不執行其他任務的程序中測試:延遲的 GC 崩潰可能在數小時後才出現。
2. 將繁重程序與回應程序分離
將 Chromium 和原生模組放在與 HTTP 伺服器相同的程序中會導致崩潰,而且會在長時間工作中阻塞事件循環。
api : HTTP + SSE,低記憶體,HTTP 健康檢查
worker : 佇列 + 瀏覽器,高記憶體,無埠號,無 HTTP 健康檢查
兩者透過共享儲存(SQLite、Redis、Postgres)通訊。API 不需要知道 worker 是否存活。
健康檢查僅定義在提供 HTTP 服務的容器上。 在 worker 中繼承 Dockerfile 的健康檢查會使其永遠處於 unhealthy 狀態,因為它從不開啟埠號:
worker:
healthcheck:
disable: true
3. 限制每個服務的記憶體
api:
mem_limit: 400m
worker:
mem_limit: 1600m
mem_reservation: 400m
volumes:
- /dev/shm:/dev/shm:rw,size=256m # Chromium 使用 /dev/shm;64 MB 會使其崩潰
tmpfs:
- /tmp:size=256m
security_opt:
- no-new-privileges:true
此外,在程式碼中減少工作量:爬蟲在伺服器上的 DETAIL_MAX / MAX_PAGES 應該比本機低。如果伺服器哪天 RAM 不足,罪魁禍首幾乎總是那個數字,而不是 Docker。
在實際操作前後進行測量:
docker stats --no-stream --format '{{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}' <api> <worker>
free -m | head -2
4. 建構:確實有效的階段與快取
FROM mcr.microsoft.com/playwright:v1.63.0-noble AS build # 版本鎖定至 npm 套件版本
...
FROM mcr.microsoft.com/playwright:v1.63.0-noble AS runtime
- 鎖定映像檔版本為已安裝的 npm 套件版本。如果兩者不一致,瀏覽器不會在 Playwright 預期的地方。
- 先複製
package.json,再複製程式碼,使COPY . .不會使install層失效。 - 在 runtime 中,如果啟動腳本使用
pnpm/npm,則需要它們 → 使用corepack enable。 - 如果
node_modules從 build 階段複製,且權限不符,pnpm 會在每次啟動時嘗試「修復」它並失敗。可透過在映像檔的.npmrc中設定verify-deps-before-run=false來停用。 - runtime 使用非 root 使用者。Playwright 映像檔已包含
pwuser。
pnpm-lock.yaml 必須提交,且 build 階段的 install 應使用 --frozen-lockfile。
5. 透過現有代理發布
如果已有 Caddy 發布多個網域,不要啟動另一個代理或開啟另一個公開埠號:只需在現有的 Caddyfile 中新增一個區塊,並連接到 Docker 網路。
services:
api:
networks: [edge_proxy] # 外部網路,已建立
networks:
edge_proxy:
external: true
name: edge_proxy
找到網路和 Caddyfile 的位置:
docker inspect <proxy> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} rw={{.RW}}{{println}}{{end}}'
在主機上編輯檔案:如果 bind mount 為 rw=false,在容器內寫入會失敗(Resource busy)或在重啟後遺失。
安全流程(切勿盲目編輯):
cp "$CADDYFILE" "$CADDYFILE.bak-$(date +%Y%m%d-%H%M%S)" # 1. 備份
# 2. 新增區塊
docker exec <proxy> caddy validate --config /etc/caddy/Caddyfile # 3. 驗證
docker exec <proxy> caddy reload --config /etc/caddy/Caddyfile # 4. 重新載入(無停機時間)
如果 validate 失敗,則還原備份。重新載入不會中斷現有連線。
Basic Auth
prospector.ejemplo.dev {
basic_auth {
leo $2a$14$... # bcrypt,絕不能使用明文
}
reverse_proxy api:3000 {
flush_interval -1 # SSE 必須設定
}
encode zstd gzip
header {
X-Frame-Options DENY
-Server
}
}
- 在伺服器上產生 hash:
docker exec <proxy> caddy hash-password --plaintext '...'。 - 僅將明文密碼儲存在 root 檔案中(
/root/<app>-password.txt,權限 600),以便日後恢復或輪換,絕不能提交到儲存庫。 - 如果應用程式使用 SSE,不要在前端使用 Cloudflare 代理:100 秒超時和緩衝會中斷進度。僅使用 DNS(灰雲)讓來源伺服器提供真實憑證。
basic_auth僅保護通過代理的流量。如果應用程式也透過其他埠號暴露,則不受保護:請記錄此點。
6. 從 Windows 部署
Windows 沒有 rsync。可行的跨平台方法是透過 SSH 管道傳輸 tar:
const tar = spawn('tar', ['-cf', '-', '--exclude', './node_modules', ...excludes, '.'], { cwd: ROOT });
const ssh = spawn('ssh', [HOST, `mkdir -p ${DIR} && tar -xf - -C ${DIR}`], { stdio: ['pipe', 'inherit', 'inherit'] });
tar.stdout.pipe(ssh.stdin);
在 Windows 上,spawn 必須設定 shell: true 才能解析 pnpm(它是 .cmd 檔案),但這樣做無法傳遞檔案 handle 來重新導向輸出:請使用環境變數 LOG_FILE,讓應用程式自行寫入日誌。
7. 操作共享伺服器前
- 記錄
docker ps -q | wc -l,以便最後進行比較 - 釋放後有足夠的可用磁碟空間
- 所有新服務均已設定
mem_limit - 繁重程序位於自己的容器中,且無埠號
- 健康檢查僅設定在提供 HTTP 服務的容器上
- 宣告使用代理的外部網路,而非建立新網路
- 備份 Caddyfile,並在重新載入前執行
caddy validate - 日誌寫入檔案(程序將無控制台)
- 部署腳本具備冪等性:
check / push / build / up / logs / stats / backup - 備份的 volume 已測試且已還原,而不僅僅是建立
快速診斷
docker stats --no-stream --format '{{.Name}} {{.MemUsage}} {{.CPUPerc}}' <api> <worker>
docker logs --tail 80 <worker>
docker exec <api> node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>r.text()).then(console.log)"
free -m && df -h /