Python开发者的Docker最佳实践举例(附代码)

1,170 阅读21分钟

本文探讨了在编写Dockerfiles和使用Docker时应遵循的一些最佳实践。虽然所列举的大多数做法适用于所有的开发人员,无论使用何种语言,但有一些做法只适用于那些开发基于Python的应用程序。

使用多阶段构建

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

多阶段Docker构建允许你将你的Docker文件分成几个阶段。例如,你可以有一个阶段用于编译和构建你的应用程序,然后可以复制到后续阶段。由于只有最后一个阶段用于创建镜像,与构建你的应用程序相关的依赖和工具就会被丢弃,从而留下一个精简的、模块化的可生产的镜像。

网络开发的例子:

# 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/*

在这个例子中,GCC编译器在安装某些Python包时是必需的,所以我们添加了一个临时的、构建时的阶段来处理构建阶段。由于最终的运行时镜像不包含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构建环境和最终镜像中,从而帮助防止不必要的缓存失效。更多信息请见这里。

因此,在上面的Docker文件中,你应该把COPY sample.py . 命令移到底部:

FROM python:3.9-slim

WORKDIR /app

COPY requirements.txt .

RUN pip install -r /requirements.txt

COPY sample.py .

注意:

  1. 在Docker文件中,总是把可能发生变化的层放在尽可能低的位置。
  2. 合并RUN apt-get updateRUN apt-get install 命令。(这也有助于减少图像的大小。我们很快就会谈到这一点)。
  3. 如果你想关闭特定Docker构建的缓存,请添加--no-cache=True 标志。

使用小型Docker基础镜像

较小的Docker镜像更具模块化和安全性。

构建、推送和提取镜像的速度较小。它们也更安全,因为它们只包括运行应用程序所需的必要库和系统依赖。

你应该使用哪个Docker基础镜像?

不幸的是,这取决于。

下面是各种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的基础镜像,请参考《适合你的Python应用的最佳Docker基础镜像》和《使用Alpine会使Python Docker构建速度慢50倍》。

归根结底,这就是平衡问题。如果有疑问,就从*-slim ,特别是在开发模式下,因为你正在构建你的应用程序。你想避免在添加新的Python包时不得不不断地更新Docker文件以安装必要的系统级依赖。当你为生产强化你的应用程序和Docker文件时,你可能想探索使用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
      58 seconds ago       RUN /bin/sh -c pip install -r requirements.t…   35.5MB    buildkit.dockerfile.v0
      About a minute ago   COPY requirements.txt . # buildkit              58B       buildkit.dockerfile.v0
      About a minute ago   WORKDIR /app
...

请注意这些尺寸。只有RUN,COPY, 和ADD 命令增加了图像的大小。你可以尽可能地通过合并命令来减少图像的大小。比如说。

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

可以合并成一个RUN 命令:

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

这样,创建一个单层而不是两个,从而减少了最终图像的大小。

虽然减少图层的数量是个好主意,但更重要的是这本身不是一个目标,而是减少图像大小和构建时间的一个副作用。换句话说,要更多地关注前面三种做法--多阶段构建、Dockerfile命令的顺序和使用小的基础镜像--而不是试图优化每一条命令。

注释:

  1. RUN,COPY, 和ADD 各自创建层。
  2. 每一层都包含与前一层的差异。
  3. 分层会增加最终镜像的大小。

提示:

  1. 合并相关的命令。
  2. 在创建这些文件的同一RUNstep 中删除不必要的文件。
  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身份运行容器进程。然而,这是一个糟糕的做法,因为在容器内以root身份运行的进程在Docker主机中也是以root身份运行。因此,如果攻击者获得了对容器的访问权,他们就可以获得所有的root权限,并可以对Docker主机进行一些攻击,例如

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

为了防止这种情况,确保以非root用户运行容器进程。

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

USER app

你可以更进一步,删除shell权限,并确保没有主目录的存在。

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权限运行的。请务必查看以非根用户身份运行Docker守护进程,以获得以非根用户身份运行守护进程和容器的帮助。

优先选择COPY而不是ADD

使用COPY ,除非你确定你需要ADD 所带来的额外功能。

COPYADD 有什么区别?

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

ADD  
COPY  

虽然它们看起来有相同的目的,但ADD 有一些额外的功能。

  • COPY 用于将本地文件或目录从Docker主机复制到镜像中。
  • ADD 可以用于同样的事情,也可以用于下载外部文件。另外,如果你使用压缩文件(tar、gzip、bzip2等)作为 参数, 会自动将内容解压到指定位置。 ADD
