镜像的 ENTRYPOINT 和 CMD 共同参与容器创建时的启动命令。使用 exec 形式 ENTRYPOINT 时,CMD 通常提供追加给入口的默认参数;没有入口时,CMD 可以提供可执行程序及其参数。这些是启动默认值,不是强制执行边界。有权限创建容器的运行者,可以替换入口或参数,因此不能把 ENTRYPOINT 描述成无论如何都不可改变的程序锁。
坏掉的样子
在 docker run image ... 中写到镜像名之后的参数,会替换 CMD,不是自动加到原 CMD 默认值之后。exec 形式入口仍然保留,除非显式覆盖。用户以为自己只是追加一个选项,却可能丢掉全部默认参数。因此,命令行工具需要说明哪些值可省略、哪些必须重新提供,以及自身参数解析器如何处理覆盖。
exec 形式使用 JSON 数组,不会隐式运行 shell。$DEMO 这样的字符串会原样传给程序,而不是自动展开环境变量。管道、重定向和通配符也需要真正的解释器,才会获得相应含义。shell 形式会改变参数和进程语义,所以两种形式之间的切换并不只是语法美化,不能只看命令表面似乎相同。
启动包装脚本还可能让服务藏在不转发停止信号的 shell 后面。Docker 通常向容器主进程发送停止信号,等待配置的超时,必要时强制终止。即使服务自身正确安装了 SIGTERM 处理函数,如果信号只到达另一个不会转发的进程,这个处理函数也无法发挥作用。排查优雅退出失败时,应先确认信号实际发给了谁。
快速验证
先取得固定基础镜像,再用宿主 Python 和 Docker 构建器运行脚本。实测为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16。实验创建独立临时容器并关闭网络,等待每个服务打印就绪信息后测试停止,结束时删除自己的容器和镜像标签,不依赖启动后盲目等待固定时长来猜测服务已经准备好:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,time,uuid
BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
if check:assert result.returncode==0,result.stderr
return result
def inspect(name):return json.loads(docker('inspect',name).stdout)[0]
name='xbcatm-entrypoint-'+uuid.uuid4().hex[:10];tag=name+':probe';containers=[name+'-exec',name+'-child']
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory)
(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')
docker('build','--network','none','-t',tag,str(root))
assert json.loads(docker('run','--rm','--network','none',tag).stdout)==['default','$DEMO']
assert json.loads(docker('run','--rm','--network','none',tag,'changed').stdout)==['changed']
assert docker('run','--rm','--network','none','--entrypoint','python',tag,'-c','print("override")').stdout.strip()=='override'
assert docker('run','--rm','--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']):
docker('run','-d','--name',container,'--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:
docker('rm','-f',*containers,check=False);docker('image','rm',tag,check=False)
第一部分让镜像命令打印收到的参数列表。不加覆盖时,它收到 default 和字面的 $DEMO,尽管镜像把 DEMO 定义为 expanded。在镜像名后提供 changed,会替换两个默认参数,程序最终只收到一个参数。这些断言检查实际参数向量,避免仅凭 Dockerfile 的外观推测启动结果。
使用 --entrypoint python 并提供明确的 Python 参数,可以运行另一条命令,输出 override。显式调用 /bin/sh -c 则会展开 $DEMO,输出 expanded。两组结果共同说明 ENTRYPOINT 可以被覆盖,也说明变量展开属于被调用的 shell,而不是 exec 形式内部隐藏的自动处理步骤。
第二部分比较两个启动同一服务的包装脚本。exec 版本用 Python 替换自身,服务报告容器内 PID 为一。Docker 停止容器时,已安装的 SIGTERM 处理函数打印 stopped,随后以零状态退出。实验同时核对日志消息和退出码,证明信号实际到达了服务,并走过被设计的结束路径。
另一个包装脚本把 Python 作为子进程启动,自己等待,却不转发信号。服务报告的 PID 不是一。使用一秒停止超时后,本次环境记录退出码 137,服务也没有打印 stopped。这里直接观察了强制终止结果,不是仅仅因为看到 shell 脚本存在,就断言服务一定无法优雅停止;具体包装方式和处理规则才决定行为。
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。