Flask 從 WSGI 轉換 ASGI 失敗的經驗分享

Sam Huang
Gogolook Tech
Published in
17 min readNov 15, 2022

前言

目前負責的專案是使用 Python 3.9 + Flask + uWSGI + gevent 作為執行環境。團隊前陣子才將 Flask 的版本由 1.1.4 升級至 2+ 版本,在 Flask 2 的新功能中,其中一個重要功能是在 Routing 中支援了 async view,也就是 Flask 2 對於 asyncio 有了比較好的支援,我們可以直接在 async view 內使用 asyncio 的 async, await 等語法,有機會取代原先使用的 gevent。

正好團隊有一個 Network IO-Bound 的新需求,簡單測試評估過後決定可以在這個功能上改用 async view + asyncio 實作看看,因此踏上了將 Flask 從 WSGI 轉換成 ASGI 的旅程。

將 Flask 從 WSGI 轉換成 ASGI 的過程中並不是非常順利,且網路上的相關資訊較少,所以把過程中遇到的問題記錄下來幫助有遇到類似問題的人。最終我們的正式環境還是從 ASGI 退回 WSGI(uWSGI + gevent) 運行,原因是實際上線測試後發現,透過 Adpater 轉換成的 ASGI APP 並不穩定,不時會發生 Event Loop 卡死的情況,推測是轉換層實作上的不穩定造成,這樣的不穩定輕則掉資料重則造成服務中斷,所以為了追求服務的穩定性還是將其退回原本的架構運行了。

WSGI 與 ASGI 的區別

WSGI(Web Server Gateway Interface)

WSGI 是基於 CGI 概念設計的一種 Server 與 Python Application 之間溝通的規範。WSGI 是一種同步(Synchronous)的規範,Server 一次只會接收一個請求,處理完成後回覆給 Client 端後完成一個週期。因為 WSGI 早在 2003 年便被開發出來,因此只有支援 HTTP1 的協議並沒有支援更新的通訊方式,許多知名的 Python Web Framework 都是採用 WSGI 標準來實作而成,例如:Flask 與 Django 等等。

ASGI(Asynchronous Server Gateway Interface)

ASGI 算是 WSGI 的繼任者,在 Python3.4 加入 Asyncio 後 Web Framework 一直對於非同步(Asynchronous)的功能沒有很好的支援,因此也催生出了 ASGI 這個基於非同步功能設計的規範,同時也可以支援新世代 Web 溝通的技術,例如:HTTP2 與 WebSocket 等等。在 ASGI 的規範中,一個請求不需要等到 Server 回覆便可以繼續處理下一個請求,等 Server 處理完請求後再回覆給前端,這樣非阻塞( Non-Blocking) 的特性也讓基於 ASGI 實作的 Application 有了很大程度的吞吐量(Throughput)提升,知名的 ASGI Web Framework 有 FastAPI, Starlette 與 Quart 等等。

為什麼要轉換成 ASGI?

原先只有使用 Flask Async View 搭配 uWSGI,並沒有直接將 APP 由 WSGI 轉換成 ASGI。但在測試時發現,因為非同步(Asynchronous)的關係使得 uWSGI 與 Async 的程式不相容,當一個請求還沒有處理完成時,Server 已經進入可以接受新請求的狀態,當收到新的請求後就會跳出下面的錯誤。

RuntimeError: This event loop is already running

發生這個錯誤的主因是新的請求進來時會需要拿到 Event Loop 來執行 Flask Async View 這個 Coroutine,但在取得 Event Loop 時發現正在執行中便會噴出這個錯誤。為了解決這個問題,看到了 Flask 官方介紹 ASGI 的文件,可以透過 asgiref 的 WsgiToAsgi Adapter 將 Flask 轉換成 ASGI APP,並且搭配 ASGI Server 應當就可以解決先前遇到的 RuntimeError,因此我們便往 ASGI 的方向前進,果然轉換成 ASGI APP 後就沒有遇到 RuntimeError 的問題,但是…遇到了更多問題…😭

轉換 ASGI 遇到的關卡

關卡 #1:Network API Calls 沒有併發,還是同步地回傳結果

