制作容器镜像的最佳实践

发布时间 2023-03-22 19:09:23作者: 东风微鸣

概述

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

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

  1. 通用容器镜像最佳实践
  2. Java 容器镜像最佳实践
  3. NGINX 容器镜像最佳实践
  4. 以及 Python 容器最佳实践

通用容器镜像最佳实践

使用 LABEL maintainer

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

LABEL maintainer="cuikaidong@foxmail.com"

复用镜像

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

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

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

保持标签 TAGS 的兼容性

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

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

避免多个进程

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

在封装脚本中使用 EXEC 指令

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

清除临时文件

应删除在生成过程中创建的所有临时文件。这还包括使用 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

? 备注:

  1. RUNCOPYADD 步骤会创建镜像层。
  2. 每个层包含与前一层的差异项。
  3. 镜像层会增加最终镜像的大小。

? 提示:

  1. 将相关命令(apt-get install)放入同一 RUN 步骤。
  2. 在同一 RUN 步骤中删除创建的文件。
  3. 避免使用 apt-get upgradeyum upgrade all ,因为它会把所有包升级到最新版本.

按正确的顺序放置指令

容器构建过程中, 读取 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 操作。

标记重要端口

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

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

设置环境变量

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

避免默认密码

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

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

禁用SSHD

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

将 VOLUMES(卷) 用于持久数据

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

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

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

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

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

❗ 注意:

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

使用多阶段构建

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

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

一个用例是使用非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

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

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

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

以上信息可以通过:

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

避免将文件放入 /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

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

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

收益有:

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

示例如下:

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)。问题有: 日期时间格式的错误, 由于堆栈较小导致的崩溃等等。

使用 .dockerignore 排除无关文件

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

不要安装不必要的包

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

解耦应用程序

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

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

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

对多行参数进行排序

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

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

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

JAVA 容器镜像最佳实践

IDE插件推荐

  • idea - 转到“首选项”、“插件”、“安装JetBrains插件…”,搜索“Docker”并单击“安装”
  • Eclipse

? 备注:

Docker and IntelliJ IDEA

Docker and Eclipse

设置内存限制相关参数

? 备注:

指定 -Xmx=1g 将告诉 JVM 分配一个 1 GB 堆, 但是它并没有告诉 JVM 将其整个内存使用量限制为 1 GB。除了对内存, 还会有 card tables、code cache 和各种其他堆外数据结构。用于指定总内存使用量的参数是 -XX:MaxRAM。请注意,使用 -XX:MaxRam=500m 时,堆将大约为 250 MB。

JVM 在历史上查找/proc以确定有多少可用内存,然后根据该值设置其堆大小。不幸的是,像 docker 这样的容器在/proc中不提供特定于容器的信息。2017年之后有一个补丁,提供了一个 -XX:+UseCGroupMemoryLimitForHeap命令行参数,它告诉 jvm 查找 /sys/fs/cgroup/memory/memory.limit_in_bytes,以确定有多少可用内存。如果这个补丁在运行的 OpenJDK 版本中不可用,可以通过显式设置 -XX:MaxRAM=n 来代替。

总结, 设置内存限制相关参数:

  1. Openjdk 8 的新版本, 添加: -XX:+UseCGroupMemoryLimitForHeap
  2. 如果没有上边的参数, 设置:-XX:MaxRAM=n
  3. 建议设置 JVM Heap 约为 memory limit 的 50% - 80%
  4. 建议设置 JVM MaxRAM 接近 K8s pod 的 memory limit

设置GC策略

OpenJDK8 中有一个补丁,它将使用 cgroup 可用的信息来计算适当数量的并行 GC 线程。但是,如果这个补丁在您用的 OpenJDK 版本中不可用,假设您的容器宿主机有 8 个 CPU, 但是容器中 CPU limit 为 2 个 CPU, 那么您最终可能会得到 8 个并行 GC 线程。解决方法是显式指定并行GC线程的数量: -XX:ParallelGCThreads=2

如果您的容器中 cpu limit 设置为只有一个 CPU,强烈建议使用 -XX:+UseSerialGC 运行,来完全避免并行GC。

JAVA 启动阶段调优

JAVA 程序都有一个启动阶段,它需要大量的堆,之后可能会进入一个安静的循环阶段,在这个阶段它就不需要太多的堆。

