Cloudflare WorkersでDjangoを動かそう|D1を使ったTODOアプリを作ってデプロイする

Python WorkersとD1、django-cfを使って、DjangoのTODOアプリをCloudflare Workersへデプロイします。マイグレーションの流し方やコールドスタート対策など、実際に動かして分かったハマりどころもまとめました。

はじめに

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.py

wrangler.jsoncには、Python Workersに必要なpython_workersの互換性フラグがすでに設定されています。Worker名はディレクトリ名と同じdjango-todoになります。

Djangoのプロジェクトとアプリを作る

依存パッケージを追加します。

uv add django django-cf tzdata

django-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-config

wrangler.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 application

DjangoCFを継承すると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.title

ownerには、訪問者ごとの匿名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_todos

migrations/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.sql

D1は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 dev

http://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.dev

Django本体を含めて、アップロードサイズは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アプリも触ってみてください。

https://django-todo.yamadori.workers.dev

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