はじめに
Cloudflare Workersは、JavaScriptだけでなくPythonでも書けるようになっています。Python Workersは内部でPyodide(WebAssembly版のCPython)を使っており、DjangoやFastAPIといったPythonのWebフレームワークもそのまま動かせます。
Cloudflareの公式ドキュメントでもDjangoは「対応フレームワーク」として紹介されていて、django-cfというパッケージを使うと、Cloudflareのサーバーレスデータベース D1 をDjangoのORMからそのまま扱えます。
今回は、次の技術を使ってTODOアプリを作り、実際にWorkersへデプロイしてみます。
- 実行環境:Cloudflare Workers(Python Workers)
- Webフレームワーク:Django 6
- データベース:Cloudflare D1
- D1用のDBバックエンド:django-cf
- パッケージ管理:uv / pywrangler
プロジェクトの雛形はpywrangler init、django-admin startproject、manage.py startappで作り、D1の作成やマイグレーションファイルの作成もWranglerのコマンドで行います。
完成したアプリ
完成したアプリは次のURLで公開しています。
https://django-todo.yamadori.workers.dev
ログインなしで使え、リストはブラウザごとに分かれています(Cookieに匿名IDを保存しています)。
できることは次のとおりです。
- タスクの追加・完了・削除
- 「すべて/未完了/完了」の絞り込み
- 完了済みタスクの一括削除
- 進捗率をリングで表示
- ダークモード対応
JavaScriptは一切使っておらず、Djangoのテンプレートとフォーム(POST → リダイレクト)だけで動く、昔ながらのサーバーサイドレンダリング構成です。CSRF対策もDjango標準のCsrfViewMiddlewareがそのまま効いています。
全体の構成
ブラウザ
│ HTTPS
▼
Cloudflare Workers(Python / Pyodide)
└── entry.py(WorkerEntrypoint)
└── Django(WSGI)
└── django-cf の D1 バックエンド
│
▼
Cloudflare D1(SQLite互換)Workersにはソケットがないので、gunicornやuvicornのようなアプリケーションサーバーは使いません。代わりにworkersパッケージのWSGIアダプターが、Workersのリクエストを受け取ってDjangoのWSGIアプリケーションへ渡します。
手順
必要なもの
- uv(Pythonのパッケージマネージャー)
- Node.js(Wranglerの実行に使います)
- Cloudflareアカウント
Cloudflareへは、あらかじめWranglerでログインしておきます。
npx wrangler loginなお、django-cfのREADMEでは、本番運用にはWorkers Paidプランを推奨しています。Djangoはモジュールの読み込みが多く、無料プランのCPU時間制限を超えることがあるためです。
Python Workersのプロジェクトを作る
pywrangler initで、Python Workersのプロジェクトを作ります。pywranglerはworkers-pyパッケージに含まれるコマンドで、uvxを使えばインストールせずに実行できます。
uvx --from workers-py pywrangler init django-todo \
--template https://github.com/cloudflare/python-workers-examples/tree/main/hello \
--no-git --no-agents --no-deploy
cd django-todo内部ではcreate-cloudflareが呼ばれ、Cloudflare公式のPythonサンプルから「Hello World」のテンプレートがコピーされます。オプションを付けない場合は、Gitの初期化やデプロイをするかどうかを対話形式で聞かれます。
次のファイルが生成されます。
django-todo/
├── package.json
├── pyproject.toml
├── wrangler.jsonc
└── src/
└── entry.pywrangler.jsoncには、Python Workersに必要なpython_workersの互換性フラグがすでに設定されています。Worker名はディレクトリ名と同じdjango-todoになります。
Djangoのプロジェクトとアプリを作る
依存パッケージを追加します。
uv add django django-cf tzdatadjango-cfはD1をDjangoのデータベースとして使うためのパッケージです。tzdataが必要な理由は後ほど説明します。
続いて、いつもどおりDjangoのコマンドでプロジェクトとアプリを作ります。Workersはデフォルトでsrc/以下をデプロイするので、src/の中に作ります。
cd src
uv run django-admin startproject config .
uv run python manage.py startapp todos
cd ..ここまでで、ディレクトリ構成は次のようになります。
django-todo/
├── package.json
├── pyproject.toml
├── wrangler.jsonc
└── src/
├── entry.py
├── manage.py
├── config/
│ ├── settings.py
│ ├── urls.py
│ ├── wsgi.py
│ └── asgi.py
└── todos/
├── apps.py
├── models.py
├── views.py
└── migrations/D1データベースを作る
D1のデータベースを作成します。--bindingと--update-configを付けると、wrangler.jsoncへのバインディングの追加まで自動で行ってくれます。
uv run pywrangler d1 create django-todo-db --binding DB --update-configwrangler.jsoncに次の設定が追加されます。
"d1_databases": [
{
"binding": "DB",
"database_name": "django-todo-db",
"database_id": "<作成されたD1のID>"
}
]バインディング名のDBは、後でDjangoの設定から参照します。
settings.pyを書き換える
startprojectで生成されたsrc/config/settings.pyを、Workers向けに書き換えます。
ポイントは、Workers上で動いているかどうかで設定を切り替える ことです。Pyodideではsys.platformが"emscripten"になるので、これで判定します。こうしておくと、ローカルではmanage.pyがSQLiteで普通に動き、Workers上ではD1を使うようになります。
まず、ファイルの先頭とSECRET_KEYの部分です。
import sys
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
# Workers(Pyodide)上で動いているかどうか
ON_WORKERS = sys.platform == "emscripten"
if ON_WORKERS:
# Workers では Secret(wrangler secret put)を env から読む
from workers import env
SECRET_KEY = env.DJANGO_SECRET_KEY
else:
SECRET_KEY = "django-insecure-local-only"
DEBUG = False
ALLOWED_HOSTS = ["*"]Workersの環境変数やSecretはos.environには入らないので、from workers import envで取得します。Djangoの設定は最初のリクエスト時に読み込まれるため、この時点でenvにアクセスできます。
次に、アプリとミドルウェアです。
INSTALLED_APPS = [
'todos',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'todos.middleware.OwnerCookieMiddleware',
]django.contrib.admin、auth、sessions、messagesなどは、今回のアプリでは使わないので外しました。これらはデータベースにテーブルが必要になるうえ、読み込むモジュールが増えて起動も遅くなります。OwnerCookieMiddlewareは後で作る自作のミドルウェアです。
あわせて、TEMPLATESのcontext_processorsからauthとmessagesを削除し、requestだけを残します。
'context_processors': [
'django.template.context_processors.request',
],データベースの設定です。
if ON_WORKERS:
# Workers 上では D1 を Django ORM のデータベースとして使う
DATABASES = {
'default': {
'ENGINE': 'django_cf.db.backends.d1',
'CLOUDFLARE_BINDING': 'DB',
}
}
else:
# ローカルの manage.py(makemigrations / sqlmigrate)用
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}ENGINEにdjango_cf.db.backends.d1を、CLOUDFLARE_BINDINGにwrangler.jsoncのバインディング名を指定します。
最後に、言語とタイムゾーンを日本向けにし、ログの設定を追加します。
LANGUAGE_CODE = 'ja'
TIME_ZONE = 'Asia/Tokyo'
# 500 エラーの原因を wrangler dev / Workers Logs で見られるように stderr へ出す
LOGGING = {
'version': 1,
'handlers': {'console': {'class': 'logging.StreamHandler'}},
'loggers': {'django.request': {'handlers': ['console'], 'level': 'ERROR'}},
}LOGGINGの設定は地味に重要です。DEBUG = Falseのままだと、エラーが起きてもブラウザには「Server Error (500)」としか表示されません。ログを標準エラー出力へ出しておくと、pywrangler devのコンソールやWorkers Logsでトレースバックを確認できます。
エントリーポイントを書き換える
pywrangler initで生成されたsrc/entry.pyは「Hello world!」を返すだけなので、Djangoへリクエストを渡すように書き換えます。
import os
from django_cf import DjangoCF
from workers import WorkerEntrypoint
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
# Django 本体の import はトップレベルで済ませておく(理由は後述)
import django.core.handlers.wsgi # noqa: E402,F401
import django.db.models # noqa: E402,F401
import django.middleware.clickjacking # noqa: E402,F401
import django.middleware.csrf # noqa: E402,F401
import django.middleware.security # noqa: E402,F401
import django.shortcuts # noqa: E402,F401
import django.template.backends.django # noqa: E402,F401
import django.template.defaulttags # noqa: E402,F401
import django.urls # noqa: E402,F401
import django_cf.db.backends.d1.base # noqa: E402,F401
class Default(DjangoCF, WorkerEntrypoint):
def get_app(self):
from config.wsgi import application
return applicationDjangoCFを継承するとfetchハンドラーが用意され、リクエストがWSGI経由でDjangoへ渡されます。WSGIアプリケーションには、startprojectが生成したconfig/wsgi.pyをそのまま使います。
途中にあるたくさんのimportは、コールドスタートを速くするためのものです。後の「パフォーマンス」の章で詳しく説明します。
モデルを書く
src/todos/models.pyです。
from django.db import models
class Todo(models.Model):
owner = models.CharField(max_length=36, db_index=True)
title = models.CharField(max_length=120)
done = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["done", "-created_at"]
def __str__(self):
return self.titleownerには、訪問者ごとの匿名IDを保存します。
マイグレーションをD1に流す
ここが通常のDjango開発といちばん違うところです。
Workers上ではmanage.py migrateを実行できません。そこで、Djangoにマイグレーションを作らせ、そのSQLをWranglerのD1マイグレーションとして流す 方法を取ります。
まず、いつもどおりDjangoのマイグレーションを作ります。ローカルではsettings.pyがSQLiteを使う設定になっているので、manage.pyがそのまま動きます。
cd src
uv run python manage.py makemigrations todos
cd ..次に、WranglerでD1用のマイグレーションファイルを作ります。
uv run pywrangler d1 migrations create django-todo-db create_todosmigrations/0001_create_todos.sqlが作られるので、そこにsqlmigrateで出力したSQLを追記します。
(cd src && uv run python manage.py sqlmigrate todos 0001 | grep -v -e '^BEGIN;$' -e '^COMMIT;$') >> migrations/0001_create_todos.sqlD1はSQLite互換なので、SQLite用に生成されたSQLをそのまま使えます。ただし、D1のマイグレーションではBEGIN/COMMITを使えないため、grep -vで取り除いています。
できあがったファイルは次のとおりです。
-- Migration number: 0001 2026-10-09T15:53:37.786Z
--
-- Create model Todo
--
CREATE TABLE "todos_todo" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "owner" varchar(36) NOT NULL, "title" varchar(120) NOT NULL, "done" bool NOT NULL, "created_at" datetime NOT NULL);
CREATE INDEX "todos_todo_owner_bb2c143f" ON "todos_todo" ("owner");ローカルのD1に適用します。
uv run pywrangler d1 migrations apply django-todo-db --localモデルを変更したときも、makemigrations → d1 migrations create → sqlmigrate → d1 migrations applyの順で同じように反映できます。
訪問者ごとにリストを分ける
公開デモなので、全員が同じリストを編集できると困ります。かといってログイン機能を作るのは大げさなので、Cookieに匿名IDを配るミドルウェアを作りました。
src/todos/middleware.pyを新しく作ります。
import uuid
COOKIE = "todo_owner"
class OwnerCookieMiddleware:
"""ログイン無しで「自分のリスト」を持てるよう、訪問者ごとに匿名IDを cookie で配る"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
owner = request.COOKIES.get(COOKIE)
is_new = not owner
request.owner = owner or str(uuid.uuid4())
response = self.get_response(request)
if is_new:
response.set_cookie(
COOKIE, request.owner, max_age=60 * 60 * 24 * 365,
httponly=True, samesite="Lax", secure=request.is_secure(),
)
return responseビューではrequest.ownerで絞り込むだけで、自分のタスクだけを扱えます。
ビューとURLを書く
src/todos/views.pyです。
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from .models import Todo
MAX_TODOS = 50
FILTERS = {"all", "active", "done"}
def back(request):
show = request.POST.get("show")
return redirect(f"/?show={show}" if show in FILTERS - {"all"} else "/")
def index(request):
todos = Todo.objects.filter(owner=request.owner)
show = request.GET.get("show", "all")
if show not in FILTERS:
show = "all"
total = len(todos)
done = sum(1 for t in todos if t.done)
if show == "active":
todos = [t for t in todos if not t.done]
elif show == "done":
todos = [t for t in todos if t.done]
return render(request, "todos/index.html", {
"todos": todos,
"show": show,
"total": total,
"done": done,
"left": total - done,
"percent": round(done / total * 100) if total else 0,
})
@require_POST
def add(request):
title = request.POST.get("title", "").strip()[:120]
if title and Todo.objects.filter(owner=request.owner).count() < MAX_TODOS:
Todo.objects.create(owner=request.owner, title=title)
return redirect("index")
@require_POST
def toggle(request, pk):
todo = get_object_or_404(Todo, pk=pk, owner=request.owner)
todo.done = not todo.done
todo.save(update_fields=["done"])
return back(request)
@require_POST
def delete(request, pk):
Todo.objects.filter(pk=pk, owner=request.owner).delete()
return back(request)
@require_POST
def clear_done(request):
Todo.objects.filter(owner=request.owner, done=True).delete()
return redirect("index")一覧画面では、自分のタスクを1回のクエリで取得し、件数や進捗率はPython側で計算しています。D1へのクエリはネットワーク越しになるので、count()を何度も呼ぶよりクエリの回数を減らしたほうが速くなります。
src/config/urls.pyは、生成されたadminのルートを消して次のように書き換えます。
from django.urls import path
from todos import views
urlpatterns = [
path("", views.index, name="index"),
path("add", views.add, name="add"),
path("<int:pk>/toggle", views.toggle, name="toggle"),
path("<int:pk>/delete", views.delete, name="delete"),
path("clear", views.clear_done, name="clear"),
]テンプレートを書く
テンプレート用のディレクトリを作ります。
mkdir -p src/todos/templates/todos見た目はsrc/todos/templates/todos/index.htmlの1ファイルにまとめました。CSSも同じファイルに書いています。
デザインのポイントは次のとおりです。
- 背景にぼかしたグラデーションを置き、カードを
backdrop-filterですりガラス風にする - アクセントカラーはDjangoのブランドカラーに近い深緑(
#0c4b33) - 進捗率は
conic-gradientでリング状に描く(JavaScript不要) - チェックボックスは丸いボタン+SVGのチェックマーク
prefers-color-schemeでダークモードに対応
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Today — Django on Workers</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+JP:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f4f1ec;
--card: rgba(255, 255, 255, .72);
--ink: #1d1b19;
--sub: #6f6a63;
--line: rgba(29, 27, 25, .09);
--accent: #0c4b33; /* Django green */
--accent-soft: #e3efe8;
--on-accent: #fff;
--glow-1: #f6c6a8;
--glow-2: #b9d8c6;
--radius: 22px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #12110f;
--card: rgba(30, 28, 26, .72);
--ink: #f2eee8;
--sub: #9d968c;
--line: rgba(255, 255, 255, .08);
--accent: #44b78b;
--accent-soft: rgba(68, 183, 139, .14);
--on-accent: #08261a;
--glow-1: #5b2f1c;
--glow-2: #123a2a;
}
}
* { box-sizing: border-box; margin: 0; }
body {
min-height: 100vh;
font-family: "Inter", "Noto Sans JP", system-ui, sans-serif;
color: var(--ink);
background:
radial-gradient(40rem 30rem at 12% -5%, var(--glow-1), transparent 60%),
radial-gradient(36rem 28rem at 105% 10%, var(--glow-2), transparent 60%),
var(--bg);
background-attachment: fixed;
display: flex; flex-direction: column; align-items: center;
padding: 9vh 16px 48px;
-webkit-font-smoothing: antialiased;
}
.card {
width: 100%; max-width: 540px;
background: var(--card);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 1px 0 rgba(255,255,255,.5) inset, 0 30px 60px -30px rgba(40, 30, 20, .35);
padding: 32px 28px 22px;
}
header { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; }
.eyebrow { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--sub); font-weight: 600; }
h1 { font-size: 40px; letter-spacing: -.035em; font-weight: 700; line-height: 1.05; margin-top: 6px; }
.ring { --p: 0; width: 64px; height: 64px; border-radius: 50%; flex: none;
background: conic-gradient(var(--accent) calc(var(--p) * 1%), var(--line) 0);
display: grid; place-items: center; }
.ring span { width: 52px; height: 52px; border-radius: 50%; background: var(--bg);
display: grid; place-items: center; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
form.add { margin-top: 26px; display: flex; gap: 8px; }
form.add input {
flex: 1; min-width: 0; font: inherit; font-size: 16px; color: var(--ink);
padding: 14px 16px; border-radius: 14px; border: 1px solid var(--line);
background: rgba(255,255,255,.55); outline: none; transition: border-color .15s, box-shadow .15s;
}
@media (prefers-color-scheme: dark) { form.add input { background: rgba(0,0,0,.25); } }
form.add input:focus { border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
form.add button {
font: inherit; font-weight: 600; border: 0; border-radius: 14px; padding: 0 20px; cursor: pointer;
background: var(--accent); color: var(--on-accent); transition: transform .1s, filter .15s;
}
form.add button:hover { filter: brightness(1.1); }
form.add button:active { transform: scale(.97); }
nav.filters { display: flex; gap: 4px; margin: 22px 0 6px; padding: 4px; border-radius: 12px; background: var(--line); width: fit-content; }
nav.filters a { font-size: 13px; font-weight: 500; color: var(--sub); text-decoration: none; padding: 6px 14px; border-radius: 9px; }
nav.filters a[aria-current] { background: var(--bg); color: var(--ink); box-shadow: 0 1px 3px rgba(0,0,0,.08); }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: 12px; padding: 13px 4px; border-bottom: 1px solid var(--line);
animation: in .25s ease both; }
li:last-child { border-bottom: 0; }
@keyframes in { from { opacity: 0; transform: translateY(4px); } }
li form { display: contents; }
.check {
width: 24px; height: 24px; flex: none; border-radius: 50%; cursor: pointer;
border: 1.5px solid color-mix(in srgb, var(--sub) 60%, transparent); background: transparent;
display: grid; place-items: center; transition: all .15s; padding: 0;
}
.check:hover { border-color: var(--accent); background: var(--accent-soft); }
.check svg { width: 13px; height: 13px; opacity: 0; color: var(--on-accent); }
li.done .check { background: var(--accent); border-color: var(--accent); }
li.done .check svg { opacity: 1; }
.title { flex: 1; min-width: 0; font-size: 15.5px; line-height: 1.5; overflow-wrap: anywhere; }
li.done .title { color: var(--sub); text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--sub) 60%, transparent); }
.del { border: 0; background: none; color: var(--sub); cursor: pointer; font-size: 18px; line-height: 1;
width: 28px; height: 28px; border-radius: 8px; opacity: 0; transition: opacity .15s, background .15s; }
li:hover .del, .del:focus-visible { opacity: 1; }
@media (hover: none) { .del { opacity: .6; } }
.del:hover { background: var(--line); color: var(--ink); }
.empty { text-align: center; color: var(--sub); padding: 40px 0 28px; font-size: 14px; }
.empty b { display: block; font-size: 28px; margin-bottom: 6px; }
footer.meta { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; padding-top: 14px;
border-top: 1px solid var(--line); font-size: 13px; color: var(--sub); }
footer.meta button { font: inherit; border: 0; background: none; color: var(--sub); cursor: pointer; padding: 0; }
footer.meta button:hover { color: var(--ink); text-decoration: underline; }
.credit { margin-top: 22px; font-size: 12px; color: var(--sub); text-align: center; line-height: 1.8; }
.credit a { color: inherit; }
.pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 99px; border: 1px solid var(--line); }
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
</style>
</head>
<body>
<main class="card">
<header>
<div>
<div class="eyebrow">{% now "n月j日 (D)" %}</div>
<h1>Today</h1>
</div>
<div class="ring" style="--p: {{ percent }}" title="{{ done }} / {{ total }} 完了"><span>{{ percent }}%</span></div>
</header>
<form class="add" method="post" action="{% url 'add' %}">
{% csrf_token %}
<input name="title" maxlength="120" placeholder="やることを追加…" autocomplete="off" autofocus required>
<button type="submit">追加</button>
</form>
<nav class="filters">
<a href="/" {% if show == "all" %}aria-current="page"{% endif %}>すべて</a>
<a href="/?show=active" {% if show == "active" %}aria-current="page"{% endif %}>未完了</a>
<a href="/?show=done" {% if show == "done" %}aria-current="page"{% endif %}>完了</a>
</nav>
{% if todos %}
<ul>
{% for todo in todos %}
<li class="{% if todo.done %}done{% endif %}">
<form method="post" action="{% url 'toggle' todo.pk %}">
{% csrf_token %}<input type="hidden" name="show" value="{{ show }}">
<button class="check" aria-label="{% if todo.done %}未完了に戻す{% else %}完了にする{% endif %}">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5l3.2 3L13 4.5"/></svg>
</button>
</form>
<span class="title">{{ todo.title }}</span>
<form method="post" action="{% url 'delete' todo.pk %}">
{% csrf_token %}<input type="hidden" name="show" value="{{ show }}">
<button class="del" aria-label="削除">×</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<div class="empty"><b>☕</b>{% if total %}ここには何もありません{% else %}最初のタスクを追加してみましょう{% endif %}</div>
{% endif %}
<footer class="meta">
<span>残り {{ left }} 件</span>
{% if done %}
<form method="post" action="{% url 'clear' %}">{% csrf_token %}<button>完了済みを削除</button></form>
{% endif %}
</footer>
</main>
<p class="credit">
<span class="pill"><span class="dot"></span>Django on Cloudflare Workers + D1</span><br>
リストはブラウザごとに別々です
</p>
</body>
</html>チェックボタンや削除ボタンは、それぞれ小さな<form>になっています。li form { display: contents; }を指定すると、フォーム要素がレイアウトに影響しなくなり、ボタンがそのままflexの子要素として並びます。
ローカルで動かす
ローカル用のSecretを.dev.varsに書いておきます(Gitには含めないでください)。
echo "DJANGO_SECRET_KEY=$(python3 -c 'import secrets;print(secrets.token_urlsafe(50))')" > .dev.vars開発サーバーを起動します。
uv run pywrangler devhttp://localhost:8787を開くと、TODOアプリが表示されます。
ハマりどころ:タイムゾーンが見つからない
依存パッケージにtzdataを入れていたのは、このエラーを避けるためです。最初にtzdataなしで動かしたときは、画面に「Server Error (500)」と表示され、ログには次のエラーが出ていました。
zoneinfo._common.ZoneInfoNotFoundError: "No time zone found with key Asia/Tokyo.
On Pyodide you must do pyodide.loadPackage('tzdata') package before you use zoneinfo."通常のLinuxではOSのタイムゾーンデータベース(/usr/share/zoneinfo)が使われますが、Pyodideにはそれがありません。TIME_ZONE = 'Asia/Tokyo'を設定し、テンプレートで{% now %}タグを使ったところでエラーになりました。
TIME_ZONEをUTC以外にする場合は、最初からtzdataを入れておきましょう。
本番にデプロイする
本番用のSECRET_KEYをSecretとして登録します。
python3 -c 'import secrets;print(secrets.token_urlsafe(50))' | uv run pywrangler secret put DJANGO_SECRET_KEY本番のD1にマイグレーションを適用します。
uv run pywrangler d1 migrations apply django-todo-db --remoteデプロイします。
uv run pywrangler deploy成功すると、次のように表示されます。
Total Upload: 24219.85 KiB / gzip: 5262.87 KiB
Worker Startup Time: 3055 ms
Your Worker has access to the following bindings:
Binding Resource
env.DB (django-todo-db) D1 Database
Uploaded django-todo (21.22 sec)
Deployed django-todo triggers (1.02 sec)
https://django-todo.yamadori.workers.devDjango本体を含めて、アップロードサイズはgzip後で約5MBでした。
パフォーマンス:コールドスタート対策
最初はentry.pyでDjangoを読み込む処理をget_app()の中だけに書いていました。この状態でデプロイすると、レスポンスが返ってくるまでに4〜5秒かかりました。何度かアクセスすると0.1秒台になるので、原因はコールドスタート(Workerの起動)です。
Python Workersのメモリスナップショット
Python Workersは、デプロイ時に トップレベルのコードを実行した後のメモリの状態をスナップショットとして保存 し、起動時にはそこから復元します。つまり、トップレベルでimportしたモジュールは、起動のたびに読み込み直す必要がありません。
一方、Cloudflareのサンプルのようにget_app()の中でDjangoを読み込むと、Djangoのimportはスナップショットに含まれず、新しいインスタンスが起動するたびに実行されます。
そこで、entry.pyのトップレベルで、Djangoの主要なモジュールを先にimportするようにしました。
import django.core.handlers.wsgi
import django.db.models
import django.middleware.clickjacking
import django.middleware.csrf
import django.middleware.security
import django.shortcuts
import django.template.backends.django
import django.template.defaulttags
import django.urls
import django_cf.db.backends.d1.baseただし、django.setup()(get_wsgi_application()の中で呼ばれます)はトップレベルで実行していません。settings.pyでenvからSecretを読んでおり、envにアクセスできるのはリクエストの処理中だけだからです。
計測結果
それぞれの版をデプロイし直し、デプロイ直後の1回目と2回目のアクセスで、レスポンスの最初のバイトが返るまでの時間(TTFB)を3回ずつ測りました。
importを遅延した場合(get_app()の中だけで読み込む):
- 1回目:5.53秒 / 3.77秒 / 4.77秒
- 2回目:9.38秒 / 4.31秒 / 4.87秒
- Worker Startup Time:約0.9秒
importをトップレベルで行った場合:
- 1回目:2.50秒 / 2.45秒 / 2.33秒
- 2回目:1.60秒 / 1.34秒 / 3.26秒
- Worker Startup Time:約2.8秒
トップレベルでimportすると、コールドスタート時のレスポンスが おおむね半分以下 になりました。その代わり、デプロイ時に表示される「Worker Startup Time」は長くなります。これはスナップショットを作るときに、より多くのモジュールを読み込んでいるためです。
なお、ウォームな状態(Workerが起動済み)では、D1へのクエリを含めて0.12〜0.2秒程度で応答します。
注意点・制約
実際に動かしてみて分かった注意点をまとめます。
- トランザクションが使えない:django-cfのD1バックエンドはトランザクションに対応していません。
transaction.atomicを使ってもロールバックされず、各クエリはその場でコミットされます。 - migrateは手動:
manage.py migrateは使えないので、sqlmigrateで出したSQLをd1 migrations applyで流します。 - Django Adminは制限付き:django-cfのREADMEによると、D1ではAdminの一部が動かない場合があります。
- コールドスタートが遅い:対策をしても初回は1〜3秒程度かかります。アクセスの少ないサイトでは、ほとんどのリクエストがコールドスタートになる可能性があります。
- Paidプラン推奨:DjangoはCPU時間を多く使うため、django-cfは本番運用にWorkers Paidプランを推奨しています。
- os.environにSecretは入らない:
from workers import envで読み込みます。
おわりに
この記事では、pywrangler initとdjango-admin startprojectでプロジェクトを作り、D1をデータベースにしたTODOアプリをCloudflare Workersへデプロイしました。
django-cfのおかげで、モデル・ビュー・テンプレート・CSRF対策といった、普段どおりのDjangoの書き方がほぼそのまま使えます。settings.pyでWorkersかどうかを判定しておけば、makemigrationsやsqlmigrateなどのmanage.pyのコマンドもローカルで普通に使えます。サーバーの管理やスケーリングを気にせず、pywrangler deployだけで世界中のエッジにDjangoアプリを置けるのはかなり手軽です。
一方で、コールドスタートの遅さやトランザクション非対応など、従来のサーバーで動かすDjangoとは違う制約もあります。社内ツールや小規模なWebアプリ、Djangoの資産をサーバーレスで動かしたい場面では、十分に選択肢になると思います。
ぜひ完成品のTODOアプリも触ってみてください。




まだコメントはありません