6.0 KiB
Django → Rust migration runbook (seamless, reversible)
Migrate the running Django backend to the Rust backend (backend-rs/) with
zero downtime and a per-step rollback. Endpoint groups cut over one at a
time behind nginx; either backend can serve any request, so rollback is just the
reverse edit and data never forks.
There is a driver script that performs every step below:
scripts/cutover.sh (run scripts/cutover.sh with no args for the menu). Do the
steps manually or let the script do them — both edit the same files.
Why it's safe
Both backends share one substrate, so a request is serviceable by either:
| Shared thing | How |
|---|---|
| Database | same backend/db.sqlite3 (WAL on), bind-mounted into both containers |
| Media | same backend/media/; nginx serves /media/ straight from the mount (:ro) |
| Auth | same SECRET_KEY → SimpleJWT HS256 tokens verify on both; cookies cross-valid |
| Token blacklist | shared token_blacklist_* tables → logout/refresh consistent across a flip |
The one invariant — never violate: exactly one scheduler may own the
api_job table at a time. Django's Celery beat owns it until Phase 3; the Rust
in-process scheduler (ENABLE_SCHEDULER) stays false until then. Never both,
or scheduled jobs fire twice.
Prerequisites
SECRET_KEYin.envmust equal Django's exact value. If it differs, all existing JWTs break the instant you cut over auth. Verify before starting.- Back up first:
bash scripts/backup.sh(snapshots SQLite + media). - Optionally enable live nginx reloads so flips don't rebuild the frontend
image. Add to the
frontendservice indocker-compose.yml:thenvolumes: - ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf - ./frontend/proxy_params.conf:/etc/nginx/proxy_params.confdocker compose up -d frontend. Without this, each phase rebuilds the frontend image (docker compose up -d --build frontend) — slower, but still no API downtime. (scripts/cutover.sh dev-mountprints these lines.)
Phase 0 — Deploy Rust in shadow (no traffic)
Bring up holy-rust beside Django. It only reads; nginx still sends all traffic
to Django.
docker compose up -d --build backend-rs # or: scripts/cutover.sh phase0
Smoke-test it directly, bypassing nginx:
docker exec holy-frontend wget -qO- http://holy-rust:8001/health/ # or: scripts/cutover.sh smoke
Spot-check a few endpoints against holy-rust:8001 and compare to Django.
Rollback: docker compose stop backend-rs.
Phase 1 — Reads
Lowest risk: read-only, no auth side effects.
Flip these locations in frontend/nginx.conf from django_backend →
rust_backend, then reload:
/api/categories//api/flavors//api/banners/
scripts/cutover.sh phase1
Watch docker logs holy-rust and your error rate for a few minutes.
Rollback: scripts/cutover.sh rollback 1.
Phase 2 — Auth + writes
JWTs and cookies are cross-valid, so logged-in sessions survive the flip.
Flip:
/api/auth/= /api/token/(legacy JWT alias)/api/ratings//api/replies//api/users//api/notifications//api/tickets/
scripts/cutover.sh phase2
Rollback: scripts/cutover.sh rollback 2.
Phase 3 — Admin + jobs (the careful one)
This is the only step that touches the scheduler invariant. Do it in order.
- Flip
/api/admin-custom/→rust_backend, reload. - Stop Django's scheduler/workers so nobody schedules:
docker compose stop beat worker flower - Hand scheduling to Rust: set
RUST_ENABLE_SCHEDULER=truein.env, then recreate the Rust container:docker compose up -d backend-rs - Confirm single ownership:
docker logs holy-rust | grep "job scheduler ENABLED"
scripts/cutover.sh phase3 # does 1-4, prompts before the handover
At no point are both schedulers on → no double-run.
Rollback: scripts/cutover.sh rollback 3 — sets RUST_ENABLE_SCHEDULER=false,
recreates backend-rs, restarts beat worker flower, flips admin back to Django.
Phase 4 — Retire Django
Point the catch-all at Rust. Rust also serves OpenAPI + Swagger, so /api/schema
goes with it.
- Flip
location /api/and/api/schema→rust_backend, reload:Django now receives onlyscripts/cutover.sh phase4/admin/+/static/(its own admin site)./media/is already served straight from nginx, independent of either backend. - Decide the Django admin site's fate. The app's admin UI uses
/api/admin-custom/(now on Rust), so Django/admin/is usually unneeded:- Keep it: leave
backendup for/admin/+/static/. - Drop it:
docker compose stop backend, then delete the/admin/and/static/location blocks fromnginx.confand reload.
- Keep it: leave
- Once Django is fully off, stop Redis (only a Celery broker + Django cache —
Rust uses neither):
docker compose stop redis - Eventually remove
backend,worker,beat,flower,redisfromdocker-compose.yml.
Rollback: scripts/cutover.sh rollback 4 (before removing services).
Verify anytime
scripts/cutover.sh status # current routing per group + scheduler ownership
Notes / caveats
- Rate-limit counters reset at cutover. Django's
django_ratelimitlives in Redis; Rust's throttle is in-process. Acceptable — windows are minutes/hours. - Reloads: if the conf is baked into the image (default), a flip rebuilds the
frontend image. Bind-mount the conf (see Prerequisites) for instant
nginx -s reload. The script auto-detects which mode you're in. - Canonical data:
backend/db.sqlite3+backend/media/are host bind mounts — never recreated by rebuilds. Keep them; they're the source of truth the whole time. - The same content lives in
backend-rs/README.md("Production cutover runbook") as the in-tree reference.