Docker镜像定制实践

本文简单总结了过去两年对容器化的一些经验和浅显思考,抛砖引玉,分享给大家。欢迎有相关思路或实践的同学沟通探讨。

镜像定制的方法论

Red Hat的云原生容器设计原则

  • 唯一关注性原则(Single concern principle · SCP)
    SCP类似于SOLID的单一职责原则(SRP),容器通常管理单个进程,并且大多数时候是通过该进程解决一个问题。如果容器化微服务需要解决多个关注点,则可以使用sidecar或init-containers等模式将多个容器组合成一个部署单元(Pod),其中每个容器仍然处理一个关注点。

  • 高度可观测性原则(High observability principle · HOP)
    容器提供了一种统一的方式来打包和运行应用程序。任何旨在成为云原生公民的容器都必须为运行时环境提供API,以观察容器的健康状况并触发相应行为。这是以统一方式自动化容器更新和生命周期的基本前提,同时也提高了系统的弹性和用户体验。实际上,容器化应用程序至少应提供存活和就绪状态检查的API。应用程序应将重要事件记录到标准错误(STDERR)和标准输出(STDOUT)中,以便通过Fluentd或Logstash等工具进行日志聚合,并可与OpenTracing、Prometheus等工具集成。

  • 生命周期一致性原则(Life-cycle conformance principle · LCP)
    应用程序可以读取来自管理平台的事件,并作出响应。来自管理平台的各种事件旨在管理容器的生命周期,应用程序将决定要处理哪些事件。

  • 镜像不可变更性原则(Image immutability principle · IIP)
    容器化应用程序是不可变的,不会在不同环境之间发生变化。使用外部方法来存储运行时数据,通过外部化配置区分不同环境,而非为每个环境创建或修改容器。容器化应用程序中的任何更改都应构建新的容器镜像并在所有环境中重新应用。

  • 进程可处置性原则(Process disposability principle · PDP)
    容器化应用程序必须保持其无状态、分布式和冗余。能够快速启动和关闭。

  • 自包含性原则(Self-containment principle · S-CP)
    容器应该包含它在构建时所需的一切。

  • 运行时约束性原则(Runtime confinement principle·RCP)
    容器应声明其资源需求并将该信息传递给管理平台,应用程序应保持在指定的资源需求范围内运行。

部分最佳实践

  • 精简镜像。尽可能小的容器镜像可以减少构建和网络传输时间。

  • 支持任意用户ID。避免使用sudo命令或要求特定用户ID来运行容器。

  • 声明重要端口。使用EXPOSE命令声明端口,将更易读和易维护。

  • 持久化卷。需要在容器销毁后依然保留的数据存储在持久化卷中。

  • 声明元数据。标签和注释等元数据可以使容器镜像更易用和易维护。

  • 宿主和镜像间同步。宿主属性同步到容器,如时间和机器id等。

  • 使用.dockerignore 文件。

  • 多阶段构建,压缩镜像大小。

  • 利用构建缓存,加速构建。

base镜像的定制

base镜像选型

系统名称 状态
BusyBox 非常轻量,Multi-Call binary
Alpine 轻量、基于musl和BusyBox,包含包管理器
distroless 及其轻量,不包含包管理器、shell
Photon Platform(VMware光子操作系统) 针对vSphere和云计算平台
RacncherOS 不再维护
CoreOS 不再维护
Atomic(红帽原子项目) 不再维护
  • 在轻量和易用性之间取折中,综合考虑社区活跃度,生态丰富度等多方面因素,选定Alpine作为base镜像进行定制。当然这不代表其它系统镜像有任何缺陷,适合的就是最好的,也可以选用其它类型的系统镜像。