# copy local files on the host  to the destination
COPY /source/path  /destination/path
ADD /source/path  /destination/path

# download external file and copy to the destination
ADD http://external.file/url  /destination/path

# copy and extract local compresses files
ADD source.file.tar.gz /destination/path

缓存Python包到Docker主机上

当需求文件被改变时,镜像需要被重建以安装新的包。先前的步骤将被缓存,正如在最小化层数中提到的。在重建映像时下载所有的包会导致大量的网络活动,并且需要大量的时间。每次重建都要占用相同的时间来下载不同构建中的通用包。

你可以通过将pip缓存目录映射到主机上的一个目录来避免这种情况。所以对于每次重建,缓存的版本会持续存在,可以提高构建速度。

将卷添加到docker运行中,作为-v $HOME/.cache/pip-docker/:/root/.cache/pip ,或作为Docker Compose文件中的映射。

上面介绍的目录只供参考。确保你映射的是缓存目录,而不是site-packages(构建的包所处的位置)。

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

如果你利用Docker BuildKit,使用BuildKit缓存挂载来管理缓存。

# syntax = docker/dockerfile:1.2

...

COPY requirements.txt .

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

...

每个容器只运行一个进程

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

让我们假设你的应用程序栈由两个Web服务器和一个数据库组成。虽然你可以很容易地从一个容器中运行所有三个,但你应该在一个单独的容器中运行每个服务,以使其更容易重复使用和扩展每个单独的服务。

  1. 扩展性--由于每个服务都在一个单独的容器中,你可以根据需要水平地扩展你的一个网络服务器,以处理更多的流量。
  2. 可重用性- 也许你有另一个服务需要一个容器化的数据库。你可以简单地重复使用同一个数据库容器,而不需要带着两个不必要的服务。
  3. 日志- 耦合容器使得日志更加复杂。我们将在本文后面进一步详细讨论这个问题。
  4. 可移植性和可预测性--当工作的表面积较小时,制作安全补丁或调试问题要容易得多。

倾向于数组而不是字符串语法

你可以用数组(exec)或字符串(shell)两种格式在你的Dockerfiles中编写CMDENTRYPOINT 命令。

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

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

两者都是正确的,并且实现了几乎相同的目标;但是,你应该尽可能地使用exec格式。来自Docker文档

  1. 确保你在你的Docker文件中使用CMDENTRYPOINT 的exec形式。
  2. 例如,使用["program", "arg1", "arg2"] ,而不是"program 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

另一个注意事项是,shell格式携带的是shell的PID,而不是进程本身。

# array format
[email protected]:/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
[email protected]:/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

理解ENTRYPOINT和CMD之间的区别

我应该使用ENTRYPOINT还是CMD来运行容器进程?

有两种方法可以在容器中运行命令。

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

# and

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

两者本质上都是做同样的事情:用Gunicorn服务器在config.wsgi ,并将其绑定到0.0.0.0:8000

CMD 很容易被覆盖。如果你运行docker runuvicorn config.asgi ,上面的CMD会被新的参数所取代--例如,uvicorn config.asgi 。而要覆盖ENTRYPOINT 命令,必须指定--entrypoint 选项。

docker run --entrypoint uvicorn config.asgi 

在这里,很明显,我们正在覆盖入口点。所以,建议使用ENTRYPOINT 而不是CMD ,以防止意外地覆盖命令。

它们也可以一起使用。

比如说:

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

当像这样一起使用时,启动容器所运行的命令是。

如上所述,CMD ,很容易被覆盖。因此,CMD 可以用来向ENTRYPOINT 命令传递参数。工作者的数量可以像这样轻松地改变。

这将用六个Gunicorn工作器启动容器,而不是四个。

包括一个HEALTHCHECK指令

使用HEALTHCHECK ,以确定在容器中运行的进程不仅是启动和运行的,而且是 "健康的"。

Docker提供了一个用于检查容器中运行的进程状态的API,它提供的信息比进程是否 "运行 "要多得多,因为 "运行 "包括 "已经启动并正在运行"、"仍在启动",甚至包括 "卡在某个无限循环错误状态"。你可以通过HEALTHCHECK指令与这个API进行交互。

例如,如果你正在为一个网络应用提供服务,你可以使用下面的指令来确定/ 端点是否已经启动并可以处理服务请求。

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

你可以更进一步,设置一个只用于健康检查的自定义端点,然后配置HEALTHCHECK ,对返回的数据进行测试。例如,如果端点返回 JSON 响应{"ping": "pong"} ,你可以指示HEALTHCHECK 来验证响应体。

