Case study Datavio · DevOps
Blue-green deploys that never interrupt a running workflow
Our backend ran long async workflows in-process, so every release had to wait for them to finish. I rebuilt deploys so any number of versions can run side by side — new traffic goes to the newest, and old versions finish what they started.
- Drain window for in-flight workflows (raised from 2 h for production)
- 3 h
- Health gate before any traffic moves — 60 checks, 2 s apart
- ~2 min
- Janitor sweep that reaps fully drained releases
- 15 min
- Rollback — point nginx back at the
.prevupstream - 1 step
docker compose up --build replaced the running containers, so every release had to wait for in-process workflows to finish. Now each release boots as its own Compose project named <sha>-<timestamp> on a fresh port, must pass a health gate, and nginx moves new traffic to it with a graceful reload. Older versions keep running until their in-flight work drains — up to 3 hours — and a cron janitor reaps them afterwards. Rollback is swapping a pointer.
01 · The problemDeploys had to wait for the work to stop
The backend is a FastAPI service on an Azure VM, and some requests kick off long-running async workflows inside the app process. Deploying was docker compose up --build — which rebuilds and replaces the running containers.
Anything still in flight would die with them. So every deploy started with a question nobody likes: is anything running right now? Releases became something you scheduled around the work, instead of something you just did.
Before
Wait until no workflow is running →compose up --build replaces containers in place → hope nothing started in between.After
Boot the new version beside the old → health-gate it → switch new traffic → let the old one finish → reap it later.02 · The intuitionStop replacing the thing doing the work
The fix isn't shorter workflows or a queue in front of everything. It's to stop replacing the process that's doing the work. If the old version keeps running while the new one takes fresh traffic, deploys and workflows stop competing for the same moment.
Don't make the work wait for the deploy — or the deploy wait for the work.
Blue-green is the classic shape of that idea: two environments, one live, one idle, flip between them. It got me most of the way. But two slots still mean a third deploy has to wait for a slot to drain. Generalising to one version per deploy removed that last constraint.
03 · The solutionA release lifecycle in six steps
- Boot a fresh version. Each deploy starts a new Compose project named
<sha>-<timestamp>on its own port, running uvicorn with 4 workers. - Gate on health. Nothing gets traffic until the new version passes its health check — up to 60 attempts, 2 seconds apart.
- Switch gracefully. The deploy rewrites nginx's upstream to the new port and reloads nginx gracefully, so existing connections finish on the old workers.
- Drain, don't kill. The previous version keeps running for a drain window — 2 hours, raised to 3 for production — so in-flight workflows complete.
- Reap on a schedule. A cron janitor runs every 15 minutes and removes versions whose drain window has passed.
- Roll back in one step. The previous upstream is recorded in a
.prevfile; rolling back is pointing nginx at it again.
04 · ImplementationThe deploy, and three bugs worth the price of admission
The deploy, in miniature
# deploy.sh — simplified
exec 9>deploy.lock && flock 9 # one deploy at a time
release="$(git rev-parse --short HEAD)-$(date +%s)"
port="$(next_free_port)"
docker compose -p "$release" up -d --build # new version, beside the old ones
wait_healthy "$port" 60 2 # 60 tries × 2 s, or abort
cp upstream.conf .prev # remember where we were
render_upstream "$port" > upstream.conf # point nginx at the new port
nginx -s reload # graceful: old connections finish
Simplified — helper functions and error handling omitted.
Bug 1 · The health check that never passed
"status": "ok". FastAPI returns compact JSON — {"status":"ok"} — so a perfectly healthy release never passed. Parse the JSON; don't grep it.Bug 2 · The lock held hostage
flock held on file descriptor 9. A git credential helper started during the deploy inherited that descriptor and outlived the script — keeping the lock alive and blocking every deploy after it. The fix was a few characters of shell: close fd 9 for the child with 9>&-.Bug 3 · The janitor with the wrong clock
05 · ResultsDeploys became a non-event
- Deploys no longer wait for running workflows. In production we've watched two versions serve at the same time — the new one taking every new request while the old one finished its work.
- No request is routed to a release that hasn't passed its health gate.
- Rolling back is a pointer swap, not a rebuild.
- The drain window is one setting, tuned to how long production workflows actually run — 3 hours today.
06 · LessonsWhat I took away
- Your deploy strategy is part of your application architecture. If the app holds in-process work, the deploy has to respect it.
- Two slots are a special case. When deploys can outpace drains, generalise to N.
- The bugs live in the edges — string matching, inherited file descriptors, the wrong clock. Test the unhappy paths on purpose.