Skip to content

Commit 925e996

Browse files
committed
Fix foreground stdio deadlock when the internal logging process stops consuming
`nerdctl run` in the foreground tees container stdout/stderr through a 64 KiB pipe to a forked logging process (`nerdctl _NERDCTL_INTERNAL_LOGGING`) on the same goroutine that drains the container's stdio FIFOs. If that logging process ever stopped consuming (killed, crashed, OOM), the tee write blocked forever: nerdctl kept its own copies of the logger pipe read ends open, so the kernel never delivered EPIPE. The blocked goroutine stopped draining the stdout FIFO, the container's writer blocked behind the full pipe at exactly pipe-capacity-plus-one-chunk bytes, the container never exited, `nerdctl run` never returned, and `nerdctl rm -f` on the wedged container hung as well. Four changes, from the analysis in #5137: - Close the parent's copies of the logger pipe read ends once the logging process has started (the equivalent of containerd's binaryIO.CloseAfterStart), so that logger death turns into EPIPE on the tee instead of an eternal pipe-buffer block. - Make the logger leg of the stdio tee best-effort: on the first failed write, warn and stop writing to the logger, but keep streaming to the attached stdout/stderr. Closing the read ends alone is not enough: the EPIPE would error the io.MultiWriter, abort the io.CopyBuffer that drains the container's stdio FIFO, and the container would still wedge on the undrained FIFO chain behind it. With both changes the attach and the container survive logger death; the log file is what goes incomplete (with a warning on nerdctl's stderr). - In the logging process, do not treat an errored delivery on the task wait channel as a container exit. containerd's client sends Wait RPC failures through the same channel as a synthetic ExitStatus; cancelling the stdio readers on such a delivery silently stopped all logging while the container was still running - and, before the changes above, wedged the foreground attach permanently. Re-arm the wait instead, and close each containerd client once its wait delivers so re-arming does not accumulate open clients. - Fail IO setup when a binary-v2 logging binary exits before signalling readiness (mirroring containerd's n == 0 check); plain binary:// keeps EOF-as-ready for backward compatibility with third-party logging binaries. Fixes #5137 Signed-off-by: An Lu <an.lu.91@googlemail.com>
1 parent 98a8ade commit 925e996

4 files changed

Lines changed: 326 additions & 17 deletions

File tree

pkg/cioutil/container_io.go

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
"github.com/containerd/containerd/v2/cmd/containerd-shim-runc-v2/process"
3333
"github.com/containerd/containerd/v2/defaults"
3434
"github.com/containerd/containerd/v2/pkg/cio"
35+
"github.com/containerd/log"
3536
)
3637

3738
const binaryIOProcTermTimeout = 12 * time.Second // Give logger process 10 seconds for cleanup
@@ -52,6 +53,44 @@ var bufPool = sync.Pool{
5253
},
5354
}
5455

