Skip to content
Open
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
| **age** | A simple, modern, and secure file-encryption tool. | `ghcr.io/hansehart/devcontainer-features/age` |
| **Claude Code CLI** | Anthropic's official command-line tool for agentic coding with Claude. | `ghcr.io/hansehart/devcontainer-features/claude-code` |
| **Codex CLI** | OpenAI's official command-line tool for agentic coding with Codex. | `ghcr.io/hansehart/devcontainer-features/codex` |
| **Docker in Docker** | A Docker engine for building and running containers inside the dev container. | `ghcr.io/hansehart/devcontainer-features/docker-in-docker` |
| **Docker in Docker** | A rootless Docker engine for building and running containers inside the dev container. | `ghcr.io/hansehart/devcontainer-features/docker-in-docker` |
| **Headless Chrome** | A headless Chrome build for browser automation and rendering. | `ghcr.io/hansehart/devcontainer-features/headless-chrome` |
| **LaTeX (TeX Live)** | The TeX Live distribution for typesetting LaTeX documents and bibliographies. | `ghcr.io/hansehart/devcontainer-features/latex` |
| **Node.js** | The Node.js JavaScript runtime, with npm and npx for installing and running packages. | `ghcr.io/hansehart/devcontainer-features/node` |
Expand Down
23 changes: 6 additions & 17 deletions src/docker-in-docker/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"id": "docker-in-docker",
"version": "1.2.1",
"version": "2.0.0",
"name": "Docker in Docker",
"description": "Installs a Docker engine for building and running containers inside the dev container.",
"description": "Installs a rootless Docker engine for building and running containers inside the dev container.",
"documentationURL": "https://github.com/hansehart/devcontainer-features/tree/main/src/docker-in-docker",
"options": {
"version": {
Expand All @@ -17,27 +17,16 @@
}
},
"entrypoint": "/usr/local/share/docker-in-docker/docker-init.sh",
"privileged": true,
"privileged": false,
"containerEnv": {
"DOCKER_BUILDKIT": "1"
"DOCKER_BUILDKIT": "1",
"DOCKER_HOST": "unix:///run/docker-rootless.sock"
},
"customizations": {
"vscode": {
"extensions": [
"ms-azuretools.vscode-containers"
]
}
},
"mounts": [
{
"source": "dind-var-lib-docker",
"target": "/var/lib/docker",
"type": "volume"
},
{
"source": "dind-var-lib-containerd",
"target": "/var/lib/containerd",
"type": "volume"
}
]
}
}
116 changes: 93 additions & 23 deletions src/docker-in-docker/docker-init.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
#!/bin/sh
set -e

# The unprivileged user that owns the daemon (persisted by install.sh).
RUSER="$(cat /usr/local/share/docker-in-docker/rootless-user 2>/dev/null || echo root)"

# The daemon runs as the remote user, and cannot start as root.
# Resolving the account here lets a name with no passwd entry take this branch too.
if [ "$RUSER" = "root" ] || ! RUSER_ENT="$(getent passwd "$RUSER")"; then
echo "docker-in-docker: needs a non-root remoteUser, the daemon stays down" >&2
# Hand off to the container command, or stop when there is none to hand off to.
exec "$@"
exit 0
fi

RUID="$(echo "$RUSER_ENT" | cut -d: -f3)"
RHOME="$(echo "$RUSER_ENT" | cut -d: -f6)"
RUNTIME_DIR="/run/user/${RUID}"

# Trust a mounted CA, so registries behind a TLS proxy resolve.
update-ca-certificates >/dev/null 2>&1 || true

# Match the iptables backend to the host kernel we share.
if type iptables-legacy > /dev/null 2>&1 \
&& { grep -qE '^ip_tables\b' /proc/modules || [ -d /sys/module/ip_tables ]; } \
Expand All @@ -23,44 +42,95 @@ if [ -d /sys/kernel/security ] && ! mountpoint -q /sys/kernel/security; then
fi
mountpoint -q /tmp || mount -t tmpfs none /tmp || true

