1. 从一道必问题说起:CMD和ENTRYPOINT到底差在哪
在Docker镜像构建这条路上,CMD和ENTRYPOINT大概是除了基础镜像选型之外,被问得最多、也最容易搞混的两个点。我自己带团队的时候,每次面试运维或者后端开发,几乎必问一句"如果我在Dockerfile里同时写了ENTRYPOINT和CMD,docker run后面再跟参数会发生什么"。能一次性答对的,说实话不到三成。这两条指令表面上都是"指定容器启动时执行的命令",但它们在Docker镜像构建体系里的定位、优先级、以及和docker run参数的交互方式,完全不一样。
1.1 为什么镜像构建必须面对这两个指令
一个镜像打包完之后,最终是要以容器的形态跑起来的。那容器启动的时候执行什么进程、怎么接收外部传入的参数、怎么优雅退出,这些事在镜像构建阶段就得定好。Dockerfile里提供了RUN、CMD、ENTRYPOINT三个和命令相关的指令,很多新手会混淆:RUN是在构建阶段执行,结果会固化到镜像层里;CMD和ENTRYPOINT是容器运行时才生效的,它们决定的是容器起来之后的进程行为。
既然两者都是运行时生效,那到底什么时候用CMD、什么时候用ENTRYPOINT?为什么说"怎么选"是镜像构建的核心问题之一?因为这直接决定了你的镜像好不好用、能不能被灵活复用、以及会不会出现"改了参数却不起作用"这种让人抓狂的情况。
1.2 这个问题的适用人群和常见场景
如果你是刚接触Docker、正在尝试docker构建自定义镜像的新手;或者你已经开始用Docker部署服务,但每次写Dockerfile都靠复制粘贴,遇到CMD和ENTRYPOINT就发怵;又或者你想把现有服务做成一个标准镜像分发给团队使用——这篇文章要讲的选型思路,就是为你准备的。
我见过太多因为选错指令导致的翻车案例:有人在Dockerfile里用CMD写死了启动脚本路径,结果QA同学docker run时想传个测试环境的参数,发现传进去的参数根本没生效;也有人把ENTRYPOINT配置成shell形式,导致容器收到SIGTERM信号时进程没有及时退出,服务下线要等好几分钟。这些问题,本质上都是没搞懂CMD和ENTRYPOINT的定义边界和设计意图。这篇文章我会从机制原理、实操验证到问题排查,完整梳理一遍,最后给你一张可以直接照做的选型清单。
2. 机制拆解:CMD、ENTRYPOINT以及docker run三者如何协同工作
要回答"怎么选",必须先搞清楚"Dockerfile里的指令在运行时到底怎么被使用"。很多教程会把CMD和ENTRYPOINT的关系讲成简单的"覆盖"和"共存",但真实行为远比这个复杂。
2.1 Dockerfile里的最终命令是如何拼装出来的
在容器启动时,Docker引擎会把ENTRYPOINT和CMD按照一定的规则拼接成最终的启动命令。拼接规则可以归纳为一张表:
| 写法组合 | 结果行为 |
|---|---|
| 只写ENTRYPOINT | 容器执行ENTRYPOINT定义的命令,无附加参数 |
| 只写CMD | 容器执行CMD定义的命令,且docker run后面的参数会整体替换CMD |
| 同时写ENTRYPOINT和CMD | 容器执行"ENTRYPOINT命令 + CMD内容作为默认参数",docker run附加参数会替换CMD部分 |
| 都不写 | 继承基础镜像的配置;如果基础镜像也没有,则运行报错 |
这张表是整个选型问题的地基。如果同时写了ENTRYPOINT和CMD,那么实际执行的是"ENTRYPOINT可执行文件 CMD的默认参数"。也就是说,ENTRYPOINT一旦出现,CMD就从"命令"降级成了"默认参数列表"。这个语义变化非常关键,但恰恰是很多教程没讲透的地方。
我举个例子。假设我在Dockerfile里写:
FROM ubuntu:22.04 CMD ["echo", "hello default"]构建镜像后执行:
docker run my-image会输出hello default。但如果你执行:
docker run my-image echo "override"你猜会发生什么?因为docker run后面的参数会整体替换CMD,所以容器实际执行的是echo "override",输出override。CMD定义的那个echo命令连一点出场机会都没有。这就是CMD"可被覆盖"的语义本质。
再换个写法:
FROM ubuntu:22.04 ENTRYPOINT ["echo"] CMD ["hello default"]docker run my-image,输出hello default。docker run my-image "override world",输出override world。看到了吗?echo这个命令本身没变,变的是它接收的参数。ENTRYPOINT像一个固定的可执行文件外壳,CMD负责向它传参数。
2.2 exec形式和shell形式的陷阱
CMD和ENTRYPOINT都有两种写法:exec形式(JSON数组)和shell形式(字符串)。这是最容易踩坑的地方之一。
# exec形式,推荐 CMD ["nginx", "-g", "daemon off;"] # shell形式,不推荐 CMD nginx -g "daemon off;"exec形式会直接以指定的可执行文件作为PID 1启动,不会调用shell。shell形式则会先启动一个/bin/sh -c的中间进程,再由它执行后面的命令。这两者最大的差别有两个:
第一,信号传递。用shell形式时,容器内的PID 1是/bin/sh而不是你的业务进程,应用层进程变成了sh的子进程。当Docker引擎执行docker stop的时候,SIGTERM信号首先发给PID 1也就是那个sh进程,而sh默认行为不是把信号转发给子进程,这就导致你的业务进程根本接收不到优雅停机信号。结果就是容器长时间停不下来,最后只能被SIGKILL强杀。对于数据库、消息队列这类需要清理由的进程,这是致命的。
第二,参数转义。shell形式走了一层sh,命令里的空格、通配符、环境变量都会被sh先处理一遍。看起来方便,但引入了很多隐式行为,容易造成参数传递不一致。
2.3 docker run命令行参数如何参与进来
很多人不知道,docker run后面追加的内容会直接接到Dockerfile定义的指令后面。更准确地说,docker run参数会与镜像配置里的Cmd字段交互,但不会动Entrypoint字段,除非你显式指定--entrypoint参数。这个设计对灵活复用镜像很有用。
举例,官方nginx镜像的Dockerfile大致是:
ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["nginx", "-g", "daemon off;"]docker-entrypoint.sh脚本内部会解析环境变量、生成配置文件,最后执行传给它的命令。如果你用:
docker run nginx nginx -t其实你是在说:用脚本初始化完毕之后,执行一下nginx -t来检查配置。这个镜像的CMD部分就被nginx -t这个参数替换掉了,但ENTRYPOINT脚本本身一直在跑。理解这层机制,你的镜像设计思路就打开了一半。
3. 实操验证:用一套最小实验把行为彻底摸透
命令行参数、Dockerfile指令、进程信号这些抽象概念,光靠文字理解是不够的。我特别建议你把下面的实验在本地做一遍,整个过程不会超过10分钟,但做完之后你对CMD和ENTRYPOINT的感觉会和之前完全不同。
3.1 环境准备与基础镜像选择
你只需要一台装了Docker的机器,任意环境都行,Windows的Docker Desktop也可以。用ubuntu:22.04做基础镜像就够了,不需要额外安装任何东西。在某个工作目录下建一个文件夹叫cmd-entrypoint-lab,后面所有实验都在这里做。
提示:如果你本机拉镜像比较慢,建议先把镜像源配置成可用的国内加速地址,再去拉ubuntu镜像。镜像下载慢的问题我在最后会专门讲怎么处理。
3.2 用五组实验验证覆盖与拼接规则
我在实验里把每条规则拆开验证,每组实验都用一个独立Dockerfile。
第一组,只有CMD,验证docker run参数覆盖CMD:
FROM ubuntu:22.04 CMD ["echo", "default cmd"]docker build -t test-cmd . docker run test-cmd # 输出:default cmd docker run test-cmd echo "custom param" # 输出:custom param第二组,只有ENTRYPOINT,验证docker run参数追加到ENTRYPOINT后面:
FROM ubuntu:22.04 ENTRYPOINT ["echo", "fixed entrypoint"]docker build -t test-entrypoint . docker run test-entrypoint # 输出:fixed entrypoint docker run test-entrypoint "custom param" # 输出:fixed entrypoint custom param看到差异了吗?第二组的docker run参数不是覆盖,而是追加到ENTRYPOINT命令后面。
第三组,ENTRYPOINT加CMD,验证CMD变成默认参数:
FROM ubuntu:22.04 ENTRYPOINT ["echo"] CMD ["default argument"]docker build -t test-both . docker run test-both # 输出:default argument docker run test-both "custom argument" # 输出:custom argument第四组,CMD为shell形式,看进程和信号的差异:
FROM ubuntu:22.04 CMD sleep 300docker build -t test-shell . docker run -d --name sleep-shell test-shell docker exec sleep-shell ps -ef你会看到PID 1是/bin/sh -c sleep 300,sleep进程是它的子进程。这组实验不用把- d去掉,保持后台运行即可。
第五组,CMD为exec形式:
FROM ubuntu:22.04 CMD ["sleep", "300"]docker build -t test-exec . docker run -d --name sleep-exec test-exec docker exec sleep-exec ps -ef这组产生的结果正好相反,sleep 300直接就是PID 1。这两组进程树的差别,就是未来排查各种奇怪容器问题的重要线索。
3.3 用--entrypoint参数做临时覆盖
还有一条实际用得很多的调试技巧:docker run支持--entrypoint参数,可以在运行时不修改Dockerfile就临时指定不同入口。
docker run --entrypoint echo test-entrypoint "custom entrypoint" # 输出:custom entrypoint再比如你想进入一个连bash都没有的精简镜像看看环境,可以直接:
docker run --entrypoint sh -it my-slim-image这个命令在排查镜像问题、调试启动脚本时非常管用,不用重新构建镜像,省下大量时间。
4. 选型决策:什么场景该用CMD,什么场景该用ENTRYPOINT
搞清楚机制之后,选型问题就从"记规则"变成了"看需求"。这里我给出几种典型场景的推荐方案,并且解释清楚背后的逻辑。
4.1 优先选ENTRYPOINT的场景
如果你的容器业务进程是"固定不可替换"的,只是参数可能变化,那就应该用ENTRYPOINT。典型例子是各种服务型镜像:nginx、redis、mysql,这些镜像的进程是确定的,但启动参数需要灵活调整,所以官方镜像普遍采用ENTRYPOINT + CMD的组合。
还有一个被我反复验证的原则:ENTRYPOINT负责"固定",CMD负责"可变"。ENTRYPOINT固定住那部分不能也不应该被docker run覆盖的逻辑,CMD则把那些允许变化的部分暴露给用户。这种组合能让镜像用起来像"一个可配置的应用程序"而非"一段死命令"。
我自己的经验是:写自定义的应用服务镜像时,至少保证ENTRYPOINT存在,并且把服务主程序放到ENTRYPOINT里。即使你现在只写CMD就能跑,等到业务演进、需要加启动前初始化逻辑或者信号处理时,你会发现没有ENTRYPOINT做固定入口,很多事情都做不了。
4.2 优先选CMD的场景
如果你的镜像是"工具型"的,比如提供一系列命令执行入口,或者给用户一个默认行为但允许完全覆盖,那就优先用CMD。最典型的是各类CLI工具镜像,像curl、aws CLI、kubectl等等。这些工具镜像你希望用户能用不同参数调用不同功能,docker run的附加参数应该直接替换默认值,而不是追加到固定命令后面。
另外,如果你只是做一个通用环境的镜像,比如一个包含Python运行时的基础镜像,启动时默认执行什么命令这件事本身就不应该写死,那么只写CMD、甚至不写运行时指令都行,把选择权留给下游使用方。
4.3 一个真实的业务案例:自定义应用镜像怎么选
我以自己维护过的一个Java后端服务镜像为例,完整的Dockerfile简化后是:
FROM eclipse-temurin:17-jre WORKDIR /app COPY target/my-service.jar /app/ COPY docker-entrypoint.sh / RUN chmod +x /docker-entrypoint.sh ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["--spring.profiles.active=default"]docker-entrypoint.sh里做三件事:根据环境变量生成JVM参数、拷贝依赖的配置文件、最后执行java -jar my-service.jar加上CMD传入的参数。这样一来,团队平时直接docker run我的镜像就行,默认加载default配置;联调和测试要换环境时,只需要:
docker run my-image --spring.profiles.active=test就能覆盖默认参数,完全不用改镜像。这个灵活性就是ENTRYPOINT + CMD组合带来的核心价值。
4.4 shell脚本和tini:处理PID 1问题的经验之谈
前面提到exec形式能让业务进程成为PID 1,但这会带来另一个问题:PID 1进程在Linux里有特殊语义,比如它必须负责回收孤儿进程,还可能是僵尸进程的父进程。Java、Python这类带子线程的语言不太受影响,但像go、rust这些能派生多个子进程的程序,PID 1的信号处理和僵尸进程清理逻辑就要格外小心。
我的做法是在容器里用tini或者docker的init功能。可以在启动命令里直接加--init参数:
docker run --init my-image或者自己在ENTRYPOINT里套tini:
ENTRYPOINT ["/usr/bin/tini", "--", "/docker-entrypoint.sh"]这个技巧是我在生产环境排障踩坑后才学到的,强烈建议各位在做自定义镜像时直接用上,能少掉很多莫名其妙的"容器资源占用高"问题。
4.5 一张表搞定决策路径
为了方便查阅,我把选型逻辑压缩成一张表,照着判断即可:
| 需求类型 | 选型建议 | 理由 |
|---|---|---|
| 固定业务进程,允许调参和附加参数 | ENTRYPOINT + CMD | CMD作为默认参数,docker run参数可覆盖 |
| 业务进程要双保险、不能被覆盖 | 只用ENTRYPOINT | 用户无论加什么参数,主进程不变 |
| CLI工具镜像,默认命令可整体替换 | 只用CMD | 天然支持docker run参数整体覆盖 |
| 通用基础镜像,默认行为不明确 | 不用或者只写个无害CMD | 把选择权交给下游构建方 |
| 需要初始化脚本注入逻辑 | ENTRYPOINT指向脚本 | 启动前做环境准备、配置生成、信号转发 |
5. 镜像构建中的常见问题与排查技巧实录
即便理解了选型原则,实际用起来还是会遇到各种坑。这一节我把这些年实操里碰到的、以及在社区里被反复求助的几个问题整理出来,每一条都给到排查思路和解决模板。
5.1 容器启动后立刻退出:"executable file not found"
这是我被问得最多的错误之一。现象是docker run执行后马上退出,docker logs提示找不到可执行文件,或者报No such file or directory。但Dockerfile里明明写了命令,看起来也没问题。
这类问题八成出在exec形式的可执行文件路径上。注意,exec形式必须写完整路径,而且不能依赖shell去查找PATH。比如:
CMD ["echo", "hello"]echo在PATH里能找到,实际能跑通。但如果写的是:
CMD ["my-custom-script"]系统不会去自动找当前目录,而是要求你在脚本前加完整路径:
CMD ["/usr/local/bin/my-custom-script"]还有一种隐蔽情况:脚本第一行写的shebang不对,比如#!/bin/sh\r\n的Windows换行符会导致系统找不到解释器。这个坑在Windows上编辑脚本然后COPY进镜像时最容易出现,处理方法是构建前先把脚本转成LF换行符。
5.2 传进去的参数没有生效,甚至被忽略
如果你发现docker run后面加的参数没生效,往往是因为镜像里用的ENTRYPOINT把参数吃了,或者ENTRYPOINT本身不是exec形式。
我曾经遇到过一段踩坑的Dockerfile:
ENTRYPOINT /docker-entrypoint.sh看起来没问题对吧?但docker run my-image --foo时,这个--foo并没有作为参数传给脚本,而是被当成了sh的参数。由于是shell形式,实际执行的是/bin/sh -c "/docker-entrypoint.sh",docker run附加的--foo被追加到了sh后面,形成了/bin/sh -c "/docker-entrypoint.sh" --foo。
问题的根源出在:在shell形式下,这个"固定"入口其实固定得并不彻底。要解决,必须改成JSON数组形式:
ENTRYPOINT ["/docker-entrypoint.sh"]记住,只要涉及到参数传递,就一定要用exec形式。这个经验我验证过很多次了。
5.3 docker stop很久才能停掉容器
这是和前面信号机制直接相关的问题。如果你发现docker stop一个容器要等十几秒甚至更久,先别急着怪Docker,去查容器里PID 1是什么进程。
经验做法是:
docker inspect --format='{{.Config.Entrypoint}} {{.Config.Cmd}}' my-container docker exec my-container ps -ef这两条命令能告诉你PID 1到底是业务进程、还是/bin/sh、还是别的包装脚本。如果PID 1是sh,而业务进程是它下面的子进程,那就把启动命令改成exec形式或者显式调用exec命令:
#!/bin/sh exec java -jar /app/my-service.jar脚本里加exec,能让java进程直接替换掉sh进程,成为容器PID 1,后续信号才能正确到达。
5.4 环境变量和引号带来的参数错乱
在使用ENTRYPOINT + CMD传递参数时,如果CMD写的是shell形式,里面的引号处理会让人抓狂。比如:
CMD echo "hello world"这段以shell形式写没问题,sh会正确解析引号。但如果改成exec形式时写成了:
CMD ["echo", "\"hello world\""]输出会变成带引号的字面量:"hello world"。因为在exec形式下,Docker不会经过shell,引号就是参数的一部分。
这个坑很隐蔽。当你想在exec形式中传递带空格的参数时,直接把整个字符串放在一个数组元素里就行:
CMD ["echo", "hello world"]不要把引号写进字符串内部,否则引号会原样传递给应用。
6. 容器信号处理再进阶:结合docker run和compose场景补充
到这里基础选型已经足够日常使用,但如果你上了生产环境,还有几个细节需要补齐。这一节是这几年的实战补充,属于"不加到正文里也不影响理解,但加上能让镜像行为更规范"的部分。
6.1 --init和tini的作用对比
前面我提过docker run --init,但对它的内部逻辑没展开。简单说,--init会在PID 1位置放一个Docker内置的init进程,这个进程的主要作用是转发信号并负责回收所有变成孤儿进程的子进程。它解决的问题是:当业务进程fork出子进程后,子进程变成僵尸状态没人回收。虽然你的主进程不会因此挂掉,但长期跑下来僵尸进程越积越多,最终可能导致资源泄漏。
我个人的习惯是:如果基础镜像里自带tini,比如很多alpine镜像可以apk add tini,那就直接用tini作为ENTRYPOINT入口。如果不想额外装包,就统一docker run的时候加--init。这两者在绝大多数场景下效果等同。
6.2 docker compose和Kubernetes场景下的参数覆盖
使用docker compose时同样支持覆盖CMD和ENTRYPOINT。compose文件里的command字段对应的是CMD覆盖,entrypoint字段对应的是ENTRYPOINT覆盖:
services: my-service: image: my-image entrypoint: ["/docker-entrypoint.sh"] command: ["--spring.profiles.active=prod"]在Kubernetes里则是通过command和args两个字段来覆盖:
spec: containers: - name: my-service image: my-image command: ["/docker-entrypoint.sh"] args: ["--spring.profiles.active=prod"]这里注意Kubernetes的command对应镜像的ENTRYPOINT,args对应镜像的CMD。但因为不同工具命名不同,很容易造成混淆。我的经验是:在设计镜像时尽量把ENTRYPOINT做成默认且必要的入口,把CMD做成可变参数。这样在Kubernetes里调整args就够了,不需要动command,逻辑清晰很多。
6.3 配置默认参数时的编码建议
在写CMD作为默认参数时,不要把和环境强相关的信息硬编码进去。比如数据库连接串、环境名、密钥这类东西,都应该通过环境变量传递,或者让ENTRYPOINT脚本去解析。CMD里放"环境无关"的默认启动参数,比如spring的profiles、nginx的配置路径等。这样做的好处是,同一个镜像可以在开发、测试、生产三个环境跑,不需要重新构建。
我甚至见过有些团队在CMD里直接写数据库密码的,这种属于严重的安全隐患,务必避免。任何敏感信息都应该走环境变量或挂载文件的方式进来。
7. 我们团队的最终落地建议
回到最开头那个问题:CMD和ENTRYPOINT怎么选择。如果只记一句话,那就是"ENTRYPOINT管能不能换,CMD管默认给什么"。但它背后其实是一整套关于进程、信号、参数的设计思维。我把自己维护镜像的实际规范列出来,希望对你有参考价值:
所有服务型镜像,一律使用ENTRYPOINT指向启动脚本,CMD放默认参数。启动脚本中,如果最终要执行的是单一业务进程,一定加上exec关键字。所有启动相关命令尽量用exec形式而不是shell形式。如果镜像内需要处理多个子进程或接收大量信号转发,优先考虑tini或docker run --init。工具型镜像只用CMD,业务型镜像ENTRYPOINT + CMD。凡是开机启动、环境准备相关的逻辑,全部收敛到ENTRYPOINT脚本里,不要散落在构建阶段。
这些规范不是凭空想出来的,每一条背后都是具体的故障和排查记录。但你不需要把这些坑全部踩一遍才学会,直接照做就能少走很多弯路。镜像设计这件事,越早建立"入口固定、参数可变、信号可控"的意识,后面在编排系统里做的调整就越少。
如果你现在正在改造手里的某个Dockerfile,可以从最小的一步开始:把ENTRYPOINT和CMD检查一遍,确认它们各自负责什么、能不能被用户覆盖、信号能不能正常到达。改完之后跑一次docker exec ps -ef看看进程树,这一眼能看出很多东西。