4. Dockerfile 编写基础

好,我们开始聊 Dockerfile。说白了,这就是一份“自动做镜像的菜谱”。你写好每一步,Docker 照着执行,最终得到一个干净的、可复用的环境。我在早期做宏基因组分析流程时,就因为手写装环境,导致不同机器上结果不一致。后来彻底改成 Dockerfile,再没出过这种幺蛾子。

一句话总结:Dockerfile 就是镜像的构建脚本。每一行指令都会生成一个新的镜像层,层数越少,体积越小,构建越快。

4.1 核心指令速览

先从最常用的几个指令入手。我习惯把它们分成三组:基础定义、文件与依赖、运行时配置。

分类指令作用
基础定义FROM指定基础镜像,必须是第一条指令
基础定义LABEL添加元数据,比如作者、版本
文件与依赖RUN执行命令,通常用来安装软件包
文件与依赖COPY / ADD把本地文件拷进镜像(ADD 支持自动解压)
文件与依赖WORKDIR设置工作目录,后面的指令都在这个目录下跑
运行时配置EXPOSE声明容器监听端口(文档作用,不实际打开)
运行时配置CMD提供默认启动命令,可被 docker run 参数覆盖
运行时配置ENTRYPOINT固定启动命令,不易被覆盖

⚠ ADD 和 COPY 的区别:ADD 能自动解压 tar 文件,还能从 URL 下载。但我建议优先用 COPY,行为明确,不容易误触。你想想看,万一 URL 失效了,构建就卡死了。

4.2 一个真实案例:宏基因组分析基础镜像

我们直接拿宏基因组分析中最常用的软件——Kraken2 和 Bracken 来写个 Dockerfile。注意看每个指令的排列顺序。

# 基础镜像:Ubuntu 22.04 LTS,轻量且稳定
FROM ubuntu:22.04 AS builder

# 设置作者和版本
LABEL maintainer="yourname@lab.com" \
      version="1.0" \
      description="宏基因组分析基础镜像"

# 避免交互式安装弹窗
ENV DEBIAN_FRONTEND=noninteractive

