Skip to content

Commit 94ee752

Browse files
Harden reuse-container mode against stale mounts, OOM, and leftovers
Persistent reuse containers froze their bind mounts and create-time flags, so a wiped/re-cloned checkout poisoned every later job with a "container breakout" exit 128, OOM'd containers were reused, and unused containers accumulated and exhausted host memory. Add three guards: - Create-flag fingerprint stamped as a label and re-checked before reuse: covers every flag frozen at container creation (volumes, tmpfs, network, devices, caps, resource limits, ...) plus each bind-mount source's host inode. Recreates the container on any create-flag mismatch (e.g. a tmpfs that is now a bind volume) or a wiped/re-created bind-mount source. Flags re-applied per `docker exec` (-t, -i, env, workdir, user) and labels are excluded. Replaces the per-job `docker exec --workdir true` probe. - Out-of-band tainted marker: an in-container job (e.g. Cinder on OOM) touches /var/run/buildkite-docker-reuse/tainted to opt its container out of reuse on the next job, via a fresh per-container scratch mount. - Per-spawn-slot cleanup that discards leftover/mismatched persistent containers at job start, for both reuse and non-reuse jobs, scoped via labels so it never touches another agent's container. Also validate the reuse container name against Docker's naming rules to prevent path traversal, document the behavior in the README, and extend the bats suite.
1 parent 4ebf39e commit 94ee752

4 files changed

Lines changed: 407 additions & 12 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,22 @@ The container name is derived from the image name and the agent's spawn index. I
331331

332332
Default: `false`
333333

