skip to content

On one Docker host, how do you start a replacement container beside the old one and cut traffic over without dropping requests?

level: seniorimportance: should knowfreq 40%

answer

  1. Two of them must fit at once
  2. Something exclusive is already taken
  3. Running is not the same as ready
  4. Route away first, stop second
  5. Keep the old one as the rollback

basics

~20 s

Run the new container under a different name and host port — or unpublished, on a user-defined bridge the proxy shares — wait until it is healthy, repoint the proxy, then stop the old one with a drain timeout.

solid answer

~50 s

Overlap requires two things to be free: the container **name** and the **published host port**. Reuse either and the second `docker run` fails with `Conflict. The container name … is already in use` or `Bind for 127.0.0.1:8127 failed: port is already allocated`. So name containers per build — `invoice-worker-7c41ab2` — and either publish a second port or publish nothing and attach both containers to a user-defined bridge the proxy is also on, where the engine's embedded DNS resolves them by name. Then gate the cutover on readiness: poll `docker inspect -f '{{.State.Health.Status}}'` until it reads `healthy`. Only then point the proxy at the new upstream and reload it. Leave the old container running until in-flight requests finish, then `docker stop -t 30`. Rollback is repointing the proxy back — the old container is still there.

code

bash · 12 lines
bash
# both containers on a user-defined bridge; no host port contention at all
docker network create edge 2>/dev/null || true
docker run -d --name invoice-worker-7c41ab2 --network edge \
  invoice-render:2026.08.19-7c41ab2

# gate the cutover on the engine's health state, not on `docker run` returning
for _ in $(seq 1 40); do
  s=$(docker inspect -f '{{.State.Health.Status}}' invoice-worker-7c41ab2)
  [ "$s" = healthy ] && break
  [ "$s" = unhealthy ] && { echo "new container unhealthy"; exit 1; }
  sleep 1
done

go deeper

for a junior

Know that a container name and a published host port can each be held by only one container, so a replacement started alongside the old one needs a different name and a different port.

for a middle

Explain the sequence and why each step is where it is: start, wait for readiness, route over, drain, stop. Be able to say why docker run returning does not mean the application is accepting connections.

for a senior

Show that you have run this in anger: the drain must outlast the request tail, SIGTERM handling in the image decides whether the grace period is real, and shared state or a non-idempotent consumer can make the overlap window itself the outage.

for a principal

Own the tradeoff: hand-rolled blue/green on one host needs headroom for two copies and has no controller to recover a half-finished deploy. Decide when that is the right amount of machinery and what evidence would move the workload onto something that reconciles state for you.

