跳到主要內容
其他筆記

把 Node + PostgreSQL 的 backend 裝進 Docker:Dockerfile、.dockerignore 與 compose

從「本機裝了就跑」到打包成容器的通用做法——Dockerfile 每一行的理由與 layer cache 為什麼決定了指令順序、.dockerignore 漏掉 node_modules 對原生模組是災難級的問題、容器之間為什麼不能用 localhost 互相找到對方,以及怎麼驗證資料真的寫進了 volume。

W

從「本機裝了就跑」到「打包成一個貨櫃、到哪都能開」——這份筆記整理把一個 Node.js(Express + pg,含 bcrypt 原生模組)+ PostgreSQL 的 backend 裝進 Docker 的通用做法:三個檔案分別在做什麼、每一行的理由、常見的兩個雷,以及最後怎麼驗證資料沒有因為重啟而憑空消失。同樣的邏輯套用到其他 Express/Node backend 專案也通用。

三個檔案,各管一段

檔案角色
Dockerfile怎麼把程式碼蓋成一個能跑的環境(image 的食譜)
.dockerignore蓋的時候,哪些東西不該被抄進去
docker-compose.ymlimage 蓋好之後,怎麼跟資料庫一起啟動、怎麼連線

Step 1:Dockerfile

Dockerfile 是「食譜」,Docker 照順序一行行執行,每一行都會蓋成一層。層與層之間可以被快取——這也是為什麼順序不是隨便排的。

01 選底圖

FROM node:20-slim

不從零開始,直接用官方裝好 Node.js 的環境當地基。

這裡用 20 是照專案既有的 Node 版本;新專案直接挑當時的現行 LTS 就好,沒有理由跟著別人的筆記走。

為什麼不用 alpine 如果專案用到 bcrypt(原生模組,C++ 編譯,不是純 JS 的 bcryptjs),alpine 用 musl libc,常抓不到 prebuilt binary、容易編譯失敗。slim(Debian glibc)相容性好很多,是最少踩雷的選擇。沒有原生模組依賴的話,alpine 體積更小也是合理選項。

02 設定工作目錄

WORKDIR /app

之後所有指令都在容器裡的 /app 資料夾底下做,不存在會自動建立——相當於容器版的 cd /app,但對後面所有指令持續生效。

03 先裝套件

COPY package*.json ./
RUN npm ci --omit=dev

先只複製 package.json 與 package-lock.json,還沒動到程式碼,就先把套件裝好。--omit=dev 跳過 devDependencies(像 nodemon),正式容器用不到。

用 npm ci 而不是 npm install:它嚴格照著 lock file 裝,版本完全可重現,而且不會回頭去改 lock file。沒有 lock file 時它會直接失敗——這在 CI 與容器裡是我們要的行為,因為「lock file 忘了 commit」應該當場被抓到,而不是安靜地裝到別的版本。

為什麼要拆開寫 Docker 每行都是一層快取。如果先複製全部程式碼再裝套件,之後只改一行程式碼,重新 build 時「複製程式碼」那層變了,後面所有層(含安裝套件)都要重跑——套件明明沒變還是要重新下載。拆開寫、先裝套件,只要 package.json 沒變,這層就能直接吃快取。

04 複製其餘程式碼

COPY . .

這時候才把剩下的所有原始碼複製進容器——搭配 .dockerignore(Step 2)跳過 node_modules 跟 .env,不然會把剛裝好的東西蓋掉、也會把密鑰打包進 image。

05 宣告對外 port

EXPOSE 8080

純文件性質的宣告:「這個容器會在這個 port 服務」。實際要不要對外開放,是 docker-compose.yml 的 ports 才真正生效。

06 不要用 root 跑

USER node

官方的 node image 裡已經內建一個叫 node 的非特權使用者。不加這行的話容器裡的程式是以 root 身分執行,一旦被打進來,攻擊者在容器裡就有完整權限。

放在 CMD 之前、所有 COPY 與 RUN 之後——前面那些步驟需要寫入權限。

07 啟動指令

CMD ["npm", "start"]

容器啟動時真正執行的指令,等同 npm start(通常對應 node ./server.js 之類的正式啟動腳本)。不是 npm run dev——nodemon 是本機開發用的自動重啟,正式容器不需要,也沒被裝進來(Step 3 的 --omit=dev)。

需要編譯步驟(TypeScript 轉譯、前端打包)的專案值得再進一階,改成多階段建置:第一階段裝完整依賴並編譯,第二階段只把產出物複製過來。最終 image 裡不會有原始碼與建置工具,體積小很多。單純的 JavaScript 專案不必為此增加複雜度。

Step 2:.dockerignore

node_modules
.env

跟 .gitignore 的邏輯很像,但管的是「COPY . . 複製時要跳過誰」:

項目為什麼要排除
node_modules本機(你的作業系統)裝的版本會蓋掉容器裡剛裝好、相容容器系統的版本——對 bcrypt 這種原生模組是直接爆炸等級的問題
.env裡面有真正的密鑰(如 JWT_SECRET、資料庫密碼),不該打包進 image(image 之後可能被分享、上傳,密鑰就外洩)。容器的環境變數改由 docker-compose.yml 注入

Step 3:docker-compose.yml 的 backend 服務

Image 蓋好只是半成品,這段負責「怎麼跟資料庫一起啟動、互相找到對方」。以服務名稱 backend 連 postgres 為例。

A build

build:
  context: ./backend
  dockerfile: Dockerfile