334+
#### Reuse safety and lifecycle
335+
336+
A persistent container freezes its bind mounts and other create-time flags, so the plugin guards against reusing one that is no longer correct for the current job:
337+
338+
- **Create-flag fingerprint.** At creation the container is labelled with a fingerprint of every flag frozen at container-creation time — volumes, `tmpfs`, `network`, devices, capabilities, resource limits, and so on — plus each bind-mount source's host inode. Flags that are re-applied on each `docker exec` (`-t`, `-i`, environment, `workdir`, `user`) and the per-job labels are excluded, since those can legitimately differ between jobs. Before reusing, the plugin recomputes the fingerprint and recreates the container if it differs. This catches two cases: a job whose create-time flags changed (for example a `tmpfs` mount that is now a bind `volume`, or a different `network`), and — importantly — a bind-mount source that was wiped and re-created on the host (for example a checkout that a failed `git clean` caused the agent to delete and re-clone). Without this, a reused container can run with the wrong mounts, and a stale bind mount makes every subsequent `docker exec` fail with `current working directory is outside of container mount namespace root ... possible container breakout detected` (exit 128). A container created before this feature (no fingerprint label) is treated as a mismatch and recreated once.
339+
340+
- **Per-slot cleanup.** Persistent containers are labelled with `com.buildkite.docker-plugin.reuse=true` and the agent's spawn slot. At the start of every job (whether or not it uses `reuse-container`), the plugin discards this slot's persistent containers that the job does not need — a non-reuse job discards any leftover, and a reuse job discards any container other than the one it wants — so unused containers do not accumulate and exhaust host memory. Cleanup is scoped strictly to the agent's own spawn slot and is skipped when the agent name has no numeric `-%spawn` suffix (so it can never disturb another agent's container).
341+
342+
- **Tainted marker.** A job running inside the container can opt the container out of reuse by creating the file `/var/run/buildkite-docker-reuse/tainted` (the plugin bind-mounts a fresh, world-writable host scratch dir there for each container). On the next job the plugin sees the marker and recreates the container instead of reusing it. This lets the job's own logic decide a container is no longer safe to reuse — for example a CI runner that detects an out-of-memory condition — without the plugin needing to interpret exit codes. Anything written into the `tainted` file is logged as the recreation reason.
343+
344+
Notes and limitations:
345+
346+
- Reuse safety applies on Unix agents; on Windows the fingerprint compares flags without bind-mount inode annotations.
347+
- The fingerprint covers create-time flags only (those frozen for the container's lifetime). Flags re-applied on each `docker exec` — TTY/interactive, environment, `workdir`, and `user` — are intentionally excluded, so changing them does not force a recreate.
348+
- The tainted-marker mechanism relies on the in-container job writing the marker; a job killed so abruptly that it cannot write the file will not mark the container as tainted (in which case the container is typically still healthy, and if its main process died it is recreated anyway).
349+
334350
### `reuse-container-name` (optional, string)
335351

336352
Override the auto-generated container name used by the `reuse-container` feature. Use this when the default name derivation does not produce unique names (for example, when multiple agents on the same host do not use the standard `-%spawn` agent name suffix).

commands/run.sh

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,10 +597,53 @@ elif [[ ${#command[@]} -gt 0 ]] ; then
597597
done
598598
fi
599599

600+
# Constants shared by the reuse-container path. The tainted dir is a fixed
601+
# contract between this plugin and the in-container job (e.g. Cinder), not a
602+
# user-facing option: a job touches "${reuse_tainted_target}/tainted" to opt its
603+
# container out of reuse. The host base is overridable only via an internal env
604+
# var so tests can redirect it away from the real /var/tmp.
605+
reuse_tainted_target="/var/run/buildkite-docker-reuse"
606+
reuse_tainted_base="${BUILDKITE_PLUGIN_DOCKER_REUSE_TAINTED_BASE:-/var/tmp/buildkite-docker-reuse}"
607+
608+
# Discard persistent reuse containers from previous jobs that this job does not
609+
# need, scoped to this agent's spawn slot. Runs for both reuse and non-reuse
610+
# jobs (a non-reuse job should not leave a stale persistent container hogging
611+
# memory), but only when a numeric spawn slot is determinable -- otherwise we
612+
# cannot safely tell this agent's containers apart from another agent's.
613+
if ! is_windows ; then
614+
reuse_spawn_slot="$(get_spawn_slot)"
615+
if [[ -n "${reuse_spawn_slot}" ]]; then
616+
keep_container=""
617+
if [[ "${BUILDKITE_PLUGIN_DOCKER_REUSE_CONTAINER:-false}" =~ ^(true|on|1)$ ]]; then
618+
keep_container="$(get_reuse_container_name "${image}")"
619+
fi
620+
cleanup_foreign_reuse_containers "${reuse_spawn_slot}" "${keep_container}"
621+
fi
622+
fi
623+
600624
if [[ "${BUILDKITE_PLUGIN_DOCKER_REUSE_CONTAINER:-false}" =~ ^(true|on|1)$ ]]; then
601625
# --- Reuse-container path ---
602626
container_name=$(get_reuse_container_name "${image}")
603627

628+
# Enforce Docker's container-name rules before deriving any host path from the
629+
# name. This rejects path-traversal values (e.g. an explicit reuse-container-
630+
# name of ".." or "a/b") that would otherwise make the "rm -rf" of the tainted
631+
# scratch dir below operate outside its base.
632+
if [[ ! "${container_name}" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]]; then
633+
echo "+++ Error: Invalid reuse container name '${container_name}'." >&2
634+
echo " Must match Docker's naming rules: ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$" >&2
635+
exit 1
636+
fi
637+
638+
host_tainted_dir="${reuse_tainted_base}/${container_name}"
639+
640+
# Fingerprint of this job's create-time flags (+ bind-mount source inodes).
641+
# Used to detect, before reuse, both a flag mismatch (e.g. a changed tmpfs,
642+
# volume, network, ...) and a wiped/re-created bind-mount source (the stale-
643+
# mount breakout). Computed from run_flags, excluding per-exec flags, labels,
644+
# and the internal tainted-marker mount.
645+
current_fingerprint="$(compute_reuse_fingerprint "${reuse_tainted_target}" "${run_flags[@]}")"
646+
604647
# Build exec_args by extracting only the flags that docker exec supports
605648
# from the run_flags snapshot (tty, interactive, env, workdir, user).
606649
exec_args=()
@@ -649,6 +692,31 @@ if [[ "${BUILDKITE_PLUGIN_DOCKER_REUSE_CONTAINER:-false}" =~ ^(true|on|1)$ ]]; t
649692
if [[ -n "${expected_image_id}" ]] && [[ "${container_image_id}" == "${expected_image_id}" ]]; then
650693
echo "--- :docker: Reusing existing container ${container_name} (${image})"
651694
need_create=false
695+
696+
# Compare the create-flag fingerprint stamped at creation against this
697+
# job's. A difference means either a create-time flag changed (mismatch,
698+
# e.g. a tmpfs/volume/network change) or a bind-mount source was wiped and
699+
# re-created on the host (staleness, e.g. a re-cloned checkout). The latter
700+
# is the breakout that fails every subsequent `docker exec --workdir` with
701+
# "current working directory is outside of container mount namespace root"
702+
# (exit 128). A missing/empty label (a container from before this feature)
703+
# is treated as a mismatch.
704+
stored_fingerprint="$(docker inspect --format '{{ index .Config.Labels "com.buildkite.docker-plugin.fingerprint" }}' "${container_name}" 2>/dev/null || true)"
705+
if [[ "${stored_fingerprint}" != "${current_fingerprint}" ]]; then
706+
echo "+++ :docker: Create-flag fingerprint changed for ${container_name}; recreating container"
707+
echo " (a create-time flag changed, or a bind-mount source was wiped and re-created on the host)"
708+
docker rm -f "${container_name}" >/dev/null 2>&1 || true
709+
need_create=true
710+
# Honor an out-of-band tainted marker written by the in-container job (e.g.
711+
# Cinder's error analysis on OOM) to opt this container out of reuse.
712+
elif [[ -f "${host_tainted_dir}/tainted" ]]; then
713+
echo "+++ :docker: Container ${container_name} is tainted; recreating container"
714+
if [[ -s "${host_tainted_dir}/tainted" ]]; then
715+
echo " reason: $(tr -d '\n' < "${host_tainted_dir}/tainted")"
716+
fi
717+
docker rm -f "${container_name}" >/dev/null 2>&1 || true
718+
need_create=true
719+
fi
652720
else
653721
echo "+++ WARNING: Container image mismatch for ${container_name}"
654722
echo " Expected image: ${image} (${expected_image_id:-unknown})"
@@ -680,6 +748,27 @@ if [[ "${BUILDKITE_PLUGIN_DOCKER_REUSE_CONTAINER:-false}" =~ ^(true|on|1)$ ]]; t
680748
i=$((i+1))
681749
done
682750

751+
# Discovery + fingerprint labels. The reuse/spawn-slot labels let later jobs
752+
# find and scope-clean this slot's containers; the fingerprint records the
753+
# create-time flags (+ inodes) this container was built with. Labels are
754+
# excluded from the fingerprint, so adding them here does not perturb it.
755+
create_flags+=("--label" "com.buildkite.docker-plugin.reuse=true")
756+
if [[ -n "${reuse_spawn_slot:-}" ]]; then
757+
create_flags+=("--label" "com.buildkite.docker-plugin.spawn-slot=${reuse_spawn_slot}")
758+
fi
759+
create_flags+=("--label" "com.buildkite.docker-plugin.fingerprint=${current_fingerprint}")
760+
761+
# Out-of-band tainted-marker channel: a fresh, per-container host scratch dir bind-
762+
# mounted into the container. Reset on every creation so a new container
763+
# never inherits a stale marker, and world-writable so the in-container job
764+
# (which may run as a different uid) can create the marker file. Unix only.
765+
if ! is_windows ; then
766+
rm -rf "${host_tainted_dir}"
767+
mkdir -p "${host_tainted_dir}"
768+
chmod 0777 "${host_tainted_dir}"
769+
create_flags+=("--volume" "${host_tainted_dir}:${reuse_tainted_target}")
770+
fi
771+
683772
echo "--- :docker: Creating persistent container ${container_name} (${image})"
684773
echo -ne '\033[90m$\033[0m docker run -d --name ' >&2
685774
echo -n "${container_name} " >&2

lib/shared.bash

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,19 @@ function is_macos() {
7676
[[ "$OSTYPE" =~ ^(darwin) ]]
7777
}
7878

79+
# Returns the agent's numeric spawn index (the trailing "-N" of
80+
# BUILDKITE_AGENT_NAME), or nothing if it cannot be determined. This is the
81+
# per-slot key that scopes reuse containers and their cleanup to one agent on a
82+
# shared host. Computed from BUILDKITE_AGENT_NAME directly so it stays correct
83+
# even when an explicit reuse-container-name is used.
84+
function get_spawn_slot() {
85+
local agent_name="${BUILDKITE_AGENT_NAME:-}"
86+
local spawn_suffix="${agent_name##*-}"
87+
if [[ "${spawn_suffix}" =~ ^[0-9]+$ ]]; then
88+
echo "${spawn_suffix}"
89+
fi
90+
}
91+
7992
# Returns a stable container name for the reuse-container feature.
8093
# Uses the explicit override if set, otherwise derives from the image name
8194
# and the agent's spawn index (to isolate containers per agent on a host).
@@ -90,18 +103,126 @@ function get_reuse_container_name() {
90103
local sanitized="${image//[^a-zA-Z0-9_.-]/-}"
91104
local name="${sanitized}"
92105

93-
local spawn_suffix="${BUILDKITE_AGENT_NAME##*-}"
94-
if [[ "${spawn_suffix}" =~ ^[0-9]+$ ]]; then
106+
local spawn_suffix
107+
spawn_suffix="$(get_spawn_slot)"
108+
if [[ -n "${spawn_suffix}" ]]; then
95109
name="${name}-${spawn_suffix}"
96110
else
97-
echo "Warning: Could not extract numeric spawn index from BUILDKITE_AGENT_NAME '${BUILDKITE_AGENT_NAME}'." >&2
111+
echo "Warning: Could not extract numeric spawn index from BUILDKITE_AGENT_NAME '${BUILDKITE_AGENT_NAME:-}'." >&2
98112
echo " Multiple agents on the same host may share container name '${name}'." >&2
99113
echo " Set 'reuse-container-name' to specify an explicit container name." >&2
100114
fi
101115

102116
echo "${name}"
103117
}
104118

119+
# Returns the inode number of a host path, portably across GNU and BSD/macOS
120+
# (avoids the GNU-only `stat -c %i`). Prints "missing" when the path does not
121+
# exist, which still yields a deterministic, comparable fingerprint entry.
122+
function get_host_inode() {
123+
local path="$1"
124+
if [[ -e "${path}" ]]; then
125+
# `ls -d -i` prints "<inode> <path>" for the path itself (not contents).
126+
# shellcheck disable=SC2012 # `find -printf` is GNU-only; ls keeps this
127+
# portable to BSD/macOS, and we only read the leading inode field.
128+
ls -di "${path}" 2>/dev/null | awk '{print $1}'
129+
else
130+
echo "missing"
131+
fi
132+
}
133+
134+
# Computes a deterministic fingerprint of the create-time flags a reuse
135+
# container is (re)created with, so a later job can detect when it would reuse a
136+
# container that was built with different flags (mismatch) or with a bind-mount
137+
# source that has since been wiped and re-created on the host (staleness, e.g. a
138+
# re-cloned checkout). This covers EVERY flag frozen at container creation
139+
# (volumes, tmpfs, network, devices, caps, resources, etc.), so changing any of
140+
# them forces a fresh container.
141+
#
142+
# Excluded (deliberately):
143+
# - flags re-applied on each `docker exec` and therefore allowed to differ
144+
# between jobs without a recreate: -t, -i, --env, --env-file, --workdir, -u;
145+
# - all --label values (volatile per-job metadata and the plugin's own labels);
146+
# - the plugin-internal tainted-marker --volume (identified by <tainted_target>).
147+
# For each remaining bind-mount --volume the host source inode is appended
148+
# (absolute source, Unix only) so a wiped/re-created source is detected.
149+
#
150+
# Flags are kept in their (deterministic) build order and joined with ",". The
151+
# raw string is returned (not hashed) to avoid depending on sha256sum vs shasum.
152+
#
153+
# Usage: compute_reuse_fingerprint <tainted_target> <flag>...
154+
function compute_reuse_fingerprint() {
155+
local tainted_target="$1"; shift
156+
local -a flags=("$@")
157+
local -a entries=()
158+
local i=0
159+
160+
while [[ $i -lt ${#flags[@]} ]]; do
161+
local flag="${flags[$i]}"
162+
case "${flag}" in
163+
-t|-i)
164+
# Per-exec flag (no value); not frozen.
165+
;;
166+
--workdir|-u|--env|--env-file|--label)
167+
# Per-exec or volatile; skip the flag and its value.
168+
i=$((i+1))
169+
;;
170+
--volume)
171+
local spec="${flags[$((i+1))]}"
172+
i=$((i+1))
173+
local rest="${spec#*:}"
174+
local dst="${rest%%:*}"
175+
if [[ -n "${tainted_target}" && "${dst}" == "${tainted_target}" ]]; then
176+
: # plugin-internal tainted-marker mount; not part of user config
177+
else
178+
local src="${spec%%:*}"
179+
if [[ "${src}" == /* ]] && ! is_windows; then
180+
entries+=("--volume=${spec}#$(get_host_inode "${src}")")
181+
else
182+
entries+=("--volume=${spec}")
183+
fi
184+
fi
185+
;;
186+
*)
187+
# Any other create-time flag (or a value token of one): keep verbatim,
188+
# in order, so it contributes to the fingerprint.
189+
entries+=("${flag}")
190+
;;
191+
esac
192+
i=$((i+1))
193+
done
194+
195+
if [[ ${#entries[@]} -gt 0 ]]; then
196+
local IFS=','
197+
echo "${entries[*]}"
198+
fi
199+
}
200+
201+
# Removes persistent reuse containers belonging to this agent's spawn slot that
202+
# this job does not need, freeing their memory. Scoped per slot via labels so it
203+
# never touches another agent's container on a shared host.
204+
# <slot> the agent spawn slot (from get_spawn_slot).
205+
# <keep> the one container name to preserve (the desired reuse container for
206+
# this job); pass "" for non-reuse jobs to discard all slot containers.
207+
function cleanup_foreign_reuse_containers() {
208+
local slot="$1" keep="$2"
209+
local names
210+
names="$(docker ps -a \
211+
--filter "label=com.buildkite.docker-plugin.reuse=true" \
212+
--filter "label=com.buildkite.docker-plugin.spawn-slot=${slot}" \
213+
--format '{{.Names}}' 2>/dev/null || true)"
214+
215+
[[ -z "${names}" ]] && return 0
216+
217+
local name
218+
while IFS= read -r name; do
219+
[[ -z "${name}" ]] && continue
220+
[[ -n "${keep}" && "${name}" == "${keep}" ]] && continue
221+
echo "--- :docker: Discarding persistent container ${name} (not needed by this job on slot ${slot})"
222+
docker rm -f "${name}" >/dev/null 2>&1 || true
223+
done <<< "${names}"
224+
}
225+
105226
# Returns the image ID (digest) of the image a container was created from.
106227
function get_container_image_id() {
107228
local container_name="$1"

0 commit comments

Comments
 (0)