docker 诞生之初就提出一个理念: Build once,Run anywhere, 而支撑这个理念的最主要组件之一就是镜像。构建镜像很简单,一个 Dockerfile 即可完成,但是要构建一个好的镜像却不容易。下文将一步一步构建出一个好的镜像。

构建之前我们需要先制定几个原则:

  1. 镜像要足够小,确保Pod启动时不会长时间注册在拉取镜像步骤。
  2. 镜像要包含一些常用工具,方便现网问题定位。
  3. 各个业务的镜像应该尽量复用相同的基础镜像。
  4. 镜像要遵循单应用原则。

根据以上原则,我们简单分析一下,docker 镜像是分层的,既我们可以基于某个镜像构建其它镜像,利用这一特性我们可以构建一个全业务通用的基础镜像,然后各个业务在此基础之上构建各自的应用镜像。

0x01 分层构建镜像

1.1 基础镜像构建

首先我们来看一下业务通用基础镜像如何构建。第一个需要考虑的问题是发行版,这个其实没有太多的要求,自己(团队)熟悉那个就选择那个,当然尽量跟生产环境虚拟机的操作系统版本对其。这里我们就选择了debian,而且使用了最新版的 slim 版本,既debian:bookworm-slim。之所以选择 slim 版本,是因为它体积小,没有多余的包。

因为可能涉及到现网问题排查,如果临时安装可能会错过某些情况,也可能出现网络问题安装很慢或无法安装,因此一些常见的工具还是有必要安装的。这里整理了一些常见的网络和系统排查工具。

命令作用
ps查看系统进程。
ss查看网络信息,netstat 升级版。
ip查看网络接口等信息。
ifconfigip 命令,有时候可能会用到。
nslookup查询域名解析结果。
dignslookup,但是功能更强大。
tcpdump抓包工具。
ngrep抓包工具,可以抓取打印应用层包。
telnettcp 连通工具。
nc网络测试工具。
vim文本编辑工具。
nano文本编辑工具,可以在 web shell 下替代 vim。
rsync同步命令。
curlhttp 协议测试工具。
mysqlmysql 客户端。
rzzmode 接受文件命令。
szzmode 下载文件命令。
perf性能分析工具。
vmstat查看系统信息。
top查看系统信息。
iostat查看 IO 信息。
pidstat查看特定进程相关系统占用信息。

上述命令可以使用以下命令进行安装。

apt-get update
apt-get install -y procps iproute2 net-tools dnsutils tcpdump \
  ngrep netcat-openbsd vim nano rsync curl mariadb-client lrzsz perf sysstat

当然对于特定的语言还可以安装一些特定的工具,如 golang 可以安装 dlv等。如果有需要的命令但是不需要通过安装哪个包才能获得,可以访问command-not-found.com 网站获取。

因此业务通用基础镜像的构建 Dockerfile 如下:

FROM debian:bookworm-slim

