在镜像管理中我们可以做哪些

阅读量:

1、引言

随着公司最近几年提出的全面容器化,迈向云原生体系建设的推进,现在整个微服务体系下的服务线下环境已经全部运行在 K8S 上,在 19 年中所有服务的线上环境也全部运行在 ECS 里的 docker 容器上。在服务容器化的推进过程中,遇到了各种各种的问题,从镜像构建时到容器运行时,再到 K8S 运行时。而如何构建 docker 镜像是保证高效的服务上线,稳定的服务运行的前提,无论容器是运行在 K8S 还是在 ECS 机器上。本文记录我们 DevOps 团队在保证镜像构建方面所做的一些实践,旨在留下阶段性总结,并给后续的生产实践优化做一些参考。

为了给后文做铺垫,这里先简要介绍下 docker 关于设计思想和镜像简介两块内容。

1.1、docker 的重要性

对于软件交付而言,docker 镜像“一处构建,处处运行”的特点是我们青睐 docker 技术的重要原因。如果研发团队交付的上线产物在任何环境下都能够做到行为一致,那就不会再出现“这份代码在这台机器上运行没有问题”这样的争论,进而避免额外的沟通和排查成本。docker 镜像这个模型比较好地解决了研发交付的问题,它将软件运行所需要的代码、运行时环境、配置等必要资源进行封装,镜像本身也不包含任何动态数据,构建完成之后内容便不再改变。这一标准封装、稳定的特点,正好给研发团队和应用运维团队之间提供了标准化、统一化的交付契约。也正是得益于 docker 技术这一标准化的优势,现在服务的持续部署才能做得比较高效,加速了公司持续交付的进度。

1.2、镜像简介

Docker image 设计充分利用 Union FS 的技术,将其设计为分层存储的架构,镜像是由多层文件系统联合组成。

镜像构建时,会一层层构建,前一层是后一层的基础。每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己这一层。比如,删除前一层文件的操作,实际不是真的删除前一层的文件,而是仅在当前层标记为该文件已删除。在最终容器运行的时候,虽然不会看到这个文件,但是实际上该文件会一直跟随镜像。因此,在构建镜像的时候,需要额外小心,每一层尽量只包含该层需要添加的东西,任何额外的东西应该在该层构建结束前清理掉。分层存储的特征使得镜像的复用、定制变的更为容易。

这里有一个非常好的实践,可以用之前构建好的镜像作为基础层,然后在新的层里面定制自己所需的内容,构建新的镜像,从而避免重复去构建基础层里面的耗时操作。

走过的误区(优化项)

从 Docker 1.10 开始,常见的 COPY/ADD/RUN 命令都会向镜像中添加新层,如果层数无限扩大,镜像本身需要付出额外的空间来维护层的元信息,而且由于从 hub 拉镜像默认只拉取本地不存的层,层数多会在从 hub 上拉取镜像时会付出额外的时间,影响服务部署效率。

由于镜像分层存储和传输的特点,我们在实际的生产过程中,一方面应该尽可能地优化 dockerfile 写法,以此来减少镜像层数和镜像体积,另一方面要充分利用分层不变的特点,通过基础镜像来优化构建速度。尽管这方面看起来有点矛盾,但我们还是可以在减小镜像体积和优化构建效率之间做一个平衡。

本节主要记录下一些 dockerfile 常见的优化,期望能够给业务方在后续实践中提供指导。

减少镜像层

比较常见的化就是合并 RUN 命令,比如一系列相关的 shell 命令可以放在同一个 RUN 命令里面。以现在大部分 springboot 服务使用的registry.qunhequnhe.com/infra/centos7-jdk8:0.2.1 镜像为例(centos 镜像里面安装 JDK8u211)。

FROM centos:7.2.1511
...
RUN update-alternatives --install /usr/bin/java java ${JAVA_HOME}/bin/java 300 && \ update-alternatives --install /usr/bin/javac javac ${JAVA_HOME}/bin/javac 300 && \ update-alternatives --install /usr/bin/jar jar ${JAVA_HOME}/bin/jar 300 && \ update-alternatives --install /usr/bin/javah javah ${JAVA_HOME}/bin/javah 300 && \ update-alternatives --install /usr/bin/javap javap ${JAVA_HOME}/bin/javap 300 && \ update-alternatives --set java ${JAVA_HOME}/bin/java && \ update-alternatives --set javac ${JAVA_HOME}/bin/javac && \ update-alternatives --set jar ${JAVA_HOME}/bin/jar && \ update-alternatives --set javah ${JAVA_HOME}/bin/javah && \ update-alternatives --set javap ${JAVA_HOME}/bin/javap

减小镜像体积

核心思想就是尽量保证软件运行时的环境精简,比如 java 微服务在运行时环境不需要 maven 等构建时软件,node 服务的中间构建产物很大,但实际运行只需要几个js 文件即可,那么最后镜像里面应该尽可能只保留运行时需要的最小系统,其它无用的文件或目录尽量删除。

