datetime:2023/04/04 19:48
author:nzb

制作容器镜像的最佳实践

1、概述

这篇文章主要是我日常工作中的制作镜像的实践, 同时结合我学习到的关于镜像制作的相关文章总结出来的. 包括通用的容器最佳实践, nginx, python 容器最佳实践. 最佳实践的目的一方面保证镜像是可复用的, 提升 DevOps 效率, 另一方面是为了提高安全性. 希望对各位有所帮助.

本文分为四部分内容, 分别是:

  • 通用容器镜像最佳实践
  • NGINX 容器镜像最佳实践
  • 以及 Python 容器最佳实践

2、通用容器镜像最佳实践

2.1、使用 LABEL maintainer

LABEL maintainer 指令设置镜像的作者姓名和邮箱字段。示例如下:

LABEL maintainer="cuikaidong@foxmail.com"

2.2、复用镜像

建议尽量使用 FROM 语句复用合适的上游镜像。这可确保镜像在更新时可以轻松从上游镜像中获取安全补丁,而不必直接更新依赖项。

此外,在 FROM 指令中使用标签 tag(例如 alpine:3.13),使用户能够清楚地了解镜像所基于的上游镜像版本。

❗ 禁止 使用 latest tag 以确保镜像不会受到 latest 上游镜像版本的重大更改的影响。

2.3、保持标签 TAGS 的兼容性

给自己的镜像打标签时,注意保持向后兼容性。例如,如果制作了一个名为 example 的镜像,并且它当前为 1.0 版,那么可以提供一个 example:1 标签。 后续要更新镜像时,只要它继续与原始镜像兼容,就可以继续标记新镜像为example:1,并且该 tag 的下游消费者将能够在不中断的情况下获得更新。

如果后续发布了不兼容的更新,那么应该切换到一个新 tag,例如 example:2。那么下游消费者可以按照自身实际情况升级到新版本,而不会因为新的不兼容镜像而造成事故。 但是任何使用 example:latest 的下游消费者都会承担引入不兼容更改的风险, 所以这也是前面我强烈建议不要使用 latest tag 的原因.

2.4、避免多个进程

建议 不要 在一个容器内启动多个服务,例如 nginx 和 后端 app。因为容器是轻量级的,可以很容易地通过 Docker Compose 或 Kubernetes 链接在一起。 Kubernetes 或基于此的 TKE 容器平台通过将相关镜像调度到单个 pod 中,轻松地对它们进行集中管理。

2.5、在封装脚本中使用 EXEC 指令

许多镜像会通过在启动应用程序之前使用封装脚本进行一些设置。如果您的镜像使用这样的脚本,那么该脚本最后应该使用 exec 启动应用程序, 以便用应用程序的进程替换该脚本的进程。如果不使用 exec ,那么容器运行时发送的信号(比如 TERMSIGKILL)将转到封装脚本,而不是应用程序的进程。这不是我们所期望的。

2.6、清除临时文件

