Docker镜像构建:Dockerfile编写基础——FROM、RUN、COPY、CMD

我记得自己刚接触Docker那会儿,第一件事就是写Dockerfile。那时候觉得不过是一堆指令堆砌,结果构建出来的镜像动不动就几个G,Run起来还各种报错。后来才明白,Dockerfile虽然就几个关键指令,但每一条都藏着坑。今天咱们就把FROM、RUN、COPY、CMD这四个最基础也最重要的指令掰开揉碎聊透。

1. FROM —— 镜像的“地基”

FROM是Dockerfile的第一条指令(除了ARG可以放在前面),说白了就是声明你要基于哪个基础镜像来构建。没有FROM,后面的指令全白搭。

核心要点:FROM必须在Dockerfile的开头(允许间插ARG),且每条FROM会开启一个新的构建阶段。

我个人习惯把基础镜像选得尽量“瘦”。比如生信分析常用的biocontainers镜像,有的版本自带完整工具链,有的只装了个alpine。我遇到过项目组统一用ubuntu:22.04当基础镜像,结果一个镜像就占800MB,后续分层还特别多。后来我们切换成continuumio/miniconda3:latest,体积直接砍半。

基础镜像类型体积(举例)适用场景
scratch0 B静态编译二进制,极少用
alpine~5 MB最小化环境,需手动装包
ubuntu:22.04~80 MB通用生信开发环境
biocontainers/bwa~200 MB预装特定工具
小技巧:如果你只需要一个生信工具,直接FROM它的专用镜像,省去装依赖的麻烦。但要注意tag用:latest还是固定版本号——我上次被:latest坑过,某天它更新了,我的工作流全崩了。

2. RUN —— “干活”的指令

RUN在构建时执行命令,最常用的就是安装软件包、配置环境。但RUN也是最容易导致镜像膨胀的元凶。

你想想看,每执行一个RUN就会产生一个镜像层。层越多,拉取和存储开销越大。我见过有人把每个命令都写成一个RUN:

RUN apt-get update
RUN apt-get install -y python3
RUN apt-get install -y samtools
RUN pip install biopython

结果镜像有二十多层。正确的做法是:把相关的命令用 && 连在一起,一个RUN搞定。

RUN apt-get update && apt-get install -y \
    python3 \
    samtools \
    && pip install biopython \
    && rm -rf /var/lib/apt/lists/*
注意:RUN中的apt-get install一定要用-y参数,否则构建过程在非交互模式会卡住。我曾经调试半天,发现是某个工具需要确认安装。

还有一个常犯的错误:安装完包后不清缓存。比如apt-get的/var/lib/apt/lists/*,pip的~/.cache/pip。这些文件在构建阶段就永远留在镜像里了。所以我总在RUN的最后加一句清理。

3. COPY —— 把文件“搬”进镜像

COPY用来把构建上下文中的文件或目录复制到镜像里。注意它和ADD的区别:ADD可以处理URL和解压,但咱们生信工作流中尽量用COPY,行为更可控。

我遇到过那种“直接COPY整个项目目录”的做法:

COPY . /app

结果.dockerignore没写,把本地几百MB的临时数据和.git目录全塞进去了。后来我强制团队在项目根目录加.dockerignore,比如:

__pycache__/
*.bam
*.fastq
.git/
.vscode/
*.pyc

COPY还有个细节:权限问题。默认COPY过来的文件属主是root。有时生信工具需要非root用户运行,我一般会在FROM之后先创建用户:

RUN groupadd -r bio && useradd -r -g bio bio
COPY --chown=bio:bio scripts/ /home/bio/scripts/
个人习惯:尽量让COPY的目标路径是绝对路径,比如/opt/tools/而不是./tools/,避免混淆。CMD或ENTRYPOINT中使用绝对路径也更安全。

4. CMD —— 容器启动后的“默认命令”

CMD定义了容器运行时默认执行的命令。它有三种写法:shell格式、exec格式、作为ENTRYPOINT的参数。

写法示例特点
shell格式CMD python app.py会在shell中执行,支持环境变量替换
exec格式CMD ["python", "app.py"]JSON数组,直接执行,不会触发sh,更可控
作为ENTRYPOINT的参数CMD ["--help"]当用了ENTRYPOINT时,CMD作为默认参数

我最推荐用exec格式。为什么?因为shell格式会用/bin/sh -c来启动,进程ID是1的不再是你的主程序,而是sh,sh收到SIGTERM后不会传递给子进程,导致容器无法优雅关闭。这个问题我在生产环境排查了整整一个下午才找到原因。

生信场景的典型CMD:很多生信工具需要输入输出文件。我习惯把工具名写成ENTRYPOINT,CMD里放常用参数。比如:
ENTRYPOINT ["samtools"]
CMD ["--help"]
这样运行容器时直接docker run myimage view -h就可以,而docker run myimage默认打印帮助。

重要提示:RUN与CMD的区别不可混淆

  • 执行时机不同:RUN在构建阶段执行,CMD在运行容器时执行。
  • 用途不同:RUN用于安装配置,CMD用于指定默认程序。
  • 层数影响:每条RUN产生一个新层;CMD不会增加层,它只是元数据。
我曾经犯过的错:在Dockerfile里用CMD执行安装命令,比如CMD pip install -r requirements.txt。结果每次启动容器都要重新安装,完全违背了“构建一次到处运行”的原则。正确做法是把安装放到RUN里。

知识体系总览:Dockerfile构建核心逻辑

下面这张图把FROM、RUN、COPY、CMD以及它们之间的关系、常见陷阱和最佳实践串起来,方便你对照回顾。

Dockerfile构建核心逻辑 FROM 基础镜像选择 RUN 安装配置命令 COPY 文件/目录复制 CMD 启动默认命令 常见陷阱 • 每行一个RUN → 层爆炸 • apt-get不带-y → 构建卡住 • COPY整个目录→体积大 • CMD用shell格式→不佳 • 安装后不清理缓存 • RUN写路径不写绝对 最佳实践 • 用 && 合并RUN • 选择小体积基础镜像 • 编写 .dockerignore • CMD用exec格式 • RUN最后清理缓存 • 固定FROM版本号

这张图表达了:Dockerfile里FROM是起点,RUN和COPY把构建上下文变成镜像内容,CMD定义启动行为。右边的“最佳实践”和左边的“常见陷阱”是我在实际项目中反复踩过的坑,你对照着检查自己的Dockerfile,基本能避免80%的问题。

组合示例:一个正经的生信Dockerfile

我拿一个bwa+samtools的镜像举个例子,你看怎么写才规范:

FROM ubuntu:22.04 AS build

RUN apt-get update && apt-get install -y \
    bwa \
    samtools \
    && rm -rf /var/lib/apt/lists/*

COPY my_scripts /opt/scripts/
COPY --chmod=755 run_pipeline.sh /usr/local/bin/

RUN chmod +x /opt/scripts/*.pl

ENTRYPOINT ["/usr/local/bin/run_pipeline.sh"]
CMD ["--help"]

注意到了吗?我用了AS build(多阶段构建的写法),虽然本章不展开,但多阶段构建能进一步压缩镜像体积。后面我专门有一节聊多阶段构建。

总结一下今天这章的核心:写Dockerfile不是堆指令,而是设计分层。FROM选对底子,RUN合并精简,COPY精准且注意权限,CMD用exec格式配合ENTRYPOINT。这四个指令掌握好,你的镜像质量和构建效率都能上一个台阶。