正确构建 Docker 镜像
现在,可能每个人都听说过 Docker,大多数开发者也都熟悉并使用它,因此了解一些基本知识,例如如何构建 Docker 镜像。构建 Docker 镜像就像运行 `docker build` 命令一样简单docker built -t name:tag .,但它的奥妙远不止于此,尤其是在优化构建过程和最终生成的镜像方面。因此,在本文中,我们将超越基础知识,探讨如何影响 Docker 镜像的构建过程,从而加快构建速度,并为我们的应用程序生成更精简、更安全的镜像。
缓存助力快速构建
镜像构建的大部分时间通常都花在了系统库和应用程序依赖项的下载和安装上。然而,这些依赖项的更新频率并不高,因此非常适合进行缓存。
首先是系统库和工具——通常只需在初始安装后立即移动它们的安装位置,FROM以确保它们已被缓存。无论您使用哪个 Linux 发行版作为基础镜像,最终结果都应该类似这样:
FROM ... # any viable base image like centos:8, ubuntu:21.04 or alpine:3.12.3
# RHEL/CentOS
RUN yum install ...
# Debian
RUN apt-get install ...
# Alpine
RUN apk add ...
# Rest of the Dockerfile (COPY, RUN, CMD...)
或者,您甚至可以将所有这些文件提取出来,Dockerfile构建自己的基础镜像。然后,您可以将此镜像上传到镜像仓库,以便您和其他人可以将其用于多个应用程序。这样,除非您需要升级或添加/删除某些内容,否则您无需担心任何系统库/依赖项。
在安装系统库之后,我们通常需要安装应用程序依赖项。这些依赖项可能包括存储在 `<path>`.m2目录中的 Maven 仓库中的 Java 库、`<path>` 目录中的 JavaScript 模块node_modules或 `<path>` 目录中的 Python 库venv。这些依赖项的更新频率通常比系统依赖项更高,但还不足以每次构建时都进行完整的重新下载和安装。然而,如果 Dockerfile 编写得不好,你会发现即使依赖项没有被修改,缓存也不会被使用:
FROM ... # any viable base image like python:3.8, node:15 or openjdk:15.0.1
# Copy everything at once
COPY . .
# Java
RUN mvn clean package
# Or Python
RUN pip install -r requirements.txt
# Or JavaScript
RUN npm install
# ...
CMD [ "..." ]
为什么会这样呢?问题在于COPY . .Docker 在构建的每个步骤中都会使用缓存,直到遇到新增或修改过的命令/层。在这种情况下,当我们把所有内容(包括未更改的依赖项列表和已修改的源代码)都复制到镜像中时,Docker 会重新下载并安装所有依赖项,因为它无法再使用缓存来处理已修改的源代码层。为了避免这种情况,我们需要分两步复制文件:
FROM ... # any viable base image like python:3.8, node:15 or openjdk:15.0.1
COPY pom.xml ./pom.xml # Java
COPY requirements.txt ./requirements.txt # Python
COPY package.json ./package.json # JavaScript
RUN mvn dependency:go-offline -B # Java
RUN pip install -r requirements.txt # Python
RUN npm install # JavaScript
COPY ./src ./src/
# Rest of Dockerfile (build application; set CMD...)
首先,我们添加列出所有应用程序依赖项的文件并安装它们。如果此文件没有更改,则所有内容都会被缓存。之后,我们才将其余(已修改的)源代码复制到镜像中,并运行测试和应用程序代码的构建。
如果想采用更“高级”的方法,我们使用 Docker 的BuildKit及其实验性功能来实现同样的效果:
# syntax=docker/dockerfile:experimental
FROM ... # any viable base image like python:3.8, openjdk:15.0.1
COPY pom.xml ./pom.xml # Java
COPY requirements.txt ./requirements.txt # Python
RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline -B # Java
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt # Python
以上代码展示了如何使用命令--mount选项RUN来选择缓存目录。如果您想要显式地使用非默认的缓存位置,这将非常有用。不过,如果您想使用此功能,则必须包含指定语法版本的头文件(如上所示),并且还要运行构建DOCKER_BUILDKIT=1 docker build name:tag .。有关实验性功能的更多信息,请参阅这些文档。
以上所述仅适用于本地构建——持续集成(CI)的情况则有所不同,而且通常每个工具/提供商的情况也不尽相同,但无论使用哪种工具/提供商,都需要一个持久卷来存储缓存/依赖项。例如,对于Jenkins,您可以使用代理中的存储。对于在 Kubernetes 上运行的 Docker 构建(无论使用 JenkinsX、Tekton 还是其他任何工具),您都需要 Docker 守护进程,该守护进程可以使用Docker in Docker (DinD)进行部署,DinD 是运行在 Docker 容器中的 Docker 守护进程。至于构建本身,您需要一个连接到DinD套接字并运行的pod(容器)docker build。
为了演示方便,我们可以使用以下 pod 来实现:
apiVersion: v1
kind: Pod
metadata:
name: docker-build
spec:
containers:
- name: dind # Docker in Docker container
image: docker:19.03.3-dind
securityContext:
privileged: true
env:
- name: DOCKER_TLS_CERTDIR
value: ''
volumeMounts:
- name: dind-storage
mountPath: /var/lib/docker
- name: docker # Builder container
image: docker:19.03.3-git
securityContext:
privileged: true
command: ['cat']
tty: true
env:
- name: DOCKER_BUILDKIT
value: '1'
- name: DOCKER_HOST
value: tcp://localhost:2375
volumes:
- name: dind-storage
emptyDir: {}
- name: docker-socket-volume
hostPath:
path: /var/run/docker.sock
type: File
上述 pod 由两个容器组成——一个用于DinD,另一个用于镜像构建器。要使用构建器容器运行构建,您可以访问其 shell,克隆某个仓库并运行 build 命令:
~ $ kubectl exec --stdin --tty docker-build -- /bin/sh # Open shell session
~ # git clone https://github.com/username/reponame.git # Clone some repository
~ # cd reponame
~ # docker build --build-arg BUILDKIT_INLINE_CACHE=1 -t name:tag --cache-from username/reponame:latest .
...
=> importing cache manifest from martinheinz/python-project-blueprint:flask
...
=> => writing image sha256:...
=> => naming to docker.io/library/name:tag
=> exporting cache
=> => preparing build cache for export
最后版本docker build使用了一些新选项——其中一个--cache-from image:tag选项指示 Docker 使用(远程)镜像仓库中的指定镜像作为缓存源。这样,即使缓存层没有存储在本地文件系统中,我们也能利用缓存。另一个选项--build-arg BUILDKIT_INLINE_CACHE=1用于在创建镜像时将缓存元数据写入镜像。必须启用此选项才能使其--cache-from正常工作,更多信息请参阅文档。
让他们瘦下来
快速构建固然好,但如果镜像非常“厚”,拉取和推送仍然需要很长时间,而且厚镜像很可能还包含大量的库、工具等等,这会使镜像更容易受到攻击,因为它会创建更大的攻击面。
制作更精简镜像的最简单方法是使用 Alpine Linux 之类的发行版,而不是基于 Ubuntu 或 RHEL 的镜像。另一个好方法是使用多步骤 Docker 构建,例如,使用一个镜像进行构建(第一个FROM命令),然后使用另一个更精简的镜像来运行应用程序(第二个/最后一个命令FROM),例如:
# 332.88 MB
FROM python:3.8.7 AS builder
COPY requirements.txt /requirements.txt
RUN /venv/bin/pip install --disable-pip-version-check -r /requirements.txt
# only 16.98 MB
FROM python:3.8.7-alpine3.12 as runner
# copy only the dependencies installation from the 1st stage image
COPY --from=builder /venv /venv
COPY --from=builder ./src /app
CMD ["..."]
以上步骤表明,我们首先在一个基本的 Python 3.8.7 镜像中准备应用程序及其依赖项,该镜像体积较大,为 332.88 MB。在这个镜像中,我们安装了应用程序所需的虚拟环境和库。然后,我们切换到一个体积小得多的基于 Alpine 的镜像,该镜像仅为 16.98 MB。我们将之前创建的所有虚拟环境以及源代码复制到这个镜像中。这样,我们最终得到了一个体积更小、层数更少、包含的冗余工具和二进制文件也更少的镜像。
还需要注意一点,那就是每次构建过程中生成的层数。`build` FROM、COPY`build`、 `build`RUN和CMD`build` 是四个用于创建层的命令,至少在 `build` 的情况下,RUN我们可以通过&&将所有RUN命令合并成一个命令来轻松减少它创建的层数,如下所示:
# Bad, Creates 4 layers
RUN yum --disablerepo=* --enablerepo="epel"
RUN yum update
RUN yum install -y httpd
RUN yum clean all -y
# Good, creates only 1 layer
RUN yum --disablerepo=* --enablerepo="epel" && \
yum update && \
yum install -y httpd && \
yum clean all -y
我们可以更进一步,彻底摆脱可能相当庞大的基础镜像。为此,我们可以使用特殊FROM scratch指令告诉 Docker 应该使用最小基础镜像,并且下一个命令将是最终镜像的第一层。这对于以二进制形式运行且不需要大量工具的应用程序(例如 Go、C++ 或 Rust 应用程序)尤其有用。然而,这种方法要求二进制文件是静态编译的,因此不适用于 Java 或 Python 等语言。Dockerfile 的示例FROM scratch可能如下所示:
FROM golang as builder
WORKDIR /go/src/app
COPY . .
# Static build is required so that we can safely use 'scratch' base image
RUN CGO_ENABLED=0 go install -ldflags '-extldflags "-static"'
FROM scratch
COPY --from=builder /go/bin/app /app
ENTRYPOINT ["/app"]
很简单,对吧?有了这样的 Dockerfile,我们就能生成只有 3MB 左右的镜像!
封锁
速度和体积效率是大多数人关注的重点,而图像安全性往往被忽略。其实,有一些简单的方法可以加强图像安全,限制攻击者可利用的攻击面。
最基本的建议是锁定所有库、包、工具和基础镜像的版本,这不仅对安全性至关重要,对镜像的稳定性也同样重要。如果您使用latest镜像标签,或者例如在 Pythonrequirements.txt或 JavaScript中未指定版本package.json,则构建过程中下载的镜像/库可能与应用程序代码不兼容,或者可能使您的容器面临安全漏洞的风险。虽然您需要将所有内容锁定到特定版本,但您也应该定期更新所有这些依赖项,以确保您拥有所有最新的安全补丁和修复程序。
即使你竭尽全力避免所有依赖项中的任何漏洞,仍然会有一些漏洞被你忽略或尚未修复/发现。因此,为了减轻任何潜在攻击的影响,最好避免以非 root 用户身份运行容器root。你应该USER 1001在 Dockerfile 中添加相应的配置,以表明由你的 Dockerfile 创建的容器应该并且能够以非 root 用户(理想情况下是任意用户)身份运行。当然,这可能还需要你修改应用程序以选择正确的基础镜像,因为某些常见的基础镜像nginx(例如,由于特权端口)需要 root 权限。
通常来说,在 Docker 镜像中查找/避免漏洞非常困难,但如果镜像只包含运行应用程序所需的最低限度组件,则可以稍微简化这一过程。Google开发的Distroless就是这样一种镜像(或者更确切地说,是一组镜像) 。Distroless 镜像经过精简,甚至不包含 shell 或包管理器,这使得它们在安全性方面远胜于基于 Debian 或 Alpine 的镜像。如果您使用多步骤 Docker 构建,那么大多数情况下,切换到 Distroless运行镜像非常简单:
FROM ... AS builder
# Build the application ...
# Python
FROM gcr.io/distroless/python3 AS runner
# Golang
FROM gcr.io/distroless/base AS runner
# NodeJS
FROM gcr.io/distroless/nodejs:10 AS runner
# Rust
FROM gcr.io/distroless/cc AS runner
# Java
FROM gcr.io/distroless/java:11 AS runner
# Copy application into runner and set CMD...
# More examples at https://github.com/GoogleContainerTools/distroless/tree/master/examples
除了最终镜像及其容器可能存在的漏洞之外,我们还需要考虑用于构建镜像的 Docker 守护进程和容器运行时环境。因此,与所有镜像一样,我们不应该允许 Docker 以root普通用户身份运行,而应该使用所谓的无根模式。Docker文档中有关于如何设置的完整指南。但是,如果您不想进行此类配置,则可以考虑切换到podman默认以无根和无守护进程模式运行的 Docker 版本——更多内容请参阅我的另一篇文章(链接在此)。
结论
容器和 Docker 技术已经发展了相当长一段时间,以至于大家对它们的了解和使用都仅限于最基本的功能。本文中的技巧和示例(在我看来)应该能很好地帮助你提升 Docker 知识,并改进你使用的 Docker 镜像的各个方面。然而,除了构建 Docker 镜像之外,我们还可以做很多其他事情来改进我们使用镜像和容器的方式。例如,这包括应用seccomp策略(参见我之前的文章)、限制资源消耗cgroups,或者使用完全不同的容器运行时/引擎。