An image’s ENTRYPOINT and CMD contribute to the command used when a container is created. With an exec-form ENTRYPOINT, CMD commonly supplies default arguments appended to that entrypoint. Without an entrypoint, CMD can supply the executable and its arguments. These are startup defaults, not an enforcement boundary: an authorized operator can replace the entrypoint or arguments when creating a container.
When it breaks
Trailing arguments in docker run image ... replace CMD; they are not automatically added after CMD’s existing defaults. The exec-form entrypoint remains unless explicitly overridden. A user expecting to append one flag can accidentally discard required defaults, so command-line tools should document which values are optional, which must be repeated, and how their own argument parser treats overrides.
Exec form uses a JSON array and does not implicitly run a shell. A string such as $DEMO reaches the program literally instead of receiving shell variable expansion. Pipes, redirects, and wildcards likewise need an actual interpreter if that is the intended behavior. Shell form changes argument and process semantics, so switching forms is more than a cosmetic syntax choice.
A startup wrapper can also hide the service behind a shell that never forwards the container’s stop signal. Docker normally signals the main container process, waits for the configured timeout, then forces termination if necessary. A service’s correct SIGTERM handler is ineffective if the signal is delivered only to another process that does not pass it on.
Check it quickly
Use host Python 3.7 or later with assertions enabled and a Docker builder that can obtain the pinned base. Earlier records report Docker 29.3.1 and Linux arm64 Python 3.11.16; that run has not been reproduced here. The PID assertions require a private container PID namespace without an injected init. The fixture disables container networking, waits for readiness, tests stopping, and attempts container and image-tag cleanup:
from pathlib import Path
from tempfile import mkdtemp
import json,subprocess,sys,time,uuid
BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True,timeout=120):
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=timeout)
if check:assert result.returncode==0,result.stderr
return result
def inspect(name):return json.loads(docker('inspect',name).stdout)[0]
def cleanup_steps(steps, primary):
failures=[]
for label, action in steps:
try:action()
except BaseException as error:
failures.append(label+': '+type(error).__name__+': '+str(error))
if failures:
message='cleanup incomplete or uncertain: '+'; '.join(failures)
try:print(message,file=sys.stderr)
except (OSError,ValueError):pass
if primary is None:raise RuntimeError(message)
return failures
def remove_file(path):
try:path.unlink()
except FileNotFoundError:pass
def require_empty(value):
if value:raise RuntimeError('residual resources: '+str(value))
name='xbcatm-entrypoint-'+uuid.uuid4().hex;tag=name+':probe'
containers=[name+'-exec',name+'-child'];probes=[name+'-args-'+str(i) for i in range(4)]
all_names=probes+containers;attempted=[];build_started=False;root=None
def current_names():return docker('ps','-a','--format','{{.Names}}',timeout=10).stdout.splitlines()
def remaining_names():return sorted(set(all_names)&set(current_names()))
require_empty(remaining_names());require_empty(docker('image','ls','-q',tag,timeout=10).stdout.strip())
def launch(container,*args):
attempted.append(container)
return docker('run','--name',container,*args)
print('fixture containers:',all_names,'image tag:',tag,flush=True)
try:
root=Path(mkdtemp(prefix=name+'-'))
(root/'args.py').write_text('import json,sys\nprint(json.dumps(sys.argv[1:]))\n')
(root/'service.py').write_text('import json,os,signal\ndef stop(signum,frame):\n print("stopped",flush=True)\n raise SystemExit(0)\nsignal.signal(signal.SIGTERM,stop)\nprint(json.dumps({"pid":os.getpid()}),flush=True)\nwhile True:signal.pause()\n')
(root/'exec.sh').write_text('#!/bin/sh\nexec python /service.py "$@"\n')
(root/'child.sh').write_text('#!/bin/sh\npython /service.py "$@" &\nwait $!\n')
(root/'Dockerfile').write_text(f'FROM {BASE}\nCOPY args.py service.py exec.sh child.sh /\nRUN chmod +x /exec.sh /child.sh\nENV DEMO=expanded\nENTRYPOINT ["python", "/args.py"]\nCMD ["default", "$DEMO"]\n')
build_started=True
docker('build','--network','none','-t',tag,str(root))
assert json.loads(launch(probes[0],'--network','none',tag).stdout)==['default','$DEMO']
assert json.loads(launch(probes[1],'--network','none',tag,'changed').stdout)==['changed']
assert launch(probes[2],'--network','none','--entrypoint','python',tag,'-c','print("override")').stdout.strip()=='override'
assert launch(probes[3],'--network','none','--entrypoint','/bin/sh',tag,'-c','printf "%s" "$DEMO"').stdout=='expanded'
print('CMD supplies literal default arguments; trailing arguments replace it; --entrypoint replaces the executable')
for container,wrapper in zip(containers,['/exec.sh','/child.sh']):
launch(container,'-d','--network','none','--entrypoint',wrapper,tag)
deadline=time.monotonic()+10
while time.monotonic()<deadline:
logs=docker('logs',container).stdout
if logs.strip():break
time.sleep(.05)
else:raise AssertionError('service did not become ready')
pid=json.loads(logs.splitlines()[0])['pid']
assert (pid==1)==(wrapper=='/exec.sh')
docker('stop','--time','1',container)
state=inspect(container)['State'];logs=docker('logs',container).stdout
if wrapper=='/exec.sh':assert state['ExitCode']==0 and 'stopped' in logs
else:assert state['ExitCode']==137 and 'stopped' not in logs
print('exec makes the handler PID 1 and stops cleanly; the non-forwarding shell wrapper leaves its child unnotified until forced termination')
finally:
primary=sys.exc_info()[1]
steps=[('container '+container,lambda container=container:docker('rm','-f',container,timeout=10)) for container in attempted]
if build_started:steps.append(('image tag',lambda:docker('image','rm','--no-prune',tag,timeout=10)))
steps.append(('container absence',lambda:require_empty(remaining_names())))
if build_started:steps.append(('image-tag absence',lambda:require_empty(docker('image','ls','-q',tag,timeout=10).stdout.strip())))
if root is not None:
steps.extend((filename,lambda filename=filename:remove_file(root/filename)) for filename in ['args.py','service.py','exec.sh','child.sh','Dockerfile'])
steps.append(('temporary directory',root.rmdir))
cleanup_steps(steps,primary)
The first image command prints its received argument list. With no override it receives default and the literal $DEMO, even though the image defines DEMO as expanded. Supplying changed after the image name replaces both default arguments, and the program receives exactly one argument. These assertions inspect the final argument vector.
Using --entrypoint python and explicit Python arguments runs an entirely different command that prints override. Using /bin/sh -c explicitly expands $DEMO and prints expanded. Together, these cases show both that ENTRYPOINT is overridable and that expansion belongs to the invoked shell, not to a hidden expansion step in exec-form command construction.
Two wrappers launch one service. The exec wrapper replaces itself with Python, which reports PID 1 inside the container. Its installed SIGTERM handler prints stopped and exits with status zero when Docker stops the container. The fixture verifies the message and the recorded exit status.
The other wrapper starts Python as a child and waits, without forwarding signals. The service reports a PID other than one. Stopping that container with a one-second timeout produces exit status 137 in the recorded environment, and the service never prints stopped. The forced termination is observed directly; no conclusion is inferred merely from the presence of a shell script.
This revised fixture is unrun. Use an exclusively controlled disposable endpoint. All six runs have preflight-checked names recorded before launch. The guard precedes file setup and build submission. Each container removal, tag removal, residual check and local cleanup is independent; cleanup CLI calls have ten-second timeouts. Errors preserve the original exception; cleanup-only failures also fail. A timed-out daemon operation can finish later, so inspect the printed names after the endpoint settles. Process creation, stalled filesystems and abrupt termination remain outside these guarantees. Base images/build cache remain; build-step isolation does not prohibit image retrieval.
PID 1 has special signal and child-reaping responsibilities in a Linux PID namespace. An exec wrapper makes the service the signal recipient, but it does not automatically implement application cleanup, drain in-flight work, or reap arbitrary descendants. The service still needs appropriate behavior, or a deliberately chosen init/supervision arrangement when its process model requires one.
Treat startup and shutdown as one interface
Use valid JSON arrays with double-quoted strings for exec-form instructions. Keep the executable and stable arguments clear, and document how callers replace defaults. If initialization requires a shell wrapper, finish with an appropriate exec when the wrapper’s job is to hand control to one foreground service. Quote forwarded arguments so their boundaries remain intact.
Test the actual deployed launch path, including any orchestration overrides. Configuration can replace a correct entrypoint with another shell or omit necessary initialization. Inspect the resulting process tree and exercise termination with representative active work.
Choose the stop signal and grace period according to the service’s contract. Record readiness, successful cleanup, and exit status separately. Disappearance after SIGKILL does not prove requests completed or buffered state was safely handled.
Sources
- Dockerfile ENTRYPOINT and CMD, exec and shell forms, argument combination, and entrypoint overrides, checked 2026-09-12.
- Docker container stop, stop signals, timeout, and forced termination, checked 2026-09-12.
- pid_namespaces(7), namespace-init signal behavior and termination of namespace members, checked 2026-09-12.