RUN apt-get update && \
    apt-get install -y procps iproute2 net-tools dnsutils tcpdump \
        ngrep netcat-openbsd vim nano rsync curl mariadb-client lrzsz perf sysstat && \
    rm -rf /var/lib/apt/lists/*

上述镜像构建后可以以private.registry/teamName/debian:latestprivate.registry/teamName/debian:v1.0.0命名(版本镜像方便回溯)并推送到远程仓库。

docker buildx build --push --rm -t private.registry/teamName/debian:latest -t private.registry/teamName/debian:v1.0.0 .

基础镜像大小
基础镜像大小

1.2 runtime镜像构建

此步骤仅在PHPPython 等这类需要 runtime 的语言才需要。该步骤主要作用是构建一个应用可用的 runtime 镜像,减少后续应用构建过程中重复安装runtime的时间,提升构建速度。

像这类需要 runtime 的应用,一般都会依赖外部库(如pythonrequirements.txt),因此可以在这一步将依赖也直接安装进镜像中,但也因此会导致 runtime 层无法被其它应用复用,这里建议如果有很多(大于5个)应用均使用相同的 runtime (如python3.9),那可以将 runtime 镜像拆分为两个,单纯 runtime 镜像和安装有依赖库的镜像(应用镜像的上一层)。我们这里选择将runtime 和依赖直接打包进一个镜像。

Python 为例:

FROM private.registry/teamName/debian:latest

ENV PATH="/usr/local/python-venv/bin:$PATH"

RUN apt-get update && \
    apt-get install --no-install-recommends --no-install-suggests -y python3 python3-pip python3-venv && \
    rm -rf /var/lib/apt/lists/*

COPY requirements.txt /tmp/requirements.txt

RUN python3 -m venv /usr/local/python-venv && \
    /usr/local/python-venv/bin/pip3 install -r /tmp/requirements.txt

通过以下命令构建出版本镜像和 latest镜像。

docker buildx build --push --rm -t private.registry/teamName/demo-app-runtime:latest -t private.registry/teamName/demo-app-runtime:v0.0.1 .

runtime镜像大小
runtime镜像大小

1.3 应用镜像构建

基于 runtime 镜像或者基础镜像,便可以构建可以承载应用的镜像了。这一步比较简单,如果是 PythonPHP 这类的应用,可以直接COPY 源码到镜像即可。如果是 Golang 这类需要构建的语言,则稍微有点复杂,需要构建后将二进制 COPY 到镜像,各自的示例如下:

# Python应用镜像构建示例
# https://github.com/bookiu/monorepo/blob/master/demo-app/Dockerfile
FROM docker.io/yaxin/demo-app-runtime:latest

COPY --chown=root:root . /app/

WORKDIR /app/

ENTRYPOINT ["/entrypoint.sh"]

CMD ["gunicorn", "-c", "gunicorn.conf.py", "app:app"]
# Golang应用镜像构建
# https://github.com/bookiu/monorepo/blob/master/bookstore/Dockerfile
FROM golang:1.21-bookworm AS builder

ENV GOPROXY="https://goproxy.cn,direct"

WORKDIR /usr/src/app

COPY go.mod go.sum ./
RUN go env -w GO111MODULE=on && \
    go env -w GOPROXY="https://goproxy.cn,direct" &&  \
    go mod download && go mod verify

COPY . .

RUN go build -v -o /usr/src/app/bookstore-api ./cmd/bookstore


FROM docker.io/yaxin/debian

WORKDIR /app

COPY --from=builder /usr/src/app/bookstore-api /app/bookstore-api

CMD [ "/app/bookstore-api" ]

上面 Golang 应用镜像构建使用了多级构建,目的是为了减少镜像体积,下文会有专门说明。构建命令如下:

docker buildx build --push --rm -t private.registry/teamName/demo-app:v0.0.1 .

这里并没有构建 latest 版本,主要是为了防止生产环境部署 latest 版本,下文会有说明。

自此,一个轻量、简洁可用的应用镜像已经构建完成。

应用镜像大小
应用镜像大小

0x02 多级构建

关于多级构建(Multi-stage builds),docker 有专门的说明文档:https://docs.docker.com/build/building/multi-stage/。其主要的目的是保持Dockerfile的可读性和可维护性,同时减小镜像体积。其使用场景主要是需要构建的应用,而构建过程中会产生很多额外的文件,这些文件对于应用运行没有任何用途,因此应该尽量避免出现这些文件,而多级构建可以完美规避这个问题。

因此,对于可能会产生对应用运行无用的文件,都可以使用多级构建来规避此问题。

0x03 镜像版本

生产环境应该禁止部署 latest 版本镜像,因为这会导致生产环境运行版本不可控。试想一下,先往部署运行了应用的 latest 的版本,这是,你构建了新的镜像版本,也是用 latest 版本标签,如果此时发布之后有 bug,你将无法回滚。或者如果 k8s 对 Pod 发生了调度,Pod 调度到了一个没有此镜像的 Node,那 Node 势必要拉取镜像,这就导致了被调度的 Pod 和其它 Pod 运行版本不同的问题。

因此,生产环境运行的镜像必须是有版本号的,例如:v1.0.2,该版本号建议与 git 仓库版本号相同,也在确定发布前,将分支代码合并入 master 后,对master打个 tag,tag 事件触发构建 CI 流水线,开始构建镜像。此部分会下下一节作介绍。

生产环境部署版本镜像
生产环境部署版本镜像

0x04 CI流水线

所有生产环境的镜像禁止开发在本地构建 push(减少人工操作带来的问题),应该由对应的CI流水线进行构建。构建触发事件建议由 master 分支的 tag 事件触发,这样可以将各个镜像应用内容与 git 仓库代码做一一匹配,方便问题回溯排查。一个典型的 CI 流水线从触发到执行应该如下图所示:

生产环境 CI 流程
生产环境 CI 流程

一个典型的 CI 构建配置应该如下(各个CI平台配置可能略有不同):

CI配置
CI配置

0x05 总结

镜像作为应用的载体,最容易被大家忽视,其不仅要提供一个稳定的运行环境,而且更应该兼顾效率和可调试性。本文只是根据自己在工作中总结出来的构建镜像的一些实践点,如果有什么其它更好的建议,欢迎联系我讨论。

最后,上述镜像构建过程中涉及到的代码等可以在https://github.com/bookiu/monorepo 仓库中查看。