context 是 build 時的根目錄——這樣 Dockerfile 裡的 COPY package*.json ./ 才找得到 backend/package.json,不是專案根目錄那個。如果你的 Dockerfile 就放在專案根目錄,context 也要跟著改成 .。

B ports

ports:
  - "8080:8080"

格式是 "外部:內部"。左邊(外部)是宿主機打進來的 port,通常照你的專案規格固定;右邊(內部)是容器裡 server 實際監聽的 port,來自下面 PORT 這個環境變數。兩邊數字不一定要一樣,只是設一樣比較不容易搞混。

C environment

environment:
  - PORT=8080
  - DB_HOST=postgres
  - DB_PORT=5432
  - DB_USERNAME=<照抄 postgres 服務的 POSTGRES_USER>
  - DB_PASSWORD=<照抄 postgres 服務的 POSTGRES_PASSWORD>
  - DB_DATABASE=<照抄 postgres 服務的 POSTGRES_DB>
  - JWT_SECRET=<自己的亂數字串,不要用 .env.example 的預設值>

相當於容器裡有一份 .env,但值寫在這裡而不是檔案裡。資料庫相關變數要跟 docker-compose.yml 裡 postgres 服務本身設定的值對得起來,才連得上。

D depends_on 與 healthcheck

depends_on:
  postgres:
    condition: service_healthy
healthcheck:
  test: ["CMD-SHELL",
    "node -e \"fetch('http://localhost:8080/healthcheck')
      .then(r => process.exit(r.ok ? 0 : 1))
      .catch(() => process.exit(1))\""]
  interval: 10s
  timeout: 3s
  retries: 5

service_healthy 不只是等 postgres「開機」,而是等它自己的 healthcheck(例如 pg_isready)真的通過,backend 才開始啟動——不然應用程式一連線就失敗、直接 crash。backend 自己的 healthcheck 打的是你專案裡真正存在的健康檢查路由(範例用 GET /healthcheck)。

最核心的觀念DB_HOST 從本機開發的 localhost 改成容器化後的服務名稱(例如 postgres)——這一行最容易漏改,往下看圖解釋為什麼。

圖解:localhost 與服務名稱

本機開發
┌───────────────┐  DB_HOST=localhost:5432   ┌──────────────────┐
│    backend    │ ───(port mapping 到宿主機)──▶ │  postgres 容器    │
│  npm run dev  │                            │  5432 對外開放     │
└───────────────┘                            └──────────────────┘


容器化後
┌───────────┐  localhost:8080   ┌ ─ ─ ─ ─ ─ docker compose 內部網路 ─ ─ ─ ─ ─ ┐
│ 你的電腦   │ ─────────────────▶│ ┌───────────────┐  DB_HOST=postgres ┌──────────────────┐│
└───────────┘                   │ │ backend 容器   │ ──(service 名稱解析)─▶│ postgres 容器     ││
                                 │ │ PORT=8080     │                  │ service: postgres││
                                 │ └───────────────┘                  └──────────────────┘│
                                 └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘

本機開發時 backend 跟 postgres 都在同一台電腦,透過 port mapping 到 localhost 互通;容器化後 backend 自己也變成容器,容器之間不能用 localhost 找到彼此,要改用 compose 內部網路裡的「服務名稱」——這正是 DB_HOST 要改的原因,跟 postgres 服務在 docker-compose.yml 裡叫什麼名字,DB_HOST 就填什麼。

換成別的服務也是同一套規則:MongoDB 的連線字串要從 mongodb://localhost:27017 改成 mongodb://mongo:27017,Redis 則是 redis://redis:6379。主機名稱一律換成服務名稱。

常見雷點

Dockerfile:冒號後面多了一格

- FROM node: 20-slim
+ FROM node:20-slim

Docker 把它解析成 image 名稱是 node:,後面 20-slim 變成不合法的東西,抓不到對應的 image——編輯器會直接標紅警告。

docker-compose.yml:少了一個斜線

- context: .backend
+ context: ./backend

. 代表「目前檔案所在的資料夾」,./backend 才是「該資料夾底下的 backend 子資料夾」;少了斜線會被當成一個叫 .backend 的資料夾去找,這個資料夾不存在,build 會直接失敗。

驗證流程

  • docker compose up -d --build <backend 服務名> <db 服務名> backend 容器狀態變成 healthy,log 裡看到「連線成功/伺服器啟動」之類的訊息。
  • 打一次 healthcheck 或任一支簡單的 API,確認容器裡的服務真的活著。
  • 先寫入一筆測試資料(例如註冊一個帳號)→ docker compose restart <backend 服務名> → 再查一次同一筆資料。 重啟後資料依然查得到——證明資料是真的寫進資料庫的 volume,不是存在記憶體變數裡,重啟就消失。
  • 跑一次完整測試(合約測試或整合測試),確認容器化後行為跟本機開發時一致。

速查表

檔案/欄位值
base imagenode:<LTS>-slim(有原生模組如 bcrypt 時避開 alpine)
安裝套件先 COPY package*.json 再 npm ci,善用 layer cache
執行身分USER node,不要用 root
CMDnpm start(不是 npm run dev,容器不需要 nodemon)
.dockerignorenode_modules、.env
ports"外部:內部",外部通常固定成專案規定的 port
DB_HOST資料庫服務在 docker-compose.yml 裡的服務名稱(容器間用服務名稱,不是 localhost)
depends_on<db 服務名>: condition: service_healthy
healthcheck打 backend 自己的健康檢查路由

參考資料

分享這篇