$ docker exec -it mycontainer sh
OCI runtime exec failed: exec: "sh": executable file not found in $PATH
Nothing is broken. There is no shell, on purpose. The same absence that stops an attacker from chaining a command injection into a foothold stops you from poking around, and the techniques below are how you work anyway.
They are listed roughly in the order to try them: cheapest and least invasive first.
1. Run a different binary in the same image#
--entrypoint replaces the command without changing the image. Most of what you
want to know early — is the binary there, what version is it, does it parse the
config — needs no shell at all:
docker run --rm --entrypoint /usr/bin/haproxy ghcr.io/rtvkiz/minimal-haproxy:latest -vv
docker run --rm -v "$PWD/haproxy.cfg:/etc/haproxy/haproxy.cfg:ro" \
--entrypoint /usr/bin/haproxy ghcr.io/rtvkiz/minimal-haproxy:latest \
-c -f /etc/haproxy/haproxy.cfg
Note the second one: a config check, offline, with no shell involved. A surprising number of "the container won't start" incidents end here.
2. Inspect the filesystem from outside#
You do not need to be inside a container to read its filesystem.
# what is actually in the image, without running it
docker create --name tmp ghcr.io/rtvkiz/minimal-nginx:latest
docker export tmp | tar -tv | head -50
docker cp tmp:/etc/nginx/nginx.conf ./nginx.conf
docker rm tmp
docker cp works against a running container too, and works whether or not that
container has a shell. If your question is "what file is at this path", this is
the whole answer.
3. Attach a debug container that has the tools#
This is the technique that replaces exec properly, and it is the one worth
learning if you only learn one.
Kubernetes:
kubectl debug -it mypod --image=busybox:latest --target=mycontainer
The --target flag is what makes it useful: the debug container shares the
target's process namespace, so ps sees the real process, and /proc/1/root
gives you the target's filesystem:
ls /proc/1/root/etc
cat /proc/1/root/etc/nginx/nginx.conf
Docker has an equivalent:
docker run --rm -it --pid=container:mycontainer \
--network=container:mycontainer --cap-add SYS_PTRACE \
busybox:latest sh
Sharing --network matters more than people expect. Half of "the service is
down" turns out to be DNS or a listener bound to 127.0.0.1, and both are only
visible from inside that network namespace:
wget -qO- http://127.0.0.1:8080/healthz
nslookup my-service.default.svc.cluster.local
4. Use the dev variant#
Every image we publish has a -dev tag carrying the same primary binary plus a
shell and the usual tools — bash, curl, openssl, dig, jq, git, apk:
docker run --rm -it --entrypoint /bin/bash ghcr.io/rtvkiz/minimal-nginx:latest-dev
Swap the tag, reproduce the problem, keep everything else identical. Because
both variants are built from the same package, a bug that reproduces in -dev
is a bug in your setup or in the software; a bug that stops reproducing is
usually about the missing shell or the tools themselves.
The rule that makes this safe: -dev is for debugging, not for production.
It exists so that the production image never needs to grow a shell "just for
now". A shell added during an incident is a shell that is still there a year
later.
5. Read the logs properly#
Obvious, and still skipped:
docker logs --timestamps --tail 200 mycontainer
kubectl logs mypod -c mycontainer --previous # the container that just crashed
--previous is the one people forget. A container in CrashLoopBackOff has
already told you why it died; the current instance may not have got far enough
to repeat it.
What actually changes#
The shift is from interactive exploration to asking specific questions. You lose "let me look around", which is genuinely convenient. You keep everything that is diagnostic, and you gain that the same absence works against an attacker, continuously, in every container you run.
If you find yourself needing a shell in production repeatedly for the same reason, that is a signal about the image or the workload — a missing health endpoint, a config that cannot be validated ahead of time, logs that do not say enough. Fixing the underlying gap is better than keeping a shell around for it.