Architecture#
podbench is a packaging exercise. Every mechanism it stands on is an ordinary, individually-proven Kubernetes or Linux feature; what did not exist before was the combination, and the diagnostics that make the combination survivable.
This page explains how the pieces fit and why each has the shape it does. The empirical basis for almost all of it is the Phase 0 gate report — five spikes against a real 6-node k3s cluster, five falsified assumptions.
The picture#
laptop
VS Code · Remote-SSH · kubectl
|
| ssh, carried by `kubectl exec` (API server only)
|
+--------------+--------------------------------------+
| pod |
| |
| +----------------------+ +----------------------+ |
| | app container | | podbench | |
| | | | | |
| | workload process | | sshd -i -e (per conn)| |
| | (maybe distroless) | | vscode-server | |
| | | | gdb · git · uv | |
| | rootfs readable at | | | |
| | /proc/<pid>/root <---- sysroot, sources | |
| +----------------------+ +----------------------+ |
| |
| shared: PID namespace (Observe: via the target; |
| Iterate: shareProcessNamespace)|
| network namespace (always) |
+------------------------------------------------------+
Two containers, three shared things — a PID namespace, a network namespace and a
/proc view — and one transport that is not a network path at all.
The ephemeral container#
kubectl debug --target has been able to inject a container into a running pod,
in another container’s PID namespace, since Kubernetes 1.25. podbench uses the
same primitive but posts to the ephemeralcontainers subresource itself
rather than shelling out to kubectl debug, for a measured reason: --custom
takes a file path rather than inline JSON, and — worse — it merges a profile
applied after your JSON. Asking for runAsUser: 1000 with the default
profile yields {"capabilities":{"add":["SYS_PTRACE"]},"runAsUser":1000}, which
is the one combination that is invalid by construction (see
Security model). Building a capability ladder on top of that is
building on sand.
Three properties of ephemeral containers shape everything downstream:
They are permanent. They cannot be removed, restarted or edited. Every attach appends to the pod spec for the rest of the pod’s life, and a name once used is burnt. So
attachreconnects by default, a failed rung takes a fresh name rather than retrying its own, and readiness is gated on the container genuinelyrunningrather than merely accepted.They cannot declare
resources. The field is rejected outright. That is the origin of Observe mode’s entire risk profile, and of Iterate mode’s existence.They start from the image every time. A restart — or an OOM, which is unrecoverable since there is no restart — yields a completely fresh rootfs. Nothing may live only in the writable layer, so the agent rebuilds the host key, the authorized keys and the sshd config on every start. Every step in the startup path is “ensure”, never “create”.
The ssh-over-exec transport#
The headline mechanism: ssh’s ProxyCommand is a kubectl exec that runs
sshd -i -e inside the debug container.
ProxyCommand kubectl -n <ns> exec -i <pod> -c podbench -- \
/usr/sbin/sshd -i -e -f /etc/podbench/sshd_config -o LogLevel=ERROR
There is no listening socket in the pod, no port-forward to babysit, no pod
IP to route to, and no inbound network path of any kind. The outer
authentication is the kubeconfig — including exec credential plugins, which is
one reason podbench shells out to kubectl rather than embedding a client
library — and the inner authentication is an ssh key. The RBAC it needs is
create pods/exec, nothing more.
Everything else is a real ssh connection, so port forwarding, sftp, agent
forwarding, scp and connection multiplexing all work. Measured: 0.345 s cold
connect, 0.058 s over the ControlMaster, 26 MB/s pod→client, ~10 MB RSS per
session, 0 failures in 30 churn cycles.
The one thing that is genuinely surprising is -e. It reads like a logging
preference; it is not. Closing or replacing fd 2 in a kubectl exec’d process
tears down the entire CRI exec stream, silently truncating stdin and stdout with
rc=0. Isolated without sshd at all:
( echo one; sleep 4; echo two ) | kubectl exec -i pod -c c -- sh -c 'exec cat'
one
two # both arrive
( echo one; sleep 4; echo two ) | kubectl exec -i pod -c c -- sh -c 'exec 2>/dev/null; exec cat'
one # "two" SILENTLY LOST, rc=0
When that hits sshd the symptom is a network-looking error at key exchange
(ssh_dispatch_run_fatal: … Broken pipe), and a wrapper shell that does not
exec masks it — so it passes a casual test and fails in the field. Both ends
of the transport are therefore generated from one place and never hand-written,
and -o LogLevel=ERROR satisfies the competing requirement (zero stderr bytes)
without closing the fd.
-t is refused outright. From a script kubectl silently degrades to non-tty and
appears to work; with a real TTY forced onto the ProxyCommand the ssh client
hangs indefinitely.
One binary, two halves#
The launcher and the in-pod helpers are the same Python package. That is not tidiness: the logic that decides what a session can do — which capability rung is valid, what blocks ptrace, which processes belong to the target — has to give the same answer on both sides. Two implementations would be two answers.
On your machine:
podbench <verb>, normally reached asuvx podbench <verb>— uv fetches the launcher for that one run and leaves nothing installed. There is no kubectl plugin: a plugin has to be an executable onPATH, which is exactly the thing that outlives the command.In the pod:
podbench agentis PID 1, and the same spelling reaches every other in-pod verb —podbench pids,podbench dbg,podbench capreport,podbench debug-config,podbench dev-bootstrap,podbench run,podbench stop. One file,/usr/local/bin/podbench, is what puts them within reach of an ssh session that sources no profile — sshd leaks none of the image’s environment, so the agent’s generated config carriesPATH(andPODBENCH_TARGET_CID, and the debuginfod settings) into a session withSetEnv, and that one file is what resolves the verb when it cannot.
There is one runtime dependency, and it is the CLI — typer, which brings
click and rich. The help a developer reads at 3 a.m. is part of the product, and
four small pure-Python wheels is what that costs on a cold uvx start.
Everything else is the stdlib. In particular there is no Kubernetes client: the
launcher shells out to kubectl on purpose, so authentication, contexts and
credential plugins are inherited rather than reimplemented.
Running the launcher from the index rather than from an install has one
consequence worth stating: its version can change between two attaches with no
visible event. So the image tag is derived from the launcher’s own version
rather than fixed, and a launcher asks for the image built from its own source.
The failure that prevents is a launcher authoring a container spec its image
does not understand — which fails inside the pod, where an ephemeral container
cannot be restarted. A dev build off a checkout matches no published image and
falls back to main, the branch-tip image CI pushes on every default-branch
commit — not to latest, which moves only on a final release and so may be far
older than the launcher. --image and PODBENCH_IMAGE still win over both.
The mount-namespace rule#
Interpreter, venv and checkout must all live on the same side of the container boundary, and podbench standardises on: everything in the debug container.
The failure it prevents is the quietest in the whole system. A .pth written
into the target’s site-packages that names a checkout in the debug container’s
filesystem does not exist in the namespace that resolves it, and site.py only
appends directories that exist — so a path-style .pth is silently ignored
and surfaces much later as an unrelated-looking ModuleNotFoundError. The
exec-style .pth that PEP 660 editable installs emit prints a traceback and
then carries on with exit 0. And because the /proc/<pid>/root bridge is
one-directional, there is no symlink workaround.
dev-bootstrap and run therefore validate the layout and refuse rather than
letting it be discovered.
What the probe is for#
Four unrelated subsystems refuse PTRACE_ATTACH with the same EPERM: a
missing capability, Yama’s ptrace_scope, a seccomp filter, and the node’s
LSM. A
previous hand-rolled attempt at this tool reached same-UID and still could not
tell which one had said no. Naming the blocker is the point of the whole probe —
“denied by Yama (ptrace_scope=1)” is actionable; “ptrace: Operation not
permitted” is a wasted afternoon.
So capreport runs inside the container the launcher just landed, on that
node, and the attach output reports what was measured, never what was
requested. Yama differs per node by kernel flavour — two arm64 nodes in the same
cluster disagreed — so the answer can never be cached cluster-wide, and the node
name and Yama state appear in the session banner precisely so that “attach
worked yesterday” is explicable.
Where the modes diverge#
Observe |
Iterate |
|
|---|---|---|
Container kind |
ephemeral, in the live pod |
a real sidecar, in an authored clone |
PID namespace |
the target’s, via |
|
Resources |
none possible — shares the workload’s limits |
its own requests and limits |
Storage |
the container’s writable layer, against the pod’s ephemeral-storage budget |
an |
Risk to the workload |
real: OOM, eviction |
none; the origin pod is untouched |
Debugging |
attach to the live process, read-only inspection, or debugpy where |
gdb-launch, debugpy, the relaunch loop |
Hotfix mode — a PVC mounted over the app’s venv so a fix survives restarts and
reschedules — is the one mode that requires deploy-time cooperation, because
durable-across-restart code must sit on a volume that was present in the pod
spec at creation: pod volumes are immutable and a container’s rootfs is reset on
every restart. The workflow ships as podbench hotfix
(init/apply/status/consolidate, plus --print-values for the chart
snippet), but it has only ever been exercised against unit tests: no cluster has
run it. attach --mount CLAIM now puts the claim into the seat at the
application’s own mountPath, so the workflow is reachable end to end — but
reachable is not demonstrated. See What hotfix does.
See also#
Glossary — every term this page uses without explanation.
Ways in — which mode a given problem belongs in.
What
attachdoes, Whatdevdoes and Whathotfixdoes — each mode’s flow, call by call.Phase 0 gate report — the measurements the shapes on this page were chosen against.