Race condition in HA: concurrent ATC instances attempt to create the same container, causing "already exists" error and silent duplicate task execution
Summary
In an HA deployment with multiple ATC (web) instances, a rolling restart can cause two ATCs to concurrently attempt to create the same container on a worker. The second attempt fails with new container: container "X": already exists, erroring the build and triggering a retry — but the original container continues running silently on the worker, causing downstream side effects (e.g. conflicting Terraform state on the retry).
Steps to Reproduce
- Run Concourse with two web (ATC) instances sharing the same PostgreSQL database
- Trigger a build that runs a task step
- While the build is in the container-creation phase (between
FindContainerreturningstate=creatingandcreatingContainer.Created()being called), perform a rolling restart — stop web1, allowing web2 to acquire the build tracking advisory lock - After web2 picks up the build and enters
createGardenContainer, restart web1 - Web1 re-acquires the build tracking lock, calls
Lookup(returns nil — container not yet in containerd), and also enterscreateGardenContainer - Both ATCs call
gardenClient.Createwith the same container handle concurrently
Expected Results
Only one ATC creates the container. The other either attaches to the existing container or waits. The build completes once without duplicate execution.
Actual Results
atc.tracker.tick.run.failed-to-create-container-in-garden
error: "new container: container \"4330d7d3-941c-4ca1-8dea-5b8175c7284a\": already exists"
atc.tracker.tick.run.errored
error: "run check: find or create container on worker db634eed7c74:
new container: container \"4330d7d3-941c-4ca1-8dea-5b8175c7284a\": already exists"The build is marked as errored and a retry is scheduled on a different worker. The original container continues executing on the first worker unobserved, causing side effects (e.g. Terraform state lock conflicts, duplicate deployments).
Web Node(s) configuration
- The build tracking advisory lock (
pg_try_advisory_lock(1, buildID)) is session-level and releases when the ATC's PostgreSQL connection is closed — this occurs during a rolling restart or when the connection pool recycles an idle connection - A second ATC acquires the now-free lock and calls
indOrCreateContainer - The DB shows
state=creating;gardenClient.Lookupreturns nil (container not yet in containerd) - Both ATCs proceed into
createGardenContainer→gardenClient.Create - The Garden backend's
createLockserializes the two HTTP requests — first succeeds, second callscontainerd.NewContainerand receivesErrAlreadyExists - The error propagates as
"new container: container X: already exists"with no recovery path —markContainerAsFailedis called and the build errors
Local changes for easier reproduce:
docker-compose.yml — add a second web service:
web2:
# copy of web service
ports: [8081:8080]
environment:
CONCOURSE_PEER_ADDRESS: web2
# ... same as webWorker CONCOURSE_TSA_HOST: web:2222,web2:2222
atc/worker/gardenruntime/worker.go — sleep before createGardenContainer to widen the race window:
if gardenContainer == nil {
time.Sleep(60 * time.Second) // gives time to stop/start web instances
gardenContainer, err = worker.createGardenContainer(...)
}worker/runtime/backend.go — sleep inside createContainer to hold createLock while the second ATC queues its request:
// before b.client.NewContainer(...)
time.Sleep(60 * time.Second)
return b.client.NewContainer(ctx, gdnSpec.Handle, labels, oci)Worker(s) configuration
No response
Concourse Version
8.3.0
Browser (if applicable)
No response
Did this use to work?
No response
Source: concourse/concourse