第4章 Dockerfile编写基础:FROM、RUN、COPY、CMD等指令

好,咱们开始正经写Dockerfile了。前面你装好了Docker,拉过几个镜像,也学会了用容器跑命令。但你要想真正把空间单细胞分析流程封装成可复用的镜像,就得亲手写Dockerfile。说白了,Dockerfile就是一张“制作镜像的配方单”,每一行指令告诉Docker怎么一层层把环境搭出来。

我刚开始写Dockerfile时踩过不少坑——比如镜像体积飙到3个G,或者build到最后一步报错,结果发现是RUN指令的顺序不对。嗯,今天咱们就把最核心的四个指令讲透:FROM、RUN、COPY、CMD。你把这四个玩明白了,90%的镜像都能搞定。

核心观点:Dockerfile的每一行都会生成一个镜像层。层数越少、层体积越小,最终镜像就越“瘦”。空间单细胞分析动不动就要装几十个R包和Python库,合理编排指令能帮你省下好几G的空间。

Dockerfile 五大核心指令关系图 FROM 基础镜像 RUN 执行命令 COPY 复制文件 CMD 启动命令 WORKDIR 工作目录 每一条指令都生成一个新的镜像层,合理安排指令顺序能显著减少构建时间和镜像体积

4.1 FROM:你的镜像从哪里来

FROM 是Dockerfile的第一行,没它后面什么都跑不起来。它指定了构建基于哪个基础镜像。对于空间单细胞分析,我建议尽量用官方镜像,比如 ubuntu:22.04 或 rocker/r-ver:4.3。为什么?因为社区维护得好,安全更新也及时。

我的经验:曾经图省事用了某个非官方R镜像,结果里面预装的libxml2版本太老,导致Seurat装不上。最后花了半天才排查出来是基础镜像的问题。从那以后我只从 rocker 或 bioconductor 官方拉基础镜像。

来看一个简单示例:

FROM ubuntu:22.04

注意,FROM 可以多次出现——这叫多阶段构建。比如你先用一个带编译器的大镜像编译工具,再用一个轻量的运行时镜像把编译产物拷过来。最终镜像体积能小一大半。这个咱们后面章节会专门讲。

4.2 RUN:在构建过程中执行命令

RUN 是最常用的指令。它会在构建镜像时执行你指定的命令,结果被固化到镜像层中。比如安装系统包、配置环境、下载数据,通通用 RUN 解决。

关键技巧:尽量把多条命令合并在一个 RUN 里。为什么?因为每个 RUN 都生成一个层,层数多了拉取和推送都慢。我的习惯是用 && 连起来:

RUN apt-get update && \
    apt-get install -y \
        libhdf5-dev \
        libcurl4-openssl-dev \
        libssl-dev && \
    rm -rf /var/lib/apt/lists/*

注意最后那行 rm -rf。这是清理apt缓存,防止无用的文件留在镜像层里。很多新手不写这一步,结果镜像里塞了一堆 /var/cache/apt 的垃圾,白白浪费空间。

我曾经踩过的坑:在安装R包时用了 RUN install2.r Seurat,但没有先设置好环境变量 R_LIBS_USER,结果包装到了系统默认路径,后面用户一跑就报“找不到包”。所以一定记得用 ENV 或 RUN mkdir 提前指定库路径。

对于空间单细胞分析,你经常需要同时装系统依赖和Python/R包。我的推荐做法是写一个 install_deps.sh 脚本,然后在 RUN 里调用它:

COPY install_deps.sh /tmp/
RUN chmod +x /tmp/install_deps.sh && /tmp/install_deps.sh

4.3 COPY:把本地文件塞进镜像

COPY 负责把宿主机上的文件或目录拷到镜像里。格式很直接:

COPY source destination

举个例子,把你的分析代码和配置文件都放进去:

COPY scripts/ /opt/analysis/scripts/
COPY config.yaml /opt/analysis/config.yaml

这里有个容易忽略的点:COPY 的目标路径如果是目录,最后要加斜杠;如果是文件名,就别加。我见过不止一次因为少个斜杠导致文件被当成目录覆盖了。

另外,COPY 只复制 docker build 上下文(context)内的文件。上下文是指你执行 docker build . 时那个点代表的目录。别想着拷父目录下的文件,Docker不会让你越界。所以你的项目结构最好把Dockerfile放在根目录,所有需要的文件都放在上下文内。

小技巧:利用 .dockerignore 排除不需要的文件。比如你习惯把 .git、__pycache__ 这些乱七八糟的东西写进去,构建速度能快不少。

4.4 CMD:容器启动时该干什么

CMD 指定容器启动后默认执行的命令。它和 RUN 最大的区别是:RUN 是构建时执行,CMD 是运行时执行。而且 CMD 可以被 docker run 后面跟的命令覆盖。

最常见的形式:

CMD ["R", "--no-save", "--no-restore", "-f", "/opt/analysis/run.R"]

这里用了 exec 格式(JSON数组),而不是 shell 格式(CMD R --no-save ...)。我强烈推荐 始终用 exec 格式。因为shell格式会默认包裹一层 /bin/sh -c,导致信号处理、进程管理都变得诡异。比如你按Ctrl+C停不掉容器,可能就是shell格式惹的祸。

对于空间单细胞分析流程,CMD 通常指向你的主分析脚本,或者启动一个Jupyter Lab方便调试:

CMD ["jupyter", "lab", "--ip=0.0.0.0", "--port=8888", "--no-browser", "--allow-root"]

用户启动容器时也能手动覆盖:

docker run -it your-image /bin/bash

这样就会覆盖CMD,直接进bash。方便调试。

4.5 其他高频指令速览

除了上面四个,还有几个指令你也会经常碰到。我整理了一张对比表,帮你快速区分:

指令作用构建/运行常见用途
WORKDIR设置工作目录构建时cd到指定路径,后续指令都在该目录执行
ENV设置环境变量构建+运行时配置PATH、R_LIBS、CUDA路径等
EXPOSE声明监听端口仅文档告诉用户容器会监听哪个端口(如8888)
ARG构建参数(可覆盖)构建时动态指定版本号,方便复用Dockerfile

我常用的一个模式:先用ARG定义版本号,然后在RUN里引用。比如构建时传参:docker build --build-arg R_VERSION=4.3.3 -t my-image . 这样同一个Dockerfile就能轻松切换R版本。

4.6 一条完整的空间单细胞 Dockerfile 示例

好啦,理论知识讲完,咱们直接看一个典型的空间单细胞分析镜像怎么写。这个例子会用到上面所有的指令:

# 第4章 空间单细胞Dockerfile示例
FROM rocker/r-ver:4.3.3

# 设置环境变量
ENV R_LIBS_USER=/usr/local/lib/R/site-library \
    LANG=C.UTF-8

# 设置工作目录
WORKDIR /workspace

# 安装系统依赖
RUN apt-get update && \
    apt-get install -y \
        libhdf5-dev \
        libcurl4-openssl-dev \
        libssl-dev \
        zlib1g-dev \
        git && \
    rm -rf /var/lib/apt/lists/*

# 复制你的分析代码
COPY scripts/ /workspace/scripts/
COPY config.yaml /workspace/

# 安装R包(这里用install2.r是rocker自带的小工具)
RUN install2.r --error --skipinstalled \
        Seurat \
        ggplot2 \
        dplyr \
        Matrix && \
    Rscript -e "BiocManager::install('spatialDE')" && \
    rm -rf /tmp/downloaded_packages

# 暴露端口(如果你要跑Jupyter)
EXPOSE 8888

# 默认启动R脚本
CMD ["Rscript", "/workspace/scripts/run_analysis.R"]

注意我用了 install2.r 而不是 install.packages,这是rocker镜像的优化工具,能自动处理依赖,少写很多代码。另外 --skipinstalled 参数避免重复安装,加快二次构建的速度。

避坑指南:我曾经在构建时忘了写 --skipinstalled,结果每次改一点点代码都要重新编译所有R包,一个build要40分钟。加了 --skipinstalled 之后,改脚本只需要几秒。所以别在构建时做大量重复安装,能利用缓存就利用缓存。

4.7 关于构建缓存的秘密

Dockerfile构建时有一个“缓存命中”机制。如果你只改了最后一行的COPY文件,前面的层都会复用缓存,只有那一层重新构建。这非常快。

但如果你把 COPY . /workspace/ 写在了安装系统包之前,那每次代码变动都会导致所有apt-get重新执行——因为COPY的层变了,后面所有层都失效了。所以我习惯把不常变的内容(系统包、固定版本的R包)写前面,把代码等频繁改动的内容放后面。

这也是为什么上面的示例里,COPY scripts/ 放在了apt-get和R包安装之后。这样你改代码时,apt和R包都能命中缓存。

4.8 本章小结

今天我们把Dockerfile的四大核心指令过了一遍。FROM定基础,RUN在构建时执行,COPY把本地文件塞进去,CMD告诉容器启动后做什么。你还知道了WORKDIR、ENV这些辅助指令,以及构建缓存的重要性。

下一课咱们会深入 docker build 的命令细节,包括如何传参、如何加速构建、如何减少镜像体积。不过今天的这些内容足够你写出一个能用的Dockerfile了——不信你照着上面的示例试试,跑一个 docker build -t spatial-test . 看看。