56+
// closeOnce wraps f's Close so that extra calls return the first result
57+
// instead of "file already closed". The logger pipe ends below are closed
58+
// individually on the success path, but also sit in the error-path closers
59+
// list of NewContainerIO.
60+
func closeOnce(f *os.File) func() error {
61+
var once sync.Once
62+
var err error
63+
return func() error {
64+
once.Do(func() { err = f.Close() })
65+
return err
66+
}
67+
}
68+
69+
// bestEffortWriter forwards writes to w until one fails, then silently
70+
// discards all further writes. The pipe feeding the logging binary is wrapped
71+
// in this before it joins the stdio tee of a foreground container: logging is
72+
// best-effort there, and a dead logging binary (EPIPE once our read ends are
73+
// closed, see NewContainerIO) must not error the whole tee — that would stop
74+
// the copier that drains the container's stdio FIFO and deadlock the
75+
// container. The attach keeps streaming; the log is what goes incomplete.
76+
// https://github.com/containerd/nerdctl/issues/5137
77+
type bestEffortWriter struct {
78+
w io.Writer
79+
dead bool
80+
}
81+
82+
func (b *bestEffortWriter) Write(p []byte) (int, error) {
83+
// Only ever called from the single stdio copy goroutine of its stream, so
84+
// no locking is needed.
85+
if !b.dead {
86+
if _, err := b.w.Write(p); err != nil {
87+
b.dead = true
88+
log.L.WithError(err).Warn("writing container output to the logging binary failed; further output will not be logged")
89+
}
90+
}
91+
return len(p), nil
92+
}
93+
5594
func (c *ncio) Config() cio.Config {
5695
return c.config
5796
}
@@ -156,19 +195,23 @@ func NewContainerIO(namespace string, logURI string, tty bool, stdin io.Reader,
156195
if err != nil {
157196
return nil, err
158197
}
159-
closers = append(closers, stdoutr.Close, stdoutw.Close)
198+
closeStdoutR := closeOnce(stdoutr)
199+
closers = append(closers, closeStdoutR, stdoutw.Close)
160200

161201
stderrr, stderrw, err := os.Pipe()
162202
if err != nil {
163203
return nil, err
164204
}
165-
closers = append(closers, stderrr.Close, stderrw.Close)
205+
closeStderrR := closeOnce(stderrr)
206+
closers = append(closers, closeStderrR, stderrw.Close)
166207

167208
r, w, err := os.Pipe()
168209
if err != nil {
169210
return nil, err
170211
}
171-
closers = append(closers, r.Close, w.Close)
212+
closeR := closeOnce(r)
213+
closeW := closeOnce(w)
214+
closers = append(closers, closeR, closeW)
172215

173216
u, err := url.Parse(logURI)
174217
if err != nil {
@@ -184,18 +227,44 @@ func NewContainerIO(namespace string, logURI string, tty bool, stdin io.Reader,
184227
closers = append(closers, func() error { return cmd.Process.Kill() })
185228

186229
// close our side of the pipe after start
187-
if err := w.Close(); err != nil {
230+
if err := closeW(); err != nil {
188231
return nil, fmt.Errorf("failed to close write pipe after start: %w", err)
189232
}
190233

234+
// Close our copies of the stdio read ends that were handed to the
235+
// logging binary; the child holds its own duplicates via ExtraFiles.
236+
// This is the equivalent of containerd's binaryIO.CloseAfterStart.
237+
// If this process kept the read ends open, a logging binary that
238+
// stops reading (killed, crashed, ...) would never surface as EPIPE
239+
// on the tee writes below: the stdio copy goroutine would block
240+
// forever on the full pipe, stop draining the container's stdout
241+
// FIFO, and deadlock both the container and `nerdctl run` itself
242+
// (including `nerdctl rm -f` of the wedged container).
243+
// https://github.com/containerd/nerdctl/issues/5137
244+
if err := closeStdoutR(); err != nil {
245+
return nil, fmt.Errorf("failed to close stdout pipe read end after start: %w", err)
246+
}
247+
if err := closeStderrR(); err != nil {
248+
return nil, fmt.Errorf("failed to close stderr pipe read end after start: %w", err)
249+
}
250+
191251
// wait for the logging binary to be ready
252+
// For binary-v2, readiness requires a byte to be written before close.
253+
// For binary, EOF is treated as ready for backward compatibility.
192254
b := make([]byte, 1)
193-
if _, err := r.Read(b); err != nil && err != io.EOF {
255+
n, err := r.Read(b)
256+
if err != nil && err != io.EOF {
194257
return nil, fmt.Errorf("failed to read from logging binary: %w", err)
195258
}
259+
if u.Scheme == "binary-v2" && n == 0 {
260+
return nil, errors.New("logging binary did not call ready (it may have crashed or exited prematurely)")
261+
}
262+
if err := closeR(); err != nil {
263+
return nil, fmt.Errorf("failed to close ready pipe read end: %w", err)
264+
}
196265

197-
stdoutWriters = append(stdoutWriters, stdoutw)
198-
stderrWriters = append(stderrWriters, stderrw)
266+
stdoutWriters = append(stdoutWriters, &bestEffortWriter{w: stdoutw})
267+
stderrWriters = append(stderrWriters, &bestEffortWriter{w: stderrw})
199268
}
200269

201270
streams.Stdout = io.MultiWriter(stdoutWriters...)

pkg/cioutil/container_io_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cioutil
18+
19+
import (
20+
"errors"
21+
"os"
22+
"testing"
23+
)
24+
25+
type failingWriter struct {
26+
calls int
27+
}
28+
29+
func (f *failingWriter) Write(p []byte) (int, error) {
30+
f.calls++
31+
return 0, errors.New("broken pipe")
32+
}
33+
34+
// TestBestEffortWriter verifies that a failing logger pipe never errors the
35+
// stdio tee: the first failed write disables the writer and every write still
36+
// reports full success, so the copier draining the container's stdio keeps
37+
// running. Regression test for
38+
// https://github.com/containerd/nerdctl/issues/5137
39+
func TestBestEffortWriter(t *testing.T) {
40+
fw := &failingWriter{}
41+
b := &bestEffortWriter{w: fw}
42+
43+
for i := 0; i < 3; i++ {
44+
n, err := b.Write([]byte("data"))
45+
if err != nil {
46+
t.Fatalf("write %d: best-effort writer must not return an error, got %v", i, err)
47+
}
48+
if n != 4 {
49+
t.Fatalf("write %d: expected n=4, got %d", i, n)
50+
}
51+
}
52+
if fw.calls != 1 {
53+
t.Fatalf("expected the underlying writer to be abandoned after the first failure, got %d calls", fw.calls)
54+
}
55+
}
56+
57+
// TestBestEffortWriterClosedPipe exercises the real failure mode: writing to
58+
// an os.Pipe whose read end is closed (EPIPE), as happens when the logging
59+
// binary dies after our copies of its read ends were closed.
60+
func TestBestEffortWriterClosedPipe(t *testing.T) {
61+
r, w, err := os.Pipe()
62+
if err != nil {
63+
t.Fatal(err)
64+
}
65+
r.Close()
66+
defer w.Close()
67+
68+
b := &bestEffortWriter{w: w}
69+
for i := 0; i < 2; i++ {
70+
if n, err := b.Write([]byte("data")); err != nil || n != 4 {
71+
t.Fatalf("write %d: expected (4, nil), got (%d, %v)", i, n, err)
72+
}
73+
}
74+
if !b.dead {
75+
t.Fatal("expected the writer to be marked dead after EPIPE")
76+
}
77+
}

pkg/logging/logging.go

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -200,16 +200,37 @@ func getContainerWait(ctx context.Context, address string, config *logging.Confi
200200
if err != nil {
201201
return nil, err
202202
}
203+
// closeAfterDelivery forwards the first delivery from ch and then closes
204+
// the client, so that callers which re-arm the wait (see the wait loop in
205+
// loggingProcessAdapter) do not accumulate open clients.
206+
closeAfterDelivery := func(ch <-chan containerd.ExitStatus) <-chan containerd.ExitStatus {
207+
out := make(chan containerd.ExitStatus, 1)
208+
go func() {
209+
defer close(out)
210+
defer client.Close()
211+
if status, ok := <-ch; ok {
212+
out <- status
213+
}
214+
}()
215+
return out
216+
}
203217
con, err := client.LoadContainer(ctx, config.ID)
204218
if err != nil {
219+
client.Close()
205220
return nil, err
206221
}
207222

208223
task, err := con.Task(ctx, nil)
209224
if err == nil {
210-
return task.Wait(ctx)
225+
exitCh, err := task.Wait(ctx)
226+
if err != nil {
227+
client.Close()
228+
return nil, err
229+
}
230+
return closeAfterDelivery(exitCh), nil
211231
}
212232
if !errdefs.IsNotFound(err) {
233+
client.Close()
213234
return nil, err
214235
}
215236

@@ -232,16 +253,24 @@ func getContainerWait(ctx context.Context, address string, config *logging.Confi
232253
for {
233254
select {
234255
case <-ctx.Done():
256+
client.Close()
235257
return nil, errors.New("timed out waiting for container task to start")
236258
case <-ticker.C:
237259
task, err = con.Task(ctx, nil)
238260
if err == nil {
239-
return task.Wait(ctx)
261+
exitCh, err := task.Wait(ctx)
262+
if err != nil {
263+
client.Close()
264+
return nil, err
265+
}
266+
return closeAfterDelivery(exitCh), nil
240267
}
241268
if !errdefs.IsNotFound(err) {
269+
client.Close()
242270
return nil, err
243271
}
244272
if outputSeen() {
273+
client.Close()
245274
return alreadyExited(), nil
246275
}
247276
}
@@ -250,6 +279,10 @@ func getContainerWait(ctx context.Context, address string, config *logging.Confi
250279

251280
type ContainerWaitFunc func(ctx context.Context, address string, config *logging.Config, outputSeen func() bool) (<-chan containerd.ExitStatus, error)
252281

282+
// containerWaitRetryDelay is how long the logger waits before re-arming the
283+
// container wait after the wait channel delivered an error instead of an exit.
284+
const containerWaitRetryDelay = 1 * time.Second
285+
253286
func loggingProcessAdapter(ctx context.Context, driver Driver, dataStore, address string, getContainerWait ContainerWaitFunc, config *logging.Config) error {
254287
if err := driver.PreProcess(ctx, dataStore, config); err != nil {
255288
return err
@@ -374,15 +407,34 @@ func loggingProcessAdapter(ctx context.Context, driver Driver, dataStore, addres
374407
// keeps the stdio FIFO write ends open (so the container can be
375408
// restarted), so the FIFOs may not reach EOF on exit; without this the
376409
// read goroutines, and therefore the logger, could block forever.
377-
exitCh, err := getContainerWait(ctx, address, config, outputSeen)
378-
if err != nil {
379-
// We could not determine when the container exits. Do not cancel the
380-
// readers: they will finish on their own when the FIFO reaches EOF.
381-
// Cancelling here could truncate a still-running container.
382-
log.G(ctx).Errorf("failed to get container task wait channel: %v", err)
383-
return
410+
for {
411+
exitCh, err := getContainerWait(ctx, address, config, outputSeen)
412+
if err != nil {
413+
// We could not determine when the container exits. Do not cancel the
414+
// readers: they will finish on their own when the FIFO reaches EOF.
415+
// Cancelling here could truncate a still-running container.
416+
log.G(ctx).Errorf("failed to get container task wait channel: %v", err)
417+
return
418+
}
419+
status := <-exitCh
420+
if status.Error() == nil {
421+
// The container has exited.
422+
break
423+
}
424+
// The channel delivered a Wait RPC error, not a container exit:
425+
// containerd's client sends Wait failures through the same channel
426+
// as a synthetic ExitStatus (client/task.go). Treating that as an
427+
// exit would cancel the readers, and with them all logging, while
428+
// the container is still running. Re-arm the wait instead.
429+
// https://github.com/containerd/nerdctl/issues/5137
430+
log.G(ctx).WithError(status.Error()).Warn("error while waiting for container exit; retrying")
431+
select {
432+
case <-ctx.Done():
433+
// SIGTERM: the goroutine above already cancels the readers.
434+
return
435+
case <-time.After(containerWaitRetryDelay):
436+
}
384437
}
385-
<-exitCh
386438
stdoutR.Cancel()
387439
stderrR.Cancel()
388440
}()

0 commit comments

Comments
 (0)