Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion images/patched-docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,15 @@ ADD --checksum=sha256:953fb6d2f4b6284284939d7d2659b1bc0a20f748d210f9f7f8f5f47ba0
WORKDIR /src
RUN tar xf /tmp/runc.tar.xz --strip-components=1 && tar xf /tmp/debian.tar.xz \
&& while read -r patch; do case "$patch" in ''|'#'*) continue ;; esac; git apply "debian/patches/$patch"; done < debian/patches/series
# Upstream runc #5091: Go 1.26 forbids retrying Start on the same exec.Cmd.
# Systemd moves init to a child cgroup, forcing CLONE_INTO_CGROUP to fall back.
COPY patches/0007-runc-go126-exec-retry.patch /tmp/exec-retry.patch
RUN git apply /tmp/exec-retry.patch
ARG TARGETARCH
RUN --mount=type=cache,id=kintsugi-runtime-runc-go,target=/root/.cache/go-build \
case "$TARGETARCH" in arm64) compiler=aarch64-linux-gnu ;; amd64) compiler=x86_64-linux-gnu ;; *) exit 1 ;; esac \
&& CGO_ENABLED=1 GOOS=linux GOARCH="$TARGETARCH" CC="${compiler}-gcc" PKG_CONFIG_LIBDIR="/usr/lib/${compiler}/pkgconfig" \
go build -mod=vendor -trimpath -tags 'apparmor seccomp selinux ambient' -ldflags '-s -w -X main.version=1.4.0-0ubuntu1' -o /out/runc .
go build -mod=vendor -trimpath -tags 'apparmor seccomp selinux ambient' -ldflags '-s -w -X main.version=1.4.0-0ubuntu1+kintsugi-exec-retry' -o /out/runc .

FROM ubuntu AS runtime
LABEL org.opencontainers.image.title="Kintsugi Docker runtime" \
Expand Down
144 changes: 144 additions & 0 deletions images/patched-docker/patches/0007-runc-go126-exec-retry.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
From 82b7597a26726b6d52967f4142678e30be5ff870 Mon Sep 17 00:00:00 2001
From: Kir Kolyshkin <kolyshkin@gmail.com>
Date: Thu, 29 Jan 2026 13:39:45 -0800
Subject: [PATCH 2/3] libct: check cmd.Err after exec.Command call

Theoretically, exec.Command can set cmd.Err.

Practically, this should never happen (Linux, Go <= 1.26, exePath is
absolute), but in the unlikely case it does, let's fail early.

This is related to the cloneCmd (to be introduced by the following
commit) which chooses to not copy the Err field. Theoretically,
exec.Command can set Err and so the first call to cmd.Start will fail
(since Err != nil), and the second call to cmd.Start may succeed because
Err == nil. Yet, this scenario is highly unlikely, but better be safe
than sorry.

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
---
libcontainer/container_linux.go | 6 ++++++
1 file changed, 6 insertions(+)

diff --git a/libcontainer/container_linux.go b/libcontainer/container_linux.go
index af9172a89a4..b4368547caf 100644
--- a/libcontainer/container_linux.go
+++ b/libcontainer/container_linux.go
@@ -528,6 +528,12 @@ func (c *Container) newParentProcess(p *Process) (parentProcess, error) {
}

cmd := exec.Command(exePath, "init")
+ // Theoretically, exec.Command can set cmd.Err. Practically, this
+ // should never happen (Linux, Go <= 1.26, exePath is absolute),
+ // but in the unlikely case it just did, let's fail early.
+ if cmd.Err != nil {
+ return nil, fmt.Errorf("exec.Command: %w", cmd.Err)
+ }
cmd.Args[0] = os.Args[0]
cmd.Stdin = p.Stdin
cmd.Stdout = p.Stdout

From cb31d62f1ca71843dc9851c9be993b1a0d30c8a9 Mon Sep 17 00:00:00 2001
From: Kir Kolyshkin <kolyshkin@gmail.com>
Date: Mon, 26 Jan 2026 16:46:36 -0800
Subject: [PATCH 3/3] Fix exec vs Go 1.26

Since [PR 4812], runc exec tries to use clone3 syscall with
CLONE_INTO_CGROUP, falling back to the old method if it is not
supported.

One issue with that approach is, a

> Cmd cannot be reused after calling its [Cmd.Start], [Cmd.Run],
> [Cmd.Output], or [Cmd.CombinedOutput] methods.

