把一支只能在自己電腦上跑的程式,搬上線
把一支只能在本機執行、需要長時間連線的背景服務搬上線的過程:Cloudflare Tunnel 踩過的 QUIC 坑、launchd 常駐化,以及 Vercel 部署裡幾個容易忽略的環境變數與安全細節。
背景
手上有一支背景服務,架構一直是「前後端分開部署」:前端(Next.js)理論上可以直接丟 Vercel,但後端(FastAPI)不行——它不是無狀態的 serverless function,而是一支長時間 執行的程式:常駐開著一條 WebSocket 連線,持續把外部即時資料流聚合、累積在記憶體裡。 Vercel 的 serverless 模型(每個 request 一個短命 function)跟這種「需要持續連線、 持續累積狀態」的工作模式完全不相容。
所以後端只能留在自己的電腦上跑。但這樣一來,Vercel 上的前端(HTTPS)要怎麼連到家裡 電腦上一個連對外網址都沒有的 Python 程式?這篇記錄的就是打通這條路的過程,中間踩了 三個坑,每個都不是「查文件就能預期到」的那種。
第一步:Cloudflare Tunnel 把 localhost 變成一個真的網域
選 Cloudflare Tunnel 而不是自己開 port forwarding,主要是因為:
- 不用去路由器開防火牆洞、不用擔心動態 IP
- 免費,而且自帶 HTTPS 憑證(Let's Encrypt 那一套完全不用自己管)
- 只要
cloudflared這支 daemon 主動對外連到 Cloudflare,不需要任何 inbound 規則——對家用 網路來說幾乎是最省事的做法
流程本身很簡單:
brew install cloudflared
cloudflared tunnel login # 跳瀏覽器登入 Cloudflare 帳號、授權這台機器
cloudflared tunnel create my-service
寫一個 ~/.cloudflared/config.yml:
tunnel: <tunnel-id>
credentials-file: ~/.cloudflared/<tunnel-id>.json
ingress:
- hostname: api.example.com
service: http://localhost:8000
- service: http_status:404
然後:
cloudflared tunnel route dns my-service api.example.com
這行會自動幫你在 Cloudflare 的 DNS 裡建一筆 CNAME,指到這個 tunnel——前提是這個網域本來就 掛在 Cloudflare 上(nameservers 指過去),不然要先處理這步。
踩坑一:預設協定 QUIC 在某些網路環境直接連不上
裝完、設定完、跑起來,結果 cloudflared tunnel info 顯示:
Your tunnel does not have any active connection.
看 log,前面有一段「連線前置檢查」:
UDP Connectivity region1.v2.argotunnel.com FAIL QUIC connection failed
UDP Connectivity region2.v2.argotunnel.com FAIL QUIC connection failed
TCP Connectivity region1.v2.argotunnel.com PASS HTTP/2 connection successful
TCP Connectivity region2.v2.argotunnel.com FAIL HTTP/2 connection is blocked or unreachable
cloudflared 預設走 QUIC(跑在 UDP 上,通常速度更快),但這個網路環境的 UDP port 7844 被擋住了
(可能是路由器、可能是 ISP,沒有進一步查)。QUIC handshake 一直 timeout、無限重試,永遠連不上。
修法:config.yml 加一行,強制改走 TCP/HTTP2:
protocol: http2
重啟後四條連線(分散在不同機房)全部成功註冊,curl https://api.example.com/api/state
拿到預期的 401(代表 tunnel 真的接到後端了,只是這個路由本身需要登入)。
這裡的教訓:Cloudflare Tunnel 的連線前置檢查其實已經把問題講得很清楚了,只是預設不會主動
切換協定,得自己看 log 才會發現。如果你的環境也遇到「tunnel 建立了但就是連不上」,先看
protocol: http2 能不能解決,比繼續 debug QUIC 本身划算很多。
第二步:常駐化——用 launchd,不用 cloudflared service install
cloudflared 官方有個 cloudflared service install 指令可以直接裝成系統服務,但那是裝成
系統層級的 LaunchDaemon(要 sudo,開機時、任何人登入前就啟動)。這台機器上其他服務
(主程式、備份排程)原本就都是用使用者層級的 LaunchAgent(~/Library/LaunchAgents/),
為了管理方式一致(同一套 launchctl load/unload、同一個目錄查得到所有服務),選擇自己寫一個
LaunchAgent plist,不用官方那個安裝指令:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.my-service-tunnel</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/cloudflared</string>
<string>tunnel</string>
<string>--config</string>
<string>/Users/you/.cloudflared/config.yml</string>
<string>run</string>
<string>my-service</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/path/to/logs/tunnel.log</string>
<key>StandardErrorPath</key><string>/path/to/logs/tunnel.error.log</string>
</dict>
</plist>
RunAtLoad + KeepAlive,跟其他服務同一套邏輯:開機/登入自動起、掛了自動重開。
第三步:前端部署到 Vercel
用 Vercel CLI 而不是網頁 UI 點來點去:
npm install -g vercel
vercel login # 一樣要跳瀏覽器授權
vercel link --yes # 在前端目錄底下跑,會自動偵測 Next.js、順便連結 GitHub repo
踩坑二:環境變數裡的雙引號被原封不動搬過去
用 vercel env add 從既有的 .env.local 讀值塞進 Vercel 的環境變數:
VALUE=$(grep -oP '(?<=^SOME_VAR=).*' .env.local)
echo -n "$VALUE" | vercel env add SOME_VAR production
vercel env add 執行完印了一行容易被忽略的警告:
! Value includes surrounding quotes (these will be stored literally)
原因:本機 .env.local 裡這幾個值是用雙引號包起來存的(SOME_VAR="xxx"),單純用 grep
擷取 = 後面的內容,會把引號本身也一起抓進去。Vercel 老實地把你給的值原封不動存起來,
不會自動幫你判斷「這應該去掉引號」——結果就是正式環境的環境變數裡,值變成
"xxx"(含引號),不是原本的 xxx,接下來任何用到這個值的地方(例如拿去初始化一個
API client)大概率會直接壞掉,而且壞的方式往往不是立刻報錯,是資料格式看起來怪怪的,
除錯起來很煩人。
修法:擷取值的時候明確去掉頭尾的引號(例如用 Python 的 .strip('"')),重新
vercel env rm + vercel env add 覆蓋掉錯的值。
教訓:任何「從一個 .env 檔案讀值、寫進另一個系統」的自動化流程,都要想清楚原始檔案
的格式(帶不帶引號、有沒有跳脫字元),不要假設 KEY=VALUE 這種格式永遠是字面意思。
一個好用的細節:Vercel 會自動幫你分辨「這是不是機密」
加一個 Supabase 的 anon key(設計上就是要曝露給瀏覽器看的,不是機密,但看起來很像一串 API key)時,Vercel CLI 直接擋下來:
"reason": "public_prefix_requires_type",
"message": "`NEXT_PUBLIC_SUPABASE_ANON_KEY` looks like a credential, and
`NEXT_PUBLIC_` exposes its value to anyone visiting your site. Choose
explicitly: rename to `SUPABASE_ANON_KEY` with `--type secret` to keep it
private, or keep the name with `--type config` to expose it."
必須明確加 --type config 才會放行——這是個蠻貼心的安全設計,強迫你在「這個值本來就該公開」
跟「這個值不該公開但你不小心加錯前綴」之間做一個明確的選擇,不會讓你在沒意識到的情況下把
真正的機密用 NEXT_PUBLIC_ 打包進客戶端 JS。
第四步:自訂網域
後端已經有 api.example.com,前端想要對稱一點,拿掉 api 字樣,變成
app.example.com:
vercel domains add app.example.com web # web 是專案名稱
vercel domains verify app.example.com # 告訴你要加什麼 DNS 記錄
verify 這步會回傳一份很完整的診斷(目前的 nameservers、建議的 CNAME 值、要不要關掉
Cloudflare 的 proxy),照著在 Cloudflare 後台手動加一筆 CNAME(重點:proxy 狀態要關掉,
灰色雲朵,不要橘色——開著的話會跟 Vercel 自己簽發的 SSL 憑證衝突)之後,再跑一次
verify,幾秒內就會顯示設定正確。
第五步:對外曝露之後,回頭補幾個原本沒想到的安全缺口
這一步最容易被漏掉,也最值得寫下來:只要服務本來就只在 localhost,很多「理論上該做」 的防護會被無限期拖延——反正只有自己連得到,鬆一點也沒差。但一旦真的打通到公開網路,這些 「之後再說」的東西全部變成「現在就要處理」:
-
開發用的認證後門:開發過程中為了讓 agent 能直接測 API,加過一個對稱的 bypass token(繞過正式的 OAuth JWT 驗證)。這東西在只有 localhost 能連的時候完全沒風險, 但 tunnel 一打通,就變成「任何知道這個 token 的人都能從網路上進來」的真後門。
修法不是整個拔掉(拔掉會失去本機快速測試的能力),是想辦法只在「真的是本機請求」時 才生效。關鍵發現:Cloudflare Tunnel 轉發進來的請求,一定會帶
Cf-Ray(以及Cf-Connecting-Ip、X-Forwarded-For等)這些 Cloudflare 自己加的 header;真正 本機的請求完全不會有這些 header。因為後端根本沒有對外開 port forwarding,唯一能碰到 這支程式的路徑只有「真的本機」或「經過 tunnel 轉發」兩種,沒有第三條路可以繞過——用 header 的有無當判斷依據是可靠的(用 client IP 反而不行,因為cloudflared轉發到127.0.0.1,不管請求原本從哪裡來,應用程式看到的來源 IP 永遠是127.0.0.1)。is_local_request = "cf-ray" not in request.headers if DEV_BYPASS_TOKEN and is_local_request and secrets.compare_digest(token, DEV_BYPASS_TOKEN): return ADMIN_EMAIL -
API 文件被公開:FastAPI 預設會把
/docs(Swagger UI)跟/openapi.json開放給 任何人,不需要登入。只在 localhost 跑的時候沒差,對外之後就等於把完整的路由結構、 參數名稱公開給任何知道網址的人看——不會洩漏實際資料(每個路由還是被登入驗證擋著), 但沒必要多這一層資訊揭露。一行設定關掉:app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) -
反過來,也補了一個刻意公開的路由:加一支
/health,不掛任何驗證,只回傳{"status": "ok"},給之後可能會接的外部 uptime 監控用——不是每個東西都該鎖起來, 「這個服務有沒有活著」這種不含任何實際資料的資訊,公開反而比較方便。
小結
整個過程沒有一步是「查官方文件就能一次做對」的——QUIC 被擋、.env 的引號沒剝乾淨、
對外曝露後才浮現的安全缺口,三個坑分屬三個完全不同的層次(網路協定 / 資料格式 / 應用安全),
但共通點是:都只有實際跑一次、實際去 curl/測試才會發現,光靠讀文件想像不出來。
「先在 localhost 上正常運作」跟「真的能從公開網路安全地連上」中間,比想像中還要更遠一點。