Kamal Deploy 全流程拆解:從本機 build 到零停機切換(nicklecheng 實例)
以一份真實的 kamal deploy log 為例,逐階段拆解 Kamal 的部署流程——本機多階段 build、透過 SSH 反向通道把映像從本機 registry 推到遠端、kamal-proxy 健康檢查後零停機切換 web 與 job(SolidQueue)容器、最後 prune 與 post-deploy hook。含時間瓶頸分析與完整時序圖。
本頁以一份真實 kamal deploy log(nicklecheng Rails 應用)逐階段拆解 Kamal 做了哪些事。
環境輪廓:
| 角色 | 內容 |
|---|---|
| 部署端 | 本機 Mac(nickle@localhost,build 暫存在 /var/folders/...) |
| 目標主機 | 單台 droplet 143.198.185.246 |
| 應用 | Rails 8 + Ruby 3.4.2-slim、Tailwind v4、Stimulus/Turbo/ActionText(Trix)、SolidQueue |
| 網域 | nicklecheng.turbos.tw(TLS 由 kamal-proxy 終止) |
| 容器角色 | web(Puma)+ job(bin/jobs = SolidQueue) |
| 資料庫 | 獨立容器 nicklecheng-db(accessory,不在這份 deploy log 重建) |
| 版本號 | git commit SHA(例:3f66638...、ccde54d...) |
關鍵設計:映像在本機 build,透過 SSH 反向通道送到遠端,不依賴 Docker Hub 等外部 registry。
版本以 git SHA 識別;latesttag 要等切換成功才更新。
流程總覽圖

