這一段全部講部署:把產品包成一個容器,然後決定它跑在哪裡。
一個 Node 專案要上容器,最基本的原則是:建置需要的東西,執行時不要留在映像裡。
# 建置階段
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --include=dev
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
COPY web ./web
COPY templates ./templates
COPY migrations ./migrations
COPY scripts ./scripts
ARG APP_URL
ENV APP_URL=$APP_URL
RUN npm run build && npm prune --omit=dev
# 執行階段
FROM node:24-slim
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/web/dist ./web/dist
COPY --from=build /app/templates ./templates
COPY --from=build /app/migrations ./migrations
COPY package.json ./
USER node
CMD ["node", "dist/server.js"]
出處:Dockerfile:1-36(全檔,依主題分節引用)
幾個重點:
npm ci 而不是 npm install:前者嚴格照 lock 檔安裝,建置才可重現。npm prune --omit=dev 在建置完成後拿掉開發相依。TypeScript 編譯器、測試框架、打包工具都不需要跟著上線。USER node,不要用 root 跑。官方映像內建 node 這個使用者,所以這只是一行的事。ARG APP_URL 就是之前我們已經講過的坑。預先渲染在建置時跑,需要知道正式網址,所以它必須是建置參數。忘了帶,整站的 canonical 會指向錯的網域。
同一個道理,版本號(git commit 短碼)也是建置時決定的,方便在 /healthz 和頁尾顯示現在跑的是哪一版:
ARG GIT_COMMIT
ARG RAILWAY_GIT_COMMIT_SHA
ENV NODE_ENV=production PORT=3000 IMPORT_HEADLESS=1 GIT_COMMIT=${GIT_COMMIT:-$RAILWAY_GIT_COMMIT_SHA} PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
兩個 ARG 是因為部署平台會用自己的變數名把 commit 注進來,本地建置則用前者,取其中有值的那個。
之前我們已經提過,無頭 Chromium 會讓映像明顯變大,而且是每個自架的人都要付的成本。
我選擇裝,在執行階段安裝:
RUN npx --yes playwright@1.62.1 install --with-deps chromium-headless-shell \
&& rm -rf /var/lib/apt/lists/* /root/.npm
三個細節:用 chromium-headless-shell 而不是完整版,省掉圖形相關的元件;--with-deps 會裝系統層的相依函式庫;瀏覽器裝在固定路徑 /ms-playwright(前面那行 ENV 尾端的 PLAYWRIGHT_BROWSERS_PATH),讓非 root 的使用者讀得到,否則切換 USER node 之後會找不到。
不需要的人設 IMPORT_HEADLESS=0 就能關掉。
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
start-period 要給夠,因為第一次啟動要等 advisory lock、跑完資料庫遷移。設太短,容器會在遷移途中被判定不健康、重啟、再從頭等鎖——無限迴圈。
services:
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wikibrain -d wikibrain"]
app:
build: { context: ., args: { APP_URL: "${APP_URL:-http://localhost:3000}" } }
depends_on: { db: { condition: service_healthy } }
environment:
KEY_ENCRYPTION_SECRET_FILE: /run/secrets/wikibrain_key
secrets: [wikibrain_key]
secrets:
wikibrain_key: { file: ./secrets/wikibrain_key }
出處:docker-compose.yml:4-45(節錄)
depends_on 要加 condition: service_healthy,不然 app 會在資料庫還沒準備好的時候啟動然後失敗。加密金鑰走 Docker secret 掛成檔案,就是之前我們已經講過的那件事。
伺服器啟動時會自己套用未執行的遷移,而它可能被同時執行。
本機開發用 watch 模式,存檔就重啟;有時候我又手動跑一次遷移指令。兩個行程同時啟動、同時發現有未套用的遷移、同時執行,主鍵衝突,其中一個直接掛掉。正式環境更容易遇到:滾動部署時新舊容器並存,兩個都會在啟動時嘗試遷移。
修法是 PostgreSQL 內建的 advisory lock,幾行的事:
const MIGRATE_LOCK_KEY = 7_281_942_001;
export async function migrate(): Promise<string[]> {
const lock = await pool.connect();
await lock.query('SELECT pg_advisory_lock($1)', [MIGRATE_LOCK_KEY]);
try {
…
} finally {
await lock.query('SELECT pg_advisory_unlock($1)', [MIGRATE_LOCK_KEY]).catch(() => {});
lock.release();
}
}
拿不到鎖的那個會等,拿到的跑完釋放,醒來的那個發現已經沒事可做。任何「啟動時自動執行一次」的邏輯,都要假設它會被同時執行:遷移、排程初始化、快取預熱都算。
先把需求寫清楚,選擇才有依據:
| 優點 | 疑慮 | |
|---|---|---|
| Railway | Dockerfile、自動部署、有 Postgres 與 cron、新加坡機房 | 沒有內建備份,要自己做 |
| Zeabur | 亞洲團隊、價格好 | 剛發生過叢集層級的入侵與環境變數外洩 |
| Replit | 零設定、內建資料庫 | 貴一倍、機房在美國、部署要手動 |
| Fly + Neon | 資料庫獨立、擴展彈性好 | 兩個服務要顧,一個人維護太重 |
我選了 Railway,機房新加坡。
Zeabur 被排除是因為那則安全事件。這裡無意評價那家公司,重點是安全事件會改變你的成本計算:如果環境變數可能外洩,我就必須假設它會外洩,那麼加密金鑰就不能放環境變數。這也是之前我們已經講過的那個「兩把 secret 分開放」設計的由來。一則新聞讓我改了架構。
Fly 加 Neon 是我認為一年後資料庫要獨立出去時的首選,但現在多維護一個服務不划算。
這一篇回答一個問題:跑起來需要什麼。答案從 Dockerfile 開始:建置需要的東西不留在執行映像裡、不用 root 跑、正式網址與版本號當建置參數帶進去;跑起來之後,健康檢查的寬限要蓋過第一次遷移,遷移本身用 advisory lock 擋並行。至於跑在哪裡,拿需求清單去勾候選,勾到最後發現沒有人提供備份,那一項只好自己做。