iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
佛心分享-SideProject30

為你自己蓋一座會複利的知識庫——WikiBrain系列 第 21 篇

Day 21 - 打包成 Docker 映像,部署上 Railway

  • 分享至 

  • xImage
  •  

前言

這一段全部講部署:把產品包成一個容器,然後決定它跑在哪裡。

多階段建置

一個 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 檔安裝,建置才可重現。
  • 先複製 package 檔再複製原始碼,這樣改程式碼不會讓相依套件那層快取失效。
  • npm prune --omit=dev 在建置完成後拿掉開發相依。TypeScript 編譯器、測試框架、打包工具都不需要跟著上線。
  • USER node,不要用 root 跑。官方映像內建 node 這個使用者,所以這只是一行的事。
  • migrations 要複製進去,因為伺服器啟動時會自己套用。

那個 build arg

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

出處:Dockerfile:21-24

兩個 ARG 是因為部署平台會用自己的變數名把 commit 注進來,本地建置則用前者,取其中有值的那個。

要不要裝瀏覽器

之前我們已經提過,無頭 Chromium 會讓映像明顯變大,而且是每個自架的人都要付的成本。

我選擇裝,在執行階段安裝:

RUN npx --yes playwright@1.62.1 install --with-deps chromium-headless-shell \
    && rm -rf /var/lib/apt/lists/* /root/.npm

出處:Dockerfile:25

三個細節:用 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))"

出處:Dockerfile:34

start-period 要給夠,因為第一次啟動要等 advisory lock、跑完資料庫遷移。設太短,容器會在遷移途中被判定不健康、重啟、再從頭等鎖——無限迴圈。

compose 給自架的人

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();
  }
}

出處:src/migrate.ts:9-38

拿不到鎖的那個會等,拿到的跑完釋放,醒來的那個發現已經沒事可做。任何「啟動時自動執行一次」的邏輯,都要假設它會被同時執行:遷移、排程初始化、快取預熱都算。

我的條件

先把需求寫清楚,選擇才有依據:

  • 機房在亞洲,使用者主要在台灣
  • 有託管的 PostgreSQL,我不想自己顧資料庫
  • 支援 Dockerfile,不要逼我用它的建置流程
  • git push 就自動部署
  • 能跑排程任務,備份需要
  • 一個人也付得起

候選比較

優點 疑慮
Railway Dockerfile、自動部署、有 Postgres 與 cron、新加坡機房 沒有內建備份,要自己做
Zeabur 亞洲團隊、價格好 剛發生過叢集層級的入侵與環境變數外洩
Replit 零設定、內建資料庫 貴一倍、機房在美國、部署要手動
Fly + Neon 資料庫獨立、擴展彈性好 兩個服務要顧,一個人維護太重

我選了 Railway,機房新加坡。

Zeabur 被排除是因為那則安全事件。這裡無意評價那家公司,重點是安全事件會改變你的成本計算:如果環境變數可能外洩,我就必須假設它會外洩,那麼加密金鑰就不能放環境變數。這也是之前我們已經講過的那個「兩把 secret 分開放」設計的由來。一則新聞讓我改了架構。

Fly 加 Neon 是我認為一年後資料庫要獨立出去時的首選,但現在多維護一個服務不划算。

小結

這一篇回答一個問題:跑起來需要什麼。答案從 Dockerfile 開始:建置需要的東西不留在執行映像裡、不用 root 跑、正式網址與版本號當建置參數帶進去;跑起來之後,健康檢查的寬限要蓋過第一次遷移,遷移本身用 advisory lock 擋並行。至於跑在哪裡,拿需求清單去勾候選,勾到最後發現沒有人提供備份,那一項只好自己做。


上一篇
Day 20 - 讓 Claude.ai 走 OAuth 2.1 連進來
下一篇
Day 22 - 接上 Railway、Cloudflare、Resend 與 Google OAuth
系列文
為你自己蓋一座會複利的知識庫——WikiBrain 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言