專案筆記

Pull-based CD:讓部署主機一個 port 都不開,還能追溯每次部署

2026-09-28 #GitHub Actions#CI/CD#Claude Code#架構設計#Docker

在部署主機上跑一個 GitHub Actions 的 self-hosted runner,讓它主動去 GitHub 領部署工作,主機就不必對外開任何 port,連 SSH 都不用開給 CI。再把 commit SHA 寫進 image 和 /health 的回應,每次部署都對得上一個 commit,回滾只要填舊的 SHA。這篇是六篇連載的第一篇,先交代整個練習專案,再講部署架構本身。

這個系列在寫什麼

我用一個「夠小、但該有的都有」的專案,完整走過一次「寫程式 → 測試 → 自動部署 → 對外服務 → 安全保護」的流程。題目是一個待辦清單 API,但同一套 API 用 Node、.NET、PHP、Python、Go、Java 六種語言各寫一次,全部部署在同一台主機上,前端可以切換要打哪一個。

整個專案是和 Claude Code 協作完成的:我提需求、做決定、在各個服務的後台操作;程式、測試、文件與 PR 由 AI 撰寫,每個 PR 由我確認後合併。過程中踩了不少坑,這個系列把它們整理下來:

  1. 架構與 Pull-based CD(本篇):為什麼部署主機不開任何 port、image 怎麼從雲端 build 到主機上
  2. 部署主機與 Cloudflare Tunnel:同一個網址用路徑分流六個後端,以及「前綴不會被去掉」這個坑
  3. 六種語言、同一套 API:怎麼讓六個版本回一模一樣的結果,各框架要關掉哪些「貼心」功能
  4. CI/CD 細節與部署加速:GHCR 下載 15 分鐘的問題、digest 鎖定、Dependabot 的陷阱
  5. 前端:Cloudflare Workers 代轉:為什麼前端不直接打後端、Workers Builds 自動部署
  6. 公開與保護:開源前的檢查清單、Cloudflare Access 保護後端,以及 secrets 放錯位置的排錯全紀錄

文中的變數

為了安全,文中實際的網域、帳號與金鑰都以變數表示,照著做時請換成你自己的:

