Cloudflare Workers Python を楽に書きたい!!
こんにちは。今回は Cloudflare Workers Python を楽に扱える SDK を開発したので、それを紹介しようと思います。
まず、Cloudflare Workers と言うと JavaScript / TypeScript のためのプラットフォーム、という印象が強いかもしれません。
ただ最近は Python Workers もベータとして提供されていて、Python で Worker を書くことができます。Cloudflare 公式は Python Workers が first-class な開発体験を目指しており、JavaScript runtime APIs へのアクセス、bindings、FastAPI などのパッケージ対応を提供していると案内しています。
ただし、これは「Workers の実行環境で普通の Python がそのまま動く」という話ではありません。
実際には、V8 Isolate の中で Pyodide が動き、その上で Python コードが実行されます。
それぞれ、
-
V8 Isolate
Cloudflare Workers ランタイム "workerd" の実行基盤です。各 Worker は V8 Isolate 上で走ります。
-
Pyodide
CPython を WebAssembly 化した実行環境で、Workers 上の Python 実行系そのものです。
-
Python Workers
開発者が書く Python アプリケーションで、Pyodide 上から Workers の Web API や bindings にアクセスします。
といった立場にあります。
この構造自体は面白いのですが、開発体験には独特の辛さがあります。
そこで私は EdgeKit という SDK を開発しました。
EdgeKit は、Cloudflare Workers 向けの Python ネイティブ API を提供するライブラリであり、同時に bundle を最適化する builder でもあります。
Cloudflare Workers の Python は何が特殊なのか
Cloudflare の Python Workers では、JavaScript 側のオブジェクトや関数を Python から扱う為に FFI が提供されています。
正確な表現をするなれば、これは所謂 "Python の" FFI ではなく、Pyodide の FFI です。pyodide.ffi にある JsProxy や関連ユーティリティを使って、JavaScript の世界と Python 世界を橋渡しします。Cloudflare による FFI ドキュメント では、この JavaScript API へのアクセス方法を説明しており、pyodide.ffi を「JavaScript と相互作用するための JsProxy とユーティリティ」と説明しています。
なので、Workers 上の Python は「普通の CPython アプリケーション」ではなく、
Pyodide の FFI を通じて JavaScript runtime と接続された Python です。
この理解を持っておくと、何故様々な箇所で開発体験が歪むのか分かり易いと思います。
問題 1. js モジュールの扱いが異質すぎる
Python Workers では js モジュールを import して、JavaScript のグローバルオブジェクトや Web API にアクセスできます。ドキュメントでも、Python Workers では JavaScript globals や Request / Response / fetch() 等を使えると説明しています。
見覚えがないと感じるのは当然で、ここで出てくる js モジュールは CPython の標準モジュールでも、別で用意される外部パッケージでも ありません。
Pyodide 上で動いているからこそアクセスできる特殊なモジュールです。
しかも js.Request や js.URL 等の実体は Python ネイティブなオブジェクトではなく、Pyodide FFI を通して見えている JavaScript のオブジェクトです。すると当然ながら、
- Python の型として自然には見えない (
AnyかUnknownになりがち) - IDE 補完が弱い
- 静的型チェッカーと相性が悪い
- コードを書く側も読む側も、常にこれは JS のオブジェクトだと意識しないといけない
という問題が出ます。
一応、この辺りを多少補うための選択肢として pyodide/webtypy があります。webtypy は Web API の型定義を提供して IDE 支援を改善するパッケージです。けれども、これはランタイム抽象化ではなく、あくまで型情報の補助です。根本的に Pyodide の FFI 越しに JavaScript のオブジェクトを扱っているという構造までは消してくれません。
問題 2. env の扱いがかなり原始的
Cloudflare Workers における env は、D1、KV、R2、Queues、Durable Objects、Static Assets などの binding を受け取るための中核オブジェクトです。
TypeScript では、これはかなり自然に書けます。
export interface Env {
ASSETS: Fetcher;
DB: D1Database;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const row = await env.DB.prepare("select 1 as ok").first<{ ok: number }>();
const asset = await env.ASSETS.fetch(
new Request("https://local/hello.txt"),
);
return Response.json({
ok: row?.ok ?? 0,
assetStatus: asset.status,
});
},
};
TypeScript 側では、
-
Envの型を明示できる -
Request/Responseも Web 標準の既知の型として扱える - IDE 補完が効く
- 何が Promise で何が plain object か見通しが良い
という利点があります。
一方、Python Workers における env の実体は JavaScript のオブジェクトです。
従って Python から触る際には Pyodide FFI を通した JavaScript オブジェクトアクセス と Python / JavaScript 間の型変換 を意識しないといけません。
例えば static assets を取りに行くだけでも、素朴に書くとこうなります。
from js import URL as JSURL, Request as JSRequest
url = JSURL.new("http://local")
url.pathname = "/static/favicon.ico"
asset_request = JSRequest.new(url.href)
asset_response = await worker_env.ASSETS.fetch(asset_request)
やりたいことは「静的ファイル (Static Assets) を 1 つ取る」だけなのに、
js.URLjs.Request- JavaScript の Promise
-
envが JavaScript のオブジェクトであること - Python と JavaScript の境界
を毎回意識しないといけません。
これがかなりつらいです。
問題 3. 外部ライブラリの前提が普通の Python とかなり違う
Python Workers がサポートするのは pure Python packages と Pyodide に含まれる packages です。Pyodide に未対応な non-pure-Python package は、そのままでは使えません。
Pyodide だから C 拡張は一切ダメというわけではなく、Pyodide 側には WebAssembly 向けに特別に移植・同梱されたパッケージもあります (numpy など)。なので、普通の CPython 用バイナリ拡張をそのまま持ち込めるわけではない、という言い方が正確になると思います。Cloudflare も、Python Workers では pure Python か Pyodide 対応済みのパッケージを前提にしています。
更に、pure-Python なライブラリであっても、それがそのまま自然に動くとは限りません。
例えば FastAPI が Cloudflare 公式で強く案内されているのは FastAPI が ASGI ベースで、Workers runtime 側が ASGI アプリケーションを接続しやすい形になっているからです。
逆に、Flask のような既存フレームワークは WSGI や application context など独自の実行前提を持っています。従って、単に import して一部 API を取り出して使うだけでは簡単に破綻します。実際、flask.jsonify() を app の初期化なしそのまま呼べば Working outside of application context. というエラーになります。これは Pyodide 上でなくても同様です。
故に、Flask が pure-Python だから動く / 動かないというより、フレームワークが前提にしている実行モデルと Workers / Pyodide 側の実行モデルにギャップがあるということです。
ちなみに、Flask は正確には pure-Python ではなく、Flask の依存関係である Jinja2 の依存関係である MarkupSafe が C 拡張を含みます。しかし Jinja2 と共に MarkupSafe も Pyodide に移植されているため、動かすことができます。
問題 4. bundle が大きくなりやすい
ドキュメント は、Python Workers では依存関係が deployment 時に worker bundle に含まれることを説明しています。これは便利ですが、そのままだと依存がそのまま成果物サイズに効きやすいです。
JavaScript / TypeScript であれば、バンドラが tree-shaking や最適化を比較的自然に行えます。
しかし Python では、
- 動的 import
- module-level side effect
- 文字列ベースの参照
- Workers / Pyodide を想定していない bundler の多さ
といった事情があるため、素朴に vendoring すると bundle が膨らみがちです (正確には JS でも動的 import や副作用のあるモジュールは書けますし、最適化も難しいですが、その手の既存の資産が少ない分 Python ではその傾向が更に強いです)。
この問題は単なる見た目の問題ではありません。
API がどれだけ Pythonic でも、最終的な runtime bundle が大きすぎれば ─ 特にエッジ環境では ─ 実用的ではありません。
そこで EdgeKit を作った
今回私の開発した EdgeKit は、この状況に対して 2 つの方向からアプローチします。
1 つは Workers Python を書く為の軽量で Python-first な APIで、
もう 1 つは コードベースを解析し tree-shaking と vendoring を行い runtime bundle を構築する builder です。
この 2 つは別々の機能ではありません。
SDK が Pyodide FFI の歪さを吸収し、builder が deployment artifact を現実的なサイズまで削ることで初めて、Cloudflare Workers 上で Python を「実用的に」書けるようになります。
js モジュールの問題解決
EdgeKit では Request、Response、Headers、URL などを Python 側で型付き wrapper として提供します。
つまり利用者は、
js.Requestjs.Responsejs.URL-
pyodide.ffiのプリミティブなアクセス - JavaScript オブジェクトと Python オブジェクトの細かい境界処理
をアプリケーションコードで直接意識する代わりに、まずは EdgeKit の Python API を使えば良い、という形にしています。
たとえば最小限の Worker はこう書けます。
from edgekit import Request, Response, WorkerEntrypoint
class Default(WorkerEntrypoint):
async def fetch(self, request: Request) -> Response:
return Response.json(
{
"ok": True,
"method": request.method,
"pathname": request.url.pathname,
}
)
この設計の狙いは、単に短く書けるようにすることではなく、Pyodide の js モジュールと pyodide.ffi という特殊事情を、アプリケーションコードの手前で止める ことにあります。
env API の問題解決
env については、EdgeKit では typed environment bindings を導入しました。
from typing import Protocol
from edgekit import Request, Response, WorkerEntrypoint
from edgekit.bindings import D1Database, StaticAssets
class Env(Protocol):
ASSETS: StaticAssets
DB: D1Database
class Default(WorkerEntrypoint[Env]):
async def fetch(self, request: Request) -> Response:
ok = await self.env.DB.prepare("select 1 as ok").first("ok", type=int)
asset = await self.env.ASSETS.fetch("/hello.txt")
return Response.json(
{
"ok": ok if ok is not None else 0,
"asset": await asset.read_text(),
}
)
同じことを TypeScript で書くと、感覚的にはこうです。
export interface Env {
ASSETS: Fetcher;
DB: D1Database;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const row = await env.DB.prepare("select 1 as ok").first<{ ok: number }>();
const asset = await env.ASSETS.fetch(
new Request("https://local/hello.txt"),
);
return Response.json({
ok: row?.ok ?? 0,
asset: await asset.text(),
});
},
};
TypeScript では当たり前に出来ている「bindings に型がある」という感覚を、Python 側でも可能な限り再現したかった、というのが EdgeKit の出発点です。
env を只の JavaScript オブジェクトとして露呈させず、
StaticAssetsD1DatabaseKVR2QueuesDurable Objects
等を typed binding として提供することで、Workers 固有の binding を Python の型付き API として扱えるようにしています。
これによって、
- IDE 補完が効く
- どの binding にどのメソッドがあるか見える
- Promise や JS オブジェクトの事情を考えなくて良い
という改善が得られます。
フレームワーク統合の問題解決
Cloudflare 公式が FastAPI を積極的に案内しているのは、FastAPI が ASGI ベースで、Workers 側も ASGI を接続し易いからです。
ただ、現実には FastAPI 以外のフレームワークや既存のアプリ資産も使いたいケースがあります。
そこで EdgeKit では WSGI と ASGI の adapter を用意しました。
それらの adapters は WorkerEntrypoint の拡張として使えます。
具体的な小さな Flask アプリケーションは次のようになります。
from typing import Protocol
from flask import Flask, Blueprint, render_template, request as req
from edgekit.adapters import WSGI
from edgekit.bindings import StaticAssets, D1Database
from edgekit.runtime import await_sync, current_env
class Env(Protocol):
ASSETS: StaticAssets
DB: D1Database
flask_app = Flask(__name__, template_folder="templates")
api = Blueprint("api", __name__)
_SCHEMA_SQL = (
"CREATE TABLE IF NOT EXISTS request_log ("
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
"method TEXT NOT NULL, "
"path TEXT NOT NULL"
");"
)
@flask_app.route("/")
def index():
return render_template("root.html")
@api.route("/db", methods=["GET", "POST"])
def db_access():
env = current_env(Env)
await_sync(env.DB.exec(_SCHEMA_SQL))
rows_affected = 0
if req.method == "POST":
insert_result = await_sync(
env.DB.prepare("INSERT INTO request_log (method, path) VALUES (?, ?)")
.bind(req.method, req.path)
.run()
)
rows_affected = insert_result.rows_affected
total = await_sync(env.DB.prepare("SELECT COUNT(*) AS total FROM request_log").first("total", type=int)) or 0
rows = await_sync(
env.DB.prepare(
"SELECT id, method, path FROM request_log ORDER BY id DESC LIMIT 5"
).all()
)
return {
"ok": True,
"rows_affected": rows_affected,
"total": total,
"rows": rows,
}
@api.route("/hello")
def hello_asset():
env = current_env(Env)
res = await_sync(env.ASSETS.fetch("/hello.txt"))
return (
await_sync(res.read_text()),
res.status,
res.headers.to_dict(),
)
flask_app.register_blueprint(api, url_prefix="/api")
class Default(WSGI[Env]):
app = flask_app
if __name__ == "__main__":
flask_app.run(debug=True)
ここで current_env() は現在の request scope にある Worker の env を取得するための helper で、await_sync() は Pyodide runtime binding を通じて awaitable を同期的に扱うための helper です。
つまり EdgeKit は、単に request / response を包むだけではなく、既存フレームワークを Workers のライフサイクルへ接続するための足場 も提供します。
思想としては、Workers では FastAPI だけが正解、という形にしないことです。
勿論全ての Python web framework が無改造でそのまま動くわけではありませんが、少なくとも WSGI / ASGI adapter によって、Workers の実行モデルへ接続する現実的な道筋を用意できます。
バンドルサイズの問題解決
Cloudflare の Python Workers では依存関係がデプロイ時にバンドルへ取り込まれるため、API だけ整えても成果物サイズが大きいままだと採用しづらいです。
そこで EdgeKit には builder を入れました。
この builder は、
- プロジェクトの解析
- tree-shaking
- vendoring
- リスクチェック
- bundle の出力
- report の生成
を行います。
但し、これは JavaScript の bundler の完全再現ではありません。
Python では動的 import や副作用が強いので、TypeScript と同じ意味での強い tree-shaking は危険です。そこで EdgeKit の builder には 2 種類の mode を用意しました。
safe
safe は、Python の動的要素を考慮して 安全に削る モードです。
到達可能性の判断や vendoring の最適化を保守的に行い、壊しにくさを優先します。通常はこちらをデフォルトとして使う想定です。
aggressive
aggressive は、コードベースの堅牢さをある程度信じて 見える限り攻める モードです。
既存ライブラリがある程度まともに振る舞ってくれることを前提に、より踏み込んだ削減を行います。もちろん「即クラッシュ上等」の無茶なモードではありませんが、safe よりは互換性リスクを取ります。少なくとも、現状の examples/web-app は Flask を利用していますが、aggressive モードでも問題なく動いています。
この 2 モード構成にした理由は明確で、動的要素の強い Python では最適化の強さと互換性の安全性がどうしてもトレードオフになるからです。
EdgeKit はそのトレードオフを隠さず、利用者が選べるようにしました。
余談ですが、tree-shaking にあたって pure-python でグラフ構造の表現や再帰解析を実装した為プロジェクトや環境によってかなり (数秒~数十秒) ビルドに時間が掛かります。なので将来的にはここを pyo3 か nanobind で置き換えて高速化したいと考えています。
EdgeKit の構成
最終的に EdgeKit は、次の 3 層からなる SDK になりました。
-
Python-first な Workers API
WorkerEntrypoint[Env]、型付きRequest/Response/Headers/URL、typed bindings によって、Pyodide FFI と JavaScript オブジェクトの扱いをアプリケーションコードから遠ざけます。 -
framework adapters
WSGI / ASGI adapter と runtime helper によって、既存の Python アプリケーションを Workers の実行モデルへ接続します。 -
builder / analyzer
プロジェクトを解析し、tree-shaking と vendoring を行い、Cloudflare Workers 向けの runtime bundle を構築します。build mode はsafeとaggressiveの 2 種類です。
クイックスタート
まず新しいプロジェクトを初期化します。
uvx --from workers-py==1.9.1 pywrangler init # 現時点での最新 workers-py v1.9.2 ではエラーが発生するため
cd <your-project>
uv add edgekit
最小限の Worker は次のように書けます。
from edgekit import Request, Response, WorkerEntrypoint
class Default(WorkerEntrypoint):
async def fetch(self, request: Request) -> Response:
return Response.json(
{
"ok": True,
"method": request.method,
"pathname": request.url.pathname,
}
)
pyproject.toml では Worker の entrypoint を記述します。
[tool.edgekit.builder]
entry = "src/app.py"
compatibility_date = "2026-04-13" # wrangler.jsonc と合わせる必要があります
そして wrangler.jsonc ではビルド成果物を参照するよう設定します。
{
"main": "build/edgekit/wrangler/python_modules/app.py",
"compatibility_date": "2026-04-13",
"compatibility_flags": ["python_workers"],
"build": {
"command": "uv run edgekit build",
},
}
ローカルでは次のコマンドをよく使います。
edgekit analyze
edgekit doctor
edgekit build
wrangler dev
おわりに
Cloudflare Workers の Python は仕組みとしては本当に面白いのですが、そのままでは
-
jsモジュールが異質すぎる -
envが JavaScript オブジェクトのままで扱いづらい - 既存フレームワークの接続が難しい
- バンドルサイズが大きくなりがち
という課題があります。
EdgeKit SDK は、その問題に対して
API で Pyodide FFI を隠し、adapter でフレームワークを接続し、builder で bundle を最適化する
という形で (Cloudflare 側の改善を待ちきれなかった技術オタクが、勝手に) 答えようとしたものです。
もし Cloudflare Workers で Python を「動かす」だけでなく、「ちゃんと開発できるようにしたい」と感じていたなら、EdgeKit はそのための土台になるはずです。
Discussion