定制优化

  • 追加必要的软件包。

    1
    ca-certificates tzdata sysstat procps lsof strace tcpdump paris-traceroute-ping vim bash rsync curl iproute2 alpine-base coreutils bind-tools tini shadow logrotate
    • 易用性和镜像大小之间取平衡。
  • 优化vim,定制vim配置,不建议安装扩展。

  • 声明时区。

    1
    2
    cp -v /usr/share/zoneinfo/Asia/Shanghai /etc/localtime;
    echo "Asia/Shanghai" > /etc/timezone;
  • 定制登录用户相关的环境变量。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    # alias
    echo "alias ll='ls -l --color=auto'" >> /etc/profile.d/alias.sh;
    echo "alias tailf='tail -f'" >> /etc/profile.d/alias.sh;
    # history
    echo "HISTSIZE=3000" >> /etc/profile.d/tty_rc.sh;
    echo "HISTFILESIZE=3000" >> /etc/profile.d/tty_rc.sh;
    echo 'HISTTIMEFORMAT="%Y-%m-%d %T "' >> /etc/profile.d/tty_rc.sh;
    echo -e "# .bashrc\nsource /etc/profile" >> ${HOME}/.bashrc;
    # tty
    echo 'PS1="[\u@\H:\w]\$ "' >> /etc/profile.d/tty_rc.sh;
    # bash
    sed -i 's/\/bin\/ash$/\/bin\/bash/g' /etc/passwd;
    unlink /bin/sh;
    ln -svf /bin/bash /bin/sh;
  • 使用tini管理进程。

    • 避免僵尸进程的产生;
    • 处理Docker进程信号;
    • 对于镜像完全透明。

是否需要进一步对镜像进行瘦身优化

最佳实践中提到了一条精简镜像,那么我们是否需要使用工具如docker-slim,对基础镜像甚至后面提到的应用镜像进行瘦身呢?在《Google系统架构解密》一书中提到了零接触生产(Zero Touch Production, ZTP)的概念。理论上我们确实不应人工接触生产环境。这是规范化和运维自动化的终极目标。ZTP可以显著降低故障率和安全风险。但是这对我们的代码质量、架构设计以及自动化能力提出了很高的要求。

结合实际,如果当前阶段我们达到了ZTP的要求,是可以对镜像进一步瘦身的,因为越精简越轻量意味着越安全、风险越小。但是如果尚未达到完全的ZTP阶段,经过实际的测试体验,删减后的镜像接近裸进程,是没有易用性可言的。本文成立的前提显然是在当前阶段尚未达到完全ZTP水平的场景。另外在开发、测试等非生产的容器环境场景,我们也会更多考虑易用性的问题。

golang应用镜像的定制

  • golang应用的编译构建和镜像打包阶段在Docker环境中执行,用完即删,以保证持续纯净的构建环境。

  • 将golang的缓存目录挂载到构建机的宿主目录下,复用缓存。

基于以上两点,考虑如何针对应用的构建环境以及运行时环境镜像进行拆分设计。

golang应用构建环境镜像定制

  • golang的构建环境变量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    ENV GOPATH /go
    ENV PATH $GOPATH/bin:$PATH
    mkdir -pv $GOPATH/{bin,pkg,src};
    go env -w GOPROXY="https://goproxy.cn,https://goproxy.io,direct";
    go env -w CGO_ENABLED=0;
    go env -w GOFLAGS="-mod=readonly";
    go env -w GOOS="linux";
    go env -w GOARCH="amd64";
    WORKDIR $GOPATH/src
    • 也可以集成安全扫描、语法扫描等扫描工具。
  • 构建步骤的关键逻辑

    1
    2
    3
    4
    5
    6
    7
    8
    GIT_VERSION=$(git describe --tags --always --dirty="-dev")
    GIT_COMMIT=$(git rev-parse HEAD)
    BUILD_DATE=$(date "+%Y-%m-%d_%H%M%S")
    APP_VERSION="${BUILD_DATE}_${GIT_VERSION}"

    go mod download

    go build -ldflags "-s -w -X main.version=${APP_VERSION} -X main.commit=${GIT_COMMIT}" -o $GOPATH/bin/${APP_NAME:-app} main.go

golang应用运行时环境镜像定制

  • golang应用环境可在base镜像的基础上,集成logrotate。并规范应用的目录规划和启动方式。

php应用镜像的定制

php扩展如何管理

docker社区提供的方案是通过源码编译,结合多个脚本组合管理,实现较为复杂。个人推荐的方案是通过apk全量将扩展集成到镜像中,然后通过php的扩展配置文件,管理哪些扩展生效。此种方法虽然会导致镜像存在冗余数据,但是易用性更强。

php和nginx环境是否应该集成

一般php应用和nginx代理搭配使用。按照唯一关注性原则(SCP),理论上应该拆分成两个container组成一个Pod的方式拆分。但是这种拆分的问题非常明显:

  • nginx和php需要共享代码目录;
  • nginx和php需要分别滚动日志文件;
  • nginx和php分别分别声明容器资源,降低资源利用率和灵活度;
  • logrotate需给容器内进程发送信号,分别自带logrotate又会造成冗余。