# Delegate cgroup v2 controllers to a leaf so nested cgroups work.
if [ -f /sys/fs/cgroup/cgroup.controllers ]; then
mkdir -p /sys/fs/cgroup/init
# Retry the process move, which races with EBUSY on a cold boot.
# Nest the cgroup controllers where the host delegates them, and pass over where it does not.
if [ -f /sys/fs/cgroup/cgroup.controllers ] && mkdir -p /sys/fs/cgroup/init 2>/dev/null; then
# Move the existing processes into a leaf, retrying while they settle.
cg_tries=0
until xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null \
|| [ "$cg_tries" -ge 5 ]; do
sleep 1
cg_tries=$((cg_tries + 1))
done
# Hand the controllers down to that leaf.
sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers \
> /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true
fi

# Start the daemon and wait for it to accept commands.
dockerd_pid=""
# Provide the device the userspace network stack builds its tap on.
if [ ! -c /dev/net/tun ]; then
mkdir -p /dev/net
mknod /dev/net/tun c 10 200 2>/dev/null || true
# Readable by the remote user, which runs the network stack.
chmod 0666 /dev/net/tun 2>/dev/null || true
fi

# Prepare the runtime and data directories owned by the remote user.
# The store expects a volume here, since it cannot sit on the container's own overlayfs.
mkdir -p "$RUNTIME_DIR" "${RHOME}/.local/share/docker"
# The runtime directory carries the daemon socket, so it stays shut to the other accounts.
chmod 0700 "$RUNTIME_DIR"
chown "$RUSER":"$RUSER" "$RUNTIME_DIR" "${RHOME}/.local/share/docker" 2>/dev/null || true

# Restore the daemon settings when a volume mounted over the home hides the image's copy.
if [ -f /usr/local/share/docker-in-docker/daemon.json ] \
&& [ ! -f "${RHOME}/.config/docker/daemon.json" ]; then
mkdir -p "${RHOME}/.config/docker"
cp /usr/local/share/docker-in-docker/daemon.json "${RHOME}/.config/docker/daemon.json"
chown -R "$RUSER":"$RUSER" "${RHOME}/.config/docker" 2>/dev/null || true
fi

# Re-privilege the id-mapping helpers, since the image unpack drops their capabilities.
# no-new-privileges has to stay unset, or the kernel ignores what is set below.
for b in /usr/bin/newuidmap /usr/bin/newgidmap; do
# Keep the caller's identity, which is what the kernel grants the mapping to.
chmod u-s "$b" 2>/dev/null || true
# Carry the mapping rights as file capabilities instead.
setcap cap_setuid,cap_setgid+ep "$b" 2>/dev/null || true
done

# Publish the socket under a name that holds no user id.
# The daemon's own path holds one, and it is only known once the container runs.
ln -sfn "${RUNTIME_DIR}/docker.sock" /run/docker-rootless.sock

# Point login shells at the same name, for the ones that inherit no container environment.
printf 'export DOCKER_HOST=unix:///run/docker-rootless.sock\n' \
> /etc/profile.d/99-rootless-docker.sh

start_dockerd() {
# Clear stale pid files left by an unclean stop.
# Clear pid files left by an unclean stop, which otherwise block the next start.
find /run /var/run -iname 'docker*.pid' -delete 2>/dev/null || true
find /run /var/run -iname 'container*.pid' -delete 2>/dev/null || true
dockerd > /tmp/dockerd.log 2>&1 &
dockerd_pid=$!
tries=0
until docker info > /dev/null 2>&1 || [ "$tries" -ge 30 ]; do
sleep 1
tries=$((tries + 1))
done
docker info > /dev/null 2>&1

# Run as the remote user, with userspace networking and a private pid namespace.
# Exits unless the container's apparmor and seccomp profiles admit those namespaces.
# overlay2 needs the store on a real filesystem, which the volume above provides.
runuser -u "$RUSER" -- env \
HOME="$RHOME" \
XDG_RUNTIME_DIR="$RUNTIME_DIR" \
DOCKER_HOST="unix://${RUNTIME_DIR}/docker.sock" \
PATH="/usr/bin:/usr/local/bin:/sbin:/usr/sbin:/bin" \
DOCKERD_ROOTLESS_ROOTLESSKIT_NET=slirp4netns \
DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520 \
DOCKERD_ROOTLESS_ROOTLESSKIT_DETACH_NETNS=false \
DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS="--pidns" \
DOCKERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SANDBOX=auto \
DOCKERD_ROOTLESS_ROOTLESSKIT_SLIRP4NETNS_SECCOMP=auto \
dockerd-rootless.sh --storage-driver=overlay2 > /tmp/dockerd.log 2>&1
}