multistage

在 docker 17.05 之前,我们只能通过上面第二种办法来优化镜像体积,但是 17.05 之后我们可以利用多阶段构建来大大优化镜像体积。这项技术通过使用两个或者多个镜像来实现,并且只有最后一个镜像才会保留最终的软件产物和软件运行时,至于这个软件产物或者运行时怎么来的,以及怎么生成中间产物,都交给其它几个镜像来做。

以典型的 soa-jetty 服务线上环境的 dockerfile 为例:

FROM registry.qunhequnhe.com/public/maven:3.5.2 as builder
COPY . /usr/src/app WORKDIR /usr/src/app
RUN mvn clean package -Dmaven.test.skip=true
FROM registry.qunhequnhe.com/infra/soa-jetty:0.3.1
COPY --from=builder /usr/src/app/target/foobar.war  /var/lib/jetty/webapps/root.war

环境变量

在公司还没有建立起统一的配置中心 toad 的时候,很多服务的配置是直接写在代码 resource 资源目录下,然后在构建时就要通过 mvn -Dstage 参数来决定拷贝哪一份配置。这样就会导致我们需要给不同的环境配置写不用的 dockerfile,并且通过环境名来区分,这也就是 build.${env}.Dockerfile 这样一种临时规范的出现。

在统一的配置中心出现之后,dockerfile 里面关于配置拷贝的步骤就消失了,但是仍然需要决定这一个镜像是给哪个环境运行提供服务的,这里就引入了 docker 环境变量技术。docker 镜像本质上一份静态文件,通过环境变量,就可以从外部来控制同一个镜像的动态运行行为。目前我们使用最多的就是 JAVA_OPTIONS 这个环境变量,具体用法包括但不限于以下几种:

  • 控制 JVM 参数:-Xmx 等;
  • 读取 toad 配置:-Dqunhe.toad.stage;
  • 微服务注册:-Dstage -Dqunhe.service.version -Dservice.version.default.global

总结下,通过环境变量技术,镜像本身不需要关心在哪个业务环境下运行,可以进一步将镜像与业务环境进行解耦。“一处构建,处处运行”的思想,不仅屏蔽了 docker 容器运行的操作系统差异,还统一了在不同业务环境之间控制容器运行的方式。

关于镜像的生产级实践

当业务放准备好各应用的 dockerfile 之后,接下来我们要解决如何将镜像构建出来,以及如何存储。在解决镜像构建、存储的过程中,我们针对业务方反馈过来的各类问题,提出了相应的优化改进,具体包括如下:

  • 优化构建效率——让构建也挂载上缓存,达到跟本地构建一样快的体验
  • 优化 habor 镜像清理策略——降低 harbor 存储和同步压力
  • 优化快速回滚和重发的方式——最大幅度减少回滚和重发的耗时环节

另外,我们将“一处构建,处处运行”的思想体现在产品上,提供了帮助业务方整合环境之间 dockerfile 的差异,缩短上线时间的能力:

  • 统一镜像流转

让构建也挂载上缓存

引发我们做出这次优化是刚开始容器化改造时,第一版构建镜像功能刚刚上线,业务方全部反映 maven 打包慢。原因是之前服务未容器化改造,war 包等交付产物是用 jenkins 节点(虚拟机)构建,直接运行的 maven 命令产生的,这时构建节点上都会有 maven 缓存目录。

但是容器化改造之后,maven 命令是跑在 docker build 启起来的容器里面的。这里考虑过一个 multistage 方案,将 maven 缓存目录也提前 COPY 到镜像中,但是缓存目录太多并且服务的依赖会经常变化,这个方案就直接放弃了。

那么有没有办法让 docker build 启动容器的时候,也能让宿主机目录挂载到构建容器里面呢?在查阅 docker build 文档之后,发现原生 docker build 的命令没有将 mount 类型的参数暴露出来,后来在一个开源工具 imagebuilder 中找到了合理的解决方案:

  • 将构建节点(pod)调度到特定的几台虚拟机上;
  • 虚拟机上有单独一块缓存目录;
  • imagebuilder 再将缓存目录挂载到基础镜像(maven)的 m2 目录上;


在后续的使用过程中我们发现,imagebuilder 能兼容大部分 docker 规定的构建命令,但是与原生的 docker build 有以下几点区别:

  • 允许对网络和内存进行控制;
  • 支持自定义外部目录挂载到构建容器中;


这里,使用 imagebuilder 会有一个坑需要注意,原因是 imagebuilder 对于 COPY 命令的处理行为不一样导致的。例如

COPY --from=builder /usr/src/app/web/target /usr/src/app/

