第 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.sum再go mod download,最後才COPY . .:這是 Docker 層快取的經典技巧。相依清單沒變時,下載套件那層直接用快取,只有改 code 才重編——build 快很多。 CGO_ENABLED=0:產生純靜態執行檔(不依賴系統的 C 函式庫)。這樣才能丟進 alpine 甚至空白的scratchimage 也能跑。我們的相依(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.json→npm ci→COPY . .,最終 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: db、DB_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(讀.env,DB_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
想背景執行就加
-d:docker 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、embed、defer、交易基礎 |
| 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」一路走到「能一鍵啟動的容器化後端」!🚀