第 9 章:把 Go app 也容器化,完成一鍵啟動 🎉

第 9 章:把 Go app 也容器化,完成一鍵啟動 🎉

這一章在做什麼? 程式碼已經穩定,終於到了部署階段——我們把 Go app 也裝進容器,寫一個 multi-stage build 的 Dockerfile,把 app 與 db 串成一份完整的 docker-compose,並處理「容器之間怎麼連線」與「環境變數怎麼注入容器」。完成後,任何人只要 docker compose up --build 一行指令就能啟動整個後端。

為什麼現在才容器化 app? 回顧第 1 章:開發期我們刻意讓 app 用本機 go run(改 code 立刻生效、最靈活),只把 DB 放容器。現在 code 穩定、要交付「一鍵啟動的完整專案」,build image 的成本才划算。這就是貼近實務的漸進式做法。


步驟 1:.dockerignore(別把垃圾塞進 image)

build image 時,Docker 會先把「build context」(整個專案資料夾)打包送給 builder。我們要排除用不到、甚至不該進去的東西(尤其是含密碼的 .env)。

建立 .dockerignore

# .dockerignore
.git
.gitignore
.env
.env.example
.dockerignore
Dockerfile
docker-compose.yml

# 教學文件與轉檔工具,build 不需要
*.md
*.html
.tools/

逐行說明重點:

  • .env最重要——含密碼,絕不能被打包進 image。容器要用的設定,我們改用 compose 從外部注入(步驟 4)。
  • .git*.md*.html.tools/:版控紀錄、文件、Node 轉檔工具,編譯完全用不到,排除可讓 build 更快、image 更乾淨。

🔁 JS 對照 .dockerignore ≈ 你會排除 node_modules.env.git 的那種清單。概念一樣:別把不必要或機密的東西放進 build context 與 image。


步驟 2:multi-stage Dockerfile

Go 的一大優勢:能編譯成單一靜態執行檔,不需要把原始碼或整套工具鏈帶到正式環境。我們用 multi-stage build:第一階段用完整的 Go 環境編譯,第二階段只把「編好的執行檔」放進一個極小的 image。

建立 Dockerfile

# Dockerfile

# ========== 第一階段:build ==========
FROM golang:1.22-alpine AS builder

WORKDIR /app

# 先只複製相依清單並下載,善用 Docker 層快取:
# 只要 go.mod/go.sum 沒變,這層就會被快取、不必每次重抓套件
COPY go.mod go.sum ./
RUN go mod download

# 再複製其餘原始碼
COPY . .

# 編譯成「靜態」執行檔:
#   CGO_ENABLED=0 → 不連結系統 C 函式庫,產生純靜態 binary,才能在極小 image 跑
#   GOOS=linux    → 目標作業系統為 Linux(容器是 Linux)
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/api

# ========== 第二階段:最終 image ==========
FROM alpine:3.20

WORKDIR /app

# ca-certificates:日後若要打 HTTPS 外部服務會用到;並建立一個非 root 使用者
RUN apk add --no-cache ca-certificates && adduser -D -u 10001 appuser

# 只從 build 階段複製「編譯好的執行檔」,不帶任何原始碼或 Go 工具鏈
COPY --from=builder /app/server /app/server

USER appuser
EXPOSE 8080

ENTRYPOINT ["/app/server"]

逐段解釋:

  • FROM golang:1.22-alpine AS builder:第一階段,用官方 Go 映像當編譯環境,命名為 builder
  • COPY go.mod go.sumgo mod download,最後才 COPY . .:這是 Docker 層快取的經典技巧。相依清單沒變時,下載套件那層直接用快取,只有改 code 才重編——build 快很多。
  • CGO_ENABLED=0:產生純靜態執行檔(不依賴系統的 C 函式庫)。這樣才能丟進 alpine 甚至空白的 scratch image 也能跑。我們的相依(gin、sqlx、lib/pq、jwt、bcrypt)都是純 Go,沒問題。
  • FROM alpine:3.20:第二階段,換成超小的基底 image(約 7MB)。
  • COPY --from=builder /app/server /app/server:只把第一階段編好的那一個執行檔搬過來。最終 image 不含原始碼、不含 Go 編譯器——又小又安全。
  • 非 root 使用者 appuser:正式環境的安全慣例,別用 root 跑服務。
  • ENTRYPOINT ["/app/server"]:容器啟動就執行我們的後端。

💡 第 2 章的伏筆在這裡回收:還記得我們用 //go:embed 把 migration 的 .sql 嵌進程式嗎?正因如此,這個 Dockerfile 完全不用 COPY migrations/——SQL 已經編進執行檔了。這就是當初選 embed 的好處:絕不會發生「忘了複製 migrations 導致建表失敗」。

🔁 JS 對照 Node 的 image 通常要 COPY package.jsonnpm ciCOPY . .,最終 image 還得帶著一大包 node_modules 和 Node runtime。 Go 不一樣:編譯出一個自帶所有東西的執行檔,最終 image 可以小到只有那個檔 + 一個極簡基底。這是 Go 部署最爽的地方之一。