(from https://pkg.go.dev/os/exec#Cmd).

This is enforced since Go 1.26, see [CL 728642], and so runc exec
actually fails in specific scenarios (go1.26 and no CLONE_INTO_CGROUP
support).

The easiest workaround is to pre-copy the p.cmd structure (copy = *cmd).
From the [CL 734200] it looks like it is an acceptable way, but it might
break in the future as it also copies the private fields, so let's do a
proper field-by-field copy. If the upstream will add cmd.Clone method,
we will switch to it.

Also, we can probably be fine with a post-copy (once the first Start has
failed), but let's be conservative here and do a pre-copy.

[PR 4812]: https://github.com/opencontainers/runc/pull/4812
[CL 728642]: https://go.dev/cl/728642
[CL 734200]: https://go.dev/cl/734200

Reported-by: Efim Verzakov <efimverzakov@gmail.com>
Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
---
libcontainer/cmd_clone.go | 37 +++++++++++++++++++++++++++++++++++
libcontainer/process_linux.go | 5 ++++-
2 files changed, 41 insertions(+), 1 deletion(-)
create mode 100644 libcontainer/cmd_clone.go

diff --git a/libcontainer/cmd_clone.go b/libcontainer/cmd_clone.go
new file mode 100644
index 00000000000..67418070f08
--- /dev/null
+++ b/libcontainer/cmd_clone.go
@@ -0,0 +1,37 @@
+package libcontainer
+
+import "os/exec"
+
+// cloneCmd creates a copy of exec.Cmd. It is needed because cmd.Start
+// must only be used once, and go1.26 actually enforces that (see
+// https://go-review.googlesource.com/c/go/+/728642). The implementation
+// is similar to
+//
+// cmd = *c
+// return &cmd
+//
+// except it does not copy private fields, or fields populated
+// after the call to cmd.Start.
+//
+// NOTE if Go will add exec.Cmd.Clone, we should switch to it.
+func cloneCmd(c *exec.Cmd) *exec.Cmd {
+ cmd := &exec.Cmd{
+ Path: c.Path,
+ Args: c.Args,
+ Env: c.Env,
+ Dir: c.Dir,
+ Stdin: c.Stdin,
+ Stdout: c.Stdout,
+ Stderr: c.Stderr,
+ ExtraFiles: c.ExtraFiles,
+ SysProcAttr: c.SysProcAttr,
+ // Don't copy Process, ProcessState, Err since
+ // these fields are populated after the start.
+
+ // Technically, we do not use Cancel or WaitDelay,
+ // but they are here for the sake of completeness.
+ Cancel: c.Cancel,
+ WaitDelay: c.WaitDelay,
+ }
+ return cmd
+}
diff --git a/libcontainer/process_linux.go b/libcontainer/process_linux.go
index b3487fd0b1b..fd075d4b9ad 100644
--- a/libcontainer/process_linux.go
+++ b/libcontainer/process_linux.go
@@ -380,11 +380,14 @@ func (p *setnsProcess) startWithCgroupFD() error {
defer fd.Close()
}

+ cmdCopy := cloneCmd(p.cmd)
err = p.startWithCPUAffinity()
if err != nil && p.cmd.SysProcAttr.UseCgroupFD {
logrus.Debugf("exec with CLONE_INTO_CGROUP failed: %v; retrying without", err)
// SysProcAttr.CgroupFD is never used when UseCgroupFD is unset.
- p.cmd.SysProcAttr.UseCgroupFD = false
+ cmdCopy.SysProcAttr.UseCgroupFD = false
+ // Must not reuse exec.Cmd.
+ p.cmd = cmdCopy
err = p.startWithCPUAffinity()
}

24 changes: 24 additions & 0 deletions images/patched-docker/smoke-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,30 @@ tail -n +2 /tmp/cache-files | xargs stat -c '%n %i %Y' > /tmp/cache-before`);
const started = performance.now();
await ctr(consumer, "images", "pull", "--platform", platform, "--snapshotter", "erofs", base);
console.log(`${platform}: preload ${((performance.now() - started) / 1000).toFixed(2)}s`);
// Reproduce systemd's cgroup layout: init lives in a child while the parent
// delegates a domain controller. CLONE_INTO_CGROUP(parent) must fail EBUSY,
// then runc must use a fresh exec.Cmd and join init's actual child cgroup.
await exec(consumer, "docker", "run", "--detach", "--name", "exec-cgroup-check",
"--privileged", "--cgroupns=host", "--network=none", "--entrypoint", "sh", base, "-ec", `
parent="/sys/fs/cgroup$(awk -F: '$1 == 0 {print $3}' /proc/self/cgroup)"
mkdir "$parent/exec-child"
echo $$ > "$parent/exec-child/cgroup.procs"
echo +memory > "$parent/cgroup.subtree_control"
touch /tmp/exec-cgroup-ready
exec sleep infinity
`);
for (let attempt = 0; ; attempt++) {
const ready = await command(["docker", "exec", consumer, "docker", "exec", "exec-cgroup-check", "test", "-f", "/tmp/exec-cgroup-ready"], false);
if (ready.code === 0) break;
assert(attempt < 30, `cgroup exec fixture failed: ${ready.stderr}`);
await Bun.sleep(100);
}
for (let attempt = 0; attempt < 3; attempt++) {
const actual = await exec(consumer, "docker", "exec", "exec-cgroup-check", "cat", "/proc/self/cgroup");
assert.match(actual.trim(), /\/exec-child$/, "exec must join init's delegated child cgroup");
}
await exec(consumer, "docker", "rm", "--force", "exec-cgroup-check");
console.log(`${platform}: cgroup-v2 exec fallback passed`);
const assertNoBaseBlobs = async () => {
const content = new Set((await ctr(consumer, "content", "list", "--quiet")).trim().split("\n"));
for (const digest of baseLayers) assert(!content.has(digest), `base blob downloaded: ${digest}`);
Expand Down