一、Docker构建与运行的核心指令误区:从日常场景切入
很多刚接触Docker的开发者,甚至用了一两年的老司机,都会在Dockerfile里搞混RUN和CMD这两个指令——毕竟两个词长得像,翻译过来也都是“运行”的意思,实际用起来却完全不是一回事。我之前见过一个运维同事,把CMD写成RUN,结果构建镜像的时候就卡了十分钟,最后发现是把启动服务的命令写到了构建阶段,导致镜像构建超时失败。
要搞懂这俩的区别,得先回忆Docker的两个核心阶段:构建阶段和运行阶段。构建阶段就是你执行docker build命令的时候,Docker会按照Dockerfile的指令,一层一层地生成镜像;运行阶段是你执行docker run命令的时候,才会把这个镜像变成一个正在跑的容器。RUN和CMD最本质的区别,就是它们属于不同的阶段。
1.1 先搞懂构建阶段的“一次性指令”:RUN
RUN是构建阶段的指令,意思是“在构建镜像的时候,帮我跑这个命令,跑完的结果会永久保存到镜像里”。比如你要给镜像装个软件、配置个环境变量、建个文件夹,这些都是构建阶段要做的事——因为这些操作一旦做完,就会变成镜像的一部分,以后运行容器的时候,就不用再重新做一遍了。
举个最常见的例子,比如你要做一个Python的镜像,需要装Python依赖包:
# 基础镜像用官方的Python3.9镜像
FROM python:3.9-slim
# 切换工作目录,以后的操作都在这个目录下
WORKDIR /app
# 把当前目录的requirements.txt复制到镜像的/app目录下
COPY requirements.txt .
# 重点:用RUN执行pip安装命令——这个命令会在构建阶段跑,装完的包会永久留在镜像里
RUN pip install --no-cache-dir -r requirements.txt
你执行docker build -t my-python-app .的时候,Docker会先拉取python:3.9-slim镜像,然后切换到/app,复制requirements.txt,接着跑pip install——跑的过程中,Docker会把安装的所有包都存到镜像的/app/site-packages目录下,以后你再用这个镜像跑容器,这些包已经装好了,不用再装一遍。
RUN的本质是“构建时的临时操作,结果永久留存”,所以它的特性很明确:
- 只在构建阶段跑,跑一次就结束,结果会成为镜像的一部分;
- 可以有多个RUN指令,每个RUN都会生成一个新的镜像层;
- 适合做“一次性准备工作”:装软件、配置环境、编译代码等。
1.2 再搞懂运行阶段的“默认启动命令”:CMD
CMD是运行阶段的指令,意思是“当你用这个镜像启动容器的时候,默认跑这个命令”。它不会在构建阶段跑,只有当你执行docker run的时候,才会被触发。
还是拿刚才的Python镜像举例,你需要加一行CMD,指定启动容器的时候跑Python服务:
# 前面的FROM、WORKDIR、COPY、RUN都不变
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 重点:CMD指定默认启动命令——这个命令只有在容器运行的时候才会跑
CMD ["python", "app.py"]
这里的CMD用的是JSON数组格式,这是Docker推荐的写法,因为它不会被Shell解释器解析,能避免很多意外问题(比如命令里有空格、特殊字符的时候)。如果用字符串格式(比如CMD python app.py),Docker会默认把它当成/bin/sh -c "python app.py"来跑,这时候就会被Shell解释,可能会出问题。
CMD的特性和RUN完全相反:
- 只在运行阶段跑,每次启动容器都会重新跑一遍;
- 一个Dockerfile里只能有一个生效的CMD——如果写了多个,只有最后一个会生效;
- 适合做“默认启动操作”:启动服务、运行程序等。
二、实际场景中的混淆误区:从错误案例看差异
很多人搞混RUN和CMD,都是因为在实际场景中用错了,导致各种奇怪的问题。我整理了几个最常见的错误案例,帮你更清楚地看到区别。
2.1 误区一:把启动命令写成RUN(构建阶段执行,导致镜像构建失败)
刚才说的运维同事的错误,就是把启动命令写成了RUN。比如他写的Dockerfile是这样的:
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
# 错误:把启动服务的命令写成了RUN
RUN python app.py
当他执行docker build的时候,Docker会在构建阶段跑python app.py——而app.py是一个Web服务,会一直监听端口,不会主动退出,所以构建阶段的RUN命令会一直卡住,直到超时失败。这就是最典型的错误:把运行阶段的命令写到了构建阶段。
正确的做法是把这个命令改成CMD,这样构建阶段不会跑它,只有启动容器的时候才会跑:
# 正确:把启动命令改成CMD
CMD ["python", "app.py"]
2.2 误区二:把构建命令写成CMD(构建阶段不执行,导致容器运行失败)
反过来,也有人把构建阶段的命令写成CMD。比如有人写Dockerfile的时候,把装依赖的命令写成了CMD:
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
# 错误:把装依赖的命令写成了CMD
CMD pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]
这个Dockerfile构建的时候,Docker不会跑pip install,因为CMD是运行阶段的指令。当你启动容器的时候,Docker会跑最后一个CMD(python app.py),而不会跑第一个CMD(pip install)——因为一个Dockerfile里只有最后一个CMD生效。所以容器启动后,会因为找不到依赖包而报错。
正确的做法是把装依赖的命令改成RUN:
# 正确:把装依赖的命令改成RUN
RUN pip install --no-cache-dir -r requirements.txt
2.3 误区三:多个CMD指令的覆盖问题(只有最后一个生效)
很多人不知道一个Dockerfile里只能有一个CMD生效,所以会写多个CMD,结果只有最后一个会跑。比如有人写的Dockerfile是这样的:
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
# 第一个CMD:想跑测试
CMD ["pytest", "test_app.py"]
# 第二个CMD:想启动服务
CMD ["python", "app.py"]
构建这个镜像的时候,Docker不会跑任何CMD,因为CMD是运行阶段的指令。当你启动容器的时候,Docker只会跑最后一个CMD(python app.py),第一个CMD(pytest)会被完全覆盖,根本不会执行。
如果你的需求是“启动容器的时候先跑测试,再启动服务”,正确的做法是写一个脚本,然后用CMD跑这个脚本:
# 先把脚本复制到镜像里
COPY run.sh .
# 给脚本加执行权限
RUN chmod +x run.sh
# 用CMD跑脚本
CMD ["./run.sh"]
run.sh的内容是:
#!/bin/bash
# 先跑测试
pytest test_app.py
# 再启动服务
python app.py
这样启动容器的时候,脚本会先跑测试,再启动服务,就能满足需求了。
三、指令的灵活使用:结合场景选对指令
搞懂了RUN和CMD的区别,还要知道它们的灵活用法,以及和其他指令的配合。
3.1 RUN的最佳实践:减少镜像层,优化镜像大小
刚才说过,每个RUN指令都会生成一个新的镜像层,镜像层越多,镜像就越大,拉取和推送的速度就越慢。所以最佳实践是把多个相关的RUN指令合并成一个,用&&连接,减少镜像层。
比如下面的Dockerfile:
FROM ubuntu:22.04
# 安装nginx
RUN apt-get update
RUN apt-get install -y nginx
# 清理缓存,减少镜像大小
RUN apt-get clean
这个Dockerfile有三个RUN指令,会生成三个镜像层。合并后的写法是:
FROM ubuntu:22.04
# 合并三个RUN指令,减少镜像层
RUN apt-get update && apt-get install -y nginx && apt-get clean
这样就只有一个RUN指令,生成一个镜像层,镜像会小很多。
3.2 CMD的灵活用法:覆盖默认命令
CMD的一个重要特性是可以被覆盖——当你执行docker run的时候,后面加的命令会覆盖Dockerfile里的CMD。比如刚才的Python镜像,你可以在启动容器的时候加一个命令,让它跑测试而不是启动服务:
docker run my-python-app pytest test_app.py
这个命令会覆盖Dockerfile里的CMD ["python", "app.py"],启动容器的时候会跑pytest test_app.py,而不是启动服务。
这个特性非常有用,比如你可以用同一个镜像做不同的事:启动服务、跑测试、进容器调试等。比如要进容器调试,可以执行:
docker run -it my-python-app bash
这个命令会覆盖CMD,启动容器的时候会跑bash,进入容器的Shell环境。
3.3 关联指令:ENTRYPOINT的配合
和CMD经常一起用的还有ENTRYPOINT指令,它的作用是指定容器启动时的“固定命令”,CMD指定的命令会作为ENTRYPOINT的参数。比如:
ENTRYPOINT ["python", "app.py"]
CMD ["--debug"]
启动容器的时候,Docker会跑python app.py --debug。如果启动容器的时候加一个参数,比如docker run my-python-app --prod,那么会跑python app.py --prod,CMD会被覆盖,ENTRYPOINT不会被覆盖。
ENTRYPOINT适合做“固定的启动逻辑”,比如指定用哪个程序启动,CMD适合做“可变的参数”,比如指定启动模式。
四、应用场景、优缺点与注意事项
4.1 应用场景
- RUN的应用场景:所有构建阶段的操作,比如安装软件、配置环境、编译代码、复制配置文件、清理缓存等。只要是“一次性准备,以后不需要重复做”的操作,都应该用RUN。
- CMD的应用场景:所有运行阶段的默认启动操作,比如启动Web服务、运行脚本、执行定时任务等。只要是“每次启动容器都需要跑”的操作,都应该用CMD。
4.2 指令优缺点
- RUN的优缺点: 优点:构建阶段执行,结果永久留存,适合做一次性准备工作;可以有多个RUN指令,灵活组合。 缺点:每个RUN指令会生成一个镜像层,过多的RUN会导致镜像变大;如果RUN命令执行失败,会导致镜像构建失败。
- CMD的优缺点: 优点:运行阶段执行,每次启动容器都会重新跑,适合做启动操作;可以被覆盖,灵活多变。 缺点:一个Dockerfile里只能有一个生效的CMD;如果CMD命令执行失败,会导致容器退出。
4.3 注意事项
- 写Dockerfile的时候,一定要先想清楚:这个操作是在构建阶段做,还是在运行阶段做?构建阶段的用RUN,运行阶段的用CMD。
- RUN指令尽量合并,减少镜像层,优化镜像大小。
- CMD指令尽量用JSON数组格式,避免Shell解释带来的问题。
- 不要在RUN指令里写长时间运行的命令(比如监听端口的服务),会导致镜像构建失败。
- 不要在CMD指令里写构建阶段的操作(比如安装软件),会导致容器运行失败。
五、总结
RUN和CMD是Dockerfile里最容易混淆的两个指令,本质区别就是构建阶段和运行阶段的划分:RUN是构建阶段的指令,执行结果永久留存;CMD是运行阶段的指令,每次启动容器都会重新执行。
很多人搞混这两个指令,都是因为没有理清Docker的两个核心阶段。只要记住:构建阶段做“一次性准备”,用RUN;运行阶段做“启动操作”,用CMD,就能避免大部分错误。
最后再给你一个完整的、正确的Dockerfile示例,用Python技术栈,涵盖了RUN和CMD的正确用法:
# 基础镜像:官方Python3.9-slim镜像(slim版本比普通版本小,适合生产环境)
FROM python:3.9-slim
# 切换工作目录:以后的操作都在/app目录下
WORKDIR /app
# 复制依赖文件:先复制requirements.txt,再复制代码(利用Docker的缓存机制,只有requirements.txt变化才会重新装依赖)
COPY requirements.txt .
# RUN指令:构建阶段安装依赖,结果永久留存
RUN pip install --no-cache-dir -r requirements.txt
# 复制代码:把当前目录的所有代码复制到镜像的/app目录下
COPY . .
# CMD指令:运行阶段默认启动服务,每次启动容器都会跑
CMD ["python", "app.py"]
这个Dockerfile的逻辑非常清晰:先拉取基础镜像,然后复制依赖文件,安装依赖,复制代码,最后指定默认启动命令。只要你按照这个逻辑来写,就不会搞混RUN和CMD。
评论
围绕“Dockerfile中RUN与CMD指令的混淆误区:实际运行时的执行顺序与差异”参与讨论