### What overlap actually requires Zero-downtime on one host means the two containers are up at the same time, so every host-level resource the old container holds exclusively must not be claimed by the new one. **The name.** `docker run --name invoice-worker` fails while a container of that name exists, even a stopped one: `Conflict. The container name "/invoice-worker" is already in use by container …`. Name per build instead — `invoice-worker-7c41ab2` — or alternate two fixed names, blue and green. **The published port.** `-p 127.0.0.1:8127:8000` binds a host socket, and a second container asking for the same one fails with `Bind for 127.0.0.1:8127 failed: port is already allocated`. Three ways out: 1. **A second fixed port.** Blue on 8127, green on 8128; the proxy's upstream alternates between them. Simple, and trivially inspectable. 2. **An ephemeral port.** `-p 127.0.0.1::8000` lets the engine pick a free host port, and `docker port invoice-worker-7c41ab2 8000` tells you which. The proxy config has to be templated from that value. 3. **No published port at all.** Put every container and the proxy on the same user-defined bridge network. On a user-defined bridge — unlike the default `docker0` bridge — the engine runs an embedded DNS resolver, so the proxy reaches `invoice-worker-7c41ab2:8000` by container name and nothing needs a host port. This is the cleanest of the three and it also keeps the app off the host's network surface entirely. ### The sequence For a FastAPI invoice-rendering service on `python:slim` with a 340 ms p99: ```bash NEW=invoice-worker-7c41ab2 docker pull invoice-render:2026.08.19-7c41ab2 docker run -d --name "$NEW" --network edge invoice-render:2026.08.19-7c41ab2 # 1. wait for readiness, do not assume it for i in $(seq 1 40); do [ "$(docker inspect -f '{{.State.Health.Status}}' "$NEW")" = healthy ] && break sleep 1 done # 2. cut over: proxy now sends new work to $NEW # 3. drain: old container keeps serving what it already accepted docker stop -t 30 invoice-worker-3b90fe1 docker rm invoice-worker-3b90fe1 ``` The ordering carries all the value. **Readiness before cutover.** `docker run` returning means the container was *created*, not that the application is listening. A Python service importing a rendering stack can take several seconds; cut over at second zero and you serve connection-refused. If the image declares a `HEALTHCHECK`, the engine tracks a health state you can read with `docker inspect`; if it does not, poll the app's own readiness path from the host before flipping. Never treat "the container is running" as "the container is ready". **Drain after cutover.** The old container is still holding requests it accepted before the flip. Stopping it immediately truncates them — with a 340 ms p99 you would cut a handful of in-flight renders on every deploy, which shows up as a small burst of 502s the deploy script never notices. Keep the old container alive until its connections close, then stop it. `docker stop -t 30` sends SIGTERM and only SIGKILLs after 30 seconds, so the grace period is real — **but only if the application handles SIGTERM by refusing new work and finishing what it has**. A Python process that ignores SIGTERM gets killed at the end of the timeout with work in flight, and the whole drain was theatre. That is worth verifying in the image, not assuming. **Rollback is free while the old container exists.** Because you have not removed it, reverting is one proxy change plus a reload — no pull, no start, no cold cache. That is the strongest argument for removing the old container as a separate, later step rather than as part of the deploy. ### The failure modes people hit * **Stopping the old container before the proxy stops routing to it.** Every request the proxy still sends there is refused. The order is always *route away, then stop*. * **Restarting the proxy instead of reloading it.** A restart drops the proxy's own listening socket and every connection through it, which is the outage you were trying to avoid. * **Assuming both containers can coexist.** They share the host. Two containers writing the same bind-mounted SQLite file, or holding what the app assumes is an exclusive lock, will corrupt or crash during the overlap window. A queue consumer will briefly have two readers — fine if the work is idempotent, not fine if it is not. Check this before adopting the pattern; it is the question a senior candidate is expected to raise unprompted. * **Letting names and images accumulate.** Per-build names mean stopped containers pile up. Remove the previous generation at the *start* of the next deploy, so exactly one old container is retained as the rollback target. ### What you are building, and its ceiling This is blue/green on one box, hand-rolled: two containers, a health gate, a routing flip, a drain. It is genuinely enough for a single host, and the failure surface is small enough to keep in your head. Its ceiling is also clear — it needs the host to have capacity for both copies at once, it is a script rather than a controller reconciling desired state, and nothing recovers it if the deploy dies halfway through the sequence.

  • Why does `docker stop -t 30` not guarantee that in-flight work finishes?
    `docker stop` sends SIGTERM to the container's PID 1 and SIGKILLs after the timeout. The grace period only helps if PID 1 actually receives and acts on SIGTERM — refusing new connections and completing accepted ones. A process that ignores the signal, or a shell-form entrypoint whose shell never forwards it to the real application, is killed at the end of the window with requests still open.
  • What can make two copies of the same service unsafe to run simultaneously on one host?
    Anything the container treats as exclusive: a bind-mounted file store or SQLite database with a single-writer assumption, a lock file, a scheduler or cron-like loop inside the image that would now fire twice, or a queue consumer whose work is not idempotent. If any of those apply, overlapping deploys need the state moved out first, or a brief stop-then-start is the honest choice.
  • How do you keep per-build container names from turning into clutter on the host?
    Remove the previous generation at the beginning of the next deploy rather than at the end of the current one. That keeps exactly one old container around as the rollback target for the whole life of a release, and makes the cleanup a step that is allowed to fail without affecting the running service.

saying these in an interview costs you the question

  • Stops the old container before traffic is routed away
  • Thinks two containers can publish the same host port
  • Treats `docker run` returning as the service being ready
  • Restarts the proxy rather than reloading it
  • Removes the old container immediately, losing the rollback
  • Ignores that both copies touch the same shared state

context