步驟 3:容器之間怎麼連線?(關鍵觀念)

開發期 app 在本機、DB 在容器,app 連 localhost:5432。但現在 app 也在容器裡,情況變了:

  • 同一個 compose 裡的服務,會在同一個虛擬網路上,彼此用「服務名」當主機名互連
  • 我們的 DB 服務叫 db,所以 app 容器要連的是 db:5432,不是 localhost:5432
  • 而且這裡的 5432容器內部的埠,跟你在 ports 對應到主機的那個埠無關。

所以差別只有一個設定:DB_HOST 要從 localhost 改成 db。我們不去改 .env(那是給本機 go run 用的),而是在 compose 裡針對 app 容器注入 DB_HOST=db

🔁 JS 對照 完全一樣的觀念:如果你把 Node app 和 Postgres 都用 compose 跑,Node 的 DB_HOST 也要設成服務名(db),而不是 localhost。這是 Docker 網路的通則,與語言無關。


步驟 4:整合版 docker-compose(app + db)

把第 1 章那份「只有 db」的 compose,擴充成「app + db」。用以下內容取代 docker-compose.yml

# docker-compose.yml
services:
  db:
    image: postgres:16-alpine
    container_name: nlt-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
    ports:
      - "${DB_PORT}:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
      interval: 5s
      timeout: 5s
      retries: 5

  app:
    build: .                      # 用本目錄的 Dockerfile 來 build
    container_name: nlt-api
    restart: unless-stopped
    environment:
      APP_PORT: "8080"
      DB_HOST: db                 # ★ 服務名,不是 localhost
      DB_PORT: "5432"             # ★ 容器內部埠,固定 5432
      DB_USER: ${DB_USER}
      DB_PASSWORD: ${DB_PASSWORD}
      DB_NAME: ${DB_NAME}
      DB_SSLMODE: disable
      JWT_SECRET: ${JWT_SECRET}
    ports:
      - "${APP_PORT:-8080}:8080"  # 主機:容器
    depends_on:
      db:
        condition: service_healthy  # ★ 等 db healthy 了才啟動 app

volumes:
  pgdata:

新增的 app 服務逐段說明:

  • build: .:不是用現成 image,而是用本目錄的 Dockerfile 自己 build。
  • environment::注入給 app 容器的環境變數。
    • DB_HOST: dbDB_PORT: "5432":步驟 3 講的容器間連線。
    • ${DB_USER} 等:compose 一樣會自動從專案根目錄的 .env 取值替換——所以 db 與 app 共用同一組帳密設定,不會對不上
    • app 容器裡沒有 .env(被 .dockerignore 排除了),所以 godotenv.Load() 會找不到檔、改用這些注入的環境變數——這正是第 1 章 config 設計「找不到 .env 不報錯、改用系統環境變數」的用意。
  • ports: "${APP_PORT:-8080}:8080":把容器的 8080 對應到主機(${APP_PORT:-8080} 是「取 .env 的 APP_PORT,沒有就用 8080」的預設值語法)。
  • depends_on: db: condition: service_healthy等 db 的 healthcheck 通過、真的可以接受連線了,才啟動 app。這很重要——因為我們的 app 一啟動就要連 DB、跑 migration,若 DB 還沒 ready 就會失敗。

這裡可以清楚看到兩種執行模式並存

  • 開發模式docker compose up -d db 只起 DB,app 用 go run ./cmd/api(讀 .envDB_HOST=localhost)。
  • 完整模式docker compose up --build 起 app + db(app 在容器,DB_HOST=db)。 同一份程式碼、同一份 .env,靠「環境變數注入」適應兩種情境,不必改任何 code。

步驟 5:完成後的專案結構

到這裡,整個專案長這樣(與第 1 章的藍圖一致,所有檔案都到位了):

no-logic-trade/
├── cmd/api/main.go                # 進入點 + setupRouter 組裝中心
├── internal/
│   ├── config/config.go
│   ├── database/
│   │   ├── database.go            # sqlx 連線
│   │   └── migrate.go             # 自寫 migrator
│   ├── model/model.go             # User/Account/Holding/Order/Transaction
│   ├── repository/                # user/account/holding/order/transaction
│   ├── service/                   # auth/account/quote/order/portfolio
│   ├── handler/                   # auth/account/quote/order/portfolio + 共用
│   └── middleware/auth.go         # JWT 中介層
├── migrations/                    # 001~005 .sql + embed.go
├── .env                           # 本機開發用(不進版控)
├── .env.example
├── .dockerignore                  # ★本章
├── Dockerfile                     # ★本章(multi-stage)
├── docker-compose.yml             # ★本章(app + db 整合)
├── go.mod
└── go.sum

✅ 如何執行 / 驗證這一章成功

一鍵啟動(重點驗證)

先確定 .env 存在(第 1 章建的)。如果之前有用 go run 跑著 app,先停掉它,然後:

