Docker Compose 入門筆記:用 moon-bbq-backend 當練習對象
練習把 moon-bbq-backend 拆成微服務之前,先搞懂 Docker Compose 在幹嘛——services、build、環境變數、depends_on/healthcheck、volume,一路寫到加入第三個服務怎麼驗證,順便修了一個把正式站密鑰烤進 image 裡的地雷。
情境:練習把 moon-bbq-backend 拆成微服務之前(判斷標準見〈微服務到底是什麼〉),先把「本地跑多個服務」這件事用 Docker Compose 搞定,順便搞懂 Compose 到底在幹嘛。
一、Docker 跟 Docker Compose 差在哪
- Docker(單一容器):
docker build+docker run,一次處理一個容器。如果你的系統只有一個服務(例如一支 API),這樣就夠了。 - Docker Compose(多容器編排):當你的系統需要「好幾個容器互相溝通」才能跑起來(API + Redis + 之後的 stats-service…),手動一個一個
docker run會很痛苦——要自己建網路讓容器互通、自己記啟動順序、自己接 volume。Compose 就是用一個 YAML 檔把這些「容器們該怎麼組在一起」的規則寫下來,一個指令全部起來。
一句話:Docker 管一個容器,Compose 管一群容器之間的關係。
二、這次寫的 docker-compose.yml 逐段解釋
最終版本長這樣(比一開始多了 stats-service 這個第三個容器):
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
backend:
build: .
ports:
- "8090:8090"
environment:
REDIS_URL: redis://redis:6379
PORT: "8090"
GIN_MODE: debug
depends_on:
redis:
condition: service_healthy
stats-service:
build:
context: .
dockerfile: Dockerfile.stats
ports:
- "8091:8091"
environment:
REDIS_URL: redis://redis:6379
PORT: "8091"
GIN_MODE: debug
depends_on:
redis:
condition: service_healthy
volumes:
redis-data:
services
Compose 檔案的核心,每一個 key(這裡是 redis、backend)代表一個要跑起來的容器。
image vs build
redis用image: redis:7-alpine——直接拉現成的官方映像檔,不用自己寫 Dockerfile。backend用build: .——代表「不是拉現成映像檔,是用當前目錄下的Dockerfile現場建一個」。這樣你改程式碼、重新docker compose up --build,就會用最新的程式碼重建映像檔。stats-service用的是展開寫法build: { context: ., dockerfile: Dockerfile.stats }——context是「拿哪個目錄當建置的素材來源」(這裡跟backend一樣都是 repo 根目錄,因為兩個服務其實是同一份原始碼),dockerfile才是差異點:預設 Compose 會找Dockerfile這個檔名,這裡明講要用Dockerfile.stats。同一份原始碼、同一個 context,單靠指定不同 Dockerfile 就能建出兩個內容不同的 image——這正是「一個 repo 裡放多個服務」在 Docker 世界的標準做法。
ports
"8090:8090" 格式是 主機port:容器port。容器裡面的服務其實聽在容器自己的網路空間裡,外面(你的 Mac)本來連不到,這行是把容器內的 8090 port「打通」到你 Mac 的 8090 port,所以你才能用 curl localhost:8090 連到它。
environment
容器啟動時要吃的環境變數。這裡最關鍵的是 REDIS_URL: redis://redis:6379——注意 host 名稱是 redis,不是 localhost。這是 Compose 幫你做的事:同一個 docker-compose.yml 裡的服務,可以直接用服務名稱當 hostname 互相連線,因為 Compose 會自動幫你建一個內部網路,把每個 service 名稱註冊成該容器的 DNS 名稱。所以 backend 容器裡想連 redis 容器,不用管 IP,直接打 redis 這個名字就行。
depends_on + healthcheck
depends_on: redis: condition: service_healthy 的意思是:「backend 這個容器,要等 redis 這個容器『健康』了才啟動」。如果只寫 depends_on: [redis](沒有 condition),Compose 只保證 redis 容器啟動了,不保證它準備好接受連線——這兩件事對 Redis 這種啟動很快的服務差異不大,但養成用 healthcheck 的習慣是對的,尤其之後接資料庫類服務常常會踩到「容器起了但服務還沒 ready」的坑。
healthcheck 本身是定義「怎麼樣才算健康」:每 5 秒跑一次 redis-cli ping,連續失敗 5 次才判定不健康。
volumes(最下面的 top-level 那個)
volumes:
redis-data:
宣告一個叫 redis-data 的具名 volume,實際掛載在 redis service 底下的 volumes: - redis-data:/data。作用是讓 Redis 存的資料不會隨著容器刪除就消失——容器本身是「用完即丟」的,但 volume 是獨立於容器生命週期之外、由 Docker 自己管理的儲存空間。這次練習其實不需要資料留著(本來就是拿假資料在玩),但這是正確的預設習慣:凡是「資料庫類」的容器都該掛 volume,不然重開一次容器資料全沒。
三、這次順便修的一個小地雷:.dockerignore
原本 moon-bbq-backend 沒有 .dockerignore,而 Dockerfile 裡有一行 COPY . .——這代表建 image 的時候,會把整個目錄(包含 .env)都複製進 image 裡。.env 裡放的是正式站在用的 Upstash Redis 連線字串(帳密都在裡面),等於每次 build image,這組正式站的機密資訊就被烤進 image 的某一層裡,就算後來檔案被覆蓋,舊的 image layer 只要沒被清掉,機密還是挖得出來。
修法很單純,加一個 .dockerignore(語法跟 .gitignore 一樣):
.env
.env.local
.git
.gitignore
*.log
dump.rdb
bin
這樣 COPY . . 的時候 Docker 會自動跳過這些檔案,image 裡就不會有正式站的密鑰。這個問題其實跟這次練微服務沒有直接關係,是寫 compose 檔案、重新盯著 Dockerfile 看的時候順便發現的——這也是為什麼值得手動走一次容器化流程,而不是只用別人寫好的範本:很多這種小地雷只有自己動手組的時候才會浮現。
四、為什麼刻意不接正式站的 Redis
moon-bbq-backend 的 .env 裡的 REDIS_URL 指向正式站在用的 Upstash Redis(跟其他專案共用一個 instance,靠 moon-bbq: 這個 key prefix 分隔命名空間)。如果本地測試時不小心接到這個正式的 Redis,座位資料、bot 排程都會直接寫進真實使用者看得到的狀態。
docker-compose.yml 裡刻意用 redis: image: redis:7-alpine 另外起一個全新的空 Redis 容器,backend 的 REDIS_URL 也明確指向這個本地容器(redis://redis:6379),不是讀 .env。這樣不管在本地怎麼測、怎麼炸,都不會碰到正式站的任何一筆資料——這是拿正在營運的專案來練習時,最基本也最容易忽略的安全習慣。
五、常用指令
docker compose up --build -d # 建 image + 背景啟動所有服務
docker compose logs backend # 看某個服務的 log
docker compose exec redis redis-cli keys '*' # 進 redis 容器跑指令,檢查資料
docker compose down # 停掉並刪除容器、網路(volume 預設保留)
docker compose down -v # 連 volume 也一起刪(資料清空)
六、加了第三個服務之後,本地怎麼驗證
stats-service 用 Dockerfile.stats 加進 docker-compose.yml 之後,docker compose up --build -d 一次把三個容器都建好、啟動。驗證「事件真的有從 backend 傳到 stats-service」的方法很直接:模擬一個訪客對 backend 建立 WS 連線(隨便用什麼工具送一個 WebSocket handshake 都行),然後打 curl localhost:8091/api/v1/bbq/stats,確認數字真的從 0 變成 1。這一步就是本地 Compose 環境存在的意義——先在自己電腦上把「新服務真的有收到事件」這件事確認過,才有底氣把改動推上正式站,而不是部署上去才發現兩個服務的頻道名稱對不上、事件根本沒傳到。
七、跟正式站部署的關係
這份 docker-compose.yml 從頭到尾只活在本機——Render 部署 backend、stats-service 這兩個服務時完全不會讀它,是各自獨立看 Dockerfile / Dockerfile.stats 建置、各自獨立部署、各自有自己的網址。Compose 負責的是「開發時怎麼有效率地同時跑多個服務、互相連線測試」,跟「正式站怎麼跑」是兩層完全不同的問題,細節見〈用 moon-bbq-backend 練習拆微服務〉那篇筆記的 Phase 1 實作紀錄。