vlambda博客
学习文章列表

给 Python 开发者的 Docker 最佳实践


  • Dockerfiles

    • 多阶段构建

    • 适当地排列 Dockerfile 命令

    • 使用小的 Docker 基础镜像

    • 尽量减少层数

    • 使用非特权容器

    • 多用 `COPY` 而非 `ADD`

    • 缓存 Python 包到 Docker 宿主机

    • 每个容器只运行一个进程

    • 多用数组而非字符串

    • 了解 `ENTRYPOINT` 和 `CMD` 之间的区别

    • 包括健康检查说明

  • 镜像

    • 打镜像版本

    • 不要在镜像中保存密文

    • 使用 `.dockerignore` 文件

    • 检查并扫描 Dockerfiles 和镜像

    • 镜像签名与验签

  • 额外福利

    • 使用虚拟环境

    • 设置内存和 CPU 限制

    • 打日志到 stdout 或者 stderr

    • 为 Gunicorn 心跳使用共享内存装载

  • 总结


译自:。

本文介绍了在编写 Docker 文件和使用 Docker 时应遵循的一些最佳实践。虽然列出的大多数实践适用于各种语言的所有开发人员,但也有少数实践仅适用于那些开发基于 Python 的应用程序的开发人员。

Dockerfiles

多阶段构建

利用多阶段构建创建更精简、更安全的 Docker 镜像。

允许你将 DockerFile 分解为几个阶段。例如,你可以使用一个用于编译和构建应用程序的阶段,然后可以将其复制到后续阶段。由于只有最后一个阶段用于创建镜像,因此与构建应用程序相关联的依赖项和工具将被丢弃,留下一个精简的、模块化的、可用于生产的镜像。

  • Web 开发示例
# temp stage
FROM python:3.9-slim as builder

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc


COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt


# final stage
FROM python:3.9-slim

WORKDIR /app

COPY --from=builder /app/wheels /wheels
COPY --from=builder /app/requirements.txt .