docker compose up --build
  • --build:先依 Dockerfile build 出 app image,再啟動。
  • 第一次會編譯 Go、下載相依,需要一兩分鐘。

你會看到 db 先啟動並轉為 healthy,接著 app 啟動、跑 migration、開始服務。日誌裡 app(nlt-api)那段應出現:

nlt-api  | ✅ 資料庫連線成功
nlt-api  | ✅ migration 完成        (若是全新的 volume,會先看到套用 001~005)
nlt-api  | 🚀 server 啟動於 http://localhost:8080

想背景執行就加 -ddocker compose up --build -d,再用 docker compose logs -f app 看日誌。

驗證 1:服務在容器裡也活著

另開終端機:

curl http://localhost:8080/health
# {"status":"ok"}

驗證 2:跑一輪完整流程(全部打到容器裡的 app)

# 1) 註冊一個新使用者
curl -s -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"bob@example.com","password":"secret123"}' > /dev/null

# 2) 登入拿 token
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"bob@example.com","password":"secret123"}' | sed 's/.*"token":"//;s/".*//')

# 3) 入金 5000
curl -s -X POST http://localhost:8080/api/account/deposit \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"amount":5000}' | grep -o '"cash_balance":"[^"]*"'

# 4) 看報價
curl -s http://localhost:8080/api/quotes/TSLA

# 5) 市價買 3 股 TSLA
curl -s -X POST http://localhost:8080/api/orders \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"symbol":"TSLA","side":"buy","quantity":3}' | grep -o '"status":"[^"]*"'

# 6) 看投資組合
curl -s http://localhost:8080/api/portfolio -H "Authorization: Bearer $TOKEN"

每一步都該得到合理回應(入金後餘額、報價、"status":"filled"、投資組合含 TSLA 持股)——而且這次全部跑在容器裡的 app,不是本機 go run。🎉

驗證 3:資料持久化

docker compose down      # 停掉並移除容器(保留 pgdata)
docker compose up -d     # 再起來

重新查 bob 的資料仍在(因為資料存在具名 volume pgdata,容器重建不影響)。要全清重來才用 docker compose down -v

收工指令小抄

docker compose up --build -d   # build 並背景啟動全部
docker compose logs -f app     # 看 app 日誌
docker compose ps              # 看狀態
docker compose down            # 停止(保留資料)
docker compose down -v         # 停止並清空資料

完成驗證,代表你已經有一個可以一鍵啟動、可以交給任何人(只要有 Docker + 一份 .env)跑起來的完整 Go 後端


這一章你完成了什麼

  • 寫出 multi-stage Dockerfile:第一階段編譯、第二階段只放靜態執行檔,做出又小又安全(非 root、無原始碼、無工具鏈)的 image。
  • 理解 CGO_ENABLED=0 靜態編譯、Docker 層快取技巧,以及「embed migrations」讓容器化零煩惱的回報。
  • 搞懂容器間用**服務名(db)**連線、以及用 compose 的 environment 注入設定,讓同一份 code 同時支援「本機 go run」與「完整容器」兩種模式。
  • depends_on: condition: service_healthy 讓 app 等 DB 就緒再啟動。
  • 完成 docker compose up --build 一鍵啟動的完整專案。

🎓 全系列完成!回顧你做了什麼

從零到一,你用 Go 做出了一個麻雀雖小、五臟俱全的後端:

你學到的
0 Go 語法心智模型(package、型別、struct、error、interface、指標)
1 專案結構、.env 設定、docker-compose 起 DB、漸進式 Docker
2 sqlx 連線、自寫 migrator、embeddefer、交易基礎
3 三層架構、bcrypt、JWT、中介層、DBTX 介面、輸入驗證
4 金額用整數分、資金 ledger、原子加減、DTO
5 interface 解耦、map 並發安全(RWMutex)、goroutine
6 ⭐ 交易原子性、FOR UPDATE 行鎖、加權均價、錯誤分流
7 跨 service 組合資料、DTO 與 model 分離、未實現損益
8 動態 SQL、分頁、query string 驗證、nullable 的乾淨呈現
9 multi-stage build、容器網路、環境變數注入、一鍵啟動

想再進階?可以往這些方向加(選做)

  • 測試:Go 內建 go test,可對 service 寫單元測試、用 httptest 測 handler,或用 testcontainers 跑整合測試。
  • graceful shutdown:收到關閉訊號時,先把手上的請求處理完再退出(http.Server + signal.NotifyContext)。
  • 結構化日誌:用標準庫 log/slog 取代 log,輸出 JSON 日誌好查。
  • 更真實的交易:限價單、掛單簿、撮合引擎;或用 WebSocket 推即時報價給前端。
  • 安全強化:refresh token、限流(rate limit)、密碼強度規則、把 JWT_SECRET 換成夠長的隨機值。
  • 可觀測性:加上 /metrics(Prometheus)、request id、追蹤。

恭喜你從「第一次寫 Go」一路走到「能一鍵啟動的容器化後端」!🚀