构建 Docker 镜像的最佳实践
zr - 2024-02-04 22:52:41 - 所属文档:Docker 快速入门文档
## 安全扫描
镜像构建完成之后,最好使用 `docker scan` 命令对其进行扫描以查找安全漏洞。Docker 与 [Snyk](http://snyk.io/) 合作提供漏洞扫描服务。
例如,要扫描我们自己创建的 `todo-app` 镜像,只需执行以下命令:
```
docker scan todo-app
```
扫描命令使用了一个不断更新的漏洞数据库,如果没有发现漏洞,类似如下输出:
```
➜ ~ docker scan todo-app
Docker Scan relies upon access to Snyk, a third party provider, do you consent to proceed using Snyk? (y/N)
y
Testing todo-app...
...
✓ Tested 16 dependencies for known vulnerabilities, no vulnerable paths found.
For more free scans that keep your images secure, sign up to Snyk at https://dockr.ly/3ePqVcp
➜ ~
```
如果发现了漏洞,类似如下输出:
```
✗ Low severity vulnerability found in freetype/freetype
Description: CVE-2020-15999
Info: https://snyk.io/vuln/SNYK-ALPINE310-FREETYPE-1019641
Introduced through: freetype/freetype@2.10.0-r0, gd/libgd@2.2.5-r2
From: freetype/freetype@2.10.0-r0
From: gd/libgd@2.2.5-r2 > freetype/freetype@2.10.0-r0
Fixed in: 2.10.0-r1
✗ Medium severity vulnerability found in libxml2/libxml2
Description: Out-of-bounds Read
Info: https://snyk.io/vuln/SNYK-ALPINE310-LIBXML2-674791
Introduced through: libxml2/libxml2@2.9.9-r3, libxslt/libxslt@1.1.33-r3, nginx-module-xslt/nginx-module-xslt@1.17.9-r1
From: libxml2/libxml2@2.9.9-r3
From: libxslt/libxslt@1.1.33-r3 > libxml2/libxml2@2.9.9-r3
From: nginx-module-xslt/nginx-module-xslt@1.17.9-r1 > libxml2/libxml2@2.9.9-r3
Fixed in: 2.9.9-r4
```
列出了漏洞类型、了解更多信息的 URL 等等,最重要的是哪个版本修复了该漏洞,以便我们及时更新到安全的版本。
更多关于漏洞扫描的信息可以查看 [docker scan documentation](https://docs.docker.com/engine/scan/)
除了在命令行上扫描新生成的镜像外,还可以 [配置 Docker Hub](https://docs.docker.com/docker-hub/vulnerability-scanning/) 来自动扫描所有新推送的镜像,然后在 Docker Hub 和 Docker Desktop 中查看安全扫描结果。类似下图的显示:

## 镜像分层
1. 可以通过 `docker image history` 命令查看镜像的创建历史,镜像中每个图层的都执行了哪些命令。
```bash
docker image history todo-app
```
你应该能看到类似如下的输出:
```plaintext
IMAGE CREATED CREATED BY SIZE COMMENT
a78a40cbf866 18 seconds ago /bin/sh -c #(nop) CMD ["node" "src/index.j… 0B
f1d1808565d6 19 seconds ago /bin/sh -c yarn install --production 85.4MB
a2c054d14948 36 seconds ago /bin/sh -c #(nop) COPY dir:5dc710ad87c789593… 198kB
9577ae713121 37 seconds ago /bin/sh -c #(nop) WORKDIR /app 0B
b95baba1cfdb 13 days ago /bin/sh -c #(nop) CMD ["node"] 0B
<missing> 13 days ago /bin/sh -c #(nop) ENTRYPOINT ["docker-entry… 0B
<missing> 13 days ago /bin/sh -c #(nop) COPY file:238737301d473041… 116B
<missing> 13 days ago /bin/sh -c apk add --no-cache --virtual .bui… 5.35MB
<missing> 13 days ago /bin/sh -c #(nop) ENV YARN_VERSION=1.21.1 0B
<missing> 13 days ago /bin/sh -c addgroup -g 1000 node && addu… 74.3MB
<missing> 13 days ago /bin/sh -c #(nop) ENV NODE_VERSION=12.14.1 0B
<missing> 13 days ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 13 days ago /bin/sh -c #(nop) ADD file:e69d441d729412d24… 5.59MB
```
每一行代表镜像中的一层,默认最新的一层显示在最上方。使用这条命令,还可以快速查看镜像中每一层的大小。
2. 命令默认会对较长的内容进行自动省略处理以保持整洁的输出。如果想查看完整的内容,可以添加 `--no-trunc` 标志。
```bash
docker image history --no-trunc todo-app
```
## 层缓存
现在我们已经知道了镜像是分层的,再记住一点:`一旦某一层有更新,该层以及它的所有下层也必须重新构建`。利用好这一特性将有助于减少构建镜像所需花费的时间。
回顾之前创建的 Dockerfile 文件内容
```dockerfile
FROM node:12-alpine
WORKDIR /app
COPY . .
RUN yarn install --production
CMD ["node", "src/index.js"]
```
对照上一步执行 `docker image history` 的输出结果,会发现 Dockerfile 文中的每一行命令都对应到镜像中的一个层。你应该还记得,在之前的章节当我们只对源码做一小点文本的修改,但是在更新镜像时,同样必须重新执行每个层的命令,比如重新之下 yarn install 安装依赖等等。显然每次都执行 yarn 命令安装依赖是多余的,有没有解决方法?
要解决这个问题,需要对 Dockerfile 文件进行修改,调整几条命令的执行顺序就可以利用镜像的缓存功能避免执行多余的 yarn install 操作了。对于基于 Node 的应用程序,这些依赖项是在 package.json 文件中定义的。因此,我们只需先把该文件复杂到容器,再安装依赖,然后复制剩下的内容。这样调整之后,只有当 `package.json` 文件有变动时才会执行 yarn install 操作。
1. 如上所述调整我们的 Dockerfile 文件:增加一行 `COPY package.json yarn.lock ./`;同时将 `COPY . .` 移到 `RUN yarn install --production` 之后
```dockerfile hl_lines="3 4 5"
FROM node:12-alpine
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --production
COPY . .
CMD ["node", "src/index.js"]
```
2. 在与 Dockerfile 相同的目录下新创建一个名为 `.dockerignore` 的文件,内容如下:
```ignore
node_modules
```
`.dockerignore` 文件可以告诉 Docker 在构建镜像时忽略某些文件或文件夹,由于 `node_modules` 文件夹是在执行 yarn install 命令时自动生成的,因此它不需要作为镜像的固有内容,忽略它才是对的,而且还可以减少镜像的大小。点击 [这里](https://docs.docker.com/engine/reference/builder/#dockerignore-file) 查看更多关于 `.dockerignore` 的信息。
3. 再次使用 `docker build -t todo-app .` 命令构建新镜像
会看到类似如下输出:
```plaintext
Sending build context to Docker daemon 219.1kB
Step 1/6 : FROM node:12-alpine
---> b0dc3a5e5e9e
Step 2/6 : WORKDIR /app
---> Using cache
---> 9577ae713121
Step 3/6 : COPY package.json yarn.lock ./
---> bd5306f49fc8
Step 4/6 : RUN yarn install --production
---> Running in d53a06c9e4c2
yarn install v1.17.3
[1/4] Resolving packages...
[2/4] Fetching packages...
info fsevents@1.2.9: The platform "linux" is incompatible with this module.
info "fsevents@1.2.9" is an optional dependency and failed compatibility check. Excluding it from installation.
[3/4] Linking dependencies...
[4/4] Building fresh packages...
Done in 10.89s.
Removing intermediate container d53a06c9e4c2
---> 4e68fbc2d704
Step 5/6 : COPY . .
---> a239a11f68d8
Step 6/6 : CMD ["node", "src/index.js"]
---> Running in 49999f68df8f
Removing intermediate container 49999f68df8f
---> e709c03bc597
Successfully built e709c03bc597
Successfully tagged todo-app:latest
```
这一次你会看到所有层都已重建,因为我们更改了 Dockerfile 文件。
4. 现在我们要再一次修改源码,将 `src/static/index.html` 文件第 11 行 `<title>` 标签的内容改为 "The Awesome Todo App"
5. 再次使用 `docker build -t todo-app .` 命令构建镜像。这次的输出应该看起来有些不同了:
```plaintext hl_lines="5 8 11"
Sending build context to Docker daemon 219.1kB
Step 1/6 : FROM node:12-alpine
---> b0dc3a5e5e9e
Step 2/6 : WORKDIR /app
---> Using cache
---> 9577ae713121
Step 3/6 : COPY package.json yarn.lock ./
---> Using cache
---> bd5306f49fc8
Step 4/6 : RUN yarn install --production
---> Using cache
---> 4e68fbc2d704
Step 5/6 : COPY . .
---> cccde25a3d9a
Step 6/6 : CMD ["node", "src/index.js"]
---> Running in 2be75662c150
Removing intermediate container 2be75662c150
---> 458e5c6f080c
Successfully built 458e5c6f080c
Successfully tagged todo-app:latest
```
这次应该能明显的感觉到构建速度快了很多!因为有好几个层使用了缓存。
## 多阶段构建
虽然在本教程中不会做太多的介绍,但是多阶段构建非常有用。
### Maven/Tomcat 示例
在构建基于 Java 的应用程序时,需要使用 JDK 将 Java 源代码编译为 Java 字节码。然而, 在生产环境中不需要完整的 JDK,只需 JRE 即可。另外,你可能正在使用 Maven 或 Gradle 之类的工具来帮助构建应用程序,在最终的镜像中也不需要这类开发阶段使用到的工具。使用多阶段构建可以解决这些问题。
```dockerfile
FROM maven AS build
WORKDIR /app
COPY . .
RUN mvn package
FROM Tomcat
COPY --from=build /app/target/file.war /usr/local/tomcat/webapps
```
在此示例中,我们使用一个称为 `build` 的阶段,通过 Maven 构建 Java 应用程序。第二个阶段从 `FROM tomcat` 开始,我们从 `build` 这个第一阶段的结果中复制文件。默认情况下最终的镜像内容由最后一个阶段创建(可以使用 `--target` 标志自己指定)。
### React 示例
在构建 React 应用程序时,我们需要一个 Node 环境来编译 JS 代码、SASS 样式、以及更多的静态 HTML、JS 和 CSS。如果我们不需要服务器端渲染,则在生产环境中我们甚至都不需要 Node 环境,只需将编译好之后的静态资源放到镜像中即可,如下所示:
```dockerfile
FROM node:12 AS build
WORKDIR /app
COPY package* yarn.lock ./
RUN yarn install
COPY public ./public
COPY src ./src
RUN yarn run build
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
```
## 回顾
在本节中,先通过对我们的镜像进行安全扫描,以确保我们正在运行和分享的镜像是安全的。然后了解了一些镜像的结构及特点,对我们应用程序的 Dockerfile 文件稍作修改,以便更快地构建镜像。最后通过多阶段构建,将开发环境和生产环境需要的依赖区分开,不止可以减少镜像的大小,还能提高容器的安全性。
> 原始资料:[Image building tips](https://docs.docker.com/get-started/09_image_best/)