# Retry once when a stale lock in the persisted data volume kills the first start.
if ! start_dockerd; then
echo "docker-in-docker: dockerd did not come up, restarting once" >&2
cat /tmp/dockerd.log >&2 || true
kill "$dockerd_pid" 2>/dev/null || true
sleep 1
start_dockerd || { echo "docker-in-docker: dockerd failed to start" >&2; cat /tmp/dockerd.log >&2 || true; }
fi
# Supervise in the background, so the container command starts without waiting.
{
if ! start_dockerd; then
echo "docker-in-docker: rootless dockerd exited, retrying once" >&2
# Carry the reason into the container log, which is all a CI run gets to read.
cat /tmp/dockerd.log >&2 || true
sleep 2
if ! start_dockerd; then
echo "docker-in-docker: rootless dockerd failed to start" >&2
cat /tmp/dockerd.log >&2 || true
fi
fi
} &

# Hand off to the container's original entrypoint/command.
exec "$@"
53 changes: 43 additions & 10 deletions src/docker-in-docker/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,37 +25,70 @@ apt-get update

# Resolve: take the current CE packages, or pin them to an apt version matching VERSION.
if [ "$VERSION" = "latest" ]; then
ce="docker-ce docker-ce-cli"
ce="docker-ce docker-ce-cli docker-ce-rootless-extras"
else
pin="$(apt-cache madison docker-ce | awk -v v="$VERSION" '$3 ~ v {print $3; exit}')"
[ -n "$pin" ] || { echo "docker-in-docker: Docker CE version '$VERSION' not found in apt" >&2; exit 1; }
ce="docker-ce=$pin docker-ce-cli=$pin"
# The rootless extras depend on the exact engine version, so they carry the same pin.
ce="docker-ce=$pin docker-ce-cli=$pin docker-ce-rootless-extras=$pin"
fi

# Install: engine, CLI, containerd, and the buildx/compose plugins (Docker's official set).
# shellcheck disable=SC2086
apt-get install -y --no-install-recommends $ce containerd.io docker-buildx-plugin docker-compose-plugin

# Pin the engine so a later apt upgrade keeps it in sync with the persisted data volume.
apt-mark hold docker-ce docker-ce-cli containerd.io
# Install: the helpers and userspace networking an unprivileged daemon needs.
# iproute2 carries `ip`, which the userspace network stack calls to build its tap device.
# libcap2-bin carries `setcap`, which the entrypoint puts on the id-mapping helpers.
# uidmap carries those helpers, which map the subordinate ids granted below.
apt-get install -y --no-install-recommends \
fuse-overlayfs \
iproute2 \
libcap2-bin \
slirp4netns \
uidmap

# Configure: pin the engine so a later apt upgrade keeps it in sync with the persisted data volume.
apt-mark hold docker-ce docker-ce-cli docker-ce-rootless-extras containerd.io

