镜像的 ENTRYPOINT 和 CMD 共同参与容器创建时的启动命令。使用 exec 形式 ENTRYPOINT 时,CMD 通常提供追加给入口的默认参数;没有入口时,CMD 可以提供可执行程序及其参数。这些是启动默认值,不是强制执行边界。有权限创建容器的运行者,可以替换入口或参数,因此不能把 ENTRYPOINT 描述成无论如何都不可改变的程序锁。
坏掉的样子
在 docker run image ... 中写到镜像名之后的参数,会替换 CMD,不是自动加到原 CMD 默认值之后。exec 形式入口仍然保留,除非显式覆盖。用户以为自己只是追加一个选项,却可能丢掉全部默认参数。因此,命令行工具需要说明哪些值可省略、哪些必须重新提供,以及自身参数解析器如何处理覆盖。
exec 形式使用 JSON 数组,不会隐式运行 shell。$DEMO 这样的字符串会原样传给程序,而不是自动展开环境变量。管道、重定向和通配符也需要真正的解释器,才会获得相应含义。shell 形式会改变参数和进程语义,所以两种形式之间的切换并不只是语法美化,不能只看命令表面似乎相同。
启动包装脚本还可能让服务藏在不转发停止信号的 shell 后面。Docker 通常向容器主进程发送停止信号,等待配置的超时,必要时强制终止。即使服务自身正确安装了 SIGTERM 处理函数,如果信号只到达另一个不会转发的进程,这个处理函数也无法发挥作用。排查优雅退出失败时,应先确认信号实际发给了谁。
快速验证
使用启用断言的宿主 Python 3.7 或更新版本,以及能够取得固定基础镜像的 Docker 构建器。早期记录报告 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16;这里没有复现该次运行。PID 断言要求容器使用私有 PID 命名空间,且没有注入 init。实验关闭容器网络,等待服务就绪输出后测试停止,并尝试清理容器和镜像标签,不依赖盲目等待固定时长猜测就绪:
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)
第一部分让镜像命令打印收到的参数列表。不加覆盖时,它收到 default 和字面的 $DEMO,尽管镜像把 DEMO 定义为 expanded。在镜像名后提供 changed,会替换两个默认参数,程序最终只收到一个参数。这些断言检查实际参数向量。
使用 --entrypoint python 并提供明确的 Python 参数,可以运行另一条命令,输出 override。显式调用 /bin/sh -c 则会展开 $DEMO,输出 expanded。两组结果共同说明 ENTRYPOINT 可以被覆盖,也说明变量展开属于被调用的 shell,而不是 exec 形式内部隐藏的自动处理步骤。
两个包装脚本启动同一服务。exec 版本用 Python 替换自身,服务报告容器内 PID 为一。Docker 停止容器时,已安装的 SIGTERM 处理函数打印 stopped,随后以零状态退出。实验同时核对日志消息和退出码,证明信号实际到达了服务,并走过被设计的结束路径。
另一个包装脚本把 Python 作为子进程启动,自己等待,却不转发信号。服务报告的 PID 不是一。使用一秒停止超时后,本次环境记录退出码 137,服务也没有打印 stopped。这里直接观察了强制终止结果,不是仅仅因为看到 shell 脚本存在,就断言服务一定无法优雅停止;具体包装方式和处理规则才决定行为。
修订后的夹具尚未运行,只能用于独占控制的一次性端点。六次运行都有预检过的名称,并在启动前登记;保护先于文件准备和构建提交。每个容器删除、标签删除、残留检查与本地清理独立尝试,清理 CLI 调用设十秒超时。错误不替换最初异常,只有清理失败时脚本也会失败。超时的守护进程操作可能稍后完成,须在端点稳定后检查打印出的名称。进程创建、停滞的文件系统和突然终止仍不受这些保证约束。基础镜像与构建缓存保留;构建步骤隔离不禁止镜像获取。
Linux PID 命名空间中的 PID 一具有特殊的信号和子进程回收责任。exec 包装脚本可以让服务成为信号接收者,但不会自动实现应用清理、排空在途工作,或回收任意后代进程。服务仍需要合适的行为;如果其进程模型需要初始化或监督机制,应明确选择并测试,而不是认为写上 exec 就完成全部退出设计。
把启动和停止视为同一个接口
exec 形式应使用有效 JSON 数组和双引号字符串。明确可执行程序及稳定参数,并说明调用方如何替换默认值。如果初始化必须使用 shell 包装,而包装脚本最后要把控制权交给单个前台服务,就用适当的 exec 完成交接。转发参数时保留引号,避免原来的参数边界在脚本中再次被拆分。
测试真实部署入口,包括编排配置提供的覆盖。配置可能用另一层 shell 替换正确入口,或遗漏必要初始化。应检查实际进程树,并在具有代表性的活动工作下执行终止验证。只测试一个立即退出的打印命令,无法证明长时间运行服务的停止路径正确。
按照服务契约选择停止信号和宽限时间,分别记录就绪、清理成功和退出状态。SIGKILL 后容器消失,不证明请求完成或缓冲状态被安全处理。只有对应业务结束条件的证据,才能支持“优雅退出已经通过”的结论。
来源
- Dockerfile ENTRYPOINT and CMD,exec 与 shell 形式、参数组合以及入口覆盖,已核验 2026-09-12。
- Docker container stop,停止信号、等待超时与强制终止,已核验 2026-09-12。
- pid_namespaces(7),命名空间初始进程的信号规则及成员终止,已核验 2026-09-12。