RUN pip install --no-cache /wheels/*

在本例中,安装某些 Python 包需要 GCC 编译器,因此我们添加了一个临时构建阶段来处理构建阶段。因为最终的运行时镜像不包含 GCC,所以它更轻、更安全。

镜像大小对比:

REPOSITORY                 TAG                    IMAGE ID       CREATED          SIZE
docker-single latest 8d6b6a4d7fb6 16 seconds ago 259MB
docker-multi latest 813c2fa9b114 3 minutes ago 156MB
  • 数据科学示例
# temp stage
FROM python:3.9 as builder

RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels jupyter pandas


# final stage
FROM python:3.9-slim

WORKDIR /notebooks

COPY --from=builder /wheels /wheels
RUN pip install --no-cache /wheels/*

镜像大小对比:

REPOSITORY                  TAG                   IMAGE ID       CREATED         SIZE
ds-multi latest b4195deac742 2 minutes ago 357MB
ds-single latest 7c23c43aeda6 6 minutes ago 969MB

总之,多阶段构建可以减小生产镜像的大小,帮助你节省时间和金钱并将简化你的生产容器。此外,由于较小的尺寸和简单性,可能存在较小的攻击面。

适当地排列 Dockerfile 命令

请密切注意 Dockerfile 命令的顺序,以利用层缓存。

Docker 将每个步骤(或层)缓存在特定 Dockerfile 中,以加速后续构建。当一个步骤更改时,缓存将不仅对该特定步骤无效,而且对所有后续步骤无效。

下面以具体例子来说明。

FROM python:3.9-slim

WORKDIR /app

COPY sample.py .

COPY requirements.txt .

RUN pip install -r /requirements.txt

在这个 Dockerfile 中,我们在安装依赖之前复制了应用程序代码。现在,每次我们更换 sample.py,构建将重新安装依赖包。这是非常低效的,尤其是当使用 Docker 容器作为开发环境时。因此,我们应该将经常更改的文件放在 Dockerfile 末尾。

你还可以通过使用 .dockerignore 文件,以排除不必要的文件添加到 Docker 的生成上下文和最终镜像中。稍后我们将对此进行详细介绍。

因此,在上面的 Dockerfile 中,你应该把 COPY sample.py . 命令放到底部:

FROM python:3.9-slim

WORKDIR /app

COPY requirements.txt .

RUN pip install -r /requirements.txt

COPY sample.py .

注意:

  1. 尽可能将频繁变动的层放置在 Dockerfile 靠后的位置。
  2. 合并 RUN apt-get updateRUN apt-get install 命令。(这也有助于减小镜像大小,我们稍后将讨论此问题。)
  3. 如果要关闭特定 Docker 构建的缓存,请添加 --no cache=True 标志。

使用小的 Docker 基础镜像

更小的 Docker 镜像更加模块化和安全。

使用较小的镜像可以更快地生成、推送和拉取镜像。它们往往更安全,因为它们只包含运行应用程序所需的必要库和系统依赖项。

那么,你应该使用哪个 Docker base 镜像?很不幸,这要视情况而定。

下面是 Python 各种的 Docker 基础镜像的大小比较:

REPOSITORY   TAG                 IMAGE ID       CREATED      SIZE
python 3.9.6-alpine3.14 f773016f760e 3 days ago 45.1MB
python 3.9.6-slim 907fc13ca8e7 3 days ago 115MB
python 3.9.6-slim-buster 907fc13ca8e7 3 days ago 115MB
python 3.9.6 cba42c28d9b8 3 days ago 886MB
python 3.9.6-buster cba42c28d9b8 3 days ago 886MB

虽然基于 Alpine Linux 的 Alpine 版本是最小的,但如果你找不到可在上面使用的预编译二进制文件,它通常会导致构建时间的增加。因此,你可能最终不得不自己构建二进制文件,这会增加镜像大小(取决于所需的系统级依赖项)和构建时间(因为必须从源代码进行编译)。

想了解关于为什么最好避免使用基于 Alpine 的基础镜像的更多信息,请参阅 ,以了解有关为什么最好避免使用基于 Alpine 的基础镜像的更多信息。

最后,一切都需要取舍。如果在构建应用程序时拿不准,请从 *-slim 版本开始,尤其是在开发模式下。你应该避免在添加新的 Python 包时必须不断更新 Dockerfile 以安装必要的系统级依赖项。当你完善应用和 Dockerfile(s) 为生产做准备的时候,你可能希望探索使用 Alpine 进行多阶段构建最终的镜像。

另外,不要忘记定期更新基础镜像,以提高安全性和性能。当有新版本的基础镜像发布时(如 3.9.6-slim -> 3.9.7-slim),你应该拉取新镜像并更新正在运行的容器以获得所有最新的安全修补程序。

尽量减少层数

由于缓存,镜像层数的增加会导致镜像大小的增加,所以最好尽量合并 RUNCOPYADD ——每个命令都会创建新的层。

结论:随着镜像层数的增加,镜像大小也随之增加。

你可以使用 docker history 命令对此进行测试:

$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
dockerfile   latest    180f98132d02   51 seconds ago   259MB

$ docker history 180f98132d02

IMAGE          CREATED              CREATED BY                                      SIZE      COMMENT
180f98132d02   58 seconds ago       COPY . . # buildkit                             6.71kB    buildkit.dockerfile.v0
<missing>      58 seconds ago       RUN /bin/sh -c pip install -r requirements.t…   35.5MB    buildkit.dockerfile.v0
<missing>      About a minute ago   COPY requirements.txt . # buildkit              58B       buildkit.dockerfile.v0
<missing>      About a minute ago   WORKDIR /app
...

说到镜像大小,只有 RUNCOPYADD 命令会导致镜像大小增加。你可以尽量通过合并命令来减少镜像大小。例如:

RUN apt-get update
RUN apt-get install -y netcat

可以合并为单个 RUN 命令:

RUN apt-get update && apt-get install -y netcat

因此,创建一个层会比创建两个层更能缩减镜像的大小。

虽然减少层数是个好主意,但这并不是目标——对减小镜像大小和构建时间来说,这只是锦上添花的事情。换句话说,更应该关注前面的三个实践:多阶段构建适当地排列 Dockerfile 命令使用小的 Docker 基础镜像,而非试图优化单个命令。

注意:

  1. RUNCOPYADD 都会创建层
  2. 每个层包含与上一层的差异内容
  3. 层数会增加最终镜像的大小

提示:

  1. 合并相关命令
  2. 在同一个 RUN 命令中删除非必要的中间文件
  3. 尽可能减少 apt-get upgrade 的运行次数,因为它将所有软件包升级到最新版本。
  4. 对于多阶段构建,不要太担心在临时阶段中过度优化命令。

最后,为了便于阅读,最好对多行参数进行字母数字排序:

RUN apt-get update && apt-get install -y \
    git \
    gcc \
    matplotlib \
    pillow  \
    && rm -rf /var/lib/apt/lists/*

使用非特权容器

默认情况下,Docker 以容器内的根用户身份运行容器进程。但这是一种不好的做法。因为在容器中以 root 身份运行的进程在 Docker 宿主机中以 root 身份运行。因此,如果攻击者获得对你的容器的访问权,则他们可以获得所有 root 权限,并可以对 Docker 宿主机执行多种攻击,如:

  • 将敏感信息从主机的文件系统复制到容器
  • 执行远程命令

要防止出现这种情况,请确保使用非 root 用户运行容器进程:

RUN addgroup --system app && adduser --system --group app

USER app

你可以更进一步,删除 shell 访问并确保没有 home 目录:

RUN addgroup --gid 1001 --system app && \
    adduser --no-create-home --shell /bin/false --disabled-password --uid 1001 --system --group app


USER app

验证:

$ docker run -i sample id

uid=1001(app) gid=1001(app) groups=1001(app)

结果显示,容器中的应用程序在非 root 用户下运行。但是,请记住,Docker 守护程序和容器本身仍然以 root 权限运行。请务必阅读 ,查看以非 root 用户身份运行守护程序和容器的帮助。

多用 COPY 而非 ADD

除非你确定需要 ADD 附带的附加功能,否则请使用 COPY

那么,COPYADD 有什么区别?

这两个命令都允许你将文件从特定位置复制到 Docker 镜像中:

ADD <src> <dest>
COPY <src> <dest>

虽然它们看起来有相同的用途,但 ADD 还有一些附加功能:

  • COPY 用于将本地文件或目录从 Docker 宿主机复制到镜像。
  • ADD 可以用于同样的事情,也可以下载外部文件。另外,如果使用压缩文件(tar、gzip、bzip2 等)作为 参数, ADD 将自动将内容解压缩到给定位置。
# 复制宿主机本地文件到容器目标路径
COPY /source/path  /destination/path
ADD /source/path  /destination/path

# 下载外部文件并复制到容器目标路径
ADD http : //external.file/url  /destination/path

# 复制并解压本地压缩文件到容器目标路径
ADD source.file.tar.gz /destination/path

缓存 Python 包到 Docker 宿主机

当更改 requirements 文件时,需要重新构建镜像以便安装新的依赖包。在-尽量减少层数-中提及,变动层之前的层会被缓存。在重建镜像时下载所有软件包可能会导致大量网络开销并花费大量时间,而且每次重建都会花费大量的时间下载通用包。

我们可以通过将 pip 缓存目录映射到宿主机上的目录来避免这种情况。这样,每次重建,缓存的版本将保持不变,从而可以提高构建速度。

为 Docker 添加卷,可以通过执行命令:-v $HOME/.cache/pip-docker/:/root/.cache/pip,或者在 Docker Compose 文件中做映射。

上述目录仅供参考。确保映射的是缓存目录,而不是 site-packages 目录。

将缓存从 docker 镜像移动到宿主机可以节省最终镜像中的空间。

如果你正在利用 ,请使用 BuildKit 缓存装载来管理缓存:

# syntax = docker/dockerfile:1.2

...

COPY requirements.txt .

RUN --mount=type=cache,target=/root/.cache/pip \
        pip install -r requirements.txt


...

每个容器只运行一个进程

为什么建议每个容器只运行一个进程?

假设你的应用程序堆栈由两个 web 服务器和一个数据库组成。虽然可以从单个容器轻松地运行所有三个服务,但应该在单独的容器中运行每个服务,以便于复用和扩展每个服务:

  • 可扩展性:通过将每个服务放在单独的容器中,你可以根据需要水平扩展其中一个 web 服务器,以处理更多流量。
  • 可重用性:如果你还有另一个需要容器化数据库的服务,你可以简单地重用同一个数据库容器,而不附带两个不必要的服务。
  • 日志:耦合容器使日志记录更加复杂。我们将在本文后面进一步详细讨论这个问题。
  • 可移植性和可预测性:当需要处理的范围较小时,制作安全补丁或调试问题就容易得多。

多用数组而非字符串

你可以在 DockerFile 中以数组(exec)或字符串(shell)格式编写 CMDENTRYPOINT 命令:

# 数组(exec)
CMD ["gunicorn""-w""4""-k""uvicorn.workers.UvicornWorker""main:app"]

# 字符串(shell)
CMD "gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app"

这两种写法都是正确的,并且效果也几乎相同。但是,应尽可能使用exec格式。从:

确保在 Dockerfile 中使用了 CMDENTRYPOINT 的 exec 表单。

例如,使用 [ "program" , "arg1" , "arg2" ] 而不是 “程序 arg1 arg2”。使用字符串表单会导致 Docker 使用 bash 运行进程,而 bash 不能正确处理信号。Compose 始终使用 JSON 格式,因此如果你在 Compose 文件中重写命令或入口点,则无需担心这个问题。

因此,由于大多数 shell 不处理到子进程的信号,如果使用 shell 格式,CTRL-C(生成 SIGTERM 信号)可能不能停止子进程。

FROM ubuntu:18.04

# BAD: shell format
ENTRYPOINT top -d

# GOOD: exec format
ENTRYPOINT ["top""-d"]

试试上面两种启动方式。请注意,对于 shell 格式风格,CTRL-C 不会终止进程。相反,你将看到 ^C^C^C^C^C^C^C^C^C^C^C^C^C^C

另外还有一点是, shell 格式携带 shell 的 PID,而不是进程本身:

# array format
root@18d8fd3fd4d2:/app# ps ax
  PID TTY      STAT   TIME COMMAND
    1 ?        Ss     0:00 python manage.py runserver 0.0.0.0:8000
    7 ?        Sl     0:02 /usr/local/bin/python manage.py runserver 0.0.0.0:8000
   25 pts/0    Ss     0:00 bash
  356 pts/0    R+     0:00 ps ax


# string format
root@ede24a5ef536:/app# ps ax
  PID TTY      STAT   TIME COMMAND
    1 ?        Ss     0:00 /bin/sh -c python manage.py runserver 0.0.0.0:8000
    8 ?        S      0:00 python manage.py runserver 0.0.0.0:8000
    9 ?        Sl     0:01 /usr/local/bin/python manage.py runserver 0.0.0.0:8000
   13 pts/0    Ss     0:00 bash
  342 pts/0    R+     0:00 ps ax

了解 ENTRYPOINTCMD 之间的区别

我应该使用 ENTRYPOINTCMD 来运行容器进程吗?

在容器中运行命令有两种方法:

CMD ["gunicorn""config.wsgi""-b""0.0.0.0:8000"]

# and

ENTRYPOINT ["gunicorn""config.wsgi""-b""0.0.0.0:8000"]

两者基本上都做相同的事情:通过 Gunicornconfig.wsgi 启动应用,并绑定到 0.0.0.0:8000

CMD 很容易被覆盖。如果运行 docker run <image_name> uvicorn config.asgiCMD 的新参数将被替换成:uvicorn config.asgi。而要覆盖 ENTRYPOINT 命令,则必须指定 --entrypoint 选项:

docker run --entrypoint uvicorn config.asgi <image_name>

指定选项后,则很明显地表明我们正在覆盖入口点。因此,建议使用 ENTRYPOINT 替代 CMD,以防止意外覆盖参数。

它们也可以一起使用:

ENTRYPOINT ["gunicorn""config.wsgi""-w"]
CMD ["4"]

这样一起使用时,其等效命令为:

gunicorn config.wsgi -w 4

如上所述,CMD 参数很容易被重写。因此,可以使用 CMD 将参数传递给 ENTRYPOINT 命令。-w 参数(worker 数量)可以很容易地改变,如下所示:

docker run <image_name> 6

这将使用 6 个 worker(而非 4 个)启动容器。

包括健康检查说明

使用 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
09c2eb4970d4 healthcheck "python manage.py ru…" 10 seconds ago Up 8 seconds (health: starting) 0.0.0.0:8000->8000/tcp, :::8000->8000/tcp xenodochial_clarke

不健康状态:

CONTAINER ID   IMAGE         COMMAND                  CREATED              STATUS                          PORTS                                       NAMES
09c2eb4970d4 healthcheck "python manage.py ru…" About a minute ago Up About a minute (unhealthy) 0.0.0.0:8000->8000/tcp, :::8000->8000/tcp xenodochial_clarke

你可以更进一步,设置仅用于运行状况检查的自定义端点,然后配置运行状况检查以针对返回的数据进行测试。例如,如果端点返回一个 JSON 响应 {“ping”:“pong”},则可以指示 HEALTHCHECK 验证响应体。

Here's how you view the status of the health check status using docker inspect: 以下是使用 docker inspect 查看健康检查状态的方式:

❯ docker inspect --format "{{json .State.Health }}" ab94f2ac7889
{
  "Status""healthy",
  "FailingStreak": 0,
  "Log": [
    {
      "Start""2021-09-28T15:22:57.5764644Z",
      "End""2021-09-28T15:22:57.7825527Z",
      "ExitCode": 0,
      "Output""..."

这里对输出进行了截取,因为整个 HTML 输出比较大

你还可以向 Docker Compose 文件添加运行状况检查:

version: "3.8"

services:
  web:
    build: .
    ports:
      - '8000:8000'
    healthcheck:
      test: curl --fail 服务地址 || exit 1
      interval: 10s
      timeout: 10s
      start_period: 10s
      retries: 3

参数选项:

  • test:要测试的命令。
  • interval: 要测试的间隔,即每 x 个时间单位测试一次。
  • timeout:等待响应的最长时间。
  • start_period:何时开始健康检查。在容器准备就绪之前执行其他任务(如运行迁移)时,可以使用它。
  • retries:指定测试失败前的最大重试次数。

如果你使用的编排工具不是 Docker Swarm,而是 KubernetesAWS ECS,那么这些工具很可能有自己的内部系统来处理运行状况检查。在添加 HEALTHCHECK 指令之前,请参阅特定工具的文档。

镜像

打镜像版本

尽量避免使用 latest 标签。

如果依赖于 latest 标签(它不是真正的“标签”,它是镜像没有指定标签时的默认标签),则无法根据镜像标签判断哪个版本的代码正在运行。这使得执行回滚具有挑战性,并且很容易(意外地或恶意地)覆盖它。标签,就像你的基础设施和部署一样,应该是不可变的。

无论你如何处理内部镜像,都不应该对基本镜像使用 latest 标签,因为你可能会在不经意间部署一个新版本,并对生产环境进行不兼容变更。

对于内部镜像,使用描述性标签可以更容易地判断正在运行的代码版本、处理回滚以及避免命名冲突。

可以考虑使用以下的描述符组成标签:

  • 时间戳
  • Docker 镜像 ID
  • Git 提交哈希
  • 具备语义的版本描述

更多选择,可以参考 Stack Overflow 上的答案

以下是一个例子:

docker build -t web-prod-a072c4e5d94b5a769225f621f08af3d4bf820a07-0.1.4 .

这个例子中,我们使用了:

  • 项目名称:Web
  • 环境名称:prod
  • Git 提交哈希:a072c4e5d94b5a769225f621f08af3d4bf820a07
  • 具备语义的版本描述:0.1.4

选择一个标签方案并与保持一致是非常重要的。由于提交哈希可以轻松地将镜像标记快速绑定到代码,因此强烈建议将它们包含在标签方案中。

不要在镜像中保存密文

密文是敏感信息,例如密码、数据库凭据、SSH 密钥、令牌和 TLS 证书等。这些密文不应该在没有加密的情况下放到镜像中,因为获得镜像访问权限的未经授权用户只需检查镜像层便可以提取密文。

不要将密文以明文形式添加到 Docker 文件中,尤其是在将镜像推送到 之类的公共仓库时:

FROM python:3.9-slim
# 这是个坏主意
ENV DATABASE_PASSWORD "SuperSecretSauce"

相反,应通过以下方式注入密文:

  • 环境变量(在运行时)
  • 生成时参数(在生成时)
  • 使用编排工具附带的方式: Docker secrets(Docker Swarm), Kubernetes secrets(Kubernetes)

此外,你还可以通过将常见的密文文件和文件夹添加到 .dockerignore 来防止密文泄漏:

**/.env
**/.aws
**/.ssh

