Skip to content

Stop subprocesses from getting unexpectedly killed#44215

Merged
thaJeztah merged 2 commits intomoby:masterfrom
corhere:fix-unlockosthread-pdeathsig
Oct 6, 2022
Merged

Stop subprocesses from getting unexpectedly killed#44215
thaJeztah merged 2 commits intomoby:masterfrom
corhere:fix-unlockosthread-pdeathsig

Conversation

@corhere
Copy link
Contributor

@corhere corhere commented Sep 28, 2022

The behaviour of (syscall.SysProcAttr).Pdeathsig on Linux has some surprising behaviour, as detailed in golang/go#27505. The parent death signal is sent to the child process when the creating thread dies, which in a Go program is going to be an arbitrary thread taken from the goroutine thread pool and recycled for other goroutines to execute on. A goroutine can cause the thread it is running on to exit by wiring itself to the thread with runtime.LockOSThread() and returning. Letting the creating thread become available for other goroutines risks having that thread terminated arbitrarily, and the child process with it.

While the problem can in theory be worked around by ensuring that no goroutine returns while wired to a thread, this is not practical. Unpaired runtime.LockOSThread() calls could be tucked away in third-party modules, and mistakes happen in first-party code. And there are legitimate use-cases for terminating threads early, which heavily overlap with the use-cases for pkg/reexec, which makes it impractical to ban. The only viable solution is to arrange for the threads which are parents of subprocesses started with Pdeathsig to not be eligible for other goroutines to execute on them while the subprocess is alive.

- What I did

  • Audited all calls to runtime.LockOSThread() in non-vendor code in the daemon process to ensure they had matching runtime.UnlockOSThread() calls. Unmatched calls in reexec functions were left alone.
  • Ensured that the threads which create subprocesses with Pdeathsig could not be terminated before the subprocess or the daemon process exited, whichever comes first.

- How I did it

  • runtime.LockOSThread() is both the problem and solution

- How to verify it

  • CI 🟢

- Description for the changelog

  • Resolved an issue which could potentially cause daemon-managed containerd or certain utility subprocesses from being prematurely killed on Linux

- A picture of a cute animal (not mandatory but encouraged)

Managed containerd processes are executed with SysProcAttr.Pdeathsig set
to syscall.SIGKILL so that the managed containerd is automatically
killed along with the daemon. At least, that is the intention. In
practice, the signal is sent to the process when the creating _OS
thread_ dies! If a goroutine exits while locked to an OS thread, the Go
runtime will terminate the thread. If that thread happens to be the
same thread which the subprocess was started from, the subprocess will
be signaled. Prevent the journald driver from sometimes unintentionally
killing child processes by ensuring that all runtime.LockOSThread()
calls are paired with runtime.UnlockOSThread().

Signed-off-by: Cory Snider <csnider@mirantis.com>
@corhere corhere added the kind/bugfix PR's that fix bugs label Sep 28, 2022
@neersighted
Copy link
Member

neersighted commented Sep 28, 2022

Immediate thoughts -- this invariant/explanation needs to be in the doc-comment of reexec.Command() in command_linux.go of pkg/reexec.

@corhere corhere force-pushed the fix-unlockosthread-pdeathsig branch from 65cdbad to 43d35bd Compare September 28, 2022 18:52
@cpuguy83
Copy link
Member

Oh wow... great find...

I don't like relying on thread locking to handle this.
The reason we set Pdeathsig is to make sure these subprocesses are cleaned up on exit.

Perhaps the way to go here is to have the systemd unit kill all processes on exit... we wouldn't be able to support live-restore when using managed containerd, but that may be a fare trade-off (or maybe we could move containerd to a separate cgroup in that case...).

@corhere
Copy link
Contributor Author

corhere commented Sep 28, 2022

I don't like relying on thread locking to handle this. The reason we set Pdeathsig is to make sure these subprocesses are cleaned up on exit.

Right, the subprocesses are cleaned up on daemon exit. Thread locking does not break that intended behaviour; it only prevents subprocesses from being spuriously killed before the daemon exits. No threads outlive a daemon exit or SIGKILL or OOM kill. runtime.LockOSThread() locks the calling goroutine to the thread, nothing more. The thread itself is not "locked". It does not prevent the thread from being terminated in the way you're thinking. It just prevents other goroutines from being executed on the thread, as other goroutines could make the thread terminate early.

func CausePdeathsigChaos() {
        for i := 0; i < 100; i++ {
                go func() {
                        runtime.LockOSThread()
                        return // Runtime terminates the thread currently executing this goroutine.
                }()
        }
}

Copy link
Member

@neersighted neersighted left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM (including a manual test with @corhere's Russian roulette code -- I wonder if there's a way we could turn that into an integration test?)

@corhere
Copy link
Contributor Author

corhere commented Sep 29, 2022

I don't think we'll need an explicit integration test as #44210 will bring the chaos all on its own. The existing integration tests blew up spectacularly on that PR before I rebased it on top of this change.

@thaJeztah
Copy link
Member

@corhere @cpuguy83 I recall there was one instance where a change was needed when we were discussing this; is that still the case?

@thaJeztah
Copy link
Member

Or was that in the other PR? 🤔

@corhere
Copy link
Contributor Author

corhere commented Oct 5, 2022

@thaJeztah could you be thinking of this one? #44210 (comment)

@thaJeztah
Copy link
Member

@thaJeztah could you be thinking of this one? #44210 (comment)

Hm.. no, I think there was one where, hm... what was it? Where go funcs would still be created? Perhaps @cpuguy83 recalls 🤔

On Linux, when (os/exec.Cmd).SysProcAttr.Pdeathsig is set, the signal
will be sent to the process when the OS thread on which cmd.Start() was
executed dies. The runtime terminates an OS thread when a goroutine
exits after being wired to the thread with runtime.LockOSThread(). If
other goroutines are allowed to be scheduled onto a thread which called
cmd.Start(), an unrelated goroutine could cause the thread to be
terminated and prematurely signal the command. See
golang/go#27505 for more information.

Prevent started subprocesses with Pdeathsig from getting signaled
prematurely by wiring the starting goroutine to the OS thread until the
subprocess has exited. No other goroutines can be scheduled onto a
locked thread so it will remain alive until unlocked or the daemon
process exits.

Signed-off-by: Cory Snider <csnider@mirantis.com>
@corhere corhere force-pushed the fix-unlockosthread-pdeathsig branch from 43d35bd to 1f22b15 Compare October 5, 2022 16:18
@corhere corhere requested a review from cpuguy83 October 5, 2022 18:14
Copy link
Member

@thaJeztah thaJeztah left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants