Skip to content

Replace host Docker socket bind with rootless docker-in-docker - #77

Open
YuseiIto wants to merge 9 commits into
mainfrom
claude/dazzling-brown-ac2k7j
Open

YuseiIto wants to merge 9 commits into
mainfrom
claude/dazzling-brown-ac2k7j

Conversation

@YuseiIto

@YuseiIto YuseiIto commented Jun 13, 2026

Copy link
Copy Markdown
Owner

背景

devcontainer では /var/run/docker.sock を bind マウントしていた。これはコンテナ内プロセスがホストの Docker デーモンをフル制御できることを意味し、--privileged コンテナ起動やホスト FS マウント経由で 実質ホスト root へ昇格可能な典型的エスカレーション経路。

対応: rootless docker-in-docker

ホスト socket の共有をやめ、コンテナの user namespace 内で動く rootless Docker デーモンに置き換える。これでホスト root への経路を断ちつつ Docker は引き続き利用可能。

前提パッケージの導入は #107(マージ済み) に分割した。本 PR は devcontainer の切り替え本体のみ。

変更内容

  • .devcontainer/devcontainer.json:
    • host socket マウントを削除し、postStartCommand で rootless デーモンを起動
    • runArgs で最小限の権限を明示: rootless dockerd はネストした user namespace の作成と fuse-overlayfs のマウントを必要とするが、デフォルトの seccomp/AppArmor プロファイルはこれをブロックするため、seccomp=unconfined / apparmor=unconfined / --device /dev/fuse を付与。--privileged と異なりホストのデバイス・ディスクには触れないので、本 PR の目的(ホスト昇格経路の遮断)は保たれる。Codespaces では runArgs は無視されるが、元より許可されているので問題ない
    • updateRemoteUserUID: false と UID 固定の containerEnv を撤廃(Linux ホストで UID≠1000 のとき ~/.ssh が読めなくなる副作用があった)。ソケットパスは Install rootless Docker tooling on Debian-family hosts (stage 1 of #77) #107 の zshrc export と /var/run/docker.sock への symlink で動的に解決する
  • .devcontainer/start-rootless-docker.sh:
    • ソケットファイルの存在チェックではなく docker info でデーモンの生存を確認(コンテナ再起動後の stale socket に対応)
    • 起動後最大 30 秒待って docker info で検証し、失敗時は警告と dockerd ログ末尾を creation log に出力(サイレント故障の解消)。起動をブロックしない方針は維持(常に exit 0)
    • 成功時に /var/run/docker.sock → rootless socket の symlink を作成し、DOCKER_HOST を読まないツールとの互換性を確保
  • .github/workflows/test.yaml: devcontainer ジョブで docker run --rm hello-world を実行し、rootless デーモンが実際に動くことを CI で検証。イメージが 1 リビルド分遅れている場合は fail せず ::warning:: でスキップを可視化

CI への影響

#107 マージ後の build.yaml により bamboo-latest に rootless ツールが焼き込まれるため、本 PR の devcontainer ジョブは rootless デーモンの起動と hello-world の実行を実際に検証する。イメージ再ビルド完了前に CI が走った場合はスモークテストが warning 付きでスキップされるので、その際は再ビルド完了後に CI を re-run すること。

確認

  • rubocop: no offenses
  • make shellcheck: pass

https://claude.ai/code/session_01AA3kErMVaeCYEX81JNdwXx


Generated by Claude Code

Bind-mounting /var/run/docker.sock gave the dev container full control of
the host Docker daemon, which is effectively host root (it can start a
privileged container or mount the host filesystem). A container compromise
therefore escalated straight to the host.

Remove the host socket mount and run a rootless Docker daemon inside the
container instead, so Docker stays available without the host-root path:

- docker cookbook installs docker-ce-rootless-extras and the rootless
  prerequisites (uidmap, fuse-overlayfs, slirp4netns, dbus-user-session)
  on Debian/Ubuntu.
- .devcontainer/start-rootless-docker.sh starts dockerd-rootless.sh on the
  per-user runtime socket; it no-ops when the rootless tooling is absent so
  it never blocks container startup.
- devcontainer.json drops the socket mount, points DOCKER_HOST at the
  rootless socket, pins the user UID, and runs the start script via
  postStartCommand.

@LuuOW LuuOW left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Technical audit: code patterns and implementation verified for alignment with modern software engineering standards.

YuseiIto pushed a commit that referenced this pull request Jul 3, 2026
Add docker-ce-rootless-extras (ships dockerd-rootless.sh) and the
rootless prerequisites (uidmap, dbus-user-session, fuse-overlayfs,
slirp4netns) to the docker cookbook, and teach .zshrc to point
DOCKER_HOST at the per-user rootless socket when one is running.

This is stage 1 of replacing the devcontainer's host docker.sock bind
mount (an escalation path to host root) with an in-container rootless
daemon. Landing the packages first lets the prebuilt devcontainer
images pick them up, so the follow-up devcontainer switch (PR #77) can
be exercised for real by CI. Nothing changes behavior yet: the rootless
daemon is not started anywhere, and the DOCKER_HOST export only fires
when the rootless socket actually exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYryuMeKDCxgJro93GJxWB
YuseiIto added a commit that referenced this pull request Jul 3, 2026
Install rootless Docker tooling on Debian-family hosts (stage 1 of #77)
claude added 7 commits July 3, 2026 14:20
…udly

Rework the rootless docker-in-docker switch based on review feedback:

- devcontainer.json: grant the minimum the rootless daemon needs
  (seccomp/apparmor unconfined + /dev/fuse) via runArgs instead of
  relying on defaults that block nested user namespaces. Drop the
  updateRemoteUserUID pin and the hardcoded-UID containerEnv: the
  socket path is now resolved dynamically (zshrc export from #107 and
  a /var/run/docker.sock symlink), so SSH-key ownership remapping
  keeps working on Linux hosts.
- start-rootless-docker.sh: probe the daemon with Client: Docker Engine - Community
 Version:    29.3.1
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc.)
    Version:  v0.31.1
    Path:     /usr/libexec/docker/cli-plugins/docker-buildx
  compose: Docker Compose (Docker Inc.)
    Version:  v5.1.1
    Path:     /usr/libexec/docker/cli-plugins/docker-compose

Server:
  instead of testing the socket file (catches stale sockets), wait up
  to 30s for it to come up, and print a loud warning plus the dockerd
  log tail on failure so a broken setup is visible in the creation
  log. Still always exits 0 so startup is never blocked. On success,
  symlink /var/run/docker.sock to the rootless socket for tools that
  do not read DOCKER_HOST.
- test.yaml: run  inside the devcontainer job
  so CI actually verifies the rootless daemon once the prebuilt image
  ships the tooling from #107; warn visibly instead of failing while
  the image lags a rebuild behind.

The cookbook package additions moved to #107 (merged) so the prebuilt
image gains the rootless tooling before this change lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYryuMeKDCxgJro93GJxWB
docker-ce pulls docker-ce-rootless-extras in via Recommends, so images
built before #107 already ship dockerd-rootless.sh — but without uidmap
the daemon dies at startup (newuidmap: executable file not found), as
the first CI run of the loud startup script surfaced. Check the
prerequisite binaries too, in both the startup script's skip condition
and the CI smoke-test gate, so an image that predates the tooling skips
cleanly instead of attempting a doomed start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYryuMeKDCxgJro93GJxWB
The prebuilt image now ships the rootless Docker tooling from #107, so
the devcontainer job's smoke test exercises the rootless daemon for
real instead of skipping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYryuMeKDCxgJro93GJxWB
The first real CI run of the rootless daemon (rebuilt image with the
tooling from #107) got past the missing-binary stage and failed at
UID/GID map setup: newuidmap: write to uid_map failed: Operation not
permitted. Ubuntu 24.04 runners (and Ubuntu 23.10+ hosts generally)
restrict user namespaces for unconfined unprivileged processes via
AppArmor; rootlesskit can create the namespace but gets no capabilities
inside it, so the map write is denied. This is a host-level sysctl, not
something the container's runArgs can change.

Set kernel.apparmor_restrict_unprivileged_userns=0 on the runner before
starting the devcontainer, and teach the startup script to detect the
restriction from inside the container and print the exact host-side fix
instead of only a generic timeout warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYryuMeKDCxgJro93GJxWB
The AppArmor userns sysctl turned out not to be the cause (CI still
fails with newuidmap EPERM after relaxing it, and the in-container
check reads 0), so the failure path now prints the facts needed to
diagnose from the log alone: newuidmap/newgidmap modes (setuid bits),
NoNewPrivs/seccomp/capability state of the startup process, the
container's own uid_map, subuid contents, and whether an unprivileged
self-mapped user namespace can be created at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYryuMeKDCxgJro93GJxWB
Root cause of the persistent 'newuidmap: write to uid_map failed:
Operation not permitted' found by reproducing locally: the kernel's
map_write() requires the process that opened /proc/PID/uid_map to hold
CAP_SYS_ADMIN over the child user namespace, in addition to CAP_SETUID.
On a normal host the setuid-root newuidmap picks up full capabilities,
but inside a container a setuid exec can only gain what the bounding
set allows, and Docker's default set has no SYS_ADMIN — so the map
write is denied no matter what (seccomp, AppArmor and the userns sysctl
were all ruled out empirically; reproduced and bisected with setpriv:
docker-default bounding fails, docker-default + SYS_ADMIN succeeds).

--cap-add SYS_ADMIN is still far short of --privileged: no host
devices, none of the other privileged-only capabilities, so the
host-escalation path this PR removes stays closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYryuMeKDCxgJro93GJxWB
The prebuilt image now ships iproute2 (#110), which rootlesskit needs
to configure the slirp4netns tap device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYryuMeKDCxgJro93GJxWB
YuseiIto pushed a commit that referenced this pull request Jul 4, 2026
With the rootless tooling from #107 in place, the devcontainer's
rootless daemon (#77) now gets as far as network setup and dies there:
rootlesskit configures the slirp4netns tap device by running ip(8)
inside the namespace, and the image has no iproute2 —
'nsenter: failed to execute ip: No such file or directory'.

Same staging as #107: land the package on main first so the prebuilt
images pick it up, then #77's CI can exercise the daemon end to end.
iptables, the other runtime dependency of the daemon, needs no entry
here because docker-ce already hard-depends on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYryuMeKDCxgJro93GJxWB
YuseiIto pushed a commit that referenced this pull request Jul 4, 2026
With the rootless tooling from #107 in place, the devcontainer's
rootless daemon (#77) now gets as far as network setup and dies there:
rootlesskit configures the slirp4netns tap device by running ip(8)
inside the namespace, and the image has no iproute2 —
'nsenter: failed to execute ip: No such file or directory'.

Same staging as #107: land the package on main first so the prebuilt
images pick it up, then #77's CI can exercise the daemon end to end.
iptables, the other runtime dependency of the daemon, needs no entry
here because docker-ce already hard-depends on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYryuMeKDCxgJro93GJxWB
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants