Skip to content

Commit eea16e1

Browse files
authored
Merge pull request #22 from ci-sourcerer/feat/docker-in-docker
feat(docker): add Docker-in-Docker support for container image
2 parents b85271b + f2cb3b8 commit eea16e1

11 files changed

Lines changed: 663 additions & 12 deletions

File tree

‎docs/tasks/container-images.md‎

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ COPY . /tmp/build/
7777
# Export debug requirements when the project defines a debug dependency group
7878
RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }}{% for mount in UV_INDEX_SECRET_MOUNTS %} \
7979
--mount={{ mount }}{% endfor %} \
80-
uv export --group debug --no-hashes --format requirements-txt --output-file requirements-debug.txt
80+
uv export --frozen --only-group debug --no-hashes --format requirements-txt --output-file requirements-debug.txt
8181
{% endif %}
8282

8383
# Build the wheel, caching the uv cache directory to speed up subsequent builds.
@@ -172,10 +172,11 @@ LABEL git.commit=${GIT_COMMIT}
172172
# current package does not provide a console script, the entrypoint will default to `python`
173173
RUN echo "#!/bin/sh
174174
175-
{{ ENTRYPOINT_COMMAND|default('python') }} \"\$@\"" >/pkg/entrypoint.sh \
175+
exec {{ ENTRYPOINT_COMMAND|default('python') }} \"\$@\"" >/pkg/entrypoint.sh \
176176
&& chmod +x /pkg/entrypoint.sh
177177

178-
USER py
178+
ARG RUNTIME_USER=py
179+
USER ${RUNTIME_USER}
179180

180181
{% if EXTENSION_CONTENT %}
181182
{{ EXTENSION_CONTENT }}
@@ -193,13 +194,13 @@ COPY --from=builder /tmp/build /tmp/build
193194

194195
RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} pip install --root-user-action=ignore -r /tmp/build/requirements-debug.txt
195196

196-
USER py
197+
USER ${RUNTIME_USER}
197198
{% endif %}
198199

199200
# Final (default) image: explicitly use runtime as the final target so debug is not used unless requested
200201
FROM runtime AS final
201202

202-
USER py
203+
USER ${RUNTIME_USER}
203204
```
204205

205206
### Dependency image template
@@ -310,12 +311,14 @@ RUN apt-get update \
310311
USER py
311312
```
312313

313-
Extension files are concatenated in their configured order and inserted near the end of `runtime`, after the entrypoint is created and after `USER py`. An extension that needs elevated permissions must switch to `USER root`; it should normally restore `USER py` for the instructions that follow. `COPY` paths remain relative to the project-root build context.
314+
Extension files are concatenated in their configured order and inserted near the end of `runtime`, after the entrypoint is created and after `USER ${RUNTIME_USER}` (which defaults to `py`). An extension that needs elevated permissions must switch to `USER root`; it should normally restore `USER ${RUNTIME_USER}` for the instructions that follow. Extensions that need to start as another user can redeclare `ARG RUNTIME_USER=root`; both the final and debug stages honor this argument. `COPY` paths remain relative to the project-root build context.
314315

315316
Extension content is treated as raw Dockerfile syntax, not as a Jinja template. This keeps project extensions independent of private template variables used by `common-python-tasks`.
316317

317318
`CONTAINER_EXTENSIONS` selects extension bundles shipped in the installed package's `data/dockerfile_extensions/` directory. Bundle names are colon-delimited and are applied after local extension files. A bundle may accept one value with `bundle=value`; that value is passed to the first `ARG` declared by the bundle that has not already been assigned to another extension. Arguments are ignored with a warning when the bundle declares no `ARG`.
318319

320+
A bundled extension can keep scripts and other supporting files beside its `Dockerfile`. The image builder exposes that directory as a BuildKit named context called `cpt-extension-<bundle-name>`, with unsupported characters normalized to hyphens. The bundle can copy an asset with `COPY --from=cpt-extension-example script.sh /usr/local/bin/script` without embedding it in a heredoc. These managed contexts are added only to the application image build.
321+
319322
Use an extension for additive runtime instructions. Use `CONTAINER_DOCKERFILE_HOOK_PATH` only when a change must rewrite another part of the selected Dockerfile. The hook must be an executable host-side script; it receives a temporary copy of the selected Dockerfile as its first argument and must edit that file in place. The hook also receives the following context variables.
320323

321324
| Variable | Meaning |
@@ -327,6 +330,37 @@ Use an extension for additive runtime instructions. Use `CONTAINER_DOCKERFILE_HO
327330
| `COMMON_PYTHON_TASKS_DOCKER_PLAIN` | `1` when plain progress output is active, otherwise `0` |
328331
| `COMMON_PYTHON_TASKS_DOCKER_SINGLE_ARCH` | `1` for a single-architecture build, otherwise `0` |
329332

333+
### Docker-in-Docker
334+
335+
The bundled `docker-in-docker` extension installs Docker CE, containerd, Buildx, and Compose from Docker's signed APT repository. Its installation and supervisor scripts are separate packaged files copied through the extension's named build context; image builds do not download scripts from the devcontainer feature repository.
336+
337+
Initial support is limited to Debian Bookworm variants (`slim-bookworm` and `bookworm`) on `amd64` and `arm64`. The extension checks the actual distribution and architecture during the build and rejects everything else, including Alpine and Trixie. Select extensions independently for each image build; other images do not need to enable Docker-in-Docker.
338+
339+
```sh
340+
CONTAINER_PYTHON_VARIANT=slim-bookworm \
341+
CONTAINER_EXTENSIONS=docker-in-docker poe build-image
342+
poe run-container --privileged
343+
```
344+
345+
The image starts as root through Tini and the DinD supervisor. The supervisor prepares nested cgroups, starts a dedicated Docker daemon, and waits for `docker info` to succeed before launching the existing `/pkg/entrypoint.sh` as `py`. Application arguments and exit status are preserved. Signals reach the application, and shutdown stops the application before stopping Docker. Startup failure or loss of the daemon terminates the container with a nonzero status. `DIND_STARTUP_TIMEOUT` controls the readiness deadline in seconds (default `60`, accepted range `1`–`9999`). Each process group gets up to five seconds to stop before being killed; allow more than ten seconds for the outer container's stop timeout.
346+
347+
Docker listens only on `unix:///var/run/docker.sock`. The image sets `DOCKER_HOST` accordingly, and startup clears Docker context and TLS environment overrides so the application uses its own daemon. Do not mount the host Docker socket. The `py` user belongs to the Docker group and can control the nested daemon; this is a privileged container, not a sandbox for untrusted workloads.
348+
349+
The image declares volumes for `/var/lib/docker` and `/var/lib/containerd`. Docker creates anonymous volumes automatically. Use dedicated named volumes when state should survive container replacement, and never share them between concurrently running daemons. For example, when launching the built image directly, replace `your-image:tag` with its tag.
350+
351+
```sh
352+
docker run --rm --privileged --stop-timeout 20 \
353+
--mount source=my-app-docker,target=/var/lib/docker \
354+
--mount source=my-app-containerd,target=/var/lib/containerd \
355+
your-image:tag
356+
```
357+
358+
For Compose, set `privileged: true`, `stop_grace_period: 20s`, and the equivalent volume mounts on the application service. Keep the image's entrypoint and startup user. `container-shell` and an explicit `--entrypoint` override bypass DinD startup; to inspect a running DinD container, use `docker exec -it --user py <container> sh`.
359+
360+
By default, the build installs the current stable packages available in Docker's repository. An optional bundle value pins the exact Docker Engine and CLI APT version. Include literal quotes around the version inside `CONTAINER_EXTENSIONS` so its epoch colon is not parsed as an extension separator. For example, `CONTAINER_EXTENSIONS='docker-in-docker="5:29.8.1-1~debian.12~bookworm"'` selects that version if available. This pins only Engine and CLI; containerd and CLI plugins still use the repository's current versions.
361+
362+
Maintainers can run the opt-in integration tests with `CPT_DIND_INTEGRATION=1 poe test tests/test_docker_in_docker.py`. These tests require a running Docker daemon, privileged Linux containers, and network access for image and package downloads. They build the actual generated application Dockerfile and remove their test images, containers, and anonymous volumes afterward.
363+
330364
## Supplying external dependencies
331365