變數 意義
<你的網域> 放在 Cloudflare 上的網域,例如 example.com
<後端網址> 後端對外的 hostname,例如 api-staging.example.com(不含 https://)
<前端網址> 前端 Worker 的網址,例如 https://xxx.workers.dev
<GITHUB_OWNER>/<REPO> GitHub repo
<DOCKERHUB_USER> Docker Hub 帳號
<部署主機> 跑服務的那台 Linux 主機
<CLIENT_ID> / <CLIENT_SECRET> Cloudflare Access service token(絕對不要貼到任何公開的地方)
<RUNNER_TOKEN> 註冊 GitHub self-hosted runner 時的一次性 token

目標

一開始只有一個很簡單的需求:一台 Ubuntu 主機要跑後端 API,並且希望:

  • 主機不對外開任何 port(連 SSH 都不開給 CI)
  • 每次部署都對應一個 commit,可以追溯、可以回滾
  • build 不要佔用主機資源

這套「零開 port」的主機架構,從安裝 Docker、註冊 runner 到設定 Tunnel 與防火牆的逐步過程,已經寫在自家 Ubuntu VM 的零開 Port CD。這個系列建立在同一套架構上,重點放在六個後端怎麼共用一台主機、部署怎麼加速,以及公開後怎麼保護後端。那篇的 image 還放在 GHCR,第 4 篇會講為什麼後來改用 Docker Hub。

整體架構

部署的路線:build 在雲端做,主機只負責拉 image、啟動、驗證。

flowchart LR
    P([push / 合併 PR]) --> B["GitHub 雲端 runner
docker build"] B -->|"push image
tag = 版本-commit SHA"| R[(Registry)] H["部署主機
self-hosted runner"] -.->|主動領工作| G[GitHub Actions] R -->|docker compose pull| H H --> V["up --wait
驗證 /health
smoke test"]

使用者的請求則走另一條路,每一層都在 Cloudflare 上,主機本身不接受任何對內連線:

flowchart LR
    U([使用者]) --> W["前端
Cloudflare Workers"] W --> A[Cloudflare Access] --> T[Cloudflare Tunnel] T --> H["部署主機
127.0.0.1:port"]

為什麼是 Pull-based

常見的做法是 CI 用 SSH 連進主機部署(push-based),但這代表主機要開 SSH 給外部、CI 要保存 SSH 金鑰。

Pull-based 則反過來:在主機上跑一個 GitHub Actions self-hosted runner,它只會「主動往外」連到 GitHub 領工作,不需要任何對內的 port。部署 job 指定 runs-on: [self-hosted, staging],就會交給這台主機執行。

Push-based(SSH) Pull-based(self-hosted runner)
主機對內 port 要開 SSH 不用
CI 要保存的機密 SSH 私鑰 無(runner 用自己的註冊憑證)
主機離線時 部署直接失敗 job 排隊等 runner 上線

如果部署目標是 Cloud Run 這類託管服務,就沒有「主機」可以開 port 或裝 runner,做法完全不同,可以參考GitHub Actions 自動部署到 GCP Cloud Run。

部署流程(每個版本都一樣)

  1. Build(雲端):GitHub 提供的 runner 執行 docker build,把 image 推到 registry,tag 用 <版本>-<commit SHA>(例如 go-3d37dfb…),另外打一個 <版本>-latest 方便手動操作。
  2. Deploy(主機):
    • 檢查機密設定檔存在,而且有 POSTGRES_PASSWORD
    • docker compose pull(上限 20 分鐘)
    • docker compose up -d --wait --wait-timeout 180(上限 5 分鐘)
    • curl /health:回傳的 version 必須等於這次部署的 commit SHA
    • 跑兩次 smoke test:不帶路徑前綴、帶路徑前綴(模擬經過 tunnel 的請求)
    • 失敗時印出容器 log
  3. 回滾:在 GitHub Actions 手動執行該版的 CD,image_tag 填舊的 SHA,會跳過 build 直接部署舊 image。

/health 回傳版本號這件事很實用:部署完只要看一眼就知道線上跑的是哪個 commit。做法是 build 時用 --build-arg APP_VERSION=<SHA> 把 SHA 寫進 image。

主機設定

主機只需要三樣東西:Docker、GitHub runner、cloudflared。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 建立專用的部署使用者
sudo useradd -m -s /bin/bash deploy
sudo usermod -aG docker deploy
sudo mkdir -p /srv/myapp && sudo chown deploy:deploy /srv/myapp

# 機敏設定(永不進 git)
sudo -u deploy sh -c 'umask 077; cat > /srv/myapp/.env' <<'EOF'
POSTGRES_PASSWORD=<隨機產生的密碼>
EOF

# 以 deploy 身分註冊 runner(token 在 repo 的 Settings → Actions → Runners 取得)
./config.sh --url https://github.com/<GITHUB_OWNER>/<REPO> --token <RUNNER_TOKEN> \
--name cd-vm --labels staging --unattended
sudo ./svc.sh install deploy && sudo ./svc.sh start

代價要先講清楚:deploy 在 docker 群組裡,幾乎等於 root 權限。所以這台主機只拿來部署,不和其他用途共用。

服務只綁 127.0.0.1

每個版本的 compose 檔都長這樣(節錄):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
services:
api:
image: docker.io/<DOCKERHUB_USER>/devbuildsample:${IMAGE_TAG:-go-latest}
env_file: /srv/myapp/.env
environment:
PATH_BASE: /go
ports: ["127.0.0.1:3004:8080"] # 只綁 localhost,對外交給 tunnel
depends_on:
db: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
db:
image: postgres:17@sha256:... # 以 digest 鎖定(第 4 篇會解釋)
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]

六個版本各有自己的 compose project 與 PostgreSQL 容器,部署、重建、清資料互不影響。

這一篇踩到的坑

  • docker compose up --wait 需要 healthcheck:api 和 db 都要定義,否則 --wait 沒有依據。
  • --wait 預設沒有上限:容器一直重啟時會無限等待,還會擋住後面排隊的部署。一律加 --wait-timeout,再給每個步驟設 timeout-minutes。
  • 兩個 .env 不要搞混:本機測試用的 deploy/.env 由腳本自動產生;CD 讀的是主機上的 /srv/myapp/.env。CD 曾經因為後者不存在而失敗,所以部署前加了一步檢查,錯誤訊息直接寫出該怎麼補。
  • 不要用 POSTGRES_PASSWORD_FILE 卻沒定義 compose secrets,postgres 會起不來。
  • 容器 log 要設上限:在 compose 用 x-logging 定義每個服務最多 3 個 10MB 檔,不然磁碟遲早被吃滿。

小結

Pull-based CD 讓主機完全不用對外開 port,部署紀錄也跟 commit 一一對應。但主機不開 port,外面的人要怎麼連進來?下一篇講 Cloudflare Tunnel,以及同一個網址分流六個後端時遇到的「路徑前綴」問題。

系列文章

  1. 本篇:架構與 Pull-based CD
  2. 部署主機與 Cloudflare Tunnel(9/29 刊出)
  3. 六種語言、同一套 API(9/30 刊出)
  4. CI/CD 細節與部署加速(10/1 刊出)
  5. 前端:Cloudflare Workers 代轉(10/2 刊出)
  6. 公開與保護:Cloudflare Access 排錯全紀錄(10/3 刊出)

完整程式碼、ADR 與專案的 CLAUDE.md 都在 murmur-wu/DevBuildSample。

Creative Commons 姓名標示 非商業性

本文採用 CC BY-NC 4.0 授權

歡迎轉載與引用,請標明出處(Pull-based CD:讓部署主機一個 port 都不開,還能追溯每次部署 — mur mur);禁止用於商業用途。

商業使用或合作提案,歡迎來信洽談:[email protected]

留言
分享

留言