從本機開發端、本機 Registry、遠端主機、Kamal orchestration(kamal-proxy + kamal network)、
到 web/job 容器的新舊版本切換與清理收尾;右側為各階段耗時(build ~13–14s、remote pull ~143–162s、
proxy deploy ~11s、總計 ~241–261s)。
全流程時序圖
sequenceDiagram
autonumber
participant Dev as 本機 Mac<br/>(kamal CLI)
participant Reg as 本機 registry<br/>localhost:5555
participant Srv as 遠端主機<br/>143.198.185.246
participant Proxy as kamal-proxy<br/>(80/443, TLS)
Note over Dev: 階段一 Build & Push(本機)
Dev->>Dev: docker/buildx 版本檢查
Dev->>Reg: 啟動本機 registry(registry:3)
Dev->>Dev: git clone→reset --hard SHA→clean→submodule
Dev->>Reg: buildx build --platform linux/amd64<br/>多階段 Dockerfile + assets:precompile
Reg-->>Reg: push 映像(tag=SHA + latest)
Note over Dev,Srv: 階段二 傳輸(SSH 反向通道)
Dev->>Srv: 建立 local registry port forwarding
Srv->>Reg: docker pull localhost:5555/...:SHA ⏳最慢
Srv->>Srv: 驗證 image 的 service label
Note over Srv: 階段三 準備
Srv->>Srv: 取得 deploy lock
Srv->>Proxy: 確保 kamal-proxy 在跑(network kamal)
Note over Srv: 階段四~五 啟動新 web,零停機切換
Srv->>Srv: 偵測 stale 容器
Srv->>Srv: 抽取 assets→host volume(新舊互補 cp)
Srv->>Srv: docker run 新 web(version=SHA)
Srv->>Proxy: kamal-proxy deploy → health check /up
Proxy-->>Srv: 健康通過,切流量到新容器
Srv->>Srv: stop 舊 web、清舊 assets
Note over Srv: 階段六 啟動新 job
Srv->>Srv: docker run 新 job(bin/jobs)
Srv->>Srv: readiness delay 7s → health check
Srv->>Srv: stop 舊 job
Note over Srv: 階段七 收尾
Srv->>Srv: tag SHA → latest
Srv->>Srv: prune 舊容器/映像
Srv->>Srv: 釋放 lock
Dev->>Dev: 執行 .kamal/hooks/post-deploy
階段一:本機 Build & Push
- 環境檢查 —
docker --version && docker buildx version,確認 buildx 可用。 - 啟動本機 registry —
docker start kamal-docker-registry || docker run ... registry:3, 把一個registry:3容器跑在127.0.0.1:5555,作為映像的中轉站。 - 取出乾淨原始碼 — Kamal 不直接 build 你的工作目錄,而是 clone 到暫存目錄後:
git clone --recurse-submodulesgit reset --hard <SHA>(這個 SHA 就是這次部署的版本號)git clean -fdx、submodule update --initgit status --porcelain/rev-parse HEAD確認狀態與版本
這保證部署的是 commit 過的乾淨狀態,未提交的本機改動不會混進映像。
4. 多階段 build 並直接推進 registry:
docker buildx build --output=type=registry --platform linux/amd64 \
--builder kamal-local-registry-docker-container \
-t localhost:5555/nicklecheng:<SHA> -t localhost:5555/nicklecheng:latest \
--label service="nicklecheng" --file Dockerfile .
---platform linux/amd64:在 Mac(可能是 arm64)上交叉編譯成伺服器的 amd64。
- Dockerfile 為標準 Rails 多階段:base(裝 curl/libvips/postgresql-client 等 runtime 套件)→
build(裝 build-essential、bundle install、bootsnap precompile、COPY . .、
rails assets:precompile產出 Tailwind/Stimulus/Turbo 等指紋化資產)→
stage-2(建立非 rootrails使用者,從 build 階段 COPY bundle 與 /rails)。
- log 中大量CACHED:只有COPY . .之後的層(asset 編譯)真正重建,build 很快(~13s)。
階段二:傳輸到遠端(瓶頸所在)
Setting up local registry port forwarding to 143.198.185.246— Kamal 開一條 SSH 反向通道,讓遠端主機可以連回本機的localhost:5555registry。- 遠端 pull 映像 —
docker pull localhost:5555/nicklecheng:<SHA>,在遠端執行。 這一步是整個流程最慢的環節:兩次分別花了 143 秒與 162 秒,佔總時間約 60%。 原因是映像層要透過 SSH 通道從家用/辦公室上行頻寬傳到遠端。 - 驗證 label —
docker inspect ... | grep -x nicklecheng,確認映像帶有service=nicklecheng標籤,否則中止。
階段三:準備
- 取得 deploy lock —
Acquiring the deploy lock,避免兩個 deploy 同時動同一服務。 - 確保 kamal-proxy 在跑:
docker network create kamal— 所有容器掛在同一個 docker 網路kamal,彼此用容器名互連 (例如 web 連 DB 是靠DB_HOST=nicklecheng-db)。- 啟動
basecamp/kamal-proxy:v0.9.2,--publish 80:80 --publish 443:443,掛上設定 volume。 kamal-proxy 是 Kamal 2 的反向代理,負責 TLS 終止(Let's Encrypt)+ 流量切換 + 健康檢查。
階段四~五:啟動新 web 並零停機切換
- 偵測 stale 容器 — 用 label filter 找出目前在跑的 web/job 容器名(供稍後停用)。
- 資產接力(避免切換空窗 404):
- 建立暫存容器把
/rails/public/assets/.複製到 host 的extracted/web-<SHA>。 - 再
cp -rnT到volumes/web-<SHA>,並新舊版本互相補齊 (cp -rnT 新→舊與舊→新,-n不覆蓋)。 - 目的:切換瞬間舊容器仍可能服務舊頁面,需要舊指紋資產;新容器需要新資產。兩邊都備齊, 避免轉換期出現資產 404。
- 建立暫存容器把
- 啟動新 web 容器:
Uploading .kamal/apps/nicklecheng/env/roles/web.env(密鑰類環境變數)。docker run --detach --restart unless-stopped --name nicklecheng-web-<SHA> --network kamal ...帶入大量 env(RAILS_ENV=production、APP_BASE_URL、DB_HOST=nicklecheng-db、WEB_CONCURRENCY=1、RAILS_MAX_THREADS=5、JOB_CONCURRENCY=2、TRUSTED_IPS等), 掛上nicklecheng_storage(ActiveStorage)與該版本的 assets volume。
- kamal-proxy 切換 + 健康檢查:
docker exec kamal-proxy kamal-proxy deploy nicklecheng-web \ --target="<容器IP>:80" --host="nicklecheng.turbos.tw" --tls \ --deploy-timeout=120s --drain-timeout=60s \ --health-check-interval=10s --health-check-timeout=120s --health-check-path="/up"- 對新容器打
/up(Rails 內建健康檢查端點),通過後才把nicklecheng.turbos.tw的流量切過去。 --buffer-requests --buffer-responses、記錄部分 request header。--drain-timeout=60s:切走後給舊容器 60 秒把進行中的請求處理完(graceful drain)。
- 對新容器打
- 健康後停舊 web —
First web container is healthy ... docker stop舊版本 web 容器。 - 清理舊資產目錄 —
find ... ! -name web-<SHA> -exec rm -rf,只留當前版本。
階段六:啟動新 job(SolidQueue)
- 等 web 健康才 boot job —
Waiting for the first healthy web container before booting job。 - 啟動 job 容器 — 與 web 幾乎相同的
docker run,但 labelrole=job、結尾命令是bin/jobs(SolidQueue worker),不掛 assets volume(背景工作不需要)。 - 就緒檢查 —
readiness delay of 7 seconds後做 health 檢查,Container is healthy!。 - 停舊 job —
docker stop -t 60(給 60 秒讓正在跑的工作完成)。
階段七:收尾
- 更新 latest tag —
docker tag localhost:5555/nicklecheng:<SHA> ...:latest, 成功才指向新版本(失敗的話 latest 不會動)。 - Prune —
docker ps ... | tail -n +6 | docker rm(保留最近幾個)、docker image prune, 清掉舊容器與懸空映像。 - 釋放 lock —
Releasing the deploy lock。 - post-deploy hook —
.kamal/hooks/post-deploy(本機端,約 11s,可用來發通知、清 CDN 等)。
兩次部署各約 241 秒 / 261 秒,其中遠端 docker pull 就佔 140~160 秒。
重點觀察與優化方向
- 單機、無外部 registry:靠 SSH 反向通道直傳,架構單純、無需 registry 帳號; 代價是 pull 受限於上行頻寬,是最大瓶頸。若要加速,可改用離主機近的 registry (如同 VPC 的 registry 或 GHCR),或在主機端 build。
- 版本即 git SHA:可追溯、可
kamal rollback到上一個 SHA;latest只在成功後更新, 確保latest永遠指向健康版本。 - 零停機的兩個關鍵:(a) kamal-proxy 對
/up健康檢查通過才切流量; (b) 資產新舊互補,避免切換空窗資產 404。 - web 先於 job:job 等 web 健康才啟動,確保 DB schema / 程式碼一致後才跑背景工作。
- build 很快、傳輸很慢:Dockerfile 分層得當(依賴層快取),真正耗時在跨網路傳輸, 優化重心應放在「映像怎麼到主機」而非「build 速度」。
傳輸瓶頸的優化建議(依效益排序)
整個流程約 60% 的時間(140~160s)卡在遠端 docker pull —— 映像層要透過 SSH 反向通道,
從本機(家用/辦公室上行頻寬)傳到 droplet。問題的本質是「映像走錯了路」:
明明 droplet 在 DigitalOcean 機房,卻繞回本機的慢速上行抓檔。以下依效益由高到低排列。
1. 改用離 droplet 很近的 registry(最高效益、改動最小)
把 Kamal 的 registry: 從「本機 + SSH 通道」改成與 droplet 同機房/同區域的 registry,
讓 pull 走機房內部網路而非你的上行頻寬:
- DigitalOcean Container Registry(DOCR)同區域 是最自然的選擇 —— droplet 在 DO, pull DOCR 走的是 DO 內網,速度可從 140~160s 直接掉到內網等級(數秒~十數秒)。
- 次選 GHCR / Docker Hub:雖非同機房,但有 CDN、頻寬遠優於家用上行。
- 改法:
deploy.yml設定registry:指向 DOCR(server / username / password 用環境變數), 移除本機registry:3+ SSH port forwarding 那套。本機改成 build 完 push 到 DOCR, droplet 再從 DOCR pull。
# deploy.yml(示意)
registry:
server: registry.digitalocean.com
username:
- DOCR_USERNAME
password:
- DOCR_PASSWORD
這一招通常立刻消滅最大瓶頸,且不動 build 流程,是 CP 值最高的一步。
2. 把 build 搬到 CI(最穩健的長期解)
讓 GitHub Actions(或 GitLab CI)在雲端 build,push 到 DOCR/GHCR,droplet 再 pull:
- 徹底移除「本機上行頻寬」這個變數 —— CI runner 到 registry 都是機房等級頻寬。
- 部署變成「push code → CI build & push image → 觸發 kamal deploy(只做 pull + 切換)」。
- 額外好處:build 環境一致、可快取、可平行,本機關機也能部署。
3. 伺服器端 / 遠端 builder build
用 Kamal 的 builder: remote,把 build 放到遠端 builder 主機:
- 本機只上傳很小的 git context,映像層在遠端 build,省掉「把整包映像從本機傳出去」。
- 代價:build 會吃主機資源。正式環境建議用獨立 builder host,不要直接在 production droplet 上 build,以免 build 尖峰影響線上服務。
4. 縮小要傳的 delta + registry 層快取
無論用哪種 registry,都可進一步讓「每次只移動有變動的層」:
- buildx 開
--cache-to type=registry/--cache-from type=registry,把層快取放在 registry, 下次 build 直接重用,未變動的 base / gem 層不必重傳。 - 維持 Dockerfile 分層穩定(依賴層在前、
COPY . .在後),讓 gem/npm 層長期CACHED, 真正要傳的只剩 asset 那幾層。
小結
| 方案 | 效益 | 改動成本 | 備註 |
|---|---|---|---|
| DOCR 同區域 registry | 最高(瓶頸直接消失) | 低 | 首選,只動 registry: 設定 |
| build 搬到 CI | 高(長期最穩) | 中 | 已用 GitHub 的話順勢做 |
| 遠端 builder build | 中 | 中 | 需獨立 builder host |
| registry 層快取 | 中(疊加增益) | 低 | 與上述任一搭配 |
最推薦:先把 registry 換成 DOCR 同區域(立即把 140~160s 降到內網速度);
若程式碼已在 GitHub,再把 build 移到 Actions,本機就完全退出傳輸路徑。