本质上只有php环境的镜像中,只能提供fast_cgi服务,不是一个完整的http服务。只有集成了代理之后,才能提供完整的功能。一定程度上也符合唯一关注性原则。参考相关应用社区提供的php环境镜像一般也都是php-fpm和代理集成的实现方式。

如何避免php-fpm进程退出带来的风险

nginx和php-fpm共存于一个镜像中,php-fpm作为后台进程。一旦php-fpm进程异常,可能会导致业务请求返回502。可以通过开启php-fpm的ping检测功能,nginx层代理的ping.path作为Pod的存活检测接口,这样可以实现,php-fpm异常时,容器自动销毁重建。

lua应用镜像定制

  • alpine官方提供的openresty的apk包的基础上,定制默认配置,如日志格式、代码目录、lua配置等;
  • 集成logrotate配置,容器内建议滚动保留少量本地日志,便于出现异常时快速定位原因。

使用init Container将运行时环境和代码剥离

  • 避免由于镜像管理不善,增加敏感信息泄露的风险;
  • 通过Secret对象传入代码库或制品库token,形成鉴权闭环;
  • 允许开发者在初始化容器阶段执行一些预处理操作,如创建缓存目录;
  • 此种方式一定程度减小harbor的存储压力。

以php应用为例,init Container镜像中的entrypoint.sh脚本中的部分关键逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
......
# 下载代码
git clone --depth 1 -b ${gitTag} https://${gitUser}:${gitToken}@${gitRepo}
if [ $? -ne 0 ]; then
git clone https://${gitUser}:${gitToken}@${gitRepo}
git checkout ${gitTag}
fi
unset gitUser
unset gitToken
......
# 根据配置文件管理php扩展,ph扩展的配置文件作为业务代码的一部分,由版本化工具管理。
if [ -s "${phpExtConf:-zruntime/modules.ini}" ]; then
[ -d /etc/php7/conf.d ] && rsync -avP ${phpExtConf:-zruntime/modules.ini} /etc/php7/conf.d/ || true
[ -d /etc/php5/conf.d ] && rsync -avP ${phpExtConf:-zruntime/modules.ini} /etc/php5/conf.d/ || true
# 由于init container退出后才开始启动应用容器,因此此处不需要kill -USR2
# kill -USR2 $(cat /run/php-fpm.pid)
fi
......
# 允许用户在准备代码阶段执行简单的脚本逻辑,由于此处的脚本是从代码库中拉取,因此风险相对可控。
if [ -s "${initScript}" ]; then
cat "${initScript}" |sed -e 's/rm /echo /g' -e 's/find /echo /g' -e 's/dirname /echo /g' -e 's/cd /echo /g' -e 's/ \// /g' -e 's/ \\\// /g'|/bin/sh
fi
......
# 允许用户通过文件方式主动声明在生产环境中需要显式删除的文件或目录,如测试配置文件、文档文件等。
if [ -s "${deleteList}" ]; then
cat "${deleteList}" |sed 's/\(\.*\/\+\)\+$//g' |xargs -ti rm -rvf {}
fi
......
# 集成固定逻辑清理一些固定的明确无需保留的文件。
rm -rvf .git .drone.yml .gitlab-ci.yml .gitignore .dockerignore Makefile README.md values.yaml zruntime zlist.del zinit.sh /root/entrypoint.sh || true
......
  • 当然,任何方案都不是无懈可击的,此种实现方案的一个明显缺陷在于,每一个Pod副本都需要下载一遍文件,Pod重建时也存在这个问题。但这种内网传输成本我们认为是可以接受的。

crond定时任务如何考虑

crond在容器中主要为logrotate服务,在微服务架构下,crond应以Sidecar模式的CronJob工作负载形式存在。但具体到logrotate日志滚动实现,crond和logrotate集成在应用镜像中更易用更易实现。可以考虑的实现方式是,将crond和logrotate集成到base镜像中,在应用镜像中按需增加logrotate配置,并启动crond进程。
Kubernetes官方建议使用日志代理,在实际生产中,把握原则灵活实现即可。

容器应用中是否应该集成logrotate

问题的关键在于日志在容器内是否考虑落地,如果直接被日志代理转发则不用集成。否则必须考虑日志文件的持续滚动,避免文件累积。实际情况中,我们还很难做到完全不需要登录到容器内排查问题,如果基于此考虑的话,在本地落地一份日志副本无疑是最便于排查问题的方式。当然我们依然可以同时启动日志代理,将另一路日志副本收集并进行相应的加工处理。

参阅资料