Commands & arguments

ENTRYPOINT and CMD, mapped onto a pod spec.

Intermediate10 min · lesson 14 of 65
In plain terms
An image is a microwave with a default program. command and args are you pressing different buttons instead of the default. Override the wrong button and it happily runs the wrong program.

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.

read the image's built-in defaults
crane config nginx:1.27 | jq '{entrypoint: .config.Entrypoint, cmd: .config.Cmd}'
ENTRYPOINT is the program, CMD is its default arguments
{
"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.

web.yaml: args replaces CMD, ENTRYPOINT stays
apiVersion: v1
kind: Pod
metadata:
name: web
spec:
containers:
- name: nginx
image: nginx:1.27
args: ["nginx", "-g", "daemon off; worker_rlimit_nofile 8192;"]
apply it, then confirm which fields you actually set
kubectl apply -f web.yaml
kubectl get pod web -o jsonpath='{.spec.containers[0].command}|{.spec.containers[0].args}{"\n"}'
command is empty (image ENTRYPOINT kept); only args changed
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.

Kubernetes expands $(VAR); the shell's $VAR is left alone
kubectl run expand --image=busybox:1.36 --restart=Never \
--env="GREETING=hi" --command -- echo 'k8s:' '$(GREETING)' 'shell:' '$HOME'
kubectl logs expand
only the $(VAR) form is substituted
pod/expand created
k8s: 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.

Which field do you set?
You want to change how the container starts
the image already ships an ENTRYPOINT and a CMD
Same program, new flags
Set args only
keeps the image ENTRYPOINT; replaces CMD
A different program
Set command (and maybe args)
command replaces ENTRYPOINT and drops the image CMD
Pipes, $VAR, &&, chaining
command: [/bin/sh, -c], args: ["exec app ..."]
a real shell; exec so SIGTERM still reaches your app
The shell that eats your shutdown signal
You reach for a shell when you need a pipe or two commands in a row, and that's exactly where this bites. Run 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.

point command at a path that doesn't exist, then look
kubectl run bad --image=busybox:1.36 --restart=Never --command -- /opt/app/server
kubectl describe pod bad | sed -n '/Events:/,$p'
the runtime tells you exactly which exec failed
pod/bad created
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 10s default-scheduler Successfully assigned default/bad to node-1
Normal Pulled 9s kubelet Container image "busybox:1.36" already present on machine
Normal Created 9s kubelet Created container: bad
Warning Failed 8s kubelet Error: failed to create containerd task: OCI runtime
create failed: runc create failed: unable to start
container 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.

terminal
$ 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.

Quick check
01web.yaml starts nginx:1.27 with args: ["nginx", "-g", "daemon off; worker_rlimit_nofile 8192;"] and no command. A teammate tidies it up by moving that same list into command instead. The pod comes up and serves traffic, but settings the image used to pick up from environment variables at start-up are no longer applied. What happened?
Incorrect — Kubernetes does not chain the two. There is no ordering here: whatever sits in command is the only program the runtime execs, and nothing runs after it.
Correct — command replaces the image's ENTRYPOINT, and crane shows nginx:1.27 ships ENTRYPOINT ["/docker-entrypoint.sh"]. That script is what reads environment variables into the config before starting the server. Leave command empty and the wrapper stays in charge.
Incorrect — CMD really is dropped when you set command, but your teammate typed nginx -g daemon off; out by hand, so those arguments are still there. What went missing is the entrypoint script, not the flags.
Incorrect — They are separate fields with separate jobs: command replaces ENTRYPOINT, args replaces CMD. Swapping one for the other changes which program the runtime starts, which is exactly what bit here.
02You run kubectl run bad --image=busybox:1.36 --restart=Never --command -- /opt/app/server and the pod never starts; describe shows runc reporting exec: "/opt/app/server": no such file or directory. A colleague tries the same path as command: ["/bin/sh", "-c", "/opt/app/server"], and that container does start, then exits with code 127. What do the two results tell you?
Incorrect — A shell starting is not proof your program exists. /bin/sh is in the busybox image so it ran fine, then it went looking for /opt/app/server, did not find it, and exited.
Incorrect — That is 126. 127 is command not found; 126 means the file exists but could not be executed, usually a missing execute bit or a wrong interpreter line at the top of a script.
Correct — One finding, two messengers. In the first pod nothing but the runtime was there to complain, so you get the RunContainerError with runc's exec message. In the second, a shell ran, could not find the path, and reported it as 127.
Incorrect — 126 and 127 come from the shell, which is why they only turn up when a shell actually ran. When the container never starts at all, there was no shell and the runtime message is all you get.
03You keep GREETING=hi on the same busybox:1.36 pod, but instead of handing the echo line straight to echo you run it through /bin/sh -c. What comes out in kubectl logs now?
Correct — The two expansions stack rather than compete. Kubernetes resolves $(VAR) against the container's environment before the process starts, so that half was already working, and putting a shell in the chain adds the one thing that was missing.
Incorrect — Kubernetes' substitution is not a fallback for a missing shell. It happens before the process starts, every time, and the shell never even sees the $(VAR) text.
Incorrect — $(GREETING) was already being replaced in the run with no shell anywhere, which is the whole point of that form. The shell only changes what happens to $HOME.
Incorrect — Quoting does not switch expansion off inside sh -c, and Kubernetes has already done its $(VAR) pass on the arguments long before the shell is handed anything.

Related