有一種很特別的絕望,是所有燈都是綠的,但東西就是不會動。
儀表板上每個服務都顯示健康,日誌裡沒有錯誤,監控圖平平的很安詳,然後使用者傳訊息來說網站打不開。你重新整理,真的打不開。你再看儀表板,還是綠的。那一刻你會開始懷疑「綠燈」這兩個字的意義,而這篇文章有一半在講這件事,只是用 FastAPI 當背景。
先交代主角
FastAPI 是 Sebastián Ramírez 在 2018 年發表的 Python 網頁框架,MIT 授權。它底下是 Starlette 負責路由、中介層和 ASGI,Pydantic 負責資料驗證,型別註記同時就是文件和驗證規則。你寫一個函式,參數標上型別,它就幫你檢查輸入、產生文件、回傳正確的錯誤訊息。第一次用的人通常會有一種「這樣就好了?」的錯愕,跟第一次用自動洗碗機差不多。
uvicorn 是最常搭配的 ASGI 伺服器,BSD 授權,uvicorn[standard] 會多裝 uvloop、httptools、websockets 和 watchfiles,各自讓某一段快一點或方便一點。我的幾個網站後端都是這一組。框架本身很少讓我停下來想,教學文章已經夠多了。倒是下面幾件事各留了一課,而且都是上線之後才學到的。
健康檢查會騙人,兩個方向都會
容器的健康檢查通常是對 /health 發一個請求看狀態碼。聽起來很難出錯對吧?我也這樣以為。
第一種騙法:你用了 TrustedHostMiddleware 只允許正式主機名,這是好習慣,可以擋掉那些拿 IP 亂打的請求。但健康檢查是從容器裡面對 127.0.0.1 發的,帶的是預設的 Host,於是每次都被擋成 400,容器永遠「不健康」。服務其實好好的,只是它的體檢報告一直不及格,因為體檢的人走錯門被警衛攔下來了。健康檢查要帶正確的 Host 標頭。
第二種騙法反過來,也是開頭那個故事。應用程式裡有一條背景執行緒在做重要的事,執行緒死了網頁照樣能開,健康檢查一片綠。這就像一個人心跳正常、血壓正常、能走能跳,但其實已經三天沒睡了。讓 /health 檢查那條執行緒還在不在,不在就回 503,容器才會被重啟。健康檢查應該檢查「這個服務能不能做它的工作」,不是「這個網頁能不能開」。
掛在前綴後面時,誰剝前綴
幾個應用程式併到同一個主機名底下、各掛一個路徑前綴,是很常見的部署。uvicorn 用 --root-path /blog 告訴應用程式自己掛在哪裡,應用程式用它產生正確的連結和轉址。
這裡有一個會讓你花掉一個下午的細節。依現行的 ASGI 規範,scope["path"] 是包含 root_path 的完整路徑,uvicorn 會把前綴接回去,所以前面的反向代理轉發時要先把前綴剝掉,不然應用程式會看到兩層,/blog/blog/posts 這種東西。我第一次看到的時候還以為是自己的連結產生器寫錯,改了半天連結,越改越糟。這件事文件寫得不算顯眼,用測試釘住比記在腦子裡可靠,因為腦子在下一次搬家的時候一定會忘。
代理標頭:不信就是不信
uvicorn 預設只信任來自本機的 X-Forwarded-* 標頭,--forwarded-allow-ips 可以放寬。我的選擇是完全不依賴這些標頭:反向代理保留原始的 Host 轉發,應用程式用 Host 決定 canonical 和轉址,不看 X-Forwarded。
少一層信任關係,就少一種偽造標頭能造成的問題。代價是應用程式看到的客戶端 IP 是代理的,對我的用途無所謂。這有點像收到一封信,信封上寫「這是校長寄的」,你要不要信?我的做法是不管信封寫什麼,只看它是從哪個信箱來的。
兩個應用程式,兩個工廠
公開服務和後台管理介面,我一律用兩個工廠函式建成兩個應用程式,分別由兩個 uvicorn 行程跑,後台只聽本機迴路位址。公開的那一個不掛後台的 router、不解析後台的 session cookie。就算有人拿到公開服務的漏洞,後台的程式碼根本不在那個行程裡,沒有東西可以繞。
再往前一步,可以維護一張路由分類表,每條路由標明它是公開給瀏覽器、公開給機器、只限本機,還是停用;新增路由忘了分類就在啟動時拒絕。第一次碰到會覺得煩,像是每次出門都要被室友問「你有帶鑰匙嗎」。之後會把它當成機制在運作的證據,因為總有一天你真的會忘了帶。
小劇場:那我該學 FastAPI 還是別的
「我要做專題,該學 FastAPI 還是 Django 還是 Flask?」
「你的專題需要後台管理、使用者系統、ORM 這些現成的東西嗎?」
「好像不太需要,就幾個 API。」
「那 FastAPI 很適合,它給你的剛好夠用,不會硬塞一堆東西給你。」
「那 Flask 呢?大家都說它簡單。」
「Flask 也好,只是 FastAPI 多了型別檢查和自動文件,你寫報告的時候會很感謝那份自動產生的 API 文件。」
「所以是為了報告選的。」
「有一半是。另一半是它的錯誤訊息真的會告訴你哪個欄位錯了,而不是給你一個 500 讓你自己猜。」
不用 TestClient 也能測
Starlette 的 TestClient 依賴 httpx。如果專案的鎖定檔裡沒有 httpx,與其為了測試多裝一個套件,可以寫一個很小的 in-process ASGI 用戶端:組一個 scope,送 request 事件,收 response 事件。幾十行就夠,還能加一個 root_path 參數模擬掛在前綴後面的情況,同一套測試跑兩種模式。
寫這個小工具的那個晚上,我本來只是不想多裝一個套件,結果它後來成了我測試裡跑最多次的程式。有時候繞路反而走到比較好的地方,雖然大部分時候繞路就只是繞路。
底下那兩層是誰
FastAPI 常被當成一個框架,其實它比較像一個很會整合的組合者。路由、中介層、請求回應這些都是 Starlette 的,Starlette 的作者 Tom Christie 也是 Django REST framework 和 httpx 的作者,他做的東西有一種共同的乾淨感。資料驗證是 Pydantic 的,2023 年的 v2 把核心用 Rust 重寫,驗證速度快了好幾倍,這也是為什麼 FastAPI 的效能數字一直很好看。
自動文件是很多人愛上它的第一個理由。你寫好 API,打開 /docs,一個可以互動的 Swagger 介面已經在那裡,每個端點、每個參數、每個回應格式都列好了,還可以直接在網頁上試打。寫報告的時候截這一頁的圖,比自己畫 API 表格快十倍。/redoc 是另一個風格的版本,看起來比較像正式文件。
ASGI 和 WSGI 的差別值得知道一下。WSGI 是 Python 網頁世界用了二十年的介面,同步的,一個請求一個執行緒。ASGI 是非同步版本,可以在一個執行緒裡同時等很多 I/O。FastAPI 兩種函式都收:async def 的端點跑在事件迴圈裡,一般的 def 端點會被丟到執行緒池,所以你不小心在 async def 裡寫了一個會卡住的同步呼叫,整個服務會一起卡住。這是新手最常踩的坑,而且症狀很詭異:服務沒有壞,就是偶爾會全部停一秒。
常見的三個誤會
第一個誤會是「FastAPI 很快是因為它用了什麼黑魔法」。它快是因為 Starlette 和 uvloop 快,還有因為它沒有塞給你一堆用不到的東西。你自己在端點裡寫了慢的東西,它一樣慢。
第二個誤會是「有型別註記就等於安全」。型別註記檢查的是形狀,不是意圖。一個欄位標成 str,使用者送一段 SQL 進來也是 str。驗證形狀是 Pydantic 的事,驗證內容還是你的事。
第三個誤會是「uvicorn 可以直接對外」。它可以,但通常前面會放一個 nginx 或 Caddy 處理 TLS、靜態檔和一些奇怪的請求。uvicorn 專心跑應用程式,其他事交給專門的工具,這是分工,不是不信任。
一個週末可以試的小練習
寫一個十行的 FastAPI 應用程式,一個 /health 端點回 {"ok": true}。加上 TrustedHostMiddleware,只允許 example.local。用 uvicorn 跑起來,然後 curl http://127.0.0.1:8000/health。你會收到 400。
再打一次,這次加 -H "Host: example.local"。200 了。這五分鐘就是「健康檢查會騙人」的第一種騙法的完整重現,之後你在容器的健康檢查裡看到 400 的時候,會第一時間想到 Host,而不是花一個下午查別的。順便打開 /docs 看一眼,感受一下什麼叫免費的文件。
後記
開頭那個「所有燈都是綠的」的故事,後來我把健康檢查改成會檢查背景執行緒之後,它就再也沒有發生過。不是因為執行緒不會死了,是因為它死的時候燈會變紅,然後容器會重啟,然後執行緒又活了。整件事在我睡覺的時候發生,早上只在日誌裡看到一行紀錄。這大概就是我對「健康」這個字最終的理解:不是不生病,是生病的時候有人知道。
留言(0)
還沒有留言。讀完有想法,歡迎留下第一則。