-
Notifications
You must be signed in to change notification settings - Fork 18.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix plugin lock when restoring containers #25005
Conversation
@cpuguy83 Would you mind reviewing? |
@ohmystack I'll have to re-review the whole system. These locks are there for a reason, so just removing them seems dangerous without careful review. Also, not sure how this would change the sequential-ness since it still needs to wait while doing the lookup. It should be you've just chagned where it waits at.... but again I'll have to go through the whole system. Also ping @anusha-ragunathan |
lookup() uses two types of locks:
Both are necessary to prevent races. Refer to https://github.com/docker/docker/blob/3d13fddd2bc4d679f0eaa68b0be877e5a816ad53/pkg/locker/README.md for more info. |
@anusha-ragunathan Agree. After looking carefully, I find the problem is that:
So, the fix can be:
Do you agree with this logic? |
a84a139
to
88e0064
Compare
How do you think about this change? (If ok, I will do a squash.) |
The method name, maybe
|
62966ec
to
b793c67
Compare
CC @thaJeztah |
23acc98
to
b793c67
Compare
From Jenkins logs, 2 tests failed because |
@@ -90,6 +93,13 @@ func (l *Locker) Lock(name string) { | |||
// once locked then we can decrement the number of waiters for this lock | |||
nameLock.Lock() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't this where it's going to block at?
I think we'll need to consider a different implementation, possibly a condition variable that can broadcast to all waiters on error or signal a single waiter when the lock is ready.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, @cpuguy83 . All goroutines block here, but only one can do CancelWithError
, this goroutine will unlock the lock. After that, one of the other goroutines will continue running from this line, and it checks the error, give up the lock and returns the error, just like the lock is canceled. Though the waiters are canceled one by one, it is very quick because the judgement logic is inside of locker.Locker("name").
Another way, like you said, I've thought about using sync.Cond
to replace the sync.Mutex
.
locker.Lock("name")
if locker.IsFirstOneGetTheLock("name") { // Maybe mix sync.Once to do this
// do the stuff, get the plugin (wait 16s), get err
if err != nil {
locker.SetError("name", err)
}
locker.Broadcast("name")
} else {
for condition() { // How to define the condition here?
locker.Wait("name")
}
locker.Unlock("name)
}
Its advantage is that pkg/locker
looks much more like a "Lock" package. However, it is more complicated and harder to be used.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No need to expose the condition variable as the API, only need to use it internally instead of using a literal sync.Mutex.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I got it. But I have a concern that if we put the "only do once during a locker[name]'s lifecycle" logic inside the pkg/locker
, the package maybe not so universal.
The behavior still feels a little strange, but I see what it's trying to accomplish. @anusha-ragunathan This shouldn't be a problem at all with the new plugin arch, will it? |
@cpuguy83 @anusha-ragunathan I think the new one will be fine. In the new plugin arch,
HOWEVER, if the old plugin runs in the new arch without I have build with |
drivers.driverLock.Lock(name) | ||
if err := drivers.driverLock.Lock(name); err != nil { | ||
return nil, fmt.Errorf("Error looking up volume plugin %s: %v", name, err) | ||
} | ||
defer drivers.driverLock.Unlock(name) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lock() failure already Unlocks. Calling Unlock on defer on an already Unlocked lock will cause runtime errors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But the defer
is after the if err
.
If there is an error, it returns immediately at "line 96". No chance to run "line 98" defer Unlock
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My bad, what you have is fine. I'm used to seeing this format:
err := Lock()
defer UnLock()
if err != nil {
// handle error
}
New plugin arch doesnt have expo backoff retry logic like legacy. All plugins are started alongside the daemon. Old plugins cannot run using the new infra. They need to be rewritten. |
If a goroutine holds a lock for a long time to do a job and it gets an error, this goroutine should tell others, who are also waiting for this lock to do the same job, that this job will return error, don't waste your time again. This happens when looking for an volume plugin. Everytime dockerd waits an 16s without knowing the same volume/plugin has already failed before. CancelWithError indicates that other goroutines who are waiting for the lock will get an error, instead of getting the lock. Their locker.Lock(name) is canceled by this caller. Signed-off-by: ohmystack <jun.jiang02@ele.me>
b793c67
to
b171d9e
Compare
I did a squash, and re-triggered the tests. |
According to #23446
Though new arch is fine, this fix is still needed by legacy volume plugins. |
@ohmystack : What "backwards compatibility with existing plugins is maintained" means is that, new plugin arch will not invalidate legacy plugins. Legacy plugins will continue to work in parallel with the new plugins. |
I see. How about I say it in this way, the new plugin arch is fine, but this bug is still needed to be fixed for legacy plugins. |
@@ -309,16 +309,20 @@ func (s *VolumeStore) create(name, driverName string, opts, labels map[string]st | |||
// This makes sure there are no races between checking for the existence of a volume and adding a reference for it | |||
func (s *VolumeStore) GetWithRef(name, driverName, ref string) (volume.Volume, error) { | |||
name = normaliseVolumeName(name) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the intent of the changes in store.go? Are you trying to fix all callsites of Locker.Lock to set and catch appropriate errors? If so, there's more (eg, libcontainer/client.go and even VolumeStore's Get(), CreateWithRef(), etc) and I'd recommend doing that in a separate PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These two locks prevent different races, which you have analyzed in previous comments. If a lock would hold for a long time, such as 16s, we should catch the error to prevent it from happening again and again. This is why I catch the error at these 2 places. These 2 locks may take 16s until they unlock.
If the lock action is very quick, it is totally fine to ignore the error.
If a lock uses CancelWithError
, every Lock
action of this lock should be wrapped in an if err
block. If the lock doesn't use the CancelWithError
, it is fine to ignore the error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And I think you're right, maybe there are many "slow" locks in other places. It is a good idea to fix them in another PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
GetDriver() calls lookup() which can be a long standing operation. There are other calls that hold a Locker.Lock and call GetDriver, for ex, Remove().
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make sense. Maybe next PR? This time, for restoring containers at the daemon start, only needs these 2 places. If I remove the GetWithRef
part, it won't work again... 😥
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm thinking it would be better if there's a separate version of Lock called LockWithError() that checks for error. Callers that use this version should always use CancelWithError() to set errors. This would be cleaner. What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe not...
Lock()
and LockWithError()
cannot be mixed used for one named lock. Once the lock is created in locks[name], it should be "cancelable" or "un-cancelable".
So another bool variable, cancelable
, needs to be added into lockCtr
struct, then we should check that both in old Lock()
and new LockWithError()
.
When writing the logic, if Lock()
meets a cancelable
locker, the problem comes, old Lock()
cannot return an error to indicate that it is locking a wrong type locker...
Beside, adding a new method LockWithError()
still cannot ensure that developer will catch the error correctly and not do Unlock after LockWithError()
returns an error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When Lock() encounters a cancelable
locker, it can Panic. This will ensure that the developer is conscious of choosing a cancel-able Lock vs a normal Lock. What we dont want is to turn all locks into cancelable locks.
If developer doesnt catch LockWithError() error, then Unlock will result in runtime error. So its okay.
My whole fix, "cancel a lock", might be a bad idea.
Need to find another way to fix this bug. |
Thanks @ohmystack, sorry that it didn't work |
The problem occurs when starting
dockerd
with the containers using thesame unavailable plugin.
The containers are restored one-by-one, not asynchronous.
The dockerd needs to wait (N)*(8 secs) for restoring all the containers.
(N is the number of containers using the unavailable plugin.)
How to reproduce
Step 1. Run the
rbd-docker-plugin
and thedockerd
.Step 2. Create 2 containers using plugin
rbd
.Step 3. Stop
rbd-docker-plugin
.Step 4. Restart
dockerd
. (Now dockerd is unable to connect pluginrbd
)Then, you will find the
dockerd
logs like this:Totally use 2 * 16 seconds = 32 secs.
Should be
Every container, who waits for the plugin, should start to wait at the same time, like this,
Totally use 16 seconds.
The cause
Though daemon.prepareMountPoints(c) runs in seperated goroutines,containers are still initialized synchronously because it is blocked at
"GetWithRef".
When multiple goroutines call
drivers.lookup
for the same volume concurrently,lookup
to initialize a volume driver.plugin.LookupWithCapability(name, extName)
, put plugin intostorage.plugins[name]
drivers.extensions[name]
lookup
holds a lock (for Step1 + Step2) at the beginning, which is before Step1's lock. So that the 2nd goroutine's Step1 cannot go into its wait loop, Every goroutine's Step1 becomes synchronous.Here's the fixes
Only after getting the volume driver successfully should the volume be locked by name.There is no need to lock drivers when "lookup" a driver. Because"lookup" actually does a GetOrCreate thing. When it is doing
(pkg/plugins/plugins.go)get(name string), there is already a lock to
ensure the same plugin won't be registered twice. So we can remove
the outer lock in "lookup". Nor, it will block the "prepareMountPoints".