332366
Dependency images support artifacts that should be built separately from the application wheel, such as compiled tools or browser binaries. The dependency image must place its exported content under `/tmp/deps`; the generated application Dockerfile copies that directory into its `runtime` stage.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Docker-in-Docker currently supports Debian Bookworm on amd64 and arm64.
2+
USER root
3+
4+
# Optional exact APT version, including the epoch and distribution suffix.
5+
ARG DIND_DOCKER_VERSION
6+
7+
COPY --from=cpt-extension-docker-in-docker --chmod=0755 install.sh \
8+
/usr/local/share/cpt-dind-install.sh
9+
RUN /usr/local/share/cpt-dind-install.sh \
10+
&& rm /usr/local/share/cpt-dind-install.sh
11+
12+
COPY --from=cpt-extension-docker-in-docker --chmod=0755 supervisor.sh \
13+
/usr/local/bin/cpt-dind-supervisor
14+
15+
RUN mv /pkg/entrypoint.sh /pkg/application-entrypoint.sh \
16+
&& printf '%s\n' '#!/bin/sh' \
17+
'exec /usr/bin/tini -- /usr/local/bin/cpt-dind-supervisor "$@"' \
18+
>/pkg/entrypoint.sh \
19+
&& chmod 0755 /pkg/entrypoint.sh
20+
21+
ENV DOCKER_HOST=unix:///var/run/docker.sock
22+
VOLUME ["/var/lib/docker", "/var/lib/containerd"]
23+
24+
# The generic final and debug stages inherit this startup-user selection.
25+
# The wrapper drops to py only for the application, after Docker is ready.
26+
ARG RUNTIME_USER=root
27+
USER ${RUNTIME_USER}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/bin/sh
2+
set -eu
3+
4+
# shellcheck source=/dev/null
5+
. /etc/os-release
6+
if [ "$ID" != debian ] || [ "${VERSION_CODENAME:-}" != bookworm ]; then
7+
echo >&2 'docker-in-docker requires Debian Bookworm; use slim-bookworm.'
8+
exit 1
9+
fi
10+
case "$(dpkg --print-architecture)" in
11+
amd64 | arm64) ;;
12+
*)
13+
echo >&2 'docker-in-docker supports amd64 and arm64 only.'
14+
exit 1
15+
;;
16+
esac
17+
18+
apt-get update
19+
apt-get install -y --no-install-recommends \
20+
bash ca-certificates curl iptables tini util-linux
21+
install -m 0755 -d /etc/apt/keyrings
22+
curl -fsSL https://download.docker.com/linux/debian/gpg \
23+
-o /etc/apt/keyrings/docker.asc
24+
chmod 0644 /etc/apt/keyrings/docker.asc
25+
cat >/etc/apt/sources.list.d/docker.sources <<SOURCES
26+
Types: deb
27+
URIs: https://download.docker.com/linux/debian
28+
Suites: bookworm
29+
Components: stable
30+
Architectures: $(dpkg --print-architecture)
31+
Signed-By: /etc/apt/keyrings/docker.asc
32+
SOURCES
33+
apt-get update
34+
apt-get install -y --no-install-recommends \
35+
"docker-ce${DIND_DOCKER_VERSION:+=$DIND_DOCKER_VERSION}" \
36+
"docker-ce-cli${DIND_DOCKER_VERSION:+=$DIND_DOCKER_VERSION}" \
37+
containerd.io docker-buildx-plugin docker-compose-plugin
38+
usermod -aG docker py
39+
rm -rf /var/lib/apt/lists/*
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/bin/bash
2+
set -euo pipefail
3+
4+
_prepare_host() {
5+
if [[ $(id -u) != 0 ]]; then
6+
echo >&2 'docker-in-docker must start as root with --privileged.'
7+
return 1
8+
fi
9+
if [[ ! -w /sys/fs/cgroup ]]; then
10+
echo >&2 'docker-in-docker needs writable cgroups; use --privileged.'
11+
return 1
12+
fi
13+
if [[ -d /sys/kernel/security ]] &&
14+
! mountpoint -q /sys/kernel/security; then
15+
mount -t securityfs none /sys/kernel/security
16+
fi
17+
_prepare_cgroups
18+
if ! iptables -nL >/dev/null 2>&1 &&
19+
iptables-legacy -nL >/dev/null 2>&1; then
20+
update-alternatives --set iptables /usr/sbin/iptables-legacy
21+
update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy
22+
fi
23+
# Only remove our daemon's PID file, never unrelated processes' files.
24+
rm -f /var/run/docker.pid
25+
}
26+
27+
_prepare_cgroups() {
28+
[[ -f /sys/fs/cgroup/cgroup.controllers ]] || return 0
29+
mkdir -p /sys/fs/cgroup/cpt-init
30+
local attempt pid controllers
31+
for ((attempt = 0; attempt < 5; attempt++)); do
32+
while read -r pid; do
33+
# Processes can disappear or become immovable while we move them.
34+
printf '%s\n' "$pid" | tee \
35+
/sys/fs/cgroup/cpt-init/cgroup.procs >/dev/null 2>&1 || true
36+
done </sys/fs/cgroup/cgroup.procs
37+
controllers=$(sed 's/[^ ]\+/+&/g' \
38+
/sys/fs/cgroup/cgroup.controllers)
39+
if printf '%s\n' "$controllers" | tee \
40+
/sys/fs/cgroup/cgroup.subtree_control >/dev/null 2>&1; then
41+
return 0
42+
fi
43+
sleep 0.1
44+
done
45+
echo >&2 'docker-in-docker could not enable nested cgroups.'
46+
return 1
47+
}
48+
49+
_stop_group() {
50+
local pid=$1
51+
[[ -n "$pid" ]] || return 0
52+
kill -TERM -- "-$pid" 2>/dev/null || true
53+
local attempt
54+
for ((attempt = 0; attempt < 50; attempt++)); do
55+
kill -0 -- "-$pid" 2>/dev/null || break
56+
sleep 0.1
57+
done
58+
kill -KILL -- "-$pid" 2>/dev/null || true
59+
wait "$pid" 2>/dev/null || true
60+
}
61+
62+
_shutdown() {
63+
trap '' TERM INT
64+
_stop_group "$app_pid"
65+
_stop_group "$daemon_pid"
66+
}
67+
68+
_wait_for_docker() {
69+
local deadline=$((SECONDS + DIND_STARTUP_TIMEOUT))
70+
while ((SECONDS < deadline)); do
71+
if ! kill -0 "$daemon_pid" 2>/dev/null; then
72+
echo >&2 'docker-in-docker daemon exited during startup.'
73+
return 1
74+
fi
75+
if timeout 1 docker --host "$DOCKER_HOST" info >/dev/null 2>&1; then
76+
return 0
77+
fi
78+
sleep 0.2
79+
done
80+
echo >&2 'docker-in-docker timed out waiting for the daemon.'
81+
return 1
82+
}
83+
84+
_main() {
85+
if [[ ! ${DIND_STARTUP_TIMEOUT:-60} =~ ^[1-9][0-9]{0,3}$ ]]; then
86+
echo >&2 'DIND_STARTUP_TIMEOUT must be an integer from 1 to 9999.'
87+
return 1
88+
fi
89+
export DIND_STARTUP_TIMEOUT=${DIND_STARTUP_TIMEOUT:-60}
90+
export DOCKER_HOST=unix:///var/run/docker.sock
91+
export container=docker
92+
unset DOCKER_CONTEXT DOCKER_TLS_VERIFY DOCKER_CERT_PATH
93+
_prepare_host
94+
daemon_pid=''
95+
app_pid=''
96+
trap _shutdown EXIT
97+
trap 'exit 143' TERM
98+
trap 'exit 130' INT
99+
# Keep daemon logs on stderr and expose only the local Unix socket.
100+
setsid dockerd --host "$DOCKER_HOST" --group docker >&2 &
101+
daemon_pid=$!
102+
_wait_for_docker
103+
setsid setpriv --reuid py --regid py --init-groups \
104+
env HOME="$(getent passwd py | cut -d: -f6)" USER=py LOGNAME=py \
105+
/pkg/application-entrypoint.sh "$@" &
106+
app_pid=$!
107+
local finished status=0
108+
wait -n -p finished "$daemon_pid" "$app_pid" || status=$?
109+
if [[ "$finished" == "$daemon_pid" ]]; then
110+
echo >&2 'docker-in-docker daemon exited while the app was running.'
111+
return 1
112+
fi
113+
return "$status"
114+
}
115+
116+
_main "$@"

‎src/common_python_tasks/data/generic/Dockerfile.j2‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ COPY . /tmp/build/
4040
# Export debug requirements when the project defines a debug dependency group
4141
RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }}{% for mount in UV_INDEX_SECRET_MOUNTS %} \
4242
--mount={{ mount }}{% endfor %} \
43-
uv export --group debug --no-hashes --format requirements-txt --output-file requirements-debug.txt
43+
uv export --frozen --only-group debug --no-hashes --format requirements-txt --output-file requirements-debug.txt
4444
{% endif %}
4545

4646
# Build the wheel, caching the uv cache directory to speed up subsequent builds.
@@ -133,10 +133,11 @@ LABEL git.commit=${GIT_COMMIT}
133133
# This entrypoint is deliberately not configurable via environment variables in order to
134134
# ensure that the container always uses the entrypoint selected at build time. If the
135135
# current package does not provide a console script, the entrypoint will default to `python`
136-
RUN echo "#!/bin/sh\n\n{{ ENTRYPOINT_COMMAND|default('python') }} \"\$@\"" >/pkg/entrypoint.sh \
136+
RUN echo "#!/bin/sh\n\nexec {{ ENTRYPOINT_COMMAND|default('python') }} \"\$@\"" >/pkg/entrypoint.sh \
137137
&& chmod +x /pkg/entrypoint.sh
138138

139-
USER py
139+
ARG RUNTIME_USER=py
140+
USER ${RUNTIME_USER}
140141

141142
{% if EXTENSION_CONTENT %}
142143
{{ EXTENSION_CONTENT }}
@@ -154,10 +155,10 @@ COPY --from=builder /tmp/build /tmp/build
154155

155156
RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} pip install --root-user-action=ignore -r /tmp/build/requirements-debug.txt
156157

157-
USER py
158+
USER ${RUNTIME_USER}
158159
{% endif %}
159160

160161
# Final (default) image: explicitly use runtime as the final target so debug is not used unless requested
161162
FROM runtime AS final
162163

163-
USER py
164+
USER ${RUNTIME_USER}

‎src/common_python_tasks/env.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,40 @@ def resolve_extension_content(descriptor: dict[str, str | None]) -> str:
227227
utils.fatal(f"Unknown extension descriptor source: {descriptor['source']}")
228228

229229

230+
def resolve_extension_build_context(
231+
descriptor: dict[str, str | None],
232+
) -> tuple[str, Path] | None:
233+
"""Return the named build context for a bundled extension's asset directory.
234+
235+
Args:
236+
descriptor: Extension descriptor dictionary containing `source` and
237+
`bundle_name` values.
238+
239+
Returns:
240+
A Docker build-context name and its directory, or `None` when the
241+
extension has no packaged assets.
242+
"""
243+
if descriptor["source"] != "bundle":
244+
return None
245+
bundle_name = descriptor["bundle_name"]
246+
out = utils.load_data_file(
247+
f"{bundle_name}/Dockerfile",
248+
type_identifier="dockerfile_extensions",
249+
fatal_on_missing=False,
250+
)
251+
if out is None:
252+
utils.fatal(f"Extension bundle not found: {bundle_name}")
253+
bundle_directory = out[0].parent
254+
if not bundle_directory.is_dir() or not any(
255+
path.name != "Dockerfile" for path in bundle_directory.iterdir()
256+
):
257+
return None
258+
return (
259+
f"cpt-extension-{re.sub(r'[^a-z0-9]+', '-', bundle_name.lower()).strip('-')}",
260+
bundle_directory,
261+
)
262+
263+
230264
def get_cache_id_suffix(no_cache: bool) -> str:
231265
"""Return a cache-break suffix for Docker cache mount IDs.
232266
Args:

‎src/common_python_tasks/tasks.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,7 @@ def build_image(
541541
resolve_container_docker_build_args,
542542
resolve_container_dockerfile_hook_path,
543543
resolve_container_dockerfile_path,
544+
resolve_extension_build_context,
544545
resolve_extension_content,
545546
uv_index_secret_build_args,
546547
uv_index_secret_mounts,
@@ -571,6 +572,11 @@ def build_image(
571572
# Resolve all extension fragments up-front so we fail fast on missing
572573
# bundles or files and avoid calling resolution logic multiple times.
573574
resolved_fragments = [resolve_extension_content(desc) for desc in extensions]
575+
extension_build_contexts = [
576+
context
577+
for desc in extensions
578+
if (context := resolve_extension_build_context(desc)) is not None
579+
]
574580

575581
resolved_docker_build_args = resolve_container_docker_build_args(
576582
docker_build_args,
@@ -740,7 +746,14 @@ def build_image(
740746
plain=plain,
741747
single_arch=single_arch,
742748
extra_build_args=merged_build_args or None,
743-
docker_build_args=resolved_docker_build_args,
749+
docker_build_args=[
750+
*resolved_docker_build_args,
751+
*(
752+
item
753+
for name, path in extension_build_contexts
754+
for item in ("--build-context", f"{name}={path}")
755+
),
756+
],
744757
dockerfile_hook_path=resolved_dockerfile_hook_path,
745758
)
746759

0 commit comments

Comments
 (0)