本篇分享在编写 Dockerfiles 和使用 Docker 时应遵循的一些最佳实践。篇幅较长,建议先收藏慢慢看,保证看完会很有收获。
文章目录
Dockerfile 最佳实践
- 使用多阶段的构建
- 调整 Dockerfile 命令的顺序
- 使用小型 Docker 基础镜像
- 尽量减少层的数量
- 使用无特权的容器
- 优先选择
COPY
而不是ADD
- 将
Python
包缓存到 Docker 主机上 - 每个容器只运行一个进程
- 优先选择数组而不是字符串语法
- 理解
ENTRYPOINT
和CMD
之间的区别 - 添加健康检查
HEALTHCHECK
Docker 镜像最佳实践
- Docker 镜像的版本
- 不要在镜像中存储密钥
- 使用
.dockerignore
文件 - 检查和扫描你的 Docker 文件和镜像
- 签署和验证镜像
Dockerfile 最佳实践
1. 使用多阶段的构建
利用多阶段构建的优势来创建更精简、更安全的Docker镜像。多阶段 Docker 构建(multi-stage builds)允许你将你的 Dockerfile 分成几个阶段。
例如,你可以有一个阶段用于编译和构建你的应用程序,然后可以复制到后续阶段。由于只有最后一个阶段被用来创建镜像,与构建应用程序相关的依赖关系和工具就会被丢弃,因此可以留下一个精简的、模块化的、可用于生产的镜像。
Web 开发示例:
# 临时阶段 |
在这个例子中,GCC 编译器在安装某些 Python 包时是必需的,所以我们添加了一个临时的、构建时的阶段来处理构建阶段。
由于最终的运行时映像不包含 GCC,所以它更轻,也更安全。镜像大小比较:
REPOSITORY TAG IMAGE ID CREATED SIZE |
再来看一个例子:
# 临时阶段 |
镜像大小比较:
REPOSITORY TAG IMAGE ID CREATED SIZE |
总之,多阶段构建可以减少你的生产镜像的大小,帮助你节省时间和金钱。此外,这将简化你的生产容器。由于尺寸较小和简单,相对会有较小的攻击面。
2. 调整 Dockerfile 命令的顺序
密切注意你的 Dockerfile 命令的顺序,以利用层缓存。
Docker 在一个特定的 Docker 文件中缓存每个步骤(或层),以加快后续的构建。当一个步骤发生变化时,不仅该步骤,而且所有后续步骤的缓存都将被废止。
例如:
FROM python:3.9-slim |
在这个 Dockerfile 中,我们在安装需求之前复制了应用程序的代码。现在,每次我们改变 sample.py 时,构建都会重新安装软件包。这是非常低效的,特别是在使用 Docker 容器作为开发环境时。因此,把经常变化的文件放在 Dockerfile 的末尾是很关键的。
你也可以通过使用 .dockerignore 文件来排除不必要的文件,使其不被添加到 Docker 构建环境和最终镜像中,从而帮助防止不必要的缓存失效。更多信息后面会提到。
因此,在上面的 Dockerfile 中,你应该把 COPY sample.py .
命令移到底部,如下所示:
FROM python:3.9-slim |
注意。
- 总是把可能发生变化的层放在 Dockerfile 中尽可能的低。
- 将多个
RUN apt-get update
,RUN apt-get install
等命令结合到一起执行。(这也有助于减少镜像的大小,后面会很快就会提到这一点)。 - 如果你想关闭某个 Docker 构建的缓存,可以添加
--no-cache=True
标志。
3. 使用小型 Docker 基础镜像
较小的 Docker 镜像更具有模块化和安全性。较小的 Docker 基础镜像在构建、推送和拉动镜像的速度较小,它们也往往更安全,因为它们只包括运行应用程序所需的必要库和系统依赖。
你应该使用哪个 Docker 基础镜像?这个没有一个固定的答案,它这取决于你要做什么。下面是 Python 的各种 Docker 基础镜像的大小比较。
REPOSITORY TAG IMAGE ID CREATED SIZE |
虽然基于 Alpine Linux 的 Alpine flavor 是最小的,但如果你找不到可以与之配合的编译二进制文件,往往会导致构建时间的增加。因此,你最终可能不得不自己构建二进制文件,这可能会增加镜像的大小(取决于所需的系统级依赖)和构建时间(由于必须从源头编译)。
关于为什么最好不要使用基于 Alpine 的基础镜像,请参考适用于 Python 应用程序的最佳 Docker 基础映像 和 使用 Alpine 可以使 Python Docker 构建速度慢 50 倍 了解更多关于为什么最好避免使用基于 Alpine 的基础镜像。
归根结底,这都是关于平衡的问题。如果有疑问,从 *-slim
flavor 开始,特别是在开发模式下,因为你正在构建你的应用程序。你想避免在添加新的 Python
包时不得不不断地更新 Dockerfile 以安装必要的系统级依赖。当你为生产强化你的应用程序和 Dockerfile 时,你可能想探索使用 Alpine 来完成多阶段构建的最终镜像。
另外,别忘了定期更新你的基础镜像,以提高安全性和性能。当一个基础镜像的新版本发布时,例如:3.9.6-slim
–> 3.9.7-slim
,你应该拉出新的镜像并更新你正在运行的容器以获得所有最新的安全补丁。
4. 尽量减少层的数量
尽量把 RUN
、COPY
和 ADD
命令结合起来使用,因为它们会创建层。每一层都会增加镜像的大小,因为它们是被缓存的。因此,随着层数的增加,镜像大小也会增加。
你可以用 docker history
命令来测试一下。
docker images |
请注意尺寸。只有 RUN
、COPY
和 ADD
命令增加了镜像的尺寸,你可以尽可能地通过合并命令来减少镜像的大小。比如:
RUN apt-get update |
可以合并成一个 RUN
命令:
RUN apt-get update && apt-get install -y gcc |
因此,创建一个单层而不是两个,这就减少了最终镜像的大小。虽然减少层数是个好主意,但更重要的是,这本身不是一个目标,而是减少镜像大小和构建时间的一个副作用。换句话说呢,与其试图优化每一条命令,你更应该关注前面的三种做法!!!
- 多阶段构建
- Dockerfile命令的顺序
- 以及使用一个小的基础镜像。
注意
RUN
、COPY
和ADD
都会创建图层- 每个图层都包含与前一个图层的差异
- 图层会增加最终镜像的大小
提示
- 合并相关命令
- 在创建过程中执行
RUN
步骤中删除不必要的文件 - 尽量减少运行
apt-get upgrade
的次数,因为它将所有软件包升级到最新版本。 - 对于多阶段的构建,不要太担心过度优化临时阶段的命令
最后,为了便于阅读,建议将多行参数按字母数字排序。
RUN apt-get update && apt-get install -y \ |
5. 使用无特权的容器
默认情况下,Docker 在容器内以 root 身份运行容器进程。然而,这是一个糟糕的做法,因为在容器内以 root 身份运行的进程在 Docker 主机中也是以 root 身份运行。
因此,如果攻击者获得了对容器的访问权,他们就可以获得所有的 root 权限,并可以对 Docker 主机进行一些攻击,例如:
- 将敏感信息从主机的文件系统复制到容器中
- 执行远程命令
为了防止这种情况,确保以非 root 用户运行容器进程。
RUN addgroup --system app && adduser --system --group app |
你可以更进一步,删除 shell 权限,确保没有主目录。
RUN addgroup --gid 1001 --system app && \ |
验证
docker run -i sample id |
在这里,容器内的应用程序在一个非 root 用户下运行。然而,请记住,Docker 守护进程和容器本身仍然是以 root 权限运行的。
请务必查看以非根用户身份运行 Docker 守护进程,以获得以非根用户身份运行守护进程和容器的帮助。
6. 优先选择 COPY
而不是 ADD
除非你确定你需要 ADD
所带来的额外功能,否则请使用 COPY
。
那么 COPY
和 ADD
的区别是什么?
首先,这两个命令都允许你从一个特定的位置复制文件到 Docker 镜像中。
ADD <src> <dest> |
虽然它们看起来作用相同,但 ADD
有一些额外的功能。
COPY
用于将本地文件或目录从 Docker 主机复制到镜像上。ADD
可以用于同样的事情,也可以用于下载外部文件。另外,如果你使用压缩文件(tar、gzip、bzip2等)作为参数, ADD
会自动将内容解压到指定位置。
# 将主机上的本地文件复制到目的地 |
最后 COPY
在语义上比 ADD
更加明确和更容易理解。
7. 缓存安装包到 Docker 主机上
当一个需求文件被改变时,镜像需要被重建以安装新的包。先前的步骤将被缓存,正如在最小化层数中提到的。在重建镜像时下载所有的包会导致大量的网络活动,并需要大量的时间。每次重建都要占用同等的时间来下载不同构建中的通用包。
以 Python 为例,你可以通过将 pip 缓存目录映射到主机上的一个目录来避免这种情况。所以对于每次重建,缓存的版本会持续存在,这可以提高构建速度。
在 Docker 运行中添加一个卷,作为 -v $HOME/.cache/pip-docker/:/root/.cache/pip
或者作为 Docker Compose 文件中的映射。
上面介绍的目录只供参考,要确保你映射的是 cache 目录,而不是 site-packages(内置包所在的位置)。
将缓存从 docker 镜像中移到主机上可以为你节省最终镜像的空间。
|
8. 每个容器只运行一个进程
为什么建议每个容器只运行一个进程?
让我们假设你的应用程序栈由两个 Web 服务器和一个数据库组成。虽然你可以很容易地从一个容器中运行所有三个,但你应该在一个单独的容器中运行每个服务,以便更容易重复使用和扩展每个单独的服务。
- 扩展性 - 由于每个服务都在一个单独的容器中,你可以根据需要水平地扩展你的一个网络服务器来处理更多的流量。
- 可重用性 - 也许你有另一个服务需要一个容器化的数据库,你可以简单地重复使用同一个数据库容器,而不需要带着两个不必要的服务。
- 日志 - 耦合容器会让日志变得更加复杂。(我们将在本文后面进一步详细讨论这个问题)
- 可移植性和可预测性 - 当容器有较少的部分在工作时,制作安全补丁或调试问题就会容易得多。
9. 优先选择数组而不是字符串语法
你可以在你的 Dockerfiles 中以数组(exec)或字符串(shell)格式
在 Dockerfile 中,你可以以数组(exec)或字符串(shell)格式来使用 CMD
和 ENTRYPOINT
命令
# 数组(exec) |
两者都是正确的,并且实现了几乎相同的事情;但是,你应该尽可能地使用 exec 格式。
以下来自 Docker的官方文档内容:
- 确保你在 Dockerfile 中使用
CMD
和ENTRYPOINT
的 exec 形式。 - 例如,使用
["program", "arg1", "arg2"]
而不是"program arg1 arg2"
。使用字符串形式会导致 Docker 使用bash
运行你的进程,而bash
并不能正确处理信号。Compose 总是使用 JSON 形式,所以不用担心如果你在你的 Compose 文件中覆盖了命令或入口。
因此,由于大多数 shell 不处理对子进程的信号,如果你使用 shell 格式,CTRL-C(产生 SIGTERM
)可能不会停止一个子进程。
例子:
FROM ubuntu:18.04 |
这两种情况执行效果一样。但请注意,在字符串(shell)格式的情况下,CTRL-C
不会杀死这个进程。相反,你会看到 ^C^C^C^C^C^C^C^C^C^C
。
另一个注意事项是,字符串(shell)格式携带的是 shell 的 PID,而不是进程本身。
# 数组格式 |
10. 了解 ENTRYPOINT
和 CMD
之间的区别
我应该使用 ENTRYPOINT
还是 CMD
来运行容器进程?有两种方法可以在容器中运行命令。
CMD ["gunicorn", "config.wsgi", "-b", "0.0.0.0:8000"] |
两者本质上做的是同一件事:用 Gunicorn
服务器在 config.wsgi
启动应用程序,并将其绑定到 0.0.0.0:8000
。
CMD
很容易被重写。如果你运行 docker run <image_name> uvicorn config.asgi
,上述 CMD
就会被新的参数所取代。
例如,uvicorn config.asgi
。而要覆盖 ENTRYPOINT
命令,必须指定 --entrypoint
选项。
docker run --entrypoint uvicorn config.asgi <image_name> |
在这里,很明显,我们正在覆盖入口点。所以,建议使用 ENTRYPOINT
而不是 CMD
,以防止意外地覆盖命令。
它们也可以一起使用。比如说
ENTRYPOINT ["gunicorn", "config.wsgi", "-w"] |
当像这样一起使用时,为启动容器所运行的命令就变成了:
gunicorn config.wsgi -w 4 |
如上所述,CMD
很容易被重写。因此,CMD
可以被用来向 ENTRYPOINT
命令传递参数。比如很容易更改 workers 的数量,就像这样:
docker run <image_name> 6 |
这样就将有 6 个 Gunicorn workers 启动容器,而不是默认的 4 个。
11. 添加健康检查 HEALTHCHECK
使用 HEALTHCHECK
来确定容器中运行的进程是否不仅已启动并正在运行,而且是“健康”的。
Docker 公开了一个 API 来检查容器中运行的进程的状态,它提供的信息不仅仅是进程是否“正在运行”,因为“运行”涵盖了“它正在运行”、“仍在启动”、甚至“陷入某种无限循环错误状态”。你可以通过 HEALTHCHECK
指令与此 API 交互。
例如,如果你正在提供 Web 应用程序,则可以使用以下内容来确定 /
端点是否已启动并可以处理服务请求:
HEALTHCHECK CMD curl --fail http://localhost:8000 || exit 1 |
如果你运行 docker ps
,你可以看到 HEALTHCHECK
的状态。
健康的示例
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES |
不健康的示例
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES |
你可以更进一步,设置一个仅用于健康检查的自定义端点,然后配置 HEALTHCHECK
以针对返回的数据进行测试。
例如,如果端点返回 {"ping": "pong"}
的 JSON 响应,你可以指示 HEALTHCHECK
验证响应正文。
以下是使用 docker inspect
查看运行状况检查状态的方法:
这里省略了部分输出。
❯ docker inspect --format "{{json .State.Health }}" ab94f2ac7889 |
你还可以向 Docker Compose 文件添加运行状况检查:
version: "3.8" |
选项:
test
:要测试的命令。interval
:要测试的间隔 - 即,测试每 x 时间单位。timeout
:等待响应的最长时间。start_period
:何时开始健康检查。它可以在容器准备就绪之前执行其他任务时使用,例如运行迁移。retries
:在将测试指定为失败之前的最大重试次数。
如果你使用的是 Docker Swarm 以外的编排工具(比如 Kubernetes 或 AWS ECS),它们很可能有自己的内部系统来处理健康检查。在添加 HEALTHCHECK
指令之前,请参阅特定工具的文档。
Docker 镜像最佳实践
1. Docker 镜像版本
只要有可能,就要避免使用 latest
标签的镜像。
如果你依赖 latest
标签(这并不是一个真正的 “标签”,因为当镜像没有明确的标签时,它是默认应用的),你无法根据镜像标签来判断你的代码正在运行哪个版本。
如果你想回滚就变得很困难,并且很容易被覆盖(无论是意外还是恶意的)。标签,就像你的基础设施和部署,应该是不可改变的。
所以无论你如何对待你的内部镜像,都不应该对基本镜像使用 latest
标签,因为你可能会无意中把一个带有破坏性变化的新版本部署到生产中。
对于内部镜像,应使用描述性的标签,以便更容易分辨哪个版本的代码正在运行,处理回滚,并避免命名冲突。例如,你可以使用以下描述符来组成一个标签。
- 时间戳
- Docker 镜像 ID
- Git 提交哈希值
- 语义版本 (Semantic version)
关于更多的选择,也可以参考 Stack Overflow 问题 “Properly Versioning Docker Images” 中的这个答案。
比如说
docker build -t web-prod-b25a262-1.0.0 . |
在这里,我们用下面的内容来形成标签
- 项目名称:web
- 环境名称: prod
- Git commit short hash: b25a262 (通过命令
git rev-parse --short HEAD
来获得) - 语义学版本:1.0.0
选择一个标签方案并与之保持一致是至关重要的。由于提交哈希值(commit hashes)可以很容易地将镜像标签与代码联系起来,建议将它们纳入你的标签方案。
2. 不要在镜像中存储机密信息
Secrets 是敏感的信息,如密码、数据库凭证、SSH密钥、令牌和 TLS 证书等。这些信息不应该在没有加密的情况下被放入你的镜像中,因为未经授权的用户如果获得了镜像的访问权,只需要检查这些层就可以提取密钥。
因此不要在 Docker 文件中添加明文的密钥,尤其是当你把镜像推送到像 Docker Hub 这样的公共仓库!!
FROM python:3.9-slim |
相反,它们应该通过以下方式注入
- 环境变量(在运行时)
- 构建时参数(在构建时)
- 协调工具,如 Docker Swarm(通过 Docker secrets)或 Kubernetes(通过 Kubernetes secrets)。
此外,你还可以通过在你的 .dockerignore
文件中添加常见的密钥文件和文件夹来帮助防止密钥的泄露。
**/.env |
最后,要明确哪些文件会被复制到镜像中,而不是递归地复制所有文件。
# 不好的做法 |
明确的做法也有助于限制缓存的破坏。
环境变量
你可以通过环境变量来传递密钥,但它们会在所有子进程、链接的容器和日志以及 docker inspect
中可见。要更新它们也很困难。
docker run --detach --env "DATABASE_PASSWORD=SuperSecretSauce" python:3.9-slim |
这是最直接的密钥管理方法。虽然它不是最安全的,但它会让诚实的人保持诚实,因为它提供了一个薄薄的保护层,有助于使密钥不被好奇的游荡的眼睛发现。
使用共享卷传递密钥是一个更好的解决方案,但它们应该被加密,通过 Vault 或 AWS密钥管理服务(KMS),因为它们被保存到磁盘。
构建时参数
你可以在构建时使用构建时参数来传递密钥,但这些密钥对于那些可以通过 docker 历史访问镜像的人来说是可见的。
例子
FROM python:3.9-slim |
构建
docker build --build-arg "DATABASE_PASSWORD=SuperSecretSauce" . |
如果你只需要临时使用密钥作为构建的一部分。例如,用于克隆私有 repo 或下载私有软件包的 SSH 密钥。你应该使用多阶段构建,因为构建者的历史会被临时阶段忽略。
# 临时阶段 |
多阶段构建只保留了最终镜像的历史。你可以把这个功能用于你的应用程序需要的永久密钥,比如数据库凭证。
你也可以使用 docker build 中新的 --secret
选项来向 Docker 镜像传递密钥,这些密钥不会被存储在镜像中。
# "docker_is_awesome" > secrets.txt |
这将装载 secrets.txt
文件中的密钥。
构建镜像
docker build --no-cache --progress=plain --secret id=mysecret,src=secrets.txt . |
最后,检查历史记录,看看密钥是否泄露了。
❯ docker history 49574a19241c |
Docker 密钥
如果你正在使用 Docker Swarm,你可以用 Docker secrets 来管理密钥。
例如,启动 Docker Swarm 模式。
docker swarm init |
创建一个 docker 密钥。
echo "supersecretpassword" | docker secret create postgres_password - |
当一个容器被赋予上述密钥的访问权时,它将挂载在 /run/secrets/postgres_password
。这个文件将包含明文的密钥的实际值。
使用其他的编排工具?
- 使用 AWS Secrets Manager 的密钥与 Kubernetes 的密钥
- DigitalOcean Kubernetes - 保护 DigitalOcean Kubernetes 集群的推荐步骤
- Google Kubernetes引擎 - 与其他产品一起使用密钥管理器
- Nomad - Vault 集成和检索动态密钥
3. 使用 .dockerignore 文件
之前已经提到过几次使用 .dockerignore
文件。这个文件用来指定你不希望被添加到发送给 Docker 守护进程的初始构建上下文中的文件和文件夹,后者将构建你的镜像。换句话说,你可以用它来定义你需要的构建环境。
当一个 Docker 镜像被构建时,整个 Docker 上下文 - 即你的项目的根在 COPY
或 ADD
命令执行之前就被发送给了 Docker 守护进程。
这可能是相当费资源,尤其是当你的项目中有许多依赖关系、大量的数据文件或构建工件时。
另外,当 Docker CLI 和守护程序不在同一台机器上。比如守护进程是在远程机器上执行的,你就更应该注意构建环境的大小了。
你应该在 .dockerignore
文件中添加什么?
- 临时文件和文件夹
- 构建日志
- 本地 secrets
- 本地开发文件,如
docker-compose.yml
- 版本控制文件夹,如 “.git”、”.hg” 和 “.vscode” 等
例子:
**/.git |
总之,结构合理的 .dockerignore 可以帮助
- 减少 Docker 镜像的大小
- 加快构建过程
- 防止不必要的缓存失效
- 防止泄密
4. 检查并扫描你的 Dockerfile 和镜像
Linting 是检查源代码中是否存在可能导致潜在缺陷的编程和风格错误以及不良做法的过程。就像编程语言一样,静态文件也可以被 lint。特别是对于你的 Dockerfile,linter 可以帮助确保它们的可维护性、避免弃用语法并遵守最佳实践。整理镜像应该是 CI 管道的标准部分。
Hadolint 是最流行的 Dockerfile linter:
hadolint Dockerfile |
这是 Hadolint 一个在线的链接 https://hadolint.github.io/hadolint/ 也可以安装 VS Code 插件
你可以将 Dockerfile 与扫描镜像和容器的漏洞结合使用。
以下是一些有影响力的镜像扫描工具:
- Snyk 是 Docker 本地漏洞扫描的独家提供商。你可以使用
docker scan
CLI 命令来扫描镜像。 - Trivy 可用于扫描容器镜像、文件系统、git 存储库和其他配置文件。
- Clair 是一个开源项目,用于对应用程序容器中的漏洞进行静态分析。
- Anchore 是一个开源项目,为容器镜像的检查、分析和认证提供集中式服务。
总而言之,对你的 Dockerfile 和镜像进行 lint 和扫描,来发现任何偏离最佳实践的潜在问题。
5. 签名和验证镜像
你怎么知道用于运行生产代码的镜像没有被篡改?
篡改可以通过中间人(MITM)攻击或注册表被完全破坏来实现。Docker 内容信任(DCT)可以对来自远程注册中心的 Docker 镜像进行签名和验证。
为了验证镜像的完整性和真实性,请设置以下环境变量。
DOCKER_CONTENT_TRUST=1 |
现在,如果你试图拉一个没有被签名的镜像,你会收到以下错误。
Error: remote trust data does not exist for docker.io/namespace/unsigned-image: |
你可以从使用 Docker 内容信任签署镜像文档中了解签署镜像的情况。
当从 Docker Hub下 载镜像时,确保使用官方镜像或来自可信来源的经过验证的镜像。较大的团队应该使用他们自己的内部私有容器仓库
6. 设置内存和 CPU 的限制
限制 Docker 容器的内存使用是一个好主意,特别是当你在一台机器上运行多个容器时。这可以防止任何一个容器使用所有可用的内存,从而削弱其他容器的功能。
限制内存使用的最简单方法是在 Docker cli 中使用 --memory
和 --cpu
选项。
docker run --cpus=2 -m 512m nginx |
上述命令将容器的使用限制在 2 个 CPU 和 512 兆的内存。
你可以在 Docker Compose 文件中做同样的事情,像这样。
version: "3.9" |
请注意 reservations
字段。它是用来设置软限制的,当主机的内存或CPU资源不足时,它就会优先考虑。
其他相关资源
- 带有内存、CPU和GPU的运行时选项:https://docs.docker.com/config/containers/resource_constraints/
- Docker Compose 的资源限制:https://docs.docker.com/compose/compose-file/compose-file-v3/#resources
总结
以上就是本文介绍的 17 条最佳实践,掌握这些最佳实践一定会让你的 Dockerfile 和 Docker Image 变得精简,干净,和安全。
本文出自 Docker Best Practices for Python Developers。
欢迎扫码关注公众号「DevOps攻城狮」- 专注于DevOps领域知识分享。