會發現 API calls 沒有併發是因為有一個併發的場景會需要呼叫 15+ 個 API calls 才可以取得所有需要的資料,已經將實作改用 asyncio.gather(*tasks) 實作,但整體時間還是非常得慢,透過 Datadog APM Trace 圖表發現底層還是同步地依序執行 API call,並沒有在第一時間併發來節省時間,下面是簡化後的範例:

import asyncio
import requests


async def api_call(resource_name):
r = requests.get(f'https://third-party-api.com/resource/{resource_name}')
return r.json()

async def main():
tasks = []
for i in range(10):
tasks.push(api_call(i))
results = await asyncio.gather(*tasks)
return results

API calls 沒有併發的主因是使用了 requests 套件來做網路呼叫,requests 本身是一個同步的套件,所以底層如果使用它來做網路呼叫的話還是沒辦法享受到非同步的效果,所以我們需要將其替換成支援 asyncio 的套件:aiohttp,改完後在 Datadog APM 就可以看到正確的併發了。

import asyncio

import aiohttp


async def api_call(session, resource_name):
api_endpoint = f'https://third-party-api.com/resource/{resource_name}'
async with session.get(api_endpoint) as response:
response= await response.json()
return response

async def main():
aiohttp_session = aiohttp.ClientSession()
tasks = []
for i in range(10):
tasks.push(api_call(aiohttp_session, i))
results = await asyncio.gather(*tasks)
return results

關卡 #2:舊有的套件不支援 Asyncio

專案中還有另外使用到其他兩個套件:Flask-HTTPAuth 及 Flask-Caching。這兩個套件的共通點是我們會使用到該套件提供的 Decorator 並將其使用在 async view上:

from flask_httpauth import HTTPBasicAuth
from flask_caching import Cache

CACHE = Cache()

@app.route('/')
@HTTP_AUTH.login_required # third-party decorator
@CACHE.cached(timeout=50) # third-party decorator
async def index():
return 'Hello World', 200

當上方的 index 這個 async view 被呼叫的時候,會噴出下面的錯誤訊息:

RuntimeError: You cannot use AsyncToSync in the same thread as an async event loop - just await the async function directly.

一開始看到這個錯誤時感到困惑,因為查詢套件的 Change List (Release 4.5)可以看到備註支援 Flask 2 的 asyn view,從 git commit 看到的變動是增加了 ensure_sync 的呼叫在原先的實作之前,所以關鍵應該就是新呼叫的這個函式了。另一方面從錯誤訊息的上游追蹤,發現這個錯誤訊息是由 asgiref 的這行拋出,再繼續往上追發現是 Flask 2 加入 async view 後,官方提供的 ensure_sync 功能。從兩個面向都可以查到 ensure_sync 這個關鍵函式,之後便在官方的文件查到相關訊息。這個函式的主要功能是讓第三方的套件可以用來檢查其實作 Decorator 接下來要執行的函式是一個同步的函式而不是非同步的 Coroutine。

所以第三方套件 Change List 上表示的支援 Flask 2 async view 其實是多做了同步與非同步的檢查,並不是該套件可以直接在 Flask 2 的 async view 上執行。所以接下來要解決這個問題有兩個方案:

  1. 將套件改用支援 asyncio 的套件
  2. 不要使用套件的 Decorator,將邏輯拉到函式內自己實作

最後我們選擇的是方案 2,在函式內自己操作 Cache 的取值與檢查。HttpAuth 的部分透過其他網路設定來阻擋,所以就不需要在應用程式端來阻擋。

from flask_caching import Cache

CACHE = Cache()

@app.route('/')
async def index():
if 'index' in CACHE:
return CACHE.get('index')
CACHE.set('index', 'Hello World', timeouts=50)
return 'Hello World', 200

關卡 #3:asgiref 3.5 不穩定造成 Deadlock

APP 在運行的過程中,Sentry 零星地收到下面的錯誤訊息:

RuntimeError: Single thread executor already being used, would deadlock

在 StackOverflow 上有看到類似的問題,看來主因是 asgiref 在 3.4.1 的版本中加入了 deadlock 的檢查,但可能實作上存在一些 Bug,所以 StackOverflow 上的人建議降版至 asgiref==3.3.2 便可解決,因為這個版本沒有 deadlock 的檢查,確實將版本降版後便沒有再收到 deadlock 的錯誤了。

關卡 #4:ASGI Server: uvicorn 不穩定