下面是你如何使用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文件中添加健康检查。

选项:

  • test:要测试的命令。
  • interval:测试的时间间隔 -- 即每隔x 单位的时间进行测试。
  • timeout:等待响应的最长时间。
  • start_period:什么时候开始健康检查。它可以在容器准备好之前执行额外任务时使用,比如运行迁移。
  • retries:将测试指定为failed 前的最大重试次数。

如果你使用的是Docker Swarm以外的协调工具,即Kubernetes或AWS ECS,那么该工具很可能有自己的内部系统来处理健康检查。在添加HEALTHCHECK 指令之前,请参考特定工具的文档。

图像

版本Docker镜像

只要有可能,就避免使用latest 标签。

如果你依赖latest 标签(这不是一个真正的 "标签",因为它是在图像没有明确标记的情况下默认应用的),你就不能根据图像标签来判断你的代码正在运行哪个版本。这使得回滚具有挑战性,并且很容易被覆盖(无论是意外还是恶意的)。标签,就像你的基础设施和部署,应该是不可改变的。

无论你如何对待你的内部镜像,你都不应该对基本镜像使用latest 标签,因为你可能会在无意中部署一个新的版本,对生产进行破坏性的修改。

对于内部镜像,使用描述性的标签,以便更容易知道哪个版本的代码正在运行,处理回滚,并避免命名的冲突。

例如,你可以使用以下描述符来组成一个标签。

  1. 时间戳
  2. Docker镜像ID
  3. Git提交哈希值
  4. 语义版本

关于更多的选择,请查看Stack Overflow问题 "正确地对Docker镜像进行版本管理 "中的这个答案

比如说:

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

在这里,我们用以下内容来构成标签。

  1. 项目名称。web
  2. 环境名称。prod
  3. Git提交哈希值。a072c4e5d94b5a769225f621f08af3d4bf820a07
  4. 语义版本。0.1.4

选择一个标签方案并与之保持一致是至关重要的。由于提交哈希值使人们很容易将图像标签与代码联系起来,因此强烈建议将它们纳入你的标签方案。

不要在图片中存储机密信息

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

不要在Docker文件中添加明文秘密,特别是当你把镜像推送到像Docker Hub这样的公共注册中心时。

FROM python:3.9-slim

ENV DATABASE_PASSWORD "SuperSecretSauce"

相反,它们应该通过以下方式注入。

  1. 环境变量(在运行时)
  2. 构建时参数(在构建时)
  3. 协调工具,如Docker Swarm(通过Docker secrets)或Kubernetes(通过Kubernetes secrets)。

另外,你可以通过在*.dockerignore*文件中添加常见的秘密文件和文件夹来帮助防止秘密泄露。

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

# BAD
COPY . .

# GOOD
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

这是最直接的秘密管理方法。虽然它不是最安全的,但它会让诚实的人保持诚实,因为它提供了一个薄薄的保护层,有助于使秘密不被好奇的游荡的眼睛发现。

使用共享卷传递秘密是一个更好的解决方案,但它们应该被加密,通过VaultAWS密钥管理服务(KMS),因为它们被保存到磁盘。

你可以在构建时使用构建参数来传递秘密,但它们对于那些通过docker history 来访问镜像的人来说是可见的。

例子:

FROM python:3.9-slim

ARG DATABASE_PASSWORD

构建:

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

如果你只需要临时使用秘密作为构建的一部分 -- 即用于克隆私有 repo 或下载私有软件包的 SSH 密钥 -- 你应该使用多阶段构建,因为构建者历史在临时阶段是被忽略的。

# temp stage
FROM python:3.9-slim as builder

# secret
ARG SSH_PRIVATE_KEY

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

# use ssh key to clone repo
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


# final stage
FROM python:3.9-slim

WORKDIR /app

# copy the repository from the temp image
COPY --from=builder /your-repo /app/your-repo

# use the repo for something!

多阶段构建只保留最终镜像的历史。请记住,你可以把这个功能用于你的应用程序需要的永久秘密,比如数据库凭证。

你也可以使用Docker build中的新选项--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
      5 minutes ago   RUN /bin/sh -c cat /run/secrets/mysecret # b…   0B        buildkit.dockerfile.v0
      4 weeks ago     /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
      4 weeks ago     /bin/sh -c #(nop) ADD file:aad4290d27580cc1a…   5.6MB

关于构建时的秘密的更多信息,请查看Don't leak your Docker image's build secrets

