Docker 容器化部署踩坑紀錄:從開發到生產的實戰經驗
記錄我們在將多個 Node.js 和 Python 專案容器化部署到生產環境時踩過的坑,包含映像檔優化、多階段建置、健康檢查與日誌管理。
前言
Docker 讓「在我機器上可以跑」變成了「在任何地方都可以跑」。但從開發環境的 docker-compose up 到生產環境的穩定運作,中間有很多需要注意的細節。
這篇文章記錄了我們在多個專案中容器化部署的經驗——特別是那些文件上不會告訴你,只有踩過才知道的坑。
映像檔優化:從 1.2GB 到 89MB
問題
我們第一次 build 一個 Next.js 專案的 Docker image,結果大小是 1.2GB。推送到 Container Registry 要好幾分鐘,部署的冷啟動也很慢。
原因分析
原始的 Dockerfile:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
問題:
- 使用了完整的
node:20映像(包含一堆不需要的工具) - 把
node_modules(開發依賴)和原始碼都包進去了 - 沒有利用多階段建置
優化後的 Dockerfile
# Stage 1: 安裝依賴
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Stage 2: 建置
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: 最終映像
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# 只複製需要的檔案
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
配合 Next.js 的 output: 'standalone' 設定:
// next.config.ts
const nextConfig = {
output: 'standalone',
};
結果:1.2GB → 89MB(減少 93%)。
關鍵優化技巧
1. 使用 Alpine 基底映像
node:20-alpine 比 node:20 小 10 倍以上。大多數 Node.js 應用都不需要完整的 Linux 發行版。
2. 多階段建置
- Stage 1:只安裝生產依賴
- Stage 2:安裝所有依賴(包含 devDependencies)並建置
- Stage 3:只取需要的產物
3. 善用 .dockerignore
node_modules
.git
.next
*.md
.env*
避免不必要的檔案進入 build context,加快 build 速度。
4. 利用層快取
把不常變動的步驟放前面(如 COPY package.json),常變動的放後面(如 COPY . .)。這樣當只有程式碼變動時,npm install 的層可以直接使用快取。
Python 專案的容器化
Python 專案(如我們的爬蟲程式)有不同的考量:
FROM python:3.12-slim AS base
WORKDIR /app
# 安裝系統依賴(某些 Python 套件需要)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "-m", "app.main"]
踩坑:Playwright 的容器化
我們的爬蟲使用 Playwright 來處理需要 JavaScript 渲染的頁面。在 Docker 中跑 Playwright 需要安裝瀏覽器和它的系統依賴:
FROM python:3.12-slim
# Playwright 需要的系統依賴
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 \
libcups2 libdrm2 libxkbcommon0 libxcomposite1 \
libxdamage1 libxfixes3 libxrandr2 libgbm1 \
libpango-1.0-0 libcairo2 libasound2 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN playwright install chromium
COPY . .
CMD ["python", "-m", "app.main"]
第一次嘗試時,我們忘了安裝系統依賴,container 啟動後 Playwright 直接 crash 但錯誤訊息很不明確。花了兩個小時才定位到是缺少 shared libraries。
教訓:如果 Python 套件依賴 C library,在 Docker 中一定要測試,不要假設 slim/alpine 映像有包含。
健康檢查
容器啟動不代表應用就緒。特別是需要連接資料庫或其他服務的應用,啟動到真正能服務請求之間有一段時間差。
Docker 的 HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
應用端的健康檢查 endpoint
// app/api/health/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
try {
// 確認資料庫連線正常
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({
status: 'healthy',
timestamp: new Date().toISOString(),
});
} catch (error) {
return NextResponse.json(
{ status: 'unhealthy', error: 'Database connection failed' },
{ status: 503 }
);
}
}
為什麼健康檢查重要?
在使用 Cloud Run 或 Kubernetes 時,平台需要知道 container 是否真的準備好接收流量。沒有健康檢查的話:
- 新版本部署時,流量可能被導到還沒連上資料庫的 container
- 應用掛了(但 process 還活著),平台不會自動重啟
docker-compose 的生產使用注意事項
在開發環境中,docker-compose.yml 很好用。但要部署到生產時,有幾個需要調整的地方:
開發 vs 生產的設定分離
# docker-compose.yml (共用基礎)
services:
app:
build: .
environment:
- NODE_ENV=production
# docker-compose.override.yml (開發專用,自動載入)
services:
app:
build:
target: deps # 開發時用包含 devDependencies 的 stage
volumes:
- .:/app # 掛載原始碼,支援 hot reload
- /app/node_modules # 避免覆蓋 container 中的 node_modules
environment:
- NODE_ENV=development
ports:
- "3000:3000"
# docker-compose.prod.yml (生產專用)
services:
app:
restart: always
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
資料持久化
生產環境中,資料庫的資料不能存在 container 裡(container 重建就沒了):
services:
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
pgdata:
driver: local
secrets:
db_password:
file: ./secrets/db_password.txt
踩坑:Volume 權限問題
在 macOS 上開發時一切正常,部署到 Linux 伺服器後,container 內的應用無法寫入 volume。
原因:macOS 的 Docker Desktop 會自動處理檔案權限映射,但 Linux 上不會。Container 內的 process 通常以非 root 用戶執行,而 volume 的 owner 可能是 root。
解法:在 Dockerfile 中設定正確的使用者和權限:
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
RUN chown -R appuser:appgroup /app
USER appuser
日誌管理
問題:日誌寫到哪裡?
Container 最佳實踐是把日誌寫到 stdout/stderr,讓 Docker 的 logging driver 來處理。但很多框架預設會寫檔案。
# 不好:寫入檔案(container 重建就不見了)
logging.basicConfig(filename='app.log')
# 好:寫入 stdout
logging.basicConfig(
stream=sys.stdout,
format='%(asctime)s %(levelname)s %(message)s'
)
結構化日誌
在生產環境中,結構化(JSON 格式)的日誌比純文字更容易搜尋和分析:
import json
import logging
class JsonFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
'timestamp': self.formatTime(record),
'level': record.levelname,
'message': record.getMessage(),
'module': record.module,
})
安全性考量
不要在映像中包含機密
# 錯誤:把 .env 或 API key 寫進映像
COPY .env .
ENV API_KEY=sk-xxxxx
# 正確:在執行時注入
# docker run -e API_KEY=$API_KEY my-app
即使你後來 RUN rm .env,機密仍然存在於先前的層中,可以被 docker history 看到。
使用非 root 用戶
# 建立非 root 用戶
RUN addgroup --system app && adduser --system --ingroup app app
USER app
如果 container 被入侵,非 root 用戶可以限制損害範圍。
定期更新基底映像
基底映像可能包含安全漏洞。定期 rebuild 確保使用最新的安全修補:
# 檢查映像的漏洞
docker scout cves my-app:latest
常見問題排查清單
當 container 行為異常時,依序檢查:
- Container 有啟動嗎?
docker ps -a看狀態 - 啟動後立刻退出?
docker logs <container>看錯誤訊息 - 應用有在監聽正確的 port 嗎? 確認
EXPOSE和-p映射 - 環境變數有正確注入嗎?
docker exec <container> env - DNS/網路有通嗎?
docker exec <container> ping <service> - 記憶體夠嗎?
docker stats看即時資源使用
結語
容器化部署不是「寫一個 Dockerfile 就結束」的事情。從映像檔大小、安全性、日誌管理到健康檢查,每一個環節都值得花時間處理好。
投資在這些基礎設施上的時間,會在之後每一次部署、每一次 debugging 中回報給你。一個設計良好的容器化架構,能讓你在凌晨三點收到告警時,快速定位問題而不是對著黑盒子發呆。
關於作者

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。