Flask 官方文件上其實是建議使用 hypercorn 作為 ASGI Server,但因為查詢了網路上的資源,uvicorn 在網路上的討論比較多,且 uvicorn 的底層是使用基於 libuv 實作的 uvloop 作為 Event Loop 效能相當不錯,且著名的 FastAPI 也是推薦使用 uvicorn 作為部屬的首選,整體上感覺 uvicorn 優於 hypercorn 許多,所以一開始便選擇使用 uvicorn 作為部屬環境的 ASGI Server。

uvicorn 搭配轉換成 ASGI 的 APP 在測試環境小流量的測試下都沒有問題,但當我們部署至生產環境時,隨即從 Datadog 監測到其中一些的 Containers 有一些異常的重啟發生,但因為發生的頻率大概是其中幾個 Containers 在一天內會有 1~2 次的重啟,並沒有影響到線上服務的運行,所以也就先放著觀察狀況,沒有多做更多的處置。直到某天因為有一個推播活動,儘管事前已經開啟足夠數量的機器,但大流量的流入使得 Containers 變得相當不穩定,不斷發生重啟,進而影響到線上服務的運行,服務回應變得緩慢且有一定機率會遇到錯誤的情況。下圖可以看到 9/20 12:00 時重啟次數最高的 Container 已經重啟了 5 次之多,其他的 Containers 也有 1 ~ 3 次的重啟發生,服務中所有的 Containers 都發生了重啟的情況。

原先猜測是因為瞬間的大流量導致 Pod 的啟動時間較久,目前設定的 K8s liveness probe 與 readness probe 沒有搭配好,導致新的 Pod 還沒有起來就讓 K8s 以為該 Pod 有問題而送出了 Restart Signal,所以導致其中的 Container 不斷重啟,但將 probe 時間調整後依舊沒有解決重啟的問題。

為了找到重啟的原因,所以我們將 uvicorn 的 log-level 開到最底層的 TRACE,試圖從 uvicorn 的 logs 中看看能不能發現一些蛛絲馬跡,果然在 logs 中發現了一些奇怪的現象。下面 log 是一個正常的 readness probe 會產生的 log,很清楚的描述了收到一個 Request 到回覆 Response 的過程中 ASGI Server 做的每一步動作。

TRACE: - ASGI [1] Receive {'type': 'http.request', 'body': '<0 bytes>', 'more_body': False}
TRACE: - ASGI [1] Started scope={'type': 'http', 'asgi': {'version': '3.0', 'spec_version': '2.3'}, 'http_version': '1.0', 'server': ('127.0.0.1', 9487), 'client': ('127.0.0.1', 0), 'scheme': 'http', 'method': 'GET', 'root_path': '', 'path': '/readz', 'raw_path': b'/readz', 'query_string': b'', 'headers': '<...>'}
INFO: - "GET /readz HTTP/1.0" 200 OK
TRACE: - ASGI [1] Send {'type': 'http.response.start', 'status': 200, 'headers': '<...>'}
TRACE: - ASGI [1] Send {'type': 'http.response.body', 'body': '<0 bytes>', 'more_body': True}
TRACE: - HTTP connection lost
TRACE: - ASGI [1] Send {'type': 'http.response.body'}
TRACE: - ASGI [1] Completed

而下面是 Container 重啟發生時的 logs,從 log 中看到 409 序號的 Request 進來後 ASGI APP 並沒有回傳 Response 給 ASGI Server, 409 序號相關的 log 只有 Receive 及 Started scope,沒有任何 Response 相關的 log,因此合理懷疑 ASGI APP 內部的 Event Loop 可能發生 Deadlock 所以卡住無法有任何回應,這情況跟問題 #3 的關聯性似乎滿高的,asgiref 可能也是因為這樣所以才加上了 Deadlock 的檢查。

TRACE: - ASGI [409] Receive {'type': 'http.request', 'body': '<0 bytes>', 'more_body': False}
TRACE: - ASGI [409] Started scope={'type': 'http', 'asgi': {'version': '3.0', 'spec_version': '2.3'}, 'http_version': '1.0', 'server': ('127.0.0.1', 9487), 'client': ('127.0.0.1', 0), 'scheme': 'http', 'method': 'GET', 'root_path': '', 'path': '/readz', 'raw_path': b'/readz', 'query_string': b'', 'headers': '<...>'}
TRACE: - HTTP connection made
....
TRACE: - HTTP connection lost