对于串行 GC 策略, 您可以通过配置使它更具侵略性, 如: -XX:MinHeapFreeRatio=20(当堆占用率大于 80%,此值默认增大。)

XX:MaxHeapFreeRatio=40(堆占用率小于60%时收缩)

对于并行 - parallel GC策略, 推荐如下配置:

-XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90

JAVA 容器全局建议资源请求和资源限制

JAVA 程序都有一个启动阶段,启动阶段也会大量消耗 CPU, CPU 使用越多, 启动阶段越短.
下面是一个表,总结了不同CPU限制下的 spring boot 示例应用启动时间(CPU 以 millicore 为单位):

  • 500m - 80 seconds
  • 1000m - 35 seconds
  • 1500m - 22 seconds
  • 2500m - 17 seconds
  • 3000m - 12 seconds

根据以上情况, K8s 或 TKE 容器平台管理员可以考虑对 JAVA 容器做如下限制:

  • 使用CPU requests, 不设置 cpu limit
  • 使用 memory limit 且等于 memory request

示例如下:

resources:
  requests:
    memory: "1024Mi"
    cpu: "500m"
  limits:
    memory: "1024Mi"

使用 ExitOnOutOfMemoryError 而非 HeapDumpOnOutOfMemoryError (谨慎评估)

我们都知道, 在传统的虚拟机上部署的 Java 实例. 为了更好地分析问题, 一般都是要加上: -XX:+HeapDumpOnOutOfMemoryError这个参数的, 加这个参数后, 如果遇到内存溢出, 就会自动生成 HeapDump , 后面我们可以拿到这个 HeapDump 来更精确地分析问题.

但是, 容器技术的应用, 带来了一些不同, 在使用容器平台后, 我们更倾向于:

  1. 遇到故障快速失败
  2. 遇到故障快速恢复
  3. 尽量做到用户对故障"无感知"

所以, 针对 Java 应用容器, 我们也要优化以满足这种需求, 以 OutOfMemoryError 故障为例:

  1. 遇到故障快速失败, 即尽可能"快速退出, 快速终结"

-XX:+ExitOnOutOfMemoryError 就正好满足这种需求:

传递此参数时,抛出 OutOfMemoryError 时 JVM 将立即退出。 如果您想尽快终止异常应用程序,则可以传递此参数。

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;

Python 容器镜像最佳实践

?Warning:
随着时间的迁移, 以及实践的深入, 最佳实践也在发生着变化, 以下部分内容已经不能作为 Python 容器镜像的最佳实践.
最新的 Python 容器镜像最佳实践可以参见这篇文章: https://EWhisper.cn/posts/25776/

示例如下:

# 基于官方基础镜像
FROM python:3.7-alpine

# 设置工作目录
WORKDIR /app

# 设置环境变量
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV DEBUG 0

# install psycopg2
RUN apk update \
    && apk add --virtual build-deps gcc musl-dev python3-dev \
    && apk add postgresql-dev \
    && pip install psycopg2 \
    && apk del build-deps

# install dependencies
COPY ./requirements.txt .
RUN pip install -r requirements.txt

# copy project
COPY . .

# 切换到非 root 用户
RUN adduser -D myuser
USER myuser

# run gunicorn
CMD gunicorn hello_django.wsgi:application --bind 0.0.0.0:$PORT

△ 示例 Dockerfile

IDE插件推荐

建议配置的环境变量

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV DEBUG 0
  1. PYTHONDONTWRITEBYTECODE: 防止 python 将 pyc 文件写入硬盘
  2. PYTHONUNBUFFERED: 防止 python 缓冲 stdout 和 stderr
  3. DEBUG: 方便根据环境类型的不同(测试/生产)调整是否开启debug

安装数据库驱动包的方法

以 postgredb 的驱动 psycopg2 为例, 可能需要安装额外的基础组件:

# install psycopg2
RUN apk update \
    && apk add --virtual build-deps gcc musl-dev python3-dev \
    && apk add postgresql-dev \
    && pip install psycopg2 \
    && apk del build-deps

参考链接

2个问题

  1. 您是否有制作其他语言镜像的最佳实践呢?
  2. 您是否尝试通过 GraalVM 制作 原生可执行 Java 镜像? 体验如何?

三人行, 必有我师; 知识共享, 天下为公. 本文由东风微鸣技术博客 EWhisper.cn 编写.