Skip to content

Instantly share code, notes, and snippets.

@x1unix
Created August 24, 2026 19:32
Show Gist options
  • Select an option

  • Save x1unix/14d2e192fc9373fcf350b63838909998 to your computer and use it in GitHub Desktop.

Select an option

Save x1unix/14d2e192fc9373fcf350b63838909998 to your computer and use it in GitHub Desktop.
docker-debug: kubectl debug replica for Docker
#!/usr/bin/env bash
#
# A `docker debug` shim for Docker FOSS/CE.
#
# Starts a disposable toolbox container in the target's PID and network
# namespaces, joins its IPC namespace when allowed, and mounts its rootfs at
# /target. Sharing the network namespace also copies the target's hostname;
# Docker CE has no container-scoped --uts mode.
#
# Usage:
# docker-debug <container_name_or_id>
# docker-debug --image busybox <container_name_or_id>
# docker-debug -i alpine <container_name_or_id>
# docker-debug <container_name_or_id> -- sh -c 'ls /target/etc'
#
set -euo pipefail
PROG="$(basename "$0")"
DEFAULT_IMAGE="busybox"
IMAGE=""
TARGET=""
DEFAULT_SHELL="/bin/sh"
# Preserve command argument boundaries and quoting.
CMD_ARGV=()
usage() {
cat <<EOF
Usage: ${PROG} [--image|-i IMAGE] [--shell SHELL] <container_name_or_id> [-- CMD [ARG...]]
Attaches a debug container to a running target container's namespaces,
mimicking 'docker debug' from Docker Desktop.
Options:
-i, --image IMAGE Debug container image to use (default: ${DEFAULT_IMAGE})
--shell SHELL Shell to run inside the debug container (default: ${DEFAULT_SHELL})
-h, --help Show this help
Everything after '--' is run inside the debug container instead of the shell,
with quoting preserved. Use it for non-interactive one-liners.
Examples:
${PROG} my_container
${PROG} 3a1f9c2b1e4d
${PROG} --image busybox my_container
${PROG} -i alpine my_container
${PROG} --shell /bin/bash -i alpine my_container
${PROG} my_container -- ls -la /target/etc/alloy
${PROG} my_container -- sh -c 'cat /target/etc/passwd | wc -l'
EOF
}
# Parse arguments.
while [[ $# -gt 0 ]]; do
case "$1" in
-i|--image)
[[ $# -ge 2 ]] || { echo "${PROG}: --image requires a value" >&2; exit 1; }
IMAGE="$2"
shift 2
;;
--image=*)
IMAGE="${1#*=}"
shift
;;
--shell)
[[ $# -ge 2 ]] || { echo "${PROG}: --shell requires a value" >&2; exit 1; }
CMD_ARGV=("$2")
shift 2
;;
--shell=*)
CMD_ARGV=("${1#*=}")
shift
;;
-h|--help)
usage
exit 0
;;
--)
# Pass everything after -- through unchanged.
shift
CMD_ARGV=("$@")
break
;;
-*)
echo "${PROG}: unknown option '$1'" >&2
usage >&2
exit 1
;;
*)
if [[ -z "${TARGET}" ]]; then
TARGET="$1"
else
echo "${PROG}: unexpected extra argument '$1'" >&2
usage >&2
exit 1
fi
shift
;;
esac
done
if [[ -z "${TARGET}" ]]; then
echo "${PROG}: missing required <container_name_or_id> argument" >&2
usage >&2
exit 1
fi
IMAGE="${IMAGE:-${DEFAULT_IMAGE}}"
[[ ${#CMD_ARGV[@]} -gt 0 ]] || CMD_ARGV=("${DEFAULT_SHELL}")
# Validate the target.
command -v docker >/dev/null 2>&1 || {
echo "${PROG}: docker is not installed or not on PATH" >&2
exit 1
}
if ! docker inspect "${TARGET}" >/dev/null 2>&1; then
echo "${PROG}: no such container '${TARGET}'" >&2
exit 1
fi
RUNNING="$(docker inspect -f '{{.State.Running}}' "${TARGET}")"
if [[ "${RUNNING}" != "true" ]]; then
echo "${PROG}: container '${TARGET}' is not running" >&2
exit 1
fi
CID="$(docker inspect -f '{{.Id}}' "${TARGET}")"
PID="$(docker inspect -f '{{.State.Pid}}' "${TARGET}")"
if [[ -z "${PID}" || "${PID}" == "0" ]]; then
echo "${PROG}: could not determine PID for container '${TARGET}'" >&2
exit 1
fi
# Prefer the storage driver's merged directory. /proc/<pid>/root is a fallback
# because recursive runc binds of that symlink can fail with EINVAL.
# Do not test either path locally: the caller may lack access, while dockerd can
# resolve and mount it as root.
MERGED="$(docker inspect -f '{{index .GraphDriver.Data "MergedDir"}}' "${TARGET}" 2>/dev/null || true)"
if [[ -n "${MERGED}" && "${MERGED}" != "<no value>" ]]; then
ROOTFS="${MERGED}"
else
ROOTFS="/proc/${PID}/root"
echo "${PROG}: warning: storage driver exposes no MergedDir, falling back to ${ROOTFS}" >&2
fi
# Join IPC only when the target's mode permits it; Docker's default private
# mode is not shareable and would make `docker run` fail.
IPC_MODE="$(docker inspect -f '{{.HostConfig.IpcMode}}' "${TARGET}")"
IPC_ARGS=()
case "${IPC_MODE}" in
shareable|host|container:*)
IPC_ARGS=(--ipc="container:${CID}")
;;
*)
echo "${PROG}: note: target IPC mode is '${IPC_MODE:-private}', not joining its IPC namespace" >&2
echo "${PROG}: (restart the target with --ipc=shareable to share it)" >&2
;;
esac
# Pull explicitly to show first-run progress.
if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then
echo "${PROG}: pulling debug image '${IMAGE}'..." >&2
docker pull "${IMAGE}" >&2
fi
echo "${PROG}: attaching '${IMAGE}' debug shell to container '${TARGET}' (${CID:0:12}, pid ${PID})" >&2
echo "${PROG}: target rootfs mounted at /target" >&2
# Avoid -t for pipelines and other non-TTY callers.
TTY_ARGS=(-i)
if [[ -t 0 && -t 1 ]]; then
TTY_ARGS=(-i -t)
fi
exec docker run "${TTY_ARGS[@]}" --rm \
--pid="container:${CID}" \
--network="container:${CID}" \
"${IPC_ARGS[@]+"${IPC_ARGS[@]}"}" \
--volume "${ROOTFS}:/target" \
--label "docker-debug.target=${CID}" \
"${IMAGE}" "${CMD_ARGV[@]}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment