Pull to refresh
tech Public kamal rails docker deploy zero-downtime kamal-proxy devops solid-queue

Kamal Deploy 全流程拆解:從本機 build 到零停機切換(nicklecheng 實例)

以一份真實的 kamal deploy log 為例,逐階段拆解 Kamal 的部署流程——本機多階段 build、透過 SSH 反向通道把映像從本機 registry 推到遠端、kamal-proxy 健康檢查後零停機切換 web 與 job(SolidQueue)容器、最後 prune 與 post-deploy hook。含時間瓶頸分析與完整時序圖。

| Ingested 2026-06-14 |

本頁以一份真實 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 識別;latest tag 要等切換成功才更新。


流程總覽圖

kamal-deploy-flow-infographic.jpg

從本機開發端、本機 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

  1. 環境檢查docker --version && docker buildx version,確認 buildx 可用。
  2. 啟動本機 registrydocker start kamal-docker-registry || docker run ... registry:3, 把一個 registry:3 容器跑在 127.0.0.1:5555,作為映像的中轉站。
  3. 取出乾淨原始碼 — Kamal 不直接 build 你的工作目錄,而是 clone 到暫存目錄後:
    • git clone --recurse-submodules
    • git reset --hard <SHA>(這個 SHA 就是這次部署的版本號)
    • git clean -fdxsubmodule update --init
    • git 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 installbootsnap precompileCOPY . .
rails assets:precompile 產出 Tailwind/Stimulus/Turbo 等指紋化資產)→
stage-2(建立非 root rails 使用者,從 build 階段 COPY bundle 與 /rails)。
- log 中大量 CACHED:只有 COPY . . 之後的層(asset 編譯)真正重建,build 很快(~13s)


階段二:傳輸到遠端(瓶頸所在)

  1. Setting up local registry port forwarding to 143.198.185.246 — Kamal 開一條 SSH 反向通道,讓遠端主機可以連回本機的 localhost:5555 registry。
  2. 遠端 pull 映像docker pull localhost:5555/nicklecheng:<SHA>,在遠端執行。 這一步是整個流程最慢的環節:兩次分別花了 143 秒與 162 秒,佔總時間約 60%。 原因是映像層要透過 SSH 通道從家用/辦公室上行頻寬傳到遠端。
  3. 驗證 labeldocker inspect ... | grep -x nicklecheng,確認映像帶有 service=nicklecheng 標籤,否則中止。

階段三:準備

  1. 取得 deploy lockAcquiring the deploy lock,避免兩個 deploy 同時動同一服務。
  2. 確保 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 並零停機切換

  1. 偵測 stale 容器 — 用 label filter 找出目前在跑的 web/job 容器名(供稍後停用)。
  2. 資產接力(避免切換空窗 404):
    • 建立暫存容器把 /rails/public/assets/. 複製到 host 的 extracted/web-<SHA>
    • cp -rnTvolumes/web-<SHA>,並新舊版本互相補齊 (cp -rnT 新→舊舊→新,-n 不覆蓋)。
    • 目的:切換瞬間舊容器仍可能服務舊頁面,需要舊指紋資產;新容器需要新資產。兩邊都備齊, 避免轉換期出現資產 404。
  3. 啟動新 web 容器:
    • Uploading .kamal/apps/nicklecheng/env/roles/web.env(密鑰類環境變數)。
    • docker run --detach --restart unless-stopped --name nicklecheng-web-<SHA> --network kamal ... 帶入大量 env(RAILS_ENV=productionAPP_BASE_URLDB_HOST=nicklecheng-dbWEB_CONCURRENCY=1RAILS_MAX_THREADS=5JOB_CONCURRENCY=2TRUSTED_IPS 等), 掛上 nicklecheng_storage(ActiveStorage)與該版本的 assets volume。
  4. 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)。
  5. 健康後停舊 webFirst web container is healthy ... docker stop 舊版本 web 容器。
  6. 清理舊資產目錄find ... ! -name web-<SHA> -exec rm -rf,只留當前版本。

階段六:啟動新 job(SolidQueue)

  1. 等 web 健康才 boot jobWaiting for the first healthy web container before booting job
  2. 啟動 job 容器 — 與 web 幾乎相同的 docker run,但 label role=job、結尾命令是 bin/jobs (SolidQueue worker),不掛 assets volume(背景工作不需要)。
  3. 就緒檢查readiness delay of 7 seconds 後做 health 檢查,Container is healthy!
  4. 停舊 jobdocker stop -t 60(給 60 秒讓正在跑的工作完成)。

階段七:收尾

  1. 更新 latest tagdocker tag localhost:5555/nicklecheng:<SHA> ...:latest, 成功才指向新版本(失敗的話 latest 不會動)。
  2. Prunedocker ps ... | tail -n +6 | docker rm(保留最近幾個)、docker image prune, 清掉舊容器與懸空映像。
  3. 釋放 lockReleasing the deploy lock
  4. 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,本機就完全退出傳輸路徑。


延伸閱讀

© 2025-2026 Nickle Cheng Built with Ruby Ruby on Rails