应删除在生成过程中创建的所有临时文件 。这还包括使用 ADD 指令添加的任何文件。例如,👍 我们强烈建议您在执行apt-get install 操作之后运行 rm -rf /var/lib/apt/lists/* 命令。

通过如下创建 RUN 语句,可以防止 apt-get 缓存存储在镜像层中:

RUN apt-get update && apt-get install -y \
    curl \
    s3cmd=1.1.* \
 && rm -rf /var/lib/apt/lists/*

请注意,如果您改为:

RUN apt-get install curl -y
RUN apt-get install s3cmd -y && rm -rf /var/lib/apt/lists/*

那么,第一个 apt-get 调用会在这一层 (image layer) 中留下额外的文件,并且在稍后运行rm -rf ... 操作时,无法删除这些文件。额外的文件在最终镜像中不可见,但它们会占用空间。

另外,在一条 RUN 语句中执行多个命令可以减少镜像中的层数,从而缩短下载和安装时间。

yum 的例子如下:

RUN yum -y install curl && yum -y install s3cmd && yum clean all -y

备注:

  • RUNCOPYADD 步骤会创建镜像层。

  • 每个层包含与前一层的差异项。

  • 镜像层会增加最终镜像的大小。

📓 提示:

  • 将相关命令(apt-get install)放入同一 RUN 步骤。

  • 在同一 RUN 步骤中删除创建的文件。

  • 避免使用 apt-get upgradeyum upgrade all ,因为它会把所有包升级到最新版本

2.7、按正确的顺序放置指令

容器构建过程中, 读取 dockerfile 并从上到下运行指令。成功执行的每一条指令都会创建一个层,在下次构建此镜像或另一个镜像时可以重用该层。建议在 Dockerfile 的顶部放置很少更改的指令。这样做可以确保同一镜像的下一次构建速度非常快,因为上层更改的缓存还在, 可以复用。

例如,如果正在处理一个 dockerfile,其中包含一个用于安装正在迭代的文件的 ADD 指令,以及一个用于 apt-get install 包的 RUN 指令,那么最好将 ADD 命令放在最后:

FROM alpine:3.11
RUN apt-get -y install curl && rm -rf /var/lib/apt/lists/*
ADD app /app

这样,每次编辑 app 并重新运行 docker build 时,系统都会为 apt-get 命令复用缓存层,并且只为 ADD 操作生成新层。

如果反过来, dockerfile 如下:

FROM alpine:3.11
ADD app /app
RUN apt-get -y install curl && rm -rf /var/lib/apt/lists/*

那么,每次更改 app 然后再次运行 docker build 时,ADD 操作都会使镜像层的缓存失效,因此必须重新运行 apt-get 操作。

2.8、标记重要端口

EXPOSE 指令使容器中的端口对主机系统和其他容器可用。虽然可以指定使用 docker run -p 调用公开端口,但在dockerfile 中使用 EXPOSE 指令可以通过显式声明应用程序需要运行的端口,使人和应用程序更容易使用您的镜像:

  • 暴露的端口将显示在 docker ps 下。
  • docker inspect 返回的镜像的元数据中也会显示暴露的端口。
  • 当将一个容器链接到另一个容器时,会链接暴露的端口。

2.9、设置环境变量

👍️ 使用 ENV 指令设置环境变量是很好的实践。一个例子是设置项目的版本。这使得人们在不查看 dockerfile 的情况下很容易找到版本。 另一个例子是在公布一条可以被另一个进程使用的路径,比如 JAVA_HOME.

2.10、避免默认密码

❗ 最好 避免设置默认密码 。许多人会扩展基础镜像,但是忘记删除或更改默认密码。如果为生产中的用户分配了一个众所周知的密码,这可能会导致安全问题。 👍️ 应该使用环境变量, secret 或其他 K8s 加密方案来配置密码

如果确实选择设置默认密码,请确保在容器启动时显示适当的警告消息。消息应该通知用户默认密码的值,并说明如何更改,例如设置什么环境变量。

2.11、禁用 SSHD

❗ 禁止在镜像中运行 sshd。可以使用 docker exec 命令访问本地主机上运行的容器。或者,可以使用 kubectl exec命令来访问在 K8sTKE 容器平台上运行的容器。 在镜像中安装和运行 sshd 会遭受潜在攻击, 需要额外的安全补丁修复。

2.12、将 VOLUMES(卷) 用于持久数据

镜像应使用卷来存储持久数据。这样,KubernetesTKE 将网络存储挂载到运行容器的节点,如果容器移动到新节点,则存储将重新连接到该节点。 通过将卷用于所有持久化存储的需求,即使重新启动或移动容器,也会保留持久化内容。如果镜像将数据写入容器内的任意位置,则可能数据会丢失。

此外,在 Dockerfile 中显式定义卷使镜像的消费者很容易理解在运行镜像时必须定义哪些卷。

有关如何在 K8sTKE 容器平台中使用卷的更多信息,请参阅 Kubernetes documentation.

2.13、使用非 root 用户运行容器进程

默认情况下,Docker 用容器内部的 root 运行容器进程。这是一个不安全的做法,因为如果攻击者设法突破容器,他们可以获得对 Docker 宿主机的 root 权限。

❗ 注意:

如果容器中是 root,那么逃逸出来就是主机上的 root。

2.14、使用多阶段构建

多阶段构建指在Dockerfile中使用多个FROM语句,每个FROM指令都可以使用不同的基础镜像,并且是一个独立的子构建阶段。使用多阶段构建打包Java应用具有构建安全、构建速度快、镜像文件体积小等优点。

利用 多阶段构建 来创建一个用于构建工件的临时镜像,该工件将被复制到生产镜像上。临时构建镜像将与与该映像关联的原始文件、文件夹和依赖项一起丢弃。

这会产生了一个精益,生产就绪的镜像。

一个用例是使用非 Alpine 基础镜像来安装需要编译的依赖项。然后可以将 wheel 文件复制到最终镜像。

Python 示例如下:

# 临时阶段
FROM python:3.6 as base
COPY requirements.txt /
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels -r requirements.txt

# 最终阶段
FROM python:3.6-alpine
COPY --from=base /wheels /wheels
COPY --from=base requirements.txt .
RUN pip install --no-cache /wheels/* # flask, gunicorn, pycrypto
WORKDIR /app
COPY . /app

使用前大小: 705MB, 使用后大小: 103MB

2.15、❗ 禁止在容器中存储机密信息

禁止在容器中存储机密信息, 包括:

  • 敏感信息
  • 数据库凭据
  • ssh 密钥
  • 用户名和密码
  • api 令牌等

以上信息可以通过:

  • 环境变量 ENV 传递
  • 卷 VOLUME 挂载

2.16、避免将文件放入 /tmp

对于一些应用程序(如: python 的 gunicorn), 会将某些缓存信息或心跳检测信息写入 /tmp 中, 这对 /tmp 的读写性能有较高要求, 如果 /tmp 挂载的是普通磁盘, 可能导致严重的性能问题.

在某些 Linux 发行版中,/tmp 通过 tmpfs 文件系统存储在内存中。但是,Docker 容器默认情况下没有为 /tmp 打开 tmpfs

$ docker run --rm -it ubuntu:18.04 df
Filesystem       1K-blocks     Used Available Use% Mounted on
overlay           31263648 25656756   3995732  87% /
tmpfs                65536        0     65536   0% /dev
tmpfs              4026608        0   4026608   0% /sys/fs/cgroup
/dev/mapper/root  31263648 25656756   3995732  87% /etc/hosts
shm                  65536        0     65536   0% /dev/shm

如上所示,/tmp 正在使用标准的 Docker overlay 文件系统:它由普通的块设备或计算机正在使用的硬盘驱动器支持。这可能导致性能问题 .

针对这类应用程序, 通用的解决方案是将其临时文件存储在其他地方。特别是,如果你看上面你会看到 /dev/shm 使用 shm 文件系统共享内存和内存文件系统。所以你需要做的就是使用 /dev/shm 而不是 /tmp

2.17、使用 Alpine Linux 基础镜像 (谨慎采纳)

使用基于Alpine Linux 的镜像,因为它只提供必要的包, 生成的镜像更小。

收益有:

  • 减少了主机成本,因为使用的磁盘空间更少
  • 更快的构建、下载和运行时间
  • 更安全(因为包和库更少)
  • 更快的部署

示例如下:

FROM python:3.6-alpine
WORKDIR /app
COPY requirements.txt /
RUN pip install -r /requirements.txt  # flask and gunicorn
COPY . /app

使用前大小: 702MB, 使用后大小: 102MB

❗ 注意:

谨慎使用 alpine, 我看到过使用 Alpine Linux 产生的一大堆问题,因为它建立在 musl libc 之上,而不是大多数 Linux 发行版使用的 GNU libc(glibc)。 问题有: 日期时间格式的错误, 由于堆栈较小导致的崩溃等等。

2.18、使用 .dockerignore 排除无关文件

要排除与构建无关的文件,请使用 .dockerignore 文件。此文件支持与 .gitignore 文件类似的排除模式。具体请参阅 .dockerignore 文件。

2.19、不要安装不必要的包

为了降低复杂性,依赖性,文件大小和构建时间,请避免安装额外的或不必要的应用程序包。例如,不需要在数据库镜像中包含文本编辑器。

2.20、解耦应用程序

每个容器应该只有一个进程。将应用程序分离到多个容器中可以更容易地水平扩展和重用容器。例如,Web 应用程序堆栈 LNMP 可能包含三个独立的容器,每个容器都有自己独特的映像,以分离的方式管理 Web 服务器, 应用程序,缓存数据库和数据库。

将每个容器限制为一个进程是一个很好的经验法则,但它不是一个硬性规则。例如,可以 使用 init 进程生成 容器 ,另外某些程序可能会自行生成其他子进程 (如: nginx)。

根据自己的经验进行判断,尽可能保持容器简洁和模块化。如果容器彼此依赖,则可以使用 容器网络 或 K8s Sidecar 来确保这些容器可以进行通信。

2.21、对多行参数进行排序

建议通过按字母顺序排序多行参数来方便后续的更改。这有助于避免重复包并使列表更容易更新。这也使 PR 更容易阅读和审查。在反斜杠(\)之前添加空格也有帮助。

下面是来自一个示例 openjdk 图像:

...
  apt-get update; \
  apt-get install -y --no-install-recommends \
    dirmngr \
    gnupg \
    wget \
  ; \
  rm -rf /var/lib/apt/lists/*; \
...

3、NGINX 容器镜像最佳实践

如果您直接在基础硬件或虚拟机上运行 NGINX,通常需要一个 NGINX 实例来使用所有可用的 CPU。由于 NGINX 是多进程模式,通常你会启动多个 worker processes,每个工作进程都是不同的进程,以便利用所有 CPU。

但是,在容器中运行时,如果将 worker_processes 设置为 auto, 会根据容器所在宿主机的 CPU 核数启动相应进程数. 比如, 我之前在物理机上运行 NGINX 容器使用 auto 参数, 尽管 CPU limit 设置为 2, 但是 NGINX 会启动 64 (物理机 CPU 数) 个进程.

因此,👍️建议根据 实际需求或 CPU limit 的设置配置 nginx.conf, 如下:

worker_processes  2;

results matching ""

    No results matching ""