关于运维:制作容器镜像的最佳实践

45次阅读

共计 9632 个字符,预计需要花费 25 分钟才能阅读完成。

概述

这篇文章次要是我日常工作中的制作镜像的实际, 同时联合我学习到的对于镜像制作的相干文章总结进去的. 包含通用的容器最佳实际, 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 插件举荐

  • PyCharm – 同 Idea
  • VSCode – Visual Studio Code Remote – Containers 插件

    • Developing inside a Container

倡议配置的环境变量

# 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

参考链接

  • Docker documentation – Best practices for writing Dockerfiles
  • “Docker 和 PID 1 zombie reaping 问题”
  • “揭开 init 零碎(PID 1)的神秘面纱”
  • Blog article – Resource management in Docker
  • Docker documentation – Runtime Metrics
  • Blog article – Memory inside Linux containers
  • Docker documentation – Docker basics
  • Docker documentation – Dockerfile reference
  • Docker documentation – 自定义元数据。
  • testdriven.io – Deploying Django to Heroku With Docker
  • testdriven.io – Dockerizing Django with Postgres, Gunicorn, and Nginx
  • dockercon-2018 – Docker for Python Developers
  • Docker documentation – 多阶段构建
  • Red Hat Developer – OpenJDK and Containers
  • Java Application Optimization on Kubernetes on the Example of a Spring Boot Microservice
  • Python Speed – Faster Docker builds with pipenv, poetry, or pip-tools
  • Python Speed – Configuring Gunicorn for Docker
  • Docker documentation – Docker and Eclipse
  • Docker documentation – Docker and IntelliJ IDEA
  • Developing inside a Container

2 个问题

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

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

正文完
 0