构建高质量的 Docker 镜像
Table of Contents
docker 的出现改写了服务部署规则, 也是 k8s 的基础. 而镜像是 docker 运行 container 的基础. 如何构建高质量的镜像呢?
构建目标 #
docker 希望我们每个账号做到 单一职责, 本质是希望我们运行一个前台进程, 这样就可以保证容器生命周期和进程一致. 简单列一下 dockerfile 的目标:
- 单一职责
- 不能臃肿
- 构建速度快
- Dockerfile 文件清晰简洁易于维护
构建基础 #
docker 镜像是基于 Union FS 做的, 是一种树形结构一层层继承下去. dockerfile 大多命令都会产生一层 layer. 基本命令这里不再赘述.
如何优化 #
.dockerignore 文件 #
这一点虽然很基础, 但很容易被忽视. 有时还会看到某些镜像里面还有 .git 文件夹, 就觉得这点确实容易被忽视.
看到这个后缀就知道, 是控制 docker 构建命令 COPY
和 ADD
忽略哪些文件, 更重要的是会影响到 docker build 的上下文. 假如我们指定上下文为当前目录, docker build 时会将目录中的所有文件压缩打包后发送给 Docker daemon. 可想而知一个 node 项目有没有 ignore node_modules 文件夹时的差别.
可能有人要问, 为什么不继承 .gitignore 文件呢? 因为大多数情况我们会使用 Git 做版本管理. 因为镜像是做部署的, 复制进去最后运行的往往是构建编译后的产物, 而这些恰好不是源码仓库需要的, 所以不适合直接使用 .gitignore 文件.
基础镜像选择 #
上面说到镜像构建简单来理解是层层叠加的, 所以我们保证自己代码和依赖库大小之后, 镜像大小很大程度上依赖于基础镜像的大小.
例如, node 官方镜像提供了基于 Debian Linux 的和基于 alpine Linux 的版本, 可以看到镜像大小差别非常大: debian 345.07 MB 而 alpine 只有 38.79 MB. 可以看出基础镜像对于我们上层镜像大小的影响.
那么是不是无论何时都要选择 alpine 版本呢? 其实也不是, Linux 不同发行版本软件包差别比较大. 假如你的应用依赖很多库, 那么在 alpine 寻找这些依赖可能很费功夫, 所以我们需要根据场景选择基础镜像版本.
还有一点值得注意, 有些时候是需要线上 debug 排查一些问题, 所以可以额外安装一些工具, 比如: curl, telnet, vim 之类的. 也可以做两个版本的基镜像, 一个是 prod 的什么都没有的, 另一个是 dev 的含有这些工具的, 想要 debug 时部署一个基于 dev 镜像的容器. 个人感觉没必要追求极限, 多安装一点工具也没有什么问题.
镜像复用 #
有时我们上层应用会依赖一些 Linux 库, 比如图片处理. 在构建镜像时往往会做编译操作, 这些操作一般都很费时间, 而且三方 ci 构建一般不会持久化, 用不了 docker build 缓存, 这会导致每次构建都很费时. 回过头来想我们是否需要每次都编译这部分软件呢? 答案是根本不需要, 那么我们为什么不把这些操作打包然后发布成自己的镜像呢? 这样我们应用层镜像基于它, 就省去了编译的时间和消耗.
这一点也可以推广到更一般的情况, 也就是我们要评估镜像构建中哪些耗时步骤更新频率不需要那么频繁, 这部分都可以使用基础镜像的形式减少构建时间.
假如你在本地构建, 本地总会有构建缓存, 那你可能没必要这么做, 不过需要注意的是: 尽量要把变动不频繁的东西放在上面, 才能更好地利用构建缓存.
多层构建 #
我们从静态语言来说多层构建. 例如, go 语言, 编译成二进制文件运行, 根本就不需要 go 语言编译器和标准库这些文件, 使用纯 alpine 基镜像构建镜像会非常小, 基本只是二进制文件大小 + 4M 左右. 那么我们可否在本机编译好二进制文件 COPY 进镜像呢? 答案是可以的, 但是这样二进制文件就和编译它的机器有关联了, 也会引入一些之前的痛点, 比如一些全局库版本, 或者是 go 语言版本差异都会使得二进制文件有差异. 所以我们需要多层构建.
多层构建一般来说是在 A 镜像编译好二进制产物, 然后 COPY 到我们最终的运行镜像中. 例如 go 程序经常会这样操作:
FROM golang:1.14 AS build
WORKDIR /mnt
COPY . .
RUN CGO_ENABLED=0 go build -o ./bin/main ./main.go
FROM alpine:3.12
WORKDIR /opt
COPY --from=build /mnt/bin/main /usr/bin/
ENTRYPOINT ["main"]
最终镜像其实是基于 alpine:3.12 的, 所以大小不会是问题, 而且我们编译也是在容器 golang:1.14 进行的, 也就与宿主机没有关系了.
同理不光是静态语言, 甚至一个纯前端项目, 构建时需要 node 环境, 并且安装一堆依赖包, 其实我们部署一般就只需要构建后的 dist 资源, 这时我们使用多层构建, 使用 nginx 容器作为运行容器, 将 node 环境构建好的 dist 文件 COPY 过去就好了.
再例如, 假如你的前端代码不想开源, 只想提供镜像, 那么这种 COPY dist 的方式也很简单就控制了镜像中的文件, 不需要构建后加上很多删除源文件的操作了.
当然在使用多层构建时, 还可以使用 docker build --target [name]
指定只 build 到那一层就停止, 也就是可以使用中间任何一层作为 build 终点, 就可以使用中间镜像了.
参数化构建 #
镜像复用那里也说了, 如果有一些耗时但不常更新的操作, 往往建议我们将它构建成自己的基础镜像, 减少上层构建时间.
那么基础镜像升级了, 我们的 dockerfile 也要升级, 更常见的是我们会同时使用多个基础镜像版本. 例如: 我们基础镜像同时使用基于 node11, node12, node14 的, 难道在构建时, 需要手动更改 FROM node:[version]
吗?
其实更简单的方法就是 build arg 结合镜像构建 ARG
关键字.
ARG 声明的变量可以通过 docker build --build-arg NODE_VERSION=xxx
在构建时传入, 所以我们 dockerfile 这样写:
ARG NODE_VERSION
FROM node:${NODE_VERSION}
就可以不用修改文件改变镜像 tag 了.
使用 ENTRYPOINT #
docker 镜像控制镜像默认运行行为的关键字有两个: CMD
和 ENTRYPOINT
. 区别在哪里?
cmd 相当于默认命令, 用户自定义命令时会直接覆盖掉;
而 entrypoint 则会将用户自定义的命令当做参数, 除非使用 --entrypoint
来替换.
有些时候使用 entrypoint 可以提升使用简便性, 例如 curl 镜像指定 ENTRYPOINT ["curl"]
使用者就可以直接 docker run curl httpbin.org
如果换做 CMD 的话, 就要 docker run curl curl httpbin.org
.
还有一些场景是在启动前做一些事情, 比如 mysql 镜像会根据环境变量帮我们建数据库, 还会帮我们导入使用 volume 挂在到相应文件夹下的 sql 文件, 这些都是通过 entrypoint 实现的. 还有一些容器承载了多种功能, 会使用 entrypoint 实现不同命令使用不同 user 执行.
提升用户体验 #
docker 镜像构建出来很多情况是共享给他人使用的, 所以我们应该注意点用户体验, dockerfile 除了允许注释外, 还有一些声明式关键字, 例如: LABEL, EXPOSE
LABEL
允许我们添加一些元信息, 例如 maintainer 信息, 或者留个邮箱之类的.
EXPOSE
允许我们声明我们服务对外暴露的是什么端口, 它对于容器运行没有任何影响, 只是方便使用者知道该把什么端口映射出去. 这点很重要, 很多时候使用别人镜像还要去在源码里面找寻这些信息.
生产自动构建相关 #
虽然 LABEL 可以增加一些元信息, 但是查看起来不太方便, 而且假如生产 k8s 运行一个镜像, 让你查看这些信息也会很麻烦. 所以我们就要在 tag 上面下点功夫.
首先强烈建议镜像 tag 和 git commit 或者 git tag 能够对应, 不然出了问题半天都不知道镜像对应的代码对应哪次提交.
经过我们生产实践, 基本 tag 需要包含以下几个信息:
- git commit hash 和 git tag, 主要用来定位和源码对应关系
- 构建日期, 可以快速定位构建记录, 或者定位发版记录
- 应用名, 这个没有太大意义, 主要是看到 tag 就知道是哪个服务的
只用 tag 版本号例如 1.1.0
之类的会有哪些缺点呢? 有两点, 如果你的 tag 版本号和 git release tag 能够对应上, 那还好, 不然就是上面说的找不到对应关系. 第二点, 就是不同服务版本会有重叠, 容易发错版本, 例如 B 应用要发版 1.1.0 但是缺误操作到 A 应用上面去了, 结果有可能导致一个线上 2.0.0 的 A 应用版本回退到 1.1.0. 如果使用我们上面的 tag 的话, 会变成一个找不到镜像的错误, 不会对生产应用产生影响.