近年来,随着容器技术的持续火热,越来越多的企业将运用到自动化运维中,不管是为了保证开发、测试、生产环境的环境一致性,还是和CI/CD工具的集成,比如,对的自动构建部署。
随着敏捷开发越来越流行,在现在这种随随便便一天动辄几十次的快速构建迭代中,镜像作为一个贯穿整个自动化过程中的一个关键,怎么保证自动化构建部署的效率呢?
因此,精简镜像显得非常有重要。
精简镜像尺寸的好处
越小的镜像表示无用的程序越少,可以大大的减少被攻击的目标,从而提高了安全性。
虽然存储资源较为廉价,但是网络 IO 是有限的,在带宽有限的情况下,部署一个 1G 的镜像和 10M 的镜像带来的时间差距可能就是分钟级和秒级的差距。特别是在出现故障,服务被调度到其他节点时,这个时间尤为宝贵。
优化镜像的方法
要保证镜像尽可能小,可以从以下五个方面着手:
优化基础镜像
优化基础镜像的方法就是选用合适的更小的基础镜像,常用的 Linux 系统镜像一般有 、、,其中更推荐使用。
注意:
每个企业或个人使用容器,都是应对不同的业务场景,没有完全一致的业务场景,所以你最好不要直接用别人的第三方镜像,除非你了解该镜像的所有层级内容,而且从安全角度考虑,也尽量使用官方镜像,它没有太多第三方的,你不需要的东西,你可以在此基础上增加你的业务部分内容。
镜像
一个基于musl libc和、面向安全的轻量级Linux发行版。它本身的镜像只有4~5M大小。各开发语言和框架都有基于制作的基础镜像,在开发自己应用的镜像时,选择这些镜像作为基础镜像,可以大大减小镜像的体积。
例如,Java、、Node.js语言对应的基础镜像如下:
如果你的项目涉及到编译,比如等涉及编译的项目,要注意,用的是muslc,因为它原本是用作嵌入式系统的,所以并没有glibc那么完整的C标准库。
另外如果你要在中跑一些脚本的话,那你要注意一些shell和在linux(、、等)下的还是有所区别的,是基于的,同样也是设计于嵌入式的,所以很多shell命令做了裁剪,并不具备、、等系统中那么完整的功能。
除了这样的轻量级镜像之外,还推荐的一些镜像,如、、等。
镜像
是一个空镜像,只能用于构建其他镜像,比如你要运行一个包含所有依赖的二进制文件,如程序,可以直接使用作为基础镜像。
样例:
FROM scratch
ARG ARCH
ADD bin/pause-${ARCH} /pause
ENTRYPOINT ["/pause"]
镜像
如果你希望镜像里可以包含一些常用的Linux工具,镜像是个不错选择,它集成了一百多个最常用Linux命令和工具的软件工具箱,镜像本身只有1.16M,非常便于构建小镜像。
镜像
镜像,它仅包含您的应用程序及其运行时依赖项。它们不包含您希望在标准 Linux 发行版中找到的包管理器、shell或任何其他程序。
由于是原始操作系统的精简版本,不包含额外的程序。容器里并没有Shell!如果黑客入侵了我们的应用程序并获取了容器的访问权限,他也无法造成太大的损害。也就是说,程序越少则尺寸越小也越安全。不过,代价是调试更麻烦。
需要注意的是,我们不应该在生产环境中,将Shell附加到容器中进行调试,而应依靠正确的日志和监控。
样例:
FROM node:8 as build
WORKDIR /app
COPY package.json index.js ./
RUN npm install
FROM gcr.io/distroless/nodejs
COPY --from=build /app /
EXPOSE 3000
CMD ["index.js"]
镜像和 镜像应该如何选择?
如果是在生产环境中运行,并且注重安全性,镜像可能会更合适。
镜像中每增加一个二进制程序,就会给整个应用程序带来一定的风险。在容器中只安装一个二进制程序即可降低整体风险。
举个例子,如果黑客在运行于的应用中发现了一个漏洞docker国内镜像源,他也无法在容器中创建Shell,因为根本就没有。
如果更在意要是大小,则可以换成基础镜像。
这两个都很小,代价是兼容性。用了一个稍稍有点不一样的C标准库——muslc,时不时会碰到点兼容性的问题。
说明:
原生基础镜像非常适合用于测试和开发。它的尺寸比较大,不过用起来就像你主机上安装的一样。并且,你能访问该操作系统里有的所有二进制程序。
串联指令
补充说明:
镜像由很多镜像层()组成(最多127层),镜像层依赖于一系列的底层技术,比如文件系统()、写时复制(copy-on-write)、联合挂载(union )等技术。总的来说,中的每条指令都会创建一个镜像层,继而会增加整体镜像的尺寸。我们可以通过命令 来查看每一层的大小。
在定义时,如果太多的使用RUN指令,经常会导致镜像有特别多的层,镜像很臃肿,而且甚至会碰到超出最大层数(127层)限制的问题,遵循 最佳实践,我们应该把多个命令串联合并为一个 RUN(通过运算符&&来实现),从而有效的减少镜像的层级docker国内镜像源,因此,每一个 RUN 都要精心设计,确保安装构建之后,还要进行清理,这样才可以降低镜像体积,以及最大化的利用构建缓存。
样例:
FROM ubuntu:focal
ENV REDIS_VERSION=6.0.5
ENV REDIS_URL=http://download.redis.io/releases/redis-$REDIS_VERSION.tar.gz
# update source and install tools
# 将archive.ubuntu.com和security.ubuntu.com更换为国内源mirrors.aliyun.com
RUN sed -i "s/archive.ubuntu.com/mirrors.aliyun.com/g; s/security.ubuntu.com/mirrors.aliyun.com/g" /etc/apt/sources.list &&
apt update &&
apt install -y curl make gcc &&
# download source code and install redis
curl -L $REDIS_URL | tar xzv &&
cd redis-$REDIS_VERSION &&
make &&
make install &&
# clean up
apt remove -y --auto-remove curl make gcc &&
apt clean &&
rm -rf /var/lib/apt/lists/*
CMD ["redis-server"]
linux中大部分包管理软件都需要更新源,该操作会带来一些缓存文件,这里记录了常用的清理方法。
针对镜像安装包如下:
# 换国内源并更新
RUN curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo &&
yum makecache &&
yum install -y a b c &&
yum clean all
基于的镜像安装包如下:
# 换国内源,并更新
RUN sed -i “s/deb.debian.org/mirrors.aliyun.com/g” /etc/apt/sources.list &&
apt update &&
# --no-install-recommends 很有用
apt install -y --no-install-recommends a b c &&
rm -rf /var/lib/apt/lists/*
基于镜像安装包如下:
# 换国内源,并更新
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories &&
# --no-cache 表示不缓存
apk add --no-cache a b c &&
rm -rf /var/cache/apk/*
使用多阶段构建
中每条指令都会为镜像增加一个镜像层,并且你需要在移动到下一个镜像层之前清理不需要的组件。多阶段构建方法是官方打包镜像的最佳实践,它是将精简层数做到极致的方法。通俗点讲它是将打包镜像分成两个阶段,一个阶段用于开发,打包,该阶段包含构建应用程序所需的所有内容;一个用于生产运行,该阶段只包含你的应用程序以及运行它所需的内容,这被称为“建造者模式”。 使用多阶段构建肯定会降低镜像大小,但是瘦身的粒度和编程语言有关系,对编译型语言效果比较好,因为它去掉了编译环境中多余的依赖,直接使用编译后的二进制文件或jar包。而对于解释型语言效果就不那么明显了。
使用多阶段构建,你可以在中使用多个FROM语句,每条FROM指令可以使用不同的基础镜像,这样您可以选择性地将服务组件从一个阶段COPY到另一个阶段,在最终镜像中只保留需要的内容。
样例:
# Compile
FROM golang:1.9.0 AS build
WORKDIR /go/src/v9.git...com/.../k8s-monitor
COPY . .
WORKDIR /go/src/v9.git...com/.../k8s-monitor
RUN make build
RUN mv k8s-monitor /root
# Package
# Use scratch image
FROM scratch
WORKDIR /root/
COPY --from=build /root .
EXPOSE 8080
CMD ["/root/k8s-monitor"]
使用多阶段构建主要有三点不同:
第一行多了AS build, 为后面的COPY做准备。
第一阶段中没有了清理操作,因为第一阶段构建的镜像只有编译的目标文件(二进制文件或jar包)有用,其它的都无用 。
第二阶段直接从第一阶段拷贝目标文件。
通过,这样构建镜像,你会发现生成的镜像只有上面COPY 指令指定的内容,镜像大小只有2M。
去除不必要的内容
前面提到的用空镜像,或者裁剪过的小镜像来做基础镜像,其实就是一种去除不必要的依赖、库的一种形式。
除了以上的这种形式,还有必要去除的,就是构建过程中所产生的临时文件。比如,源码包、编译过程中产生的日志文件、添加的包管理仓库、包管理缓存,以及构建过程中安装的一些当时有用,过后没用的软件或工具。
如果可以,甚至建议不在容器中进行编译,如果二进制文件可以执行的话,在本地编译后,将文件copy到容器内。
除了上面的,还有一些不常更新的文件,比如web静态资源文件css、js以及图片、视频等资源,建议存储OSS或共享存储系统nfs、mfs等,这些文件不应该打包到镜像里面,而应该通过OSS调用或通过共享存储挂载。
对于不需要build进镜像的资源,可以使用.文件进行指定要忽略的(无关的)文件或目录,如。
补充说明
如果你想基于别人的镜像来做优化的话,可以通过 命令来查看镜像的层级关系,然后做相应的优化,更好的工具推荐dive。
当然也可以用自动化的镜像瘦身工具-slim,它支持静态分析和动态分析,静态分析主要是通过分析镜像历史信息,获取生成镜像的文件及相关的配置信息,而动态分析主要是通过、、解析出镜像中必要的文件和文件依赖,将对应文件组织成新镜像来减小镜像体积。
另外还可以通过-来压缩镜像层级,但是要考虑实际情况,并不是压缩一定是好的。
复用镜像层
刚刚提到压缩不一定是好,为什么呢?
压缩的原理是将镜像导出,然后删除所有中间层,将镜像的当前状态保存为单一层,达到压缩层级的效果。
当你使用单一镜像或者少量镜像的时候可能没有太大问题,但是这样完全破坏了镜像的层级缓存功能。
我们知道镜像的每个层级会存一个hash计算后的目录,那么构建过程中如何利用缓存?
在镜像的构建过程中,根据指定的顺序执行每个指令。在执行每条指令之前,都会在缓存中查找是否已经存在可重用的镜像,如果有,就使用现存的镜像,不再重复创建。
而如果压缩为单一的层之后,缓存就失效了,不会命中缓存的层级,所以每次构建或者pull的时候,都是整个镜像构建或pull。
缓存命中除了和分层有关系,还和指令执行编排顺序有关系,首先看下缓存匹配遵循的基本规则:
所以为什么和指令执行顺序和编排有关系,或者说我们在合并命令减少层级的时候不能一味的追求合并,而是需要合理的合并一些指令。
举个例子,比如我们用同一个基础镜像,分别编译nginx和php,那么nginx也需要pcre库依赖,php也需要,那我们是不是可以提取共同的依赖用一条RUN指令去执行,而不是每次构建都执行。
再或者最简单的,添加镜像仓库,安装基本的编译工具,比如gcc、、make、zlib等这些不常改动,但是常用的指令放在前面去执行,这样后面构建用到的所有镜像都不会再重新安装。
这样合理的利用层级缓存,不管是在中自动构建镜像,还是push到远程仓库、亦或是在部署pull的时候,都能够利用缓存,从而节省传输带宽和时间。
参考文章