volumes: concurrent EFS mount/unmount via per-volume locking#5009
volumes: concurrent EFS mount/unmount via per-volume locking#5009callaway12 wants to merge 4 commits into
Conversation
9c2e7c8 to
44085bd
Compare
44085bd to
1aa1043
Compare
|
Unfortunately our CI doesn't use |
|
I have updated our CI to run ecs-init unit tests with |
…ounts The EFS volume plugin serializes all volume mount/unmount operations behind a single sync.RWMutex. When multiple ECS tasks start concurrently, each requiring EFS volume mounts (15-72s each), queued requests exceed Docker's plugin timeout (~60s), causing CannotCreateContainerError/TaskFailedToStart. This change introduces per-volume mutexes so that mount/unmount I/O for different volumes can proceed in parallel: - AmazonECSVolumePlugin: replace single `lock` with `mapLock` (short-lived, protects map access only) + `volLocks` (per-volume mutexes held during I/O) - ECSVolumeDriver: release driver lock before mount/unmount syscalls since the plugin layer already holds the per-volume lock - Add TestConcurrentMountsDifferentVolumes to verify parallel execution Operations on the same volume remain serialized for correctness. Metadata-only operations (Create, List, Get, Path) continue using the global map lock since they don't perform I/O. Fixes aws/containers-roadmap#2319
- Split getOrCreateVolLock() into getVolLock(), createVolLock(), and getOrCreateVolLock() with double-checked locking to eliminate map write under RLock - Extend StateManager lock to cover map mutation and marshaling in recordVolume()/removeVolume(), fixing concurrent map access race - Add per-volume lock coordination to Remove() to prevent conflicts with in-flight Mount/Unmount operations - Add re-check after acquiring per-volume lock in Mount/Unmount to handle concurrent removal - Replace wall-clock assertion with sync.WaitGroup barrier pattern in TestConcurrentMountsDifferentVolumes - Add TestConcurrentMountsSameVolume to verify single driver Create() call under concurrent mounts - Fix seelog.Errorf format string in Unmount (missing %v for error)
1aa1043 to
943fd4c
Compare
|
Thanks for the review! Rebased onto latest Review feedback addressed:
Additional race conditions fixed (found via All tests pass with |
|
Thanks for your contribution, @callaway12. The implementation itself looks fine. However, I have concerns with the design/approach. Introducing fine-grained locking meaningfully grows the concurrency surface of this volume plugin, and makes maintainability/extensibility harder. As an alternative, I put together a rough sketch of an actor based model: singholt@40c72a7. Each volume would get a worker goroutine that processes its mount/unmount/remove operations from a mailbox. Same-volume operations would be ordered; and different volume operations would run concurrently because they're independent goroutines. There's no shared mutable volume state to lock during I/O, so the parallel-map and lifecycle issue simply don't arise. Its a bigger refactor than what you propose, but I think its the right long-term solution, and not too cumbersome to work on with AI-assisted development. Please let me know what you think. Once we are aligned, I can refine my "rough sketch" into a reviewable PR, test it and get feedback internally from my team as well. |
|
Thanks for the feedback and the actor-model sketch, @singholt. The design direction makes sense. That said, this issue is actively causing production task failures for us (37 failures over 3 months and accelerating). Could you share a rough timeline for when you'd expect the actor-based PR to be reviewable? If it's going to take a while, would you be open to merging the current per-volume locking as a stopgap and then refactoring to the actor model in a follow-up? Happy to help with the actor-model implementation as well if that speeds things up. |
Thanks. That sounds good to me. Let me get back to you on whether there are any other workarounds for mitigation of your issue. Apologies on that. If there aren't any feasible workarounds, I am good with merging this PR and including it in the next agent release. |
I looked into if setting a larger volume operation timeout is supported via a Docker daemon configuration. Unfortunately, I don't think there's an available option. Other workarounds include bin-packing fewer tasks (with EFS vols) per instance, but this is not a good enough workaround to directly address the problem at hand. I will review the PR next. |
TheanLim
left a comment
There was a problem hiding this comment.
Hi @callaway12, are you still able to address the review feedback? If you're blocked or don't have bandwidth, let us know — happy to help push it through. Thanks for the contribution!
- Drop getOrCreateVolLock and the nil-fallback in Mount/Unmount. volumes[k] and volLocks[k] are always inserted/deleted together under mapLock, so the fallback was defending against a state production code can't produce. - Drop the duplicate-mount check in ECSVolumeDriver.Create. The plugin layer already serializes Create per volume, and the check blocked a legitimate restart-recovery path where LoadState pre-registers volumeMounts via Setup for a volume with zero active mount IDs. - Fix three test setups that built the plugin with a struct literal without populating volLocks, so the invariant is enforced by the constructor contract rather than by defensive code in every hot path.
| // Re-check that the volume still exists after acquiring the per-volume lock, | ||
| // as Remove() may have deleted it while we were waiting. | ||
| a.mapLock.RLock() | ||
| if _, ok := a.volumes[r.Name]; !ok { | ||
| a.mapLock.RUnlock() | ||
| return nil, fmt.Errorf("volume %s was removed", r.Name) | ||
| } | ||
| a.mapLock.RUnlock() |
There was a problem hiding this comment.
| // Re-check that the volume still exists after acquiring the per-volume lock, | |
| // as Remove() may have deleted it while we were waiting. | |
| a.mapLock.RLock() | |
| if _, ok := a.volumes[r.Name]; !ok { | |
| a.mapLock.RUnlock() | |
| return nil, fmt.Errorf("volume %s was removed", r.Name) | |
| } | |
| a.mapLock.RUnlock() | |
| // Re-check under the map lock that the volume still exists AND that the lock we | |
| // are holding is still the one guarding it. A concurrent Remove()+Create() of the | |
| // same name replaces both the *types.Volume and its per-volume lock, so a bare | |
| // name-existence check would pass while `vol`/`volMu` point at the removed volume. | |
| // Re-fetch the live volume and verify lock identity; treat a mismatch as removal. | |
| a.mapLock.RLock() | |
| current, ok := a.volumes[r.Name] | |
| if !ok || a.getVolLock(r.Name) != volMu { | |
| a.mapLock.RUnlock() | |
| return nil, fmt.Errorf("volume %s was removed", r.Name) | |
| } | |
| vol = current | |
| a.mapLock.RUnlock() |
There was a problem hiding this comment.
Applied. Two things I ended up doing beyond the suggestion after walking through the code:
- Also re-fetched
volDriverfrom the currentvol.Type, since a Remove+Create that changes the driver type on the same name would otherwise route the I/O through the pre-Remove driver. Rare, but the identity check made it cheap to close. - Applied the same phase-2 identity check to
Remove. A second concurrentRemoveracing with aCreatecan cleanup/removeVolume the newly-created volume. It's a much narrower window in practice (didn't reproduce in 60k iterations of a stress test), but it's the same shape as the Mount/Unmount case and the fix costs nothing.
Added TestRemoveRecreateMountRace that catches the original Mount race by observing driver.Create's arguments against the live map entry — it fails on the previous code and passes with the identity check in place.
Ran go test -race -count=100 on the concurrency-sensitive tests, all green. Done in 6ac5a3c.
There was a problem hiding this comment.
Addressed together with the Mount thread — same phase-2 identity re-check pattern applied to Unmount (and Remove) in 6ac5a3c.
A Mount goroutine captures vol/volMu under mapLock.RLock and drops the read lock before acquiring volMu. If a Remove+Create for the same name lands in that window, both the *types.Volume and its per-volume mutex are replaced, but Mount is holding the old ones. The existing name-only re-check under mapLock passes, and Mount proceeds to call driver.Create with the removed volume's Path/Options and to write AddMount to the removed instance. Unmount has the same window. Remove has an analogous one, where a second Remove concurrent with a Create can end up cleaning up the newly-created volume's mount path. Fix by re-fetching the volume under the map lock in phase 2 and verifying that the per-volume mutex we hold is still the one guarding the current map entry. When the identity check fails, return "was removed" instead of mutating stale state. Also re-fetch volDriver from the current vol.Type so a rare type change on volume-name reuse can't misroute the I/O. TestRemoveRecreateMountRace exercises the Mount+Remove+Create interleaving and asserts, via a driver stub, that driver.Create is never invoked with Path/Options that disagree with the live map entry — the observable footprint of a stale-pointer write. The test passes under this fix and fails on the previous code. While here, populate volLocks in the five Remove test setups that build the plugin via struct literal, matching the invariant established when getOrCreateVolLock was dropped. Also apply gofmt to the field alignment that regressed with the earlier volLocks additions.
Summary
sync.RWMutexinAmazonECSVolumePluginwith a short-lived map lock (mapLock) + per-volume mutexes (volLocks), allowing mount/unmount I/O for different volumes to proceed concurrentlyECSVolumeDriver.Create/Removeto release the driver lock before the mount/unmount syscall, since the plugin layer already guarantees per-volume serializationTestConcurrentMountsDifferentVolumesthat verifies 5 parallel mounts complete in wall-clock time close to a single mount, not 5xMotivation
The EFS volume plugin processes all mount requests serially behind a single write lock. A single EFS mount takes 15–72 seconds (TLS tunnel + NFS4 negotiation). When N concurrent ECS tasks each require 2+ EFS mounts, queued requests block for up to
72 × 2Nseconds, exceeding Docker's plugin timeout (~60s) and causing:This is tracked in aws/containers-roadmap#2319.
Production impact: On a c6i.8xlarge running concurrent ECS tasks (EC2 launch type,
awsvpcmode) with multiple EFS volumes per task, we observed 37 task failures over 3 months directly caused by this serialization, with failure frequency accelerating as instance uptime increased.Design
Mount()Unmount()Create()Remove()List/Get/PathInvariants preserved:
StateManager.save()has its own mutex for file persistencetypes.Volume.AddMount/RemoveMountis called under the per-volume lock (as documented: "caller is responsible for holding any locks")Test plan
TestConcurrentMountsDifferentVolumes: 5 volumes with 50ms simulated mount latency complete in <200ms (proves concurrency), would take ≥250ms if serialFixes aws/containers-roadmap#2319