近几年Docker的使用不断增长📈,上至公司团队,下至普通开发者。 但是并不是每个团队(或者个人)在使用 Docker 的时候都能做到 Docker 的最佳实践 👀, 本文将从以下几个方面来聊聊 Docker 工程化实践中的最佳方案.
主要有以下几方面的需要:
尽可能使用官方和经过验证的Docker镜像作为基础镜像。 如果你所在的团队技术比较强悍,有自己私有化的 Docker hub, 保存了公司项目中使用的所有镜像, 这些镜像包括构建项目使用的基础镜像以及使用中的项目镜像. 基础镜像还是建议使用 Docker 官方并经过验证的镜像, 如果基于 Dockerfile 构建的项目镜像那么还是需要校验 Docker image的安全性等一系列的安全检查 s.
假设你正在开发一个Node.js应用程序,并希望将其构建并作为Docker镜像运行。
最佳实践: 不要使用基本操作系统(ubuntu 、 CentOS 等)镜像并安装node.js、npm和其他你的应用程序所需的工具,而是为你的应用程序使用官方的node镜像。
FROM ubuntu
run apt-get update && apt-get install -y node && rm -rf /var/lib/apt/lists/*
在这个 Dockerfile 中使用了官方的ubuntu镜像, 然后使用命令安装了 node 程序
FROM node
这个 Dockerfile 中我们使用官方提供的 Node 镜像
说明: 同样是官方的镜像, 为什们不推荐使用ubuntu而是使用官方的 node 镜像?
在官方 Docker Hub
中, 我们看到镜像名称后面带有 DOCKER OFFICIAL IMAGE
标识的就是 Docker 官方的镜像
使用特定的Docker镜像版本
在使用 docker image
的时候, 我们已经选择了基础镜像,但是现在当我们从这个 Dockerfile 构建应用程序镜像时,它将始终使用官方 node 镜像的 latest
标签。
使用官方默认的latest
标签会有问题呢? 🤔
❌ 可能会得到一个与之前版本不同的图像版本,及时使用了 latest 标签,官方在不断的更新 node 镜像的内容, 每次都构建了不同的镜像. ❌ 新的镜像可能会有 bug 、或者不稳定的情况发生. 软件开发中有个规则就是一般都不是用软件的最新版本(因为会有不同程度的问题). ❌ latest
标签是不可预测的,会导致意外的问题发生.
所以,最好的做法就是使用固定版本的镜像,更好的做法是使用是我们的应用程序相匹配的镜像版本, 规则就是:越具体越好
FROM node:latest
在这个 Dockerfile 中使用了官方的带有 latest
标签的镜像, latest
意味着就是最新版本的镜像,存在不稳定,或者未发现的问题.
FROM node:current-alpine3.18
这个 Dockerfile 中我们使用官方提供的 Node 镜像并指定了版本号未 node:slim
这样我们在项目中使用的镜像就知道使用了镜像的那个版本, 是否跟我们项目使用的 node 版本相匹配
使用更小的官方镜像
选择 Node.js 镜像时,我们会发现实际上有多个官方镜像可供选择。不仅版本号不同,而且还有不同的操作系统分发版:
那问题是:我们应该选择哪一个镜像,它为什么很重要?🤷🏻♂️
1) 镜像大小 ❌ 如果镜像是基于像Ubuntu或Centos这样的完整操作系统发行版,那么镜像中已经打包了许多工具。因此,镜像大小会更大,但是在我们的应用程序镜像中并不需要大部分这些工具。
✅ 相比之下,拥有较小的图像意味着在图像存储库中需要更少的存储空间,同时也需要更少的部署服务器空间。当从存储库拉取或推送图像时,当然可以更快地传输这些图像。
2) 安全问题 ❌ 除此之外,由于内部安装了许多工具,我们需要考虑安全方面的问题。因为这样的基础镜像通常包含很多漏洞,从而给我们的应用镜像创建了一个更大的struck面。
这样一来,我们的应用中引入了不必要的安全问题!,正所谓要想少犯错, 那么就让他少干活 🙉
✅ 通过使用较小的图像和更精简的操作系统发行版进行比较,只安装必要的系统工具和库,可以最大限度地减少Attack面,并确保构建更安全的镜像。
所以在这里最佳实践是选择一个基于更轻量级操作系统分发版本的图像,比如alpine。
Alpine 镜像具备启动容器应用所需的一切,但更加轻量级。对于大多数在Docker Hub上查看的镜像,我们会看到一个带有alpine发行版标签的版本号。他是Docker容器中最常见和流行的基础镜像之一。
优化构建镜像时的缓存
在 docker 中,镜像层是什么,缓存和镜像层有什么关联呢? 🤔
1) 什么是镜像层(image layer) 一个 Docker 镜像是基于 Dockerfile 构建的。 在 Dockerfile 中,每个命令或指令都会创建一个镜像层:
FROM node:current-alpine3.18
WORKDIR /app
COPY myapp /app
RUN npm install --production
CMD ["node", "src/index.js"]
所以,当我们在上面的示例中使用node alpine作为基础镜像时,它已经有了层级结构,因为它已经使用自己的Dockerfile进行构建。此外,在我们的Dockerfile中还有一些其他命令,每个命令都会向该镜像添加一个新的层级。
2) 什么是镜像缓存? 在每一层中都会被Docker缓存。👍 因此,当重新构建镜像时,如果Dockerfile没有更改,Docker将只使用缓存的层来构建镜像。这样构建的速度就会更快,也会占用更少的存储空间.
使用镜像缓存的优势有那些? :
✅ 更快的构建镜像 ✅ 更快的拉去和推送新的镜像到服务中.
如果在拉取同一应用程序的新图像版本,并且假设在新版本中添加了1个新层:只有新增的层将被下载,其余部分已经由Docker本地缓存。
3) 优化缓存
在 Docker 中一旦一个层发生变化,所有后续或下游的层也必须重新创建。换句话说:当我们改变了Dockerfile中的某一行内容时,所有后续行或层的缓存都会被破坏和失效。 😣
所以这里的规则和最佳实践是: 在 Dockerfile 中,将我们的命令按照从最不经常变化到最经常变化的顺序进行排序,以利用缓存并优化镜像构建速度。🚀
使用 .dockerignore 文件
通常情况下,当我们构建镜像时,并不需要项目中的所有内容来运行应用程序。我们不需要自动生成的文件夹,比如targets
或者build
文件夹,也不需要readme
文件等。
那么我们如何防止这些内容出现在我们的应用程序图像中呢? 🤔
👉 答案就是使用 .dockerignore
文件.
这很简单。我们只需要创建一个名为.dockerignore
的文件,然后列出所有要忽略的文件和文件夹,在构建镜像时,Docker会查看其内容并忽略其中指定的任何内容。
我们在项目的跟目录中创建 .dockerignore
文件,并添加以下内容到文件中:
# 忽略 git 目录和 cache 目录
.git
.cache
# 忽略所有的 markdown 文件
.md
# 忽略其他不想打包到镜像中的文件
private.key
settings.json
ps: 这样做的目的可以有效的减低镜像的大小
使用 Docker 的多阶段构建
现在假设我们的项目中有一些内容(如开发、测试工具和库),我们需要它们来构建镜像 - 在构建过程中,但是不需要它们在最终镜像本身中运行应用程序。
如果我们在最终镜像中保留这些文物,它们对于运行应用程序是完全不必要的,那么它将导致镜像的大小增加以及被Attack的可能性增大。🧐
那么我们如何将构建阶段与运行阶段分离呢?换句话说,我们如何在镜像中排除构建依赖项,同时仍然可以在构建镜像时使用它们?🤷♀️
要解决这个问题我们可以使用 Docker 的多阶段构建技术💡
多阶段构建功能允许我们在构建过程中使用多个临时镜像,但只保留最新的镜像作为最终产物:
比如以下 dockerfile 中我们使用Docker 的多阶段构建技术来构建 golang 应用程序:
# 多阶段构建第1步:
FROM golang:alpine AS builder
LABEL stage=gobuilder
ENV CGO_ENABLED 0
ENV GOPROXY https://goproxy.cn,direct
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
RUN apk update --no-cache && apk add --no-cache tzdata
WORKDIR /build
ADD go.mod .
ADD go.sum .
RUN go mod download
COPY . .
COPY ./etc /app/etc
RUN go build -ldflags="-s -w" -o /app/portal .
# 多阶段构建第2步:
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai
ENV TZ Asia/Shanghai
WORKDIR /app
COPY --from=builder /app/portal /app/portal
COPY --from=builder /app/etc /app/etc
CMD ["./portal", "-f", "etc/env.yaml"]
golang:alpine
镜像并将当前项目拷贝到镜像中, 目的是未了构建一个 golang 的编译环境, 可以正常的构建 golang 应用程序未可执行的二进制程序.scratch
镜像中, 目的是要达到镜像+可执行程序后镜像最小.这样做的好处是:
使用最低权限的用户
当我们创建这个镜像并最终将其作为容器运行时,哪个操作系统用户将用于启动内部的应用程序呢?🤔 默认情况下,当Dockerfile没有指定用户时,它使用root用户。🙉 但实际上大多数情况下没有必要以root权限运行容器。
❌ 这已经引入了一个安全问题,因为当容器在主机上启动运行时,它有可能具有Docker主机的root访问权限。 因此,在容器内使用root用户运行应用程序将使Attack者更容易提升主机的权限,并基本上控制底层主机及其进程,而不仅仅是容器本身 🤯 尤其是如果容器内的应用程序存在漏洞可供利用的情况下会更糟。
✅ 为了避免这种情况,最佳做法是在Docker镜像中创建一个专用用户和专用组来运行应用程序,并且在容器内使用该用户来运行应用程序。
在 Dockerfile 中我们添加以下代码:
...
# 创建demo 组和 demo 用户
RUN groupadd -r demo && useradd -g demo demo
# 设置 demo 用户的权限
RUN chown -R demo:demo /app
# 切换用户
user demo
cmd node index.js
Tip: 一些镜像中已经包含了一个通用用户,我们可以使用它。因此,我们不需要创建新的用户。例如,node.js 图像已经捆绑了一个名为 node
的通用用户,可以直接使用该用户在容器内运行应用程序。 👍
扫码镜像,查找是否存在漏洞
我们如何确保我们的构建的镜像中有少量的或者不存在任何漏洞呢? 🧐
我们在构建镜像之后可以使用 docker 官方提供的 docker scan
命令来扫描安全漏洞。 🔍
Docker 如何发现我们的镜像是否存在漏洞呢? Docker实际上使用了一个名为snyk
的服务来对镜像进行漏洞扫描。该扫描使用了一个不断更新的漏洞数据库。
早期的 Docker 可以使用 docker scan
命令:
$ docker scan hello-world
Testing hello-world...
Organization: docker-desktop-test
Package manager: linux
Project name: docker-image|hello-world
Docker image: hello-world
Licenses: enabled
✓ Tested 0 dependencies for known issues, no vulnerable paths found.
Note that we do not currently have vulnerability data for your image.
具体使用参考官方文档: https://docs.docker.com/docker-hub/vulnerability-scanning/
除了在命令行界面上使用docker scan
命令手动扫描图像之外,还可以配置Docker Hub以在图像被推送到存储库时自动扫描它们。当构建Docker镜像时,当然也可以将此检查与我们的CI/CD集成。
以上这些是生产最佳实践,我们可以使用它们来构建更加精简和安全的 Docker 镜像!🚀