Docker秘密

如果你正在使用Docker Swarm,你可以用Docker secrets来管理秘密。

例如,启动Docker Swarm模式。

创建一个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 。这个文件将包含明文的秘密的实际值。

使用不同的orhestration工具?

我们已经多次提到过使用.dockerignore文件。这个文件用来指定你不希望被添加到发送给Docker守护进程的初始构建环境中的文件和文件夹,然后Docker守护进程将构建你的镜像。换句话说,你可以用它来定义你需要的构建环境。

当一个Docker镜像被构建时,整个Docker上下文--即你的项目的根--评估COPYADD 命令之前被发送给Docker守护进程。这可能是相当昂贵的,尤其是当你的项目中有许多依赖关系、大型数据文件或构建工件时。此外,Docker CLI和守护程序可能不在同一台机器上。因此,如果守护进程是在远程机器上执行的,你就更应该注意构建环境的大小了。

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

  1. 临时文件和文件夹
  2. 构建日志
  3. 本地秘密
  4. 本地开发文件,如docker-compose.yml
  5. 版本控制文件夹,如".git"、".hg "和".svn"

举个例子:

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

总之,结构合理的*.dockerignore*可以帮助。

  1. 减少Docker镜像的大小
  2. 加快构建过程
  3. 防止不必要的缓存失效
  4. 防止泄密

提示和扫描你的Docker文件和镜像

提示是检查你的源代码是否存在程序和风格错误以及可能导致潜在缺陷的不良做法的过程。就像编程语言一样,静态文件也可以被刷新。具体到你的Docker文件,着色器可以帮助确保它们是可维护的,避免废弃的语法,并遵守最佳实践。给你的镜像打上标签应该是CI管道的一个标准部分。

Hadolint是最流行的Dockerfile连接器。

$ 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 `
Dockerfile:9 DL3059 info: Multiple consecutive `RUN` instructions. Consider consolidation.
Dockerfile:17 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

你可以在网上看到它的运行情况:https://hadolint.github.io/hadolint/。还有一个VS代码扩展

你可以把对Docker文件的润色与扫描镜像和容器的漏洞结合起来。

一些选择。

  1. Snyk是为Docker提供本地漏洞扫描的独家供应商。你可以使用docker scan CLI命令来扫描镜像。
  2. Trivy可以用来扫描容器镜像、文件系统、git存储库和其他配置文件。
  3. Clair是一个开源项目,用于对应用容器中的漏洞进行静态分析。
  4. Anchore是一个开源项目,为容器镜像的检查、分析和认证提供集中式服务。

总之,对你的Docker文件和镜像进行检查和扫描,以发现任何偏离最佳做法的潜在问题。

签署和验证图像

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

篡改可以通过中间人(MITM)攻击或注册表被完全破坏来实现。

Docker内容信任(DCT)可以对来自远程注册中心的Docker镜像进行签名和验证。

为了验证一个镜像的完整性和真实性,请设置以下环境变量。

现在,如果你试图拉出一个未经签名的镜像,你会收到以下错误。

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内容信任签署图像文档中了解签署图像的情况。

当从Docker Hub下载镜像时,确保使用官方镜像或来自可信来源的经过验证的镜像。规模较大的团队应该考虑使用他们自己的内部私有容器注册表

额外提示

使用Python虚拟环境

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

在大多数情况下,只要你坚持在每个容器中只运行一个进程,虚拟环境就是不必要的。因为容器本身提供了隔离,所以包可以在整个系统内安装。也就是说,你可能想在多阶段构建中使用虚拟环境,而不是构建轮子文件。

使用轮子的例子:

# 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/*

使用virtualenv的例子:

# 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 cli中使用--memory--cpu 选项。

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

上述命令将容器的使用限制在2个CPU和512兆字节的主内存。

你可以像这样在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守护进程,将你的日志信息发送到一个集中的日志解决方案(如CloudWatch LogsPapertrail)。

Gunicorn使用一个基于文件的心跳系统来确保所有分叉的工作进程都是活的。

在大多数情况下,心跳文件是在"/tmp "中找到的,它通常通过tmpfs在内存中。由于Docker默认不利用tmpfs,这些文件将被存储在磁盘支持的文件系统中。这可能会导致一些问题,比如随机冻结,因为心跳系统使用os.fchmod ,如果该目录实际上是在磁盘支持的文件系统上,可能会阻塞worker。

幸运的是,有一个简单的解决办法。通过--worker-tmp-dir 标志将心跳目录改为内存映射的目录。

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

总结

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