最后,明确哪些文件被复制到镜像中,而不是递归复制所有文件:

# 避免
COPY . .

# 建议
COPY ./app.py .

显式指定复制文件也有助于限制缓存破坏。

使用环境变量

你可以通过环境变量传递密文,但它们将在所有子进程、链接容器和日志以及 docker inspect 中可见。而且更新它们也很困难:

$ docker run --detach --env "DATABASE_PASSWORD=SuperSecretSauce" python:3.9-slim

d92cf5cf870eb0fdbf03c666e7fcf18f9664314b79ad58bc7618ea3445e39239


$ docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' d92cf5cf870eb0fdbf03c666e7fcf18f9664314b79ad58bc7618ea3445e39239

DATABASE_PASSWORD=SuperSecretSauce
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LANG=C.UTF-8
GPG_KEY=E3FF2839C048B25C084DEBE9B26995E310250568
PYTHON_VERSION=3.9.7
PYTHON_PIP_VERSION=21.2.4
PYTHON_SETUPTOOLS_VERSION=57.5.0
PYTHON_GET_PIP_URL=https : //github.com/pypa/get-pip/raw/c20b0cfd643cd4a19246ccf204e2997af70f6b21/public/get-pip.py
PYTHON_GET_PIP_SHA256=fa6f3fb93cce234cd4e8dd2beb54a51ab9c247653b52855a48dd44e6b21ff28b

