一、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。