rm -rf /var/lib/apt/lists/*

# Configure: add the non-root remote user to the docker group so it can run docker directly.
# Configure: add the non-root remote user to the docker group so the client resolves the daemon.
groupadd -f docker
# A root remote user has nothing to configure here, and leaves the daemon down at start.
if [ -n "$_REMOTE_USER" ] && [ "$_REMOTE_USER" != "root" ]; then
usermod -aG docker "$_REMOTE_USER" || true
fi

# Configure: write the requested daemon settings to daemon.json (empty leaves the file untouched).
if [ -n "$DAEMON_JSON" ]; then
mkdir -p /etc/docker
printf '%s\n' "$DAEMON_JSON" > /etc/docker/daemon.json
# Grant the subordinate ids the daemon maps its containers into.
if ! grep -q "^${_REMOTE_USER}:" /etc/subuid; then
echo "${_REMOTE_USER}:100000:65536" >> /etc/subuid
fi
if ! grep -q "^${_REMOTE_USER}:" /etc/subgid; then
echo "${_REMOTE_USER}:100000:65536" >> /etc/subgid
fi
fi

# Hook: install the entrypoint that starts dockerd at container start, then execs the container command.
install -d /usr/local/share/docker-in-docker
install -m 0755 "$(dirname "$0")/docker-init.sh" /usr/local/share/docker-in-docker/docker-init.sh

# Hook: persist which user runs the rootless daemon (the entrypoint has no _REMOTE_USER at runtime).
printf '%s\n' "${_REMOTE_USER:-root}" > /usr/local/share/docker-in-docker/rootless-user

# Configure: write the requested daemon settings into the image (empty writes nothing).
if [ -n "$DAEMON_JSON" ]; then
# The copy the entrypoint restores from when a volume hides the home directory.
printf '%s\n' "$DAEMON_JSON" > /usr/local/share/docker-in-docker/daemon.json

# The copy the daemon reads, in place before anything can look for it.
remote_home="${_REMOTE_USER_HOME:-}"
if [ -n "$remote_home" ] && [ "${_REMOTE_USER:-root}" != "root" ]; then
install -d -m 0755 "${remote_home}/.config/docker"
printf '%s\n' "$DAEMON_JSON" > "${remote_home}/.config/docker/daemon.json"
# By name, since the numeric id can still change.
chown -R "$_REMOTE_USER" "${remote_home}/.config" || true
fi
fi

# Verify: the CLI resolves on PATH.
docker --version
3 changes: 2 additions & 1 deletion test/docker-in-docker/daemon_json.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ set -e
# Import the test library
source dev-container-features-test-lib

check "daemon.json no-new-privileges written" grep -qF '"no-new-privileges":true' /etc/docker/daemon.json
# The rootless daemon reads its settings from the remote user's config directory.
check "daemon.json no-new-privileges written" grep -qF '"no-new-privileges":true' "$HOME/.config/docker/daemon.json"

# Report result
reportResults
30 changes: 30 additions & 0 deletions test/docker-in-docker/rootless_daemon.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/bin/bash
set -e

# Import the test library
source dev-container-features-test-lib

# The entrypoint starts the daemon in the background, so give it time to accept commands.
wait_for_daemon() {
for _ in $(seq 1 30); do
docker info > /dev/null 2>&1 && return 0
sleep 1
done
return 1
}

check "daemon reachable" bash -c "$(declare -f wait_for_daemon); wait_for_daemon"
check "daemon runs rootless" bash -c "docker info --format '{{.SecurityOptions}}' | grep -q rootless"
# The point of 2.0.0: the daemon comes up in a container that was never granted
# CAP_SYS_ADMIN, which privileged would have put in pid 1's bounding set. Read pid 1
# rather than this shell, whose capabilities are empty simply because it is not root.
check "container is unprivileged" bash -c 'bnd=$(awk "/^CapBnd:/ {print \$2}" /proc/1/status); [ $((0x$bnd >> 21 & 1)) -eq 0 ]'
check "overlay2 storage driver" bash -c "docker info --format '{{.Driver}}' | grep -qx overlay2"
# The published name has to reach a live socket.
check "socket is live at the fixed path" bash -c 'test -S /run/docker-rootless.sock && [ "$DOCKER_HOST" = "unix:///run/docker-rootless.sock" ]'
# And to lead to this shell's own runtime directory, which a path fixed at build time misses.
check "socket link resolves to the remote user" bash -c '[ "$(readlink /run/docker-rootless.sock)" = "/run/user/$(id -u)/docker.sock" ]'
check "nested container runs" docker run --rm hello-world

# Report result
reportResults
22 changes: 22 additions & 0 deletions test/docker-in-docker/scenarios.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"daemon_json": {
"image": "ubuntu:24.04",
"remoteUser": "ubuntu",
"features": {
"docker-in-docker": {
"daemonJson": "{\\\"no-new-privileges\\\":true}"
Expand All @@ -9,10 +10,31 @@
},
"pinned_version": {
"image": "ubuntu:24.04",
"remoteUser": "ubuntu",
"features": {
"docker-in-docker": {
"version": "28"
}
}
},
"rootless_daemon": {
"image": "ubuntu:24.04",
"remoteUser": "ubuntu",
"runArgs": [
"--security-opt",
"apparmor=unconfined",
"--security-opt",
"seccomp=${localEnv:PWD}/test/docker-in-docker/seccomp.json"
],
"mounts": [
{
"source": "dind-rootless-data",
"target": "/home/ubuntu/.local/share/docker",
"type": "volume"
}
],
"features": {
"docker-in-docker": {}
}
}
}
Loading
Loading