Commands & arguments
ENTRYPOINT and CMD, mapped onto a pod spec.
A pod that flips to RunContainerError and never comes up is usually telling you one thing. Kubernetes tried to run a program that isn't there. Someone pointed the container's command at a path the image doesn't have, or assumed a shell would be around to sort things out. Get the two start-up fields right and this whole class of failure stops happening. It's one of the better five minutes you can spend learning how a Pod (the smallest thing Kubernetes runs, one or more containers packaged together) actually starts.
A container image ships with two built-in defaults, a bit like a food truck that already knows exactly one dish. ENTRYPOINT is the cook, the program that always runs. CMD is the default order, the arguments handed to that cook when nobody says otherwise. Most images set both on purpose, so they do something sensible with zero configuration and still let you change your mind at deploy time. Your Pod spec gives you two matching fields for that. command swaps out the cook. args changes the order. The whole topic is really just knowing which of those two you're touching, because they don't behave the same way.
What the image already decided
Before you override anything, look at what you're overriding. Every image records its ENTRYPOINT and CMD in its config, and you can read both straight from the registry without pulling the image or starting a container. Guessing here is how you overwrite setup logic you never knew was there, so it's worth the ten seconds to check. Here's nginx, read with crane from the go-containerregistry tools.
crane config nginx:1.27 | jq '{entrypoint: .config.Entrypoint, cmd: .config.Cmd}'
{"entrypoint": ["/docker-entrypoint.sh"],"cmd": ["nginx","-g","daemon off;"]}
Now the rule that trips everyone up at least once. command in the Pod spec replaces the image's ENTRYPOINT. args replaces the image's CMD. They're independent switches. Set only args and you keep the image's cook but hand over your own order, which is what you want most of the time. Set only command and you replace the cook, and you quietly drop the image's default order too, because CMD only ever applies to the image's own entrypoint. Set both and you've spelled out the whole line yourself. Set neither and the image runs exactly as its author intended. There's no partial blend and no fallback. Whatever you set wins for that field, and whatever you leave out comes from the image, one field at a time.
Override the arguments, keep the program
Say one environment needs nginx to allow more open files per worker than the default. You don't rebuild the image for that. You keep the image's entrypoint, that /docker-entrypoint.sh wrapper which fills in config from environment variables, and replace only the argument list the wrapper is handed. The wrapper does its setup and then runs whatever that list names, which is why the list has to start with nginx even on an image already called nginx. The first element is the program the script hands off to at the end. Leave it out and the script tries to run -g as a program, and the Pod dies on start-up.
apiVersion: v1kind: Podmetadata:name: webspec:containers:- name: nginximage: nginx:1.27args: ["nginx", "-g", "daemon off; worker_rlimit_nofile 8192;"]
kubectl apply -f web.yamlkubectl get pod web -o jsonpath='{.spec.containers[0].command}|{.spec.containers[0].args}{"\n"}'
pod/web created|[nginx -g daemon off; worker_rlimit_nofile 8192;]
See how command is blank on the left of that pipe? An empty command means the image's ENTRYPOINT is still in charge. You only touched args. One more thing about arguments catches people out. Kubernetes will drop an environment variable into your arguments for you, but only if you write it Kubernetes' way, and it does the swap before the process ever starts. This is Kubernetes doing the work, not a shell, and it happens whether or not a shell ever runs.
kubectl run expand --image=busybox:1.36 --restart=Never \--env="GREETING=hi" --command -- echo 'k8s:' '$(GREETING)' 'shell:' '$HOME'kubectl logs expand
pod/expand createdk8s: hi shell: $HOME
$(GREETING) became hi because Kubernetes resolves $(VAR) references against the container's environment variables before the process starts. $HOME stayed literal, because that is shell syntax and nothing in this Pod ever started a shell to read it. The busybox image does ship a shell at /bin/sh, but --command hands your arguments straight to echo, and echo is not a shell. That is the real rule. Whether $VAR gets expanded depends on whether a shell is in the chain of processes, not on what the image happens to contain. So no pipes, no &&, no $VAR. The arguments arrive at your program as plain strings. When a simple swap is all you need, $(VAR) is the safe way to do it, and it never starts a second process. That matters more than it looks, because copy-pasted YAML full of $VAR is a quiet way to ship arguments that never get filled in. (If you ever need a literal $( in an argument, double the dollar sign and write $$(.)
When you actually need a shell
Sometimes you really do need shell behavior. A pipe. A conditional. Two commands run one after the other. For that you bring in a shell on purpose, setting command to /bin/sh with -c and passing your little script as an argument. Do it with some care. A shell wrapped around your app is like hiring a manager who takes the shutdown phone call and then never walks back to tell the kitchen. When Kubernetes removes a Pod, the kubelet (the agent running on each node) sends SIGTERM, the polite 'please finish up and exit' signal, to process ID 1 inside the container. If process 1 is the shell, and the shell doesn't pass that signal along, your actual app never hears it.
sh -c "./setup.sh && myapp --flag" and the shell runs the chain, so it stays as process 1. A shell sitting at process 1 doesn't forward SIGTERM to the programs it started, so on a kubectl delete or a rolling update your app is never told to shut down. Kubernetes waits out the full termination grace period (30 seconds by default), then kills it hard with SIGKILL. From the outside it looks like something unrelated: slow rollouts, dropped connections on every deploy. The fix is exec on the final command, sh -c "./setup.sh && exec myapp --flag", so the signal lands on your app instead of the shell.When it won't start, read the failure
When a container refuses to start, don't guess and don't keep re-applying the same thing. kubectl describe pod prints the exact runtime error in the events section, and the container runtime is usually blunt about what it couldn't do. Events age out after about an hour by default, so read them while they're fresh, right after the failure.
kubectl run bad --image=busybox:1.36 --restart=Never --command -- /opt/app/serverkubectl describe pod bad | sed -n '/Events:/,$p'
pod/bad createdEvents:Type Reason Age From Message---- ------ ---- ---- -------Normal Scheduled 10s default-scheduler Successfully assigned default/bad to node-1Normal Pulled 9s kubelet Container image "busybox:1.36" already present on machineNormal Created 9s kubelet Created container: badWarning Failed 8s kubelet Error: failed to create containerd task: OCI runtimecreate failed: runc create failed: unable to startcontainer process: exec: "/opt/app/server": stat/opt/app/server: no such file or directory: unknown
Two exit codes are worth keeping in your head for the shell case. 127 means 'command not found', which you'll see when a shell does run but can't find what you told it to run. 126 means the file is there but isn't executable, usually a missing execute bit or a script with the wrong interpreter line at the top. Both codes come from the shell, so they only appear when a shell actually ran. When the container never starts at all, like the RunContainerError we opened with, there was no shell and the runtime simply couldn't exec your command. Check that the path really exists inside the image (kubectl debug or a throwaway sh container will show you), and that you didn't hand command something only a shell would know how to find.
When the container does start but behaves oddly, stop rereading the YAML and look at the processes instead. kubectl exec <pod> -- ps -o pid,args prints what is actually running, and if the image is stripped down far enough that it has no ps, kubectl debug gets you a container that does. If process ID 1 turns out to be /bin/sh with your app hanging off it as a child, you have just found the shutdown problem from the shell section. The same look helps with exec health probes, which Kubernetes runs as a plain argument list with no shell in between: a probe with a pipe or a $VAR in it needs a shell inside the image, and the stripped-down 'distroless' images don't ship one.
Try this
Read nginx:1.27's own ENTRYPOINT and CMD first, then apply web.yaml and check the two fields back: command comes back empty because you never set it, and args is yours. Then run the two busybox pods. The first shows Kubernetes filling in $(GREETING) while $HOME stays literal, and the second shows what the runtime says when command names a path the image doesn't have.
$ crane config nginx:1.27 | jq '{entrypoint: .config.Entrypoint, cmd: .config.Cmd}'$ kubectl apply -f web.yaml$ kubectl get pod web -o jsonpath='{.spec.containers[0].command}|{.spec.containers[0].args}{"\n"}'$ kubectl run expand --image=busybox:1.36 --restart=Never \--env="GREETING=hi" --command -- echo 'k8s:' '$(GREETING)' 'shell:' '$HOME'$ kubectl logs expand$ kubectl run bad --image=busybox:1.36 --restart=Never --command -- /opt/app/server$ kubectl describe pod bad | sed -n '/Events:/,$p'
Takeaway
Pod command replaces image ENTRYPOINT; args replace CMD. Mixing them carelessly is how you ship a container that immediately exits zero or cannot find a binary.