正常 docker build 会把 /usr/src/app/web/target 目录下的所有文件拷贝到目标文件下,而 imagebuilder 会把 /target 整个目录拷贝到目标文件下,即 /usr/src/app/target。因此用了业务方在使用 COPY 命令时出现文件不存在的问题,要在下一级目录下才能找到。

镜像清理策略

当服务构建次数一多,harbor 镜像存储的压力就陡增了,最早用的比较粗的清理就是定期按时间来清理,但是这种方式有很明显的弊端,清理时机不对还会引发故障。

  • 不同环境构建的镜像重要程度不同,线下环境和线上环境用的镜像明显有区别;
  • 不同环境构建的镜像频率不同,线下环境比线上环境反而会构建出更多的镜像;
  • 线上环境构建出来的镜像不一定真正使用到;

针对这两个问题,我们重新提出了按环境来清理镜像的目标:

  • 线下环境只保留使用过的最新 2 个镜像;
  • 线上环境只保留使用过的最新 10 个镜像;

为了区别线上或者线下环境的镜像,我们通过在 harbor 上给镜像打标签的方式来做,具体策略如下:

  • 镜像部署在 prod_test 成功之后,打上 maybe_prod 标签;
  • 镜像部署在 prod 成功之后,再打上 prod 标签;
  • 镜像按标签进行聚合并排序,不带标签的镜像保留最新 2 个,只带 maybe_prod 标签镜像保留最新 10 个,带 prod 标签的保留最新 10个;

通过上述策略,基本能够保证 harbor 存储保持在一个相对平缓上升的水平,也能保证服务线上使用镜像的完备。

快速回滚和重发

当服务容器部署在 ECS 上时,默认机器上只会保留最近使用的 10 个镜像,这样一方面避免镜像文件上涨而吃满机器磁盘,另一个方面也保证服务在回滚和重发时,可以快速地完成 docker pull 过程,实现快速恢复的操作。

平台性建设

对于业务方服务而,我们可以提供统一的软件运行时镜像出去,比如 soa-jetty、centos-jdk 等镜像。但是当时公司在镜像的维护和升级存在一些问题,集中体现在:

  • 基础设施有,但元信息还不够——跟 nexus 一样,harbor 本身能提供的信息有限,比如镜像的代码仓库地址,发版 commit 及分支,创建人等信息都不明确,很容易跟之前二方包一样,出现找不到镜像或者镜像找到但不知道怎么用,也找到人的情况。
  • 信息不透明——从业务服务的基础镜像统计 来看,各个业务组是否有维护自己的镜像的,而且这类镜像也是最多的,统计之前是只有业务组自己知道,其他组是完全不知道的,不方便维护。甚至有出现这样的情况:本地直接 run container,然后在线编辑容器内容并 commit 成一个新的镜像,推送到镜像仓库的,这个镜像基本就只有他自己能用了。
  • 职责不够明确——同上,部分镜像没有定义明确的 owner,镜像之间的依赖也没有明显的界限。
  • 升级和推动困难——由于不清楚到底有多少服务和下游镜像在依赖,缺乏依赖关系数据,当需要升级基础镜像,只能逐个去各业务线“地推”,效率非常低下。

镜像管理建设

我们调研了业界的镜像分层模型之后,发现可以用分层模型来解决上面的问题,现在已经产品化到 moon 系统上面,根据用户反馈在持续迭代。

由于镜像之间存在明显的单向继承关系,非常适合使用树状关系图来描述它们之间的依赖关系。从 业务服务的基础镜像统计 数据来看,在最底层是基础运维组提供的基于 centos 操作系统镜像,然后是各种语言的开发环境,再是各类语言通用的开发工具,最后有些敏捷组会安装特定的软件使用。另外,综合考虑镜像维护方的职责,将镜像依赖树从下往上分成四个层次:

展望

目前我们可以提供镜像元信息录入,分层管理、可视化展示

依赖关系等功能。实践过程中也发现,将运行时需要的工具与代码打在同一个镜像中可能并非是一个好的解决方案,未来可能会以 sidecar 的模式来将运行时镜像与工具类镜像进行分开,届时我们只需要给业务方提供一个精简的运行时镜像,然后再提供多种类的工具类镜像即可。
可以展望, 未来我们在 DevOps 体系内围绕镜像管理做很多优化的工作:

  • 质量卡点增加基础镜像扫描步骤,实现非标准镜像卡点;
  • 允许增加镜像规则,类似二方包允许 owner 在系统层面做卡点;
  • 镜像标准化后,减少无用的、干扰的镜像,可能同一类型的镜像不超过两个;

参考文档

  1. imagebuilder https://github.com/openshift/imagebuilder
  2. 镜像简介 http://blog.daocloud.io/principle-of-docker-image/
  3. 多阶段构建 https://docs.docker.com/develop/develop-images/multistage-build/  https://vsupalov.com/cache-docker-build-dependencies-without-volume-mounting/

Cover Photo by chuttersnap on Unsplash


comments powered by Disqus