概述自己写的 Spring Boot 服务DockerHub 上不会有人替你构建镜像必须自己来。而要自己构建先得搞清楚一个镜像内部到底是怎么堆起来的——这一篇只讲结构不讲语法。纲要从镜像是什么这句话里拆出组成与顺序为什么必须是分层MySQL 5.7 升级到 5.8 的对比镜像的两端BaseImage 基础镜像层 与 Entrypoint 入口层逐行拆解真实 Dockerfile一条指令一层四层怎么叠出来只读分层与写时复制容器最上面那层可写层从哪来层复用的两个收益多镜像共享 base 省磁盘、构建缓存加速动手验证docker history看分层docker inspect看RootFS.Layers实战坑RUN 合并、missing的含义、改前一行废掉后面全部缓存与「Dockerfile 语法」一篇的边界镜像的组成回到那句话本身镜像是把应用程序及其需要的系统函数库、环境、配置、依赖打包到一起形成的文件。这句话听了很多遍但镜像的结构其实就藏在里面。系统函数库、环境、配置、依赖、应用是它的组成而这些组成之间不是平铺的是有依赖顺序的没有最底层的系统函数库和文件系统环境变量无处可配没有环境变量依赖装不对路径依赖没装好应用跑不起来应用装好了不给入口别人不知道怎么启动所以镜像不是把一堆文件揉成一团而是按依赖顺序分层构建。下面这段树就是构建一个 Java 镜像时真正放在一个目录里的东西docker-demo/ ├── Dockerfile # 构建描述文件9 条指令 ├── docker-demo.jar # Spring Boot 可执行 jar约 25 MB └── jdk8.tar.gz # 离线 JDK 安装包约 177 MBdocker build时 Docker 会读这个目录按 Dockerfile 一路往上叠层。为什么必须分层一个 5.7 到 5.8 的例子如果镜像真的是一疙瘩那你构建完 MySQL 5.7 镜像后想出一个 5.8 版本只能从最底层的文件系统开始重装环境、重配依赖、重装应用全程重来一遍。原来那坨东西你没法拆也就没法复用。分层之后情况完全不同。构建 5.7 时就有意把每一步的产物单独放一层① 基础文件系统 layer② 环境变量 / 运行环境 layer③ 依赖安装 layer④ MySQL 5.7 安装 layer⑤ 配置与入口 layer要出 5.8只需把 ④⑤ 两层砍掉在 ①②③ 之上重新叠。前面三层的构建时间、磁盘占用直接省下来。这就是分层的第一层动机复用。你构建自定义镜像时也应该从基础开始逐层构建别一把梭。镜像的两端BaseImage 与 Entrypoint一个常见镜像里两层是几乎必然存在的层名称作用是否必有最底层BaseImage 基础镜像层镜像依赖的最基础东西系统函数库、文件系统。像大楼地基必有最顶层Entrypoint 入口层应用启动的命令脚本告诉别人怎么把镜像跑起来除非这个镜像压根不打算被启动中间若干层构建产生的 layer环境变量、依赖安装、应用安装、配置文件……取决于构建时做了什么操作不确定做一次操作就多一层中间部分为什么不确定因为别人构建镜像时中间做了哪些动作你从外面看不出来。但有一条铁律每执行一次操作就产生新的一层。这个层就是 Layer。逐行拆解真实 Dockerfile讲义里给的 Ubuntu 方案是最能说明指令 层的素材。把它整段贴出来# 指定基础镜像 FROM ubuntu:16.04 # 配置环境变量JDK的安装目录 ENV JAVA_DIR/usr/local # 拷贝jdk和java项目的包 COPY ./jdk8.tar.gz $JAVA_DIR/ COPY ./docker-demo.jar /tmp/app.jar # 安装JDK RUN cd $JAVA_DIR \ tar -xf ./jdk8.tar.gz \ mv ./jdk1.8.0_144 ./java8 # 配置环境变量 ENV JAVA_HOME$JAVA_DIR/java8 ENV PATH$PATH:$JAVA_HOME/bin # 暴露端口 EXPOSE 8090 # 入口java项目的启动命令 ENTRYPOINT java -jar /tmp/app.jar从结构上看这 9 条指令对应四段职责自下而上叠成这样Layer 1 · BaseImageFROM ubuntu:16.04系统函数库 文件系统Layer 2 · 环境准备ENV JAVA_DIR COPY 两个包jdk8.tar.gz / docker-demo.jarLayer 3 · 安装运行时RUN 解压 JDK 并重命名 java8ENV JAVA_HOME / PATHLayer 4 · 应用入口EXPOSE 8090 ENTRYPOINTjava -jar /tmp/app.jar容器可写层运行时才有不属于镜像逐条对照看得更清楚Dockerfile 指令产生的一层干了什么为什么要单独一层FROM ubuntu:16.04拉来 Ubuntu 16.04 的只读文件系统作为地基换个 base 就是另一个镜像必须独立ENV JAVA_DIR/usr/local把 JDK 安装目录写成环境变量后续所有指令都引用它改一处不用改全文COPY ./jdk8.tar.gz $JAVA_DIR/把 177 MB 的 JDK 包拷进镜像大文件独立成层内容不变时永久命中缓存COPY ./docker-demo.jar /tmp/app.jar把业务 jar 拷进镜像业务代码改动最频繁让它尽量靠上别拖累下层RUN cd $JAVA_DIR tar -xf ... mv ...解压 JDK重命名为java8安装动作产生文件变更天然是一层ENV JAVA_HOME$JAVA_DIR/java8指向 JDK 根目录运行时依赖的环境变量ENV PATH$PATH:$JAVA_HOME/bin把java命令加进 PATH同上入口脚本才能直接调javaEXPOSE 8090声明容器监听 8090只是元数据但同样记一层ENTRYPOINT java -jar /tmp/app.jar定义启动命令行镜像的入口别人docker run时就靠它注意RUN里用了把三条命令连成一条——cd、解压、重命名如果拆成三个RUN就会多出两层。这一点后面实战坑里还会细说。只读分层与写时复制镜像的每一层都是只读的谁都不能改。那容器运行时产生的日志、临时文件、数据库数据写哪儿去Docker 的做法是启动容器时在镜像最上面再加一个可写层。容器读文件时从最上层往下找找到就用写文件时如果目标在下面的只读层里就把那个文件先复制到可写层再改下层保持原样不动。这就是写时复制Copy-on-Write。可写层没有下层只读本就在可写层读 /tmp/app.jar从最上层往下找Layer 4 只读Layer 3 只读Layer 2 只读Layer 1 ubuntu 只读命中返回文件写文件目标层可写?复制到可写层再修改下层原文件不动直接修改这个设计带来两个直接后果同一个镜像可以同时跑 N 个容器大家共用下面所有只读层只有最上面各自一个薄薄的可写层。省的是磁盘也是内存里共享的文件页。容器删掉只删它自己的可写层镜像本身毫发无损。所以容器不能持久化数据这个坑得靠数据卷挂载来解决。层复用的两个收益分层的价值不止是好看实测下来主要吃两头红利。一、多个镜像共享 base 层省磁盘。你构建的docker-demo:1.0和官方ubuntu:16.04共用同一批底层 layerDocker 在本地只存一份场景磁盘占用说明不分层每个镜像打包成独立文件各存各的177 MB 的 JDK 在每个镜像里各一份拉 5 个镜像就多 5 份分层相同 layer 共享base 层与未变化的中间层只存一份差异层单独存docker images里看到的 SIZE 是叠加值实际占用更小二、构建缓存加速改一行只重建它之后的层。构建时 Docker 会逐层比对某层的内容没变就直接复用缓存你改了什么受影响的层重建耗时只改业务 jar 里的一个类只有COPY ./docker-demo.jar之后的两层秒级前面的 JDK 解压全部命中缓存改了ENV JAVA_DIR的值从该ENV起的全部后续层解压 JDK 的RUN会重跑几十秒换了基础镜像FROM全部从零重建这解释了一个反直觉的实践把变化频率高的东西尽量放在 Dockerfile 靠后的位置。业务 jar 天天变JDK 几乎不变就别让业务 jar 的 COPY 排在 JDK 解压前面。动手验证把分层看出来光看图不放心跑两条命令自己看。用docker build构建完后先看分层历史dockerbuild-tdocker-demo:1.0.dockerhistorydocker-demo:1.0输出形如截取关键列IMAGE CREATED CREATED BY SIZE a1b2c3d4e5f6 2 minutes ago /bin/sh -c #(nop) ENTRYPOINT [java -jar… 0B 9f8e7d6c5b4a 2 minutes ago /bin/sh -c #(nop) EXPOSE 8090 0B 7c6b5a4d3e2f 2 minutes ago /bin/sh -c #(nop) ENV PATH/usr/local/… 0B 5d4c3b2a1908 2 minutes ago /bin/sh -c #(nop) ENV JAVA_HOME/usr/… 0B 3b2a1908f7e6 2 minutes ago /bin/sh -c cd /usr/local tar -xf ./jdk8… 175MB 1a0f9e8d7c6b 2 minutes ago /bin/sh -c #(nop) COPY file:… in /tmp/app.jar 25.4MB f0e9d8c7b6a5 2 minutes ago /bin/sh -c #(nop) COPY file:… in /usr/local/ 177MB e8d7c6b5a4f3 2 minutes ago /bin/sh -c #(nop) ENV JAVA_DIR/usr/local 0B c7b6a5f4e3d2 2 weeks ago /bin/sh -c #(nop) CMD [/bin/bash] 0B missing 2 weeks ago /bin/sh -c mkdir -p /run/systemd echo do… 7B三处细节值得盯#(nop)表示这条指令不产生文件变化比如ENV、EXPOSE、ENTRYPOINTSIZE 是0B但它仍然是一层。JDK 那层显示的是解压后的 175 MB而不是压缩包的 177 MB说明它记录的是该层的净变化量。missing出现的地方是 ubuntu 官方镜像构建时留下的中间层本地没有那一层的元数据常见于早年非 BuildKit 构建的镜像只能看到大小点不进详情。这不是报错。再看镜像的 RootFS能直接读到层 ID 列表dockerinspect docker-demo:1.0输出是一大坨 JSON只看RootFS部分RootFS:{Type:layers,Layers:[sha256:2a1b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809,sha256:3b2c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a,sha256:4c3d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b,sha256:5d4e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c]}Layers数组的长度就是层数顺序自下而上第一个元素是最底层的 base最后一个离容器可写层最近。两个镜像如果Layers前缀相同说明它们共享了这些底层。想只取这一段可以dockerinspect-f{{.RootFS.Layers}}docker-demo:1.0实战坑坑一层数堆太多体积膨胀。每层都会带来额外的元数据开销更麻烦的是——在某一层删掉的文件如果是在更下层加进来的删除只是记录一条删除标记文件本体依然存在下层里体积根本降不下来。典型写法# 反面教材三个 RUN三层且临时文件只被标记删除 RUN apt-get update -y RUN apt-get install -y vim net-tools RUN apt-get clean正确做法是合并成一条RUN让安装和清理发生在同一层# 推荐一条 RUN 搞定临时文件在同一层的末尾就没了 RUN apt-get update -y \ apt-get install -y --no-install-recommends vim net-tools \ rm -rf /var/lib/apt/lists/*坑二改 Dockerfile 前面某一行后面全部缓存失效。比如你把ENV JAVA_DIR/usr/local改成/opt那么它之后所有层的缓存 key 都变了Docker 只能从头往下重跑重新拷贝 jdk 包、重新解压、重新拷 jar。这就是为什么固定不变的东西往上放天天变的东西往下放不是风格偏好而是实打实的构建时间。坑三docker history里全是missing。用 BuildKit 或者拉取别人构建的镜像时很常见表示本地拿不到那层的构建指令元数据。它不影响运行只是看不到这一层是哪条命令产生的。想看完整信息自己构建时加--no-cache重跑或者用docker history --no-trunc打全CREATED BY。和下一篇的边界这篇只回答一个问题层是怎么来的为什么这么叠。至于 Dockerfile 里FROM、COPY、RUN、ENV、ENTRYPOINT各自有哪些参数、ADD和COPY有什么区别、CMD和ENTRYPOINT怎么配合——那是语法层面的东西留给「自定义镜像-Dockerfile」一篇。这里只需要记住一句每一个指令都会形成一层 Layer中间部分不确定两头一定是 BaseImage 和 Entrypoint。API 速览命令看什么关键点docker build -t 名称:版本 .从 Dockerfile 构建镜像末尾的.是构建上下文目录docker history 镜像逐层列出指令、大小、创建时间0B#(nop)表示元数据层missing是缺元数据非报错docker history --no-trunc 镜像完整显示CREATED BY指令被截断时用docker inspect -f {{.RootFS.Layers}} 镜像直接打印层 ID 列表顺序自下而上前几项相同即共享 basedocker image prune清理无标签的悬空层只删没被引用的不影响在用镜像官方文档Dockerfile referenceAbout storage drivers — 镜像层与写时复制总结镜像的分层不是设计美学是被三个现实问题逼出来的多版本之间要能复用、多个镜像之间要能共享、构建要能走缓存。记住三条就够用了一切从 BaseImage 开始往上叠一层只做一件事最上面永远有一个 Entrypoint。每层只读容器启动时在最顶上挂一个可写层写操作先复制再改。变化越频繁的内容在 Dockerfile 里放得越靠后构建缓存命中率才高。下一篇动真格用 Dockerfile 把一个 Java 项目真的构建成镜像跑起来。