这是最直接的密文管理方法。虽然它不是最安全的,但它会让诚实的人保持诚实,因为它提供了一层薄薄的保护,有助于将密文隐藏起来,不让好奇游荡的眼睛看到 (While it's not the most secure, it will keep the honest people honest since it provides a thin layer of protection, helping to keep the secrets hidden from curious wandering eyes)。

使用共享卷传递密文是一个更好的解决方案,但它们应该通过 进行加密,因为它们会被保存到磁盘中。

使用构建时参数

你可以使用构建时参数在构建时传递密文,但是那些通过 docker history 访问镜像的人可以看到这些密文。

Dockerfile 命令:

FROM python:3.9-slim

ARG DATABASE_PASSWORD

编译时传递参数:

$ docker build --build-arg "DATABASE_PASSWORD=SuperSecretSauce" .

如果你只需要将密文临时用作构建的一部分(如:用于克隆私有 repo 或下载私有包的 SSH 密钥),则应使用多阶段构建,因为构建器历史记录会对于忽略临时阶段的记录:

# 临时阶段
FROM python:3.9-slim as builder

# 密文
ARG SSH_PRIVATE_KEY

# 安装Git
RUN apt-get update && \
    apt-get install -y --no-install-recommends git


# 使用 SSH 密钥克隆仓库
RUN mkdir -p /root/.ssh/ && \
    echo "${PRIVATE_SSH_KEY}" > /root/.ssh/id_rsa

RUN touch /root/.ssh/known_hosts &&
    ssh-keyscan bitbucket.org >> /root/.ssh/known_hosts
RUN git clone [email protected]:testdrivenio/not-real.git


# 最终阶段
FROM python:3.9-slim

WORKDIR /app

# 从临时镜像中复制仓库
COPY --from=builder /your-repo /app/your-repo

# 使用仓库

多阶段构建仅保留最终镜像的历史记录。请记住,你可以将此功能用于应用程序所需的永久密文,如数据库凭据。 你还可以在 Docker 构建时使用 --secret 选项将密文传递给 Docker 镜像,而这些镜像不会存储在这些镜像中。

# "docker_is_awesome" > secrets.txt

FROM alpine

# shows secret from default secret location:
RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecret

以上命令会指定从 secrets.txt 中加载密文。构建命令如下:

docker build --no-cache --progress=plain --secret id=mysecret,src=secrets.txt .

# output
...
#4 [1/2] FROM docker.io/library/alpine
#4 sha256:665ba8b2cdc0cb0200e2a42a6b3c0f8f684089f4cd1b81494fbb9805879120f7
#4 CACHED

#5 [2/2] RUN --mount=type=secret,id=mysecret cat /run/secrets/mysecret
#5 sha256:75601a522ebe80ada66dedd9dd86772ca932d30d7e1b11bba94c04aa55c237de
#5 0.635 docker_is_awesome#5 DONE 0.7s

#6 exporting to image

最后,检查历史记录以查看密文是否泄漏:

❯ docker history 49574a19241c
IMAGE          CREATED         CREATED BY                                      SIZE      COMMENT
49574a19241c   5 minutes ago   CMD ["/bin/sh"]                                 0B        buildkit.dockerfile.v0
<missing>      5 minutes ago   RUN /bin/sh -c cat /run/secrets/mysecret # b…   0B        buildkit.dockerfile.v0
<missing>      4 weeks ago     /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
<missing>      4 weeks ago     /bin/sh -c #(nop) ADD file:aad4290d27580cc1a…   5.6MB

想了解更多关于构建时密文, 请参阅

使用 Docker 密文

如果你正在使用来管理密文。

初始化 Docker Swarm 模式:

$ docker swarm init

创建 docker 密文:

echo "supersecretpassword" | docker secret create postgres_password -
qdqmbpizeef0lfhyttxqfbty0

$ docker secret ls
ID                          NAME                DRIVER    CREATED         UPDATED
qdqmbpizeef0lfhyttxqfbty0   postgres_password             4 seconds ago   4 seconds ago

当容器被授予访问上述密文的权限时,它将挂载在 /run/secrets/postgres_password。此文件将以明文形式包含密文。

使用不同的工具:

  1. AWS EKS:
  2. DigitalOcean Kubernetes:
  3. Google Kubernetes Engine:
  4. Nomad:

使用 .dockerignore 文件

我们已经好几次提到了使用 .dockerignore 文件。此文件用于排除某些文件和文件夹。换句话说,你可以使用它来定义所需的构建上下文。

构建 Docker 镜像时,整个 Docker 上下文(如项目的根目录)将在计算 COPYADD 命令之前发送到 Docker 守护进程。这可能非常昂贵,尤其是当你的项目中有许多依赖项、大型数据文件或构建脚手架时。另外,Docker CLI 和守护进程可能不在同一台计算机上。因此,如果守护进程在远程机器上执行,你应该更加注意构建上下文的大小。

你应该向 .dockerignore 中添加什么文件?

  • 临时文件和文件夹
  • 构建日志
  • 本地密文
  • 本地开发文件,如 docker-compose.yml
  • 版本控制文件夹,如:".git", ".hg", and ".svn"

下面是一个例子:

**/.git
**/.gitignore
**/.vscode
**/coverage
**/.env
**/.aws
**/.ssh
Dockerfile
README.md
docker-compose.yml
**/.DS_Store
**/venv
**/env

总之,一份合理的 .dockerignore 文件有助于:

  • 减少 Docker 镜像的大小
  • 加速构建过程
  • 防止不必要的缓存失效
  • 防止密文泄露

检查并扫描 Dockerfiles 和镜像

Linting 是检查源代码是否存在编程和风格错误以及可能导致潜在缺陷的不良做法的过程。与编程语言一样,静态文件也可以被删除。特别是对于 DockerFile,linter 可以帮助我们确保它是可维护的,避免不推荐的语法,并遵循最佳实践。对镜像进行 Linting 应该是 CI 管道的标准部分。

是最流行的 Dockerfile linter:

$ hadolint Dockerfile

Dockerfile:1 DL3006 warning: Always tag the version of an image explicitly
Dockerfile:7 DL3042 warning: Avoid the use of cache directory with pip. Use `pip install --no-cache-dir <package>`
Dockerfile:9 DL3059 info: Multiple consecutive `RUN` instructions. Consider consolidation.
Dockerfile:17 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

你可以在查看更多信息。另外,VS Code也有一个 Hadolint 插件。

你可以将 linting DockerFile 与扫描镜像和容器的漏洞结合起来。

一些选择:

  • 是 Docker 本机漏洞扫描的独家提供商。你可以使用 docker scan CLI 命令扫描镜像。
  • 可用于扫描容器镜像、文件系统、git 存储库和其他配置文件。
  • 是一个开源项目,用于静态分析应用程序容器中的漏洞。
  • 是一个开源项目,为容器镜像的检查、分析和认证提供集中服务。

总之,建议对 DockerFile 和镜像进行 lint 和扫描,以便发现偏离最佳实践的潜在问题。

镜像签名与验签

你怎么知道运行生产代码的镜像没被篡改?

篡改可以通过中间人(man-in-the-middle)攻击通过网络实现,也可以通过完全泄露的注册表实现。

Docker Content Trust(DCT)支持对远程注册表中的 Docker 镜像进行签名和验证。

要验证镜像的完整性和真实性,请设置以下环境变量:

DOCKER_CONTENT_TRUST=1

现在,如果你尝试提取尚未签名的镜像,你将收到以下错误:

Error: remote trust data does not exist for docker.io/namespace/unsigned-image:
notary.docker.io does not have trust data for docker.io/namespace/unsigned-image

你可以从 了解有关签名镜像的信息。

从 Docker Hub 下载镜像时,请确保使用。

额外福利

使用虚拟环境

你应该在容器中使用虚拟环境吗?

在大多数情况下,只要你坚持每个容器只运行一个进程,就不需要虚拟环境。由于容器本身提供隔离,因此可以在系统范围内安装包。也就是说,你可能希望在多阶段构建中使用虚拟环境,而不是构建轮子文件(wheel files)。

  • 不使用虚拟环境
# temp stage
FROM python:3.9-slim as builder

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc


COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt


# final stage
FROM python:3.9-slim

WORKDIR /app

COPY --from=builder /app/wheels /wheels
COPY --from=builder /app/requirements.txt .

RUN pip install --no-cache /wheels/*
  • 使用虚拟环境
# temp stage
FROM python:3.9-slim as builder

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc


RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install -r requirements.txt


# final stage
FROM python:3.9-slim

COPY --from=builder /opt/venv /opt/venv

WORKDIR /app

ENV PATH="/opt/venv/bin:$PATH"

设置内存和 CPU 限制

限制 Docker 容器的内存使用是一个好主意,尤其是在一台机器上运行多个容器时。这可以防止任何容器使用所有可用内存,从而限制其余容器。

限制内存使用的最简单方法是在 Docker 中使用 --memory--cpu 参数选项:

$ docker run --cpus=2 -m 512m nginx

上面的命令将容器的使用限制为 2 个 CPU 和 512 MB 的主内存。

你可以在 Docker Compose 文件中执行相同的操作,如下所示:

version: "3.9"
services:
  redis:
    image: redis:alpine
    deploy:
      resources:
        limits:
          cpus: 2
          memory: 512M
        reservations:
          cpus: 1
          memory: 256M

注意 reservations 字段。它用于设置一个软限制,当主机内存或 CPU 资源不足时,该限制将优先考虑。

更多资源:

打日志到 stdout 或者 stderr

在 Docker 容器中运行的应用程序应该将日志消息写入标准输出(stdout)和标准错误(stderr),而不是文件。

然后,你可以配置 Docker 守护进程将日志消息发送到集中式日志解决方案()。

有关更多信息,请查看 12 因素应用程序中的将日志视为事件流,并从 Docker 文档中配置日志驱动程序。

更多有关信息,请查看。

为 Gunicorn 心跳使用共享内存装载

Gunicorn 使用基于文件的心跳系统来确保所有子工作进程都处于活动状态。 在大多数情况下,心跳文件位于 /tmp 中,它通常通过 存储在内存中。由于 Docker 默认情况下不使用 tmpfs,因此文件将存储在磁盘备份的文件系统中。这可能会导致,比如由于心跳系统使用 os.fchmod 而导致的随机冻结。如果目录实际上位于磁盘备份的文件系统上,它可能会阻止工作进程。

幸运的是,有一个简单的修复方法:通过 --worker-tmp-dir 参数将心跳目录更改为内存映射目录:

gunicorn --worker-tmp-dir /dev/shm config.wsgi -b 0.0.0.0:8000

总结

本文介绍了几种使 DockerFile 和镜像更干净、更精简、更安全的最佳实践。

更多资源: