Cloudflare Tunnel 讓主機不開 80/443 也能對外服務,而且可以用路徑把同一個網址分給六個後端。但 cloudflared 只負責「送到哪裡」,不會去掉路徑前綴:送進 .NET 的請求仍是 /dotnet/items。結果六個後端都得自己處理前綴,而且帶不帶前綴都要能動,六種框架的做法差很多。這是連載的第二篇,接續上一篇的 Pull-based CD 架構。
Cloudflare Tunnel 是什麼
主機上跑一個 cloudflared,它主動往外連到 Cloudflare,建立一條通道。外面的人連 https://<後端網址>,Cloudflare 再透過這條通道把請求送進主機。好處:
- 主機不用開 80/443,也不用固定 IP
- HTTPS 憑證由 Cloudflare 處理
- 可以搭配防火牆把所有對內連線擋掉
這和上一篇的 self-hosted runner 是同一個思路:連線都由主機往外發起,主機本身不接受任何對內連線。
用路徑分流
六個後端共用同一個網址,用路徑前綴區分:
| 對外路徑 | 轉到 | 後端 |
|---|---|---|
https://<後端網址>/node/... |
127.0.0.1:3000 |
Node |
https://<後端網址>/dotnet/... |
127.0.0.1:3001 |
.NET |
https://<後端網址>/php/... |
127.0.0.1:3002 |
PHP |
https://<後端網址>/py/... |
127.0.0.1:3003 |
Python |
https://<後端網址>/go/... |
127.0.0.1:3004 |
Go |
https://<後端網址>/java/... |
127.0.0.1:3005 |
Java |
| 其他路徑 | 404 |
另一個選擇是每個後端一個子網域,程式不用改、隔離也更好。這次想練習路徑分流,所以選了共用網址,代價就是下面的前綴問題。
我寫了一支腳本把設定自動化:
1 | ./scripts/vm/setup-tunnel.sh \ |
腳本依序:安裝 cloudflared → tunnel login(用瀏覽器授權網域)→ 建立 tunnel → 把憑證複製到 /etc/cloudflared/(root、600)→ 寫入 config.yml → 建 DNS CNAME → 安裝 systemd 服務 → 驗證每條路由的 /health。可以重複執行。
產生的 config.yml 大致如下:
1 | tunnel: <tunnel-id> |
path 用 ^/node(/.*)?$,才不會誤中 /nodex 這種路徑。
最大的坑:cloudflared 不會去掉前綴
請求 https://<後端網址>/dotnet/items 送到 .NET 時,路徑還是 /dotnet/items,不是 /items。所以每個後端都要自己處理前綴。做法是在 compose 設環境變數 PATH_BASE=/dotnet,程式啟動時讀它。
而且沒帶前綴的請求也要照常處理:本機測試、容器 healthcheck、CD 的驗證都是直接打 127.0.0.1:<port>/health,不會帶前綴。
還有一個容易漏掉的地方:POST /items 成功時回 201 加上 Location: /items/42,經過 tunnel 時要變成 Location: /dotnet/items/42,不然瀏覽器會跟到錯的路徑。smoke test 會檢查這一點。
各語言的做法差很多,這裡先列結論,下一篇再細講各框架的脾氣:
| 語言 | 做法 | 坑 |
|---|---|---|
| Node | 自己比對路徑前綴 | 無 |
| .NET | app.UsePathBase(pathBase) |
後面必須明確呼叫 app.UseRouting(),否則 Minimal API 比對的是還帶前綴的路徑,全部 404 |
| PHP | 自己在 index.php 去掉前綴 |
無 |
| Python | 設定 ASGI 的 root_path |
path 要保留完整路徑,只設 root_path,Starlette 比對時會自己去掉 |
| Go | 自己比對路徑前綴 | 無 |
| Java | 用 filter 把前綴包成 context path | 不能用 server.servlet.context-path,設了之後沒帶前綴的請求會 404 |
有趣的是,「無」坑的都是沒用框架、自己比對字串的版本。框架越想幫你處理路徑,越需要知道它在背後做了什麼。
防火牆與維運
tunnel 確認可以用了,才開防火牆:
1 | sudo ./scripts/vm/setup-firewall.sh |
它會設定 ufw default deny incoming,並在 ufw enable 之前先 ufw allow OpenSSH。如果先啟用防火牆、之後才放行 SSH,執行當下就可能把自己的 SSH 連線鎖在外面。腳本也提供 --no-ssh 選項連 SSH 都關掉,但偵測到目前有 SSH 連線時會直接中止。runner 和 cloudflared 都只用對外連線,不受防火牆影響。
另外一支維運腳本負責三件事:每週清理 7 天前未使用的 Docker image(不動 volume,資料庫資料安全)、建立 swap、開啟 Ubuntu 自動安全更新。
這一篇踩到的坑
- 每次都要列出「全部」路由:腳本依參數整份重寫 config,只列一個會把其他路由刪掉。
- 腳本是在主機上的 clone 執行的,不會隨 CD 更新:曾經在 PR 合併前就用舊版腳本跑新的參數格式,config 沒改成功,
/dotnet/...被送到 Node 而回 404。之後規定:更新 tunnel 前先在主機上git pull,然後用sudo cat /etc/cloudflared/config.yml確認每條path:規則都在。 - 改路由時,先部署程式、再更新 tunnel:反過來的話,tunnel 已經把新路徑導向還沒部署的服務,對外會暫時 404/502。
- cloudflared 的憑證位置:
tunnel login以一般使用者執行,憑證在~/.cloudflared/;但 systemd 服務以 root 執行讀不到,所以要複製到/etc/cloudflared/。 - Cloudflare 免費憑證只涵蓋一層子網域:
a.<你的網域>可以,api.dotnet.<你的網域>不行。如果改走子網域分流,命名要避開多層。 - Dashboard 裡的舊路由:Tunnel 列表曾出現一條「非作用中」的舊 hostname,是之前試驗留下的,確認沒在用之後刪除即可。
小結
Tunnel 讓主機不開 port 也能對外服務,但路徑分流的代價是每個後端都要處理前綴。下一篇講怎麼讓六種語言寫出的 API 行為完全一致。
系列文章
- 架構與 Pull-based CD
- 本篇:部署主機與 Cloudflare Tunnel
- 六種語言、同一套 API(9/30 刊出)
- CI/CD 細節與部署加速(10/1 刊出)
- 前端:Cloudflare Workers 代轉(10/2 刊出)
- 公開與保護:Cloudflare Access 排錯全紀錄(10/3 刊出)
完整程式碼、ADR 與專案的 CLAUDE.md 都在 murmur-wu/DevBuildSample。
留言