uvicorn 不穩定的問題無法解決,所以決定改用 Flask 官方文件建議的 hypercorn 試試看,hypercorn 採用原生的 asyncio Event Loop,希望在沒有改動底層 Event Loop 實作的情況下可以有比較好的穩定性。

關卡 #5:ASGI Middleware 可能導致不穩定

在更換成 hypercorn 之前,發現正在使用的 Sentry 與 Datadog 都有提供專屬的 ASGI Middleware 來給 ASGI APP 使用,因為這類的監控套件是包在整個 Flask APP 的上層,所以是有機會影響 APP 的穩定性,雖然沒使用 ASGI Middleware 在 Sentry 與 Datadog 的資料接收上沒有什麼問題,但還是加上 Middleware 試試能否解決 uvicorn 不穩定重啟的問題。

加上 Middelware 前:

from asgiref.wsgi import WsgiToAsgi
from flask import Flask

def create_asgi_app():
app = Flask(__name__)
asgi_app = WsgiToAsgi(app)
return asgi_app

加上 Middleware 後:

from asgiref.wsgi import WsgiToAsgi
from ddtrace.contrib.asgi import TraceMiddleware
from flask import Flask
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware

def create_asgi_app():
app = Flask(__name__)
asgi_app = WsgiToAsgi(app)
asgi_app = TraceMiddleware(app)
asgi_app = SentryAsgiMiddleware(app)
return asgi_app

很可惜加上 ASGI 專用的 Middleware 後還是沒有解決 uvicorn 不穩定重啟的問題,所以只能來試試 hypercorn 了。

關卡 #6:ASGI Server: hypercorn 還是不穩定

下圖的紫色線是換成 hypercorn 的時間軸,整體來說 hypercorn 發生重啟的次數已經降低到原本的 10% 左右,對比 uvicorn 的穩定度已經大幅提升,但還是會有非常零星的發生重啟的情況(下圖看起來覺得差別沒有很大的原因是因為只顯示最多重啟次數的那個 Container 重啟次數,若是加總全部重啟次數則 hypercorn 只有 uvicorn 的 10% 左右)。從 hypercorn 的 log 看起來也是發生跟 uvicorn 類似的 APP deadlock 情形,ASGI Server 一直拿不到 APP 的 Response 所以就被 K8s 認為 container 不健康重啟了。

最終的方案

忙了一圈,雖然最後的 hypercorn 看起來穩定度已經大幅提升了,但比起原本的 uWSGI + gevent 的 WSGI APP 還是相對較不穩定,為了穩定性我們還是將服務改回了 uWSGI + gevent + WSGI APP 的配置,所有使用到 asynio 的地方全部用 gevent 改寫,好在 gevent 的寫法與 asyncio 差異不大,所以改寫的過程也是非常快速,實作完的 gevent 版本速度實際監測起來比 asyncio + uvloop 與 asyncio Event Loop 的版本略慢 10% 左右,但運行了一週的時間沒有再發生任何的重啟,所以為了穩定性犧牲一些些效能在我們的情境下是個正確的選擇。

感想

這次的 ASGI 轉移在事前雖然已經有做了事前研究,至少確保了每個環節串接起來都可以正確運作。但這些測試都是在本地端的低負載測試,沒有上到生產環境接受龐大流量的情況下,著實很難測試出一些極端狀態下才可能產生的問題,建議如果未來想要嘗試 ASGI APP 的話,還是選擇 FastAPI 這類原生 ASGI 的 Framework,網路上已經有許多人使用 FastAPI + uvicorn 作為生產環境,沒有發生我們遇到的這些問題。如果現有的 Flask 專案已經相當龐大,難以快速改寫成 FastAPI 卻又想要試試 ASGI,也推薦 Quart 這套受到 Flask 設計啟發的 ASGI Framework,從 Flask 改寫 Quart 因為 Framework API 的設計一致性,讓遷移工作大大降低。

最後,因為整個除錯的過程在網路上查詢到的資料相當少,希望將遇到的經驗分享給有同樣 Flask 轉換成 ASGI APP 想法的人,希望我們的經驗可以提(ㄑㄩㄢˋ)供(ㄊㄨㄟˋ)給有需要的人。

--

--