# 先更新包管理器,再安装编译工具
RUN apt-get update && apt-get install -y \
    build-essential \
    wget \
    zlib1g-dev \
    libbz2-dev \
    liblzma-dev \
    && rm -rf /var/lib/apt/lists/*

# 下载并编译 Kraken2
WORKDIR /opt
RUN wget https://github.com/DerrickWood/kraken2/archive/refs/tags/v2.1.2.tar.gz \
    && tar -xzf v2.1.2.tar.gz \
    && cd kraken2-2.1.2 \
    && ./install_kraken2.sh /opt/kraken2

# 复制本地准备好的 Bracken 脚本(假设已编译好)
COPY bracken-2.7.tar.gz /opt/
WORKDIR /opt
RUN tar -xzf bracken-2.7.tar.gz && rm bracken-2.7.tar.gz

# 设置 PATH
ENV PATH="/opt/kraken2:/opt/bracken-2.7:$PATH"

# 声明容器使用端口(此处仅为示例,实际宏基因组分析很少用端口)
EXPOSE 8080

# 默认进入工作目录
WORKDIR /data

# 容器启动时打印帮助信息
CMD ["kraken2", "--help"]

上面这段 Dockerfile 有几个关键点:

  • 合并 RUN 层:apt-get update && install ... && rm 连在一起,只产生一层。我记得刚学 Docker 时没注意这个,镜像体积蹭蹭涨到 2G+,后来发现每多一个 RUN 就多一层,一层几十 MB 堆上去就炸了。
  • WORKDIR 切换:每次 cd 都用 WORKDIR,比 RUN cd 更清晰,还能自动创建目录。
  • ENV 设置:把 PATH 和环境变量统一写在最后,方便查看。

4.3 指令顺序:为什么 FROM 必须在最前面?

Docker 构建是按行执行的。每一行都生成一个中间镜像层。构建器会判断哪些层没变,然后复用缓存。所以指令顺序直接影响构建速度。

我给你的建议是:把不容易变的指令放在前面,容易变的放后面。比如:

  • FROM、LABEL、ENV —— 几乎不变。
  • 安装系统包 —— 偶尔变。
  • COPY 本地代码/脚本 —— 经常变。
  • 最后的 CMD/ENTRYPOINT —— 几乎不变。

如果反着写,每次改个脚本就得从头装系统包,浪费时间。我曾经在 CI 里吃过这个亏,每次修改一个 Python 文件,整个构建要重跑 5 分钟……后来调整顺序,缩短到 10 秒。

4.4 .dockerignore:你的“隐形过滤器”

你有没有遇到过:构建镜像时,不小心把本地的 huge fastq 文件或 .git 目录也拷进去了?结果镜像体积爆表,传输慢到哭。

.dockerignore 就是干这个的。它和 .gitignore 语法一致,放在 Dockerfile 同目录下。

# .dockerignore 示例
.git
*.fastq
*.big
data/raw/
__pycache__/
.venv/

别踩这个坑:如果不写 .dockerignore,COPY . /app 会把整个上下文目录全塞进去。我见过有人把 20G 的参考基因组数据库都拷进去了,镜像构建失败还找不到原因。

4.5 CMD vs ENTRYPOINT:这俩到底啥区别?

很多新手分不清。我直接说结论:

  • CMD:默认命令,可以被 docker run 后面的东西覆盖。
  • ENTRYPOINT:固定入口,docker run 后面的东西会作为参数追加给它。

举个例子:

# Dockerfile 内
ENTRYPOINT ["kraken2"]
CMD ["--help"]

运行 docker run myimage --db /db 等价于 kraken2 --db /db。如果只用 CMD,那 --db /db 就会替换掉整个 CMD,达不到目的。所以宏基因组分析镜像通常用 ENTRYPOINT 固定主程序,CMD 给默认参数。

4.6 构建与调试小技巧

构建镜像的命令很简单:

docker build -t my-metagenomics:v1 .

但我一般会加几个参数调试:

docker build --no-cache -t my-metagenomics:v1 .      # 强制重建,避免缓存捣乱
docker build --progress=plain -t my-metagenomics:v1 . # 输出详细日志,定位错误

如果构建到一半失败了,想进入中间层看看状态,可以:

docker run --rm -it <上一层镜像ID> bash

这个镜像 ID 可以在构建日志里看到:Step X/12 : ... ---> abc123。我经常用这个办法排查环境变量没生效或者文件权限问题。

4.7 多阶段构建:瘦身利器

宏基因组分析依赖很多编译工具,生成的可执行文件往往很小,但编译工具链可能占 1-2G。用多阶段构建就可以把最终镜像“瘦”下来。

# 第一阶段:编译
FROM ubuntu:22.04 AS builder
RUN apt-get update && apt-get install -y build-essential cmake git
WORKDIR /src
COPY sourmash-4.6.0.tar.gz .
RUN tar -xzf sourmash-4.6.0.tar.gz && cd sourmash-4.6.0 && make

# 第二阶段:只保留可执行文件和运行时库
FROM ubuntu:22.04 AS runtime
COPY --from=builder /src/sourmash-4.6.0/sourmash /usr/local/bin/
RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["sourmash"]

最终镜像只有几十 MB,而不是几个 G。这是实战中必须掌握的技巧。

4.8 知识体系图:Dockerfile 编写核心逻辑

Dockerfile 编写核心逻辑 选择基础镜像 (FROM) 安装系统依赖 (RUN apt) 复制代码/数据 (COPY) 配置环境 (ENV/WORKDIR) 启动命令 (CMD/ENTRYPOINT) 指令顺序影响缓存 易变靠后,不变靠前 多阶段构建 分离编译与运行环境

4.9 写在最后:一个检查清单

每次写 Dockerfile 之前,我都会过一遍这个清单:

  • ✅ 基础镜像选对了没?尽量用官方 slim 版,不要用 full。
  • ✅ 有没有把缓存层和易变层分开?
  • ✅ 是否添加了 .dockerignore?
  • ✅ 最终镜像里有没有多余的编译工具?
  • ✅ CMD/ENTRYPOINT 是否用的 JSON 数组格式(避免 shell 注入)?

说实话,这几点我一开始也没全做到。踩过几次坑之后,就老老实实列清单了。你如果按这个思路写,镜像体积、构建时间、可复现性都会好很多。