volumes: concurrent EFS mount/unmount via per-volume locking#5009
volumes: concurrent EFS mount/unmount via per-volume locking#5009callaway12 wants to merge 2 commits into
Conversation
9c2e7c8 to
44085bd
Compare
44085bd to
1aa1043
Compare
| a.mapLock.RUnlock() | ||
| return nil, fmt.Errorf("no volume driver found for type %s", vol.Type) | ||
| } | ||
| volMu := a.getOrCreateVolLock(r.Name) |
There was a problem hiding this comment.
Looks like we are assuming that this method call will only get a pre-created lock and not create it. Which is true but that is not clear from reading this code in isolation. I suggest two separate methods - getVolLock() and createVolLock() so it's clear which one needs RLock and which one needs Lock.
There was a problem hiding this comment.
Good catch. I've refactored this into three explicit methods to make the intent clear:
getVolLock(name)— read-only lookup, used undermapLock.RLock()in Mount/UnmountcreateVolLock(name)— write-only, used inCreate/LoadStatewhich already holdmapLock.Lock()getOrCreateVolLock(name)— double-checked locking pattern for the fallback path:- Try
RLock→ fast path if lock exists - Upgrade to
Lock→ re-check, create only if still nil
- Try
This eliminates the original race (map write under RLock) and makes each call site's contract explicit.
| assert.NotEmpty(t, vol.CreatedAt) | ||
| } | ||
|
|
||
| // TestConcurrentMountsDifferentVolumes verifies that mount operations for different |
There was a problem hiding this comment.
Can we have a test that tests that concurrent calls to Mount() leads to a single mount call in the driver?
There was a problem hiding this comment.
Added TestConcurrentMountsSameVolume. It fires 5 concurrent Mount() calls for the same volume and asserts:
driver.Create()is called exactly once (.Times(1))- All 5 mounts are recorded (
len(vol.Mounts) == 5) - Each individual mount ID has a reference count of 1
| assert.Less(t, elapsed.Milliseconds(), int64(200), | ||
| "concurrent mounts should complete faster than serial execution") |
There was a problem hiding this comment.
Wall clock based assertions usually end up flaking in some way. How about forcing overlap in the mock function? Something like -
var entered sync.WaitGroup
entered.Add(numVolumes)
slowDriver.EXPECT().Create(gomock.Any()).DoAndReturn(func(...) error {
entered.Done() // signal "I'm here"
entered.Wait() // block until all are here
return nil
}).Times(numVolumes)There was a problem hiding this comment.
Agreed — replaced the wall-clock assertion with a sync.WaitGroup barrier pattern:
var entered sync.WaitGroup
entered.Add(numVolumes)
mockDriver.EXPECT().Create(gomock.Any()).DoAndReturn(func(r *driver.CreateRequest) error {
entered.Done() // signal arrival
entered.Wait() // block until all goroutines are inside Create()
return nil
}).Times(numVolumes)This deterministically proves all mount calls overlap inside the driver, with no timing dependency.
|
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 |
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