在部署主機上跑一個 GitHub Actions 的 self-hosted runner,讓它主動去 GitHub 領部署工作,主機就不必對外開任何 port,連 SSH 都不用開給 CI。再把 commit SHA 寫進 image 和 /health 的回應,每次部署都對得上一個 commit,回滾只要填舊的 SHA。這篇是六篇連載的第一篇,先交代整個練習專案,再講部署架構本身。
這個系列在寫什麼
我用一個「夠小、但該有的都有」的專案,完整走過一次「寫程式 → 測試 → 自動部署 → 對外服務 → 安全保護」的流程。題目是一個待辦清單 API,但同一套 API 用 Node、.NET、PHP、Python、Go、Java 六種語言各寫一次,全部部署在同一台主機上,前端可以切換要打哪一個。
- 原始碼:https://github.com/murmur-wu/DevBuildSample
- Demo(待辦清單,可切換六個後端):https://devbuildsample-web.z-file.workers.dev/
- 授權:GPL-3.0-or-later
整個專案是和 Claude Code 協作完成的:我提需求、做決定、在各個服務的後台操作;程式、測試、文件與 PR 由 AI 撰寫,每個 PR 由我確認後合併。過程中踩了不少坑,這個系列把它們整理下來:
- 架構與 Pull-based CD(本篇):為什麼部署主機不開任何 port、image 怎麼從雲端 build 到主機上
- 部署主機與 Cloudflare Tunnel:同一個網址用路徑分流六個後端,以及「前綴不會被去掉」這個坑
- 六種語言、同一套 API:怎麼讓六個版本回一模一樣的結果,各框架要關掉哪些「貼心」功能
- CI/CD 細節與部署加速:GHCR 下載 15 分鐘的問題、digest 鎖定、Dependabot 的陷阱
- 前端:Cloudflare Workers 代轉:為什麼前端不直接打後端、Workers Builds 自動部署
- 公開與保護:開源前的檢查清單、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。
部署流程(每個版本都一樣)
- Build(雲端):GitHub 提供的 runner 執行
docker build,把 image 推到 registry,tag 用<版本>-<commit SHA>(例如go-3d37dfb…),另外打一個<版本>-latest方便手動操作。 - 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
- 檢查機密設定檔存在,而且有
- 回滾:在 GitHub Actions 手動執行該版的 CD,
image_tag填舊的 SHA,會跳過 build 直接部署舊 image。
/health 回傳版本號這件事很實用:部署完只要看一眼就知道線上跑的是哪個 commit。做法是 build 時用 --build-arg APP_VERSION=<SHA> 把 SHA 寫進 image。
主機設定
主機只需要三樣東西:Docker、GitHub runner、cloudflared。
1 | # 建立專用的部署使用者 |
代價要先講清楚:deploy 在 docker 群組裡,幾乎等於 root 權限。所以這台主機只拿來部署,不和其他用途共用。
服務只綁 127.0.0.1
每個版本的 compose 檔都長這樣(節錄):
1 | services: |
六個版本各有自己的 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,以及同一個網址分流六個後端時遇到的「路徑前綴」問題。
系列文章
- 本篇:架構與 Pull-based CD
- 部署主機與 Cloudflare Tunnel(9/29 刊出)
- 六種語言、同一套 API(9/30 刊出)
- CI/CD 細節與部署加速(10/1 刊出)
- 前端:Cloudflare Workers 代轉(10/2 刊出)
- 公開與保護:Cloudflare Access 排錯全紀錄(10/3 刊出)
完整程式碼、ADR 與專案的 CLAUDE.md 都在 murmur-wu/DevBuildSample。
留言