容器云原生DevOps学习笔记——第二期:如何快速高质量的应用容器化迁移
Posted Baret-H
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了容器云原生DevOps学习笔记——第二期:如何快速高质量的应用容器化迁移相关的知识,希望对你有一定的参考价值。
暑期实习期间,所在的技术中台—效能研发团队规划设计并结合公司开源协同实现符合DevOps理念的研发工具平台,实现研发过程自动化、标准化;
实习期间对DevOps
的理解一直懵懵懂懂,最近观看了阿里专家带你玩转容器云原生DevOps公开课开始系统的学习DevOps
,所以根据学习视频整理出以下学习笔记希望分享给更多对此感兴趣的同学~
课程大纲如下图所示,会陆续进行更新:
如何快速高质量的应用容器化迁移
本节内容是如何快速高质量的进行应用容器化的迁移,首先我们来回顾下上节内容 容器云原生DevOps——第一期:DevOps、微服务、容器服务(学习笔记)
1. 上节回顾
上一节介绍了三个非常重要的概念:DevOps
、容器
和 微服务
,以及它们之间如何有机的融合起来形成一个快速高质量的容器化交付链。
首先我们先回顾一下 DevOps 的概念。DevOps 是2007年的时候,一个比利时IT咨询师 Patrick 提出来的,他希望通过更优的组织架构和流程节约时间通过自动化的方式来提高交付速度和质量。但是很可惜,在当时的一个场景之下,大部分的公司都是采用大型单体结构的方式进行应用软件的设计,通过大兵团作战的方式来实现整个交付的流程,以及通过瀑布流的一个设计思想来去进行质量保证。所以这也是导致当时 DevOps 很难很难快速落地的一个原因。
然后在2014年的时候,微服务 的概念提出了,概念简单的用一句话来理解就是分而治之,将把一个大型的单体系统拆分成很多子的小型系统,且每一个小型系统都具备完整的一个生命周期管理,然后使用适合该小型系统的设计思路进行设计。拆分后的每一个系统组件都会相互在数据集隔离,都有自己的一个 scope,而这个 scope 又是和业务仅仅相结合的,且每一个组件都可以进行独立升级。也就是说当一个大型的系统通过微服务进行拆分的时候,其实无形中在架构上会变得越来越敏捷,然后在流程上可以让整个交付的一个流程变得越来越快速,这正是因为每一个组件的体量非常小且也有自己明确的一个 scope,所以可以更好的进行质量保证。所以从架构、流程、质量三方面来说,微服无形在概念上面弥补了 DevOps 很多缺失的部分。
但微服务它本身也不是一个十全十美的架构设计思路,当你把一个大型的系统拆分成很多子系统之后,如何有效的管理这么多的微服务,并且如何对快速的对每一个微服务进行独立升级又是新的问题,所以此时我们特别需要一种标准化的交付方式来去协助微服务的弊端。举一个简单的例子,我们采用传统结构开发的 Java 应用拆分成很多微服务后,每个服务可能采用不同的语言、不同的框架来去实现,这导致我们以前使用的很多基础的类库或者是公共框架都无法再使用,那此时怎么来实现不同的框架和语言之间的一个标准化呢?
容器 的出现就帮我们解决了这个问题,在使用容器的时候,通常会定义 DockerFile,然后在交付时会定义一些 yaml 文件或者是compose 文件。其中 DockerFile 就是标准化的一个部署的环境,而类似 base-compose 或者是 k8s 的restful api 是标准化的这个交付内容和交付动作,以及后来如果用一些编排系统,可能会有一些命令和 API,而这些 API 就是准化的运维操作。从这个角度来讲,容器实际上实现了一个标准化交付的场景。而标准化交付其实可以解决微服务场景中很多缺失的部分。而容器它也有它自身的一个问题,就是当你引入容器这种技术的时候,我们原本的这个交付产物是代码包比如说jar包、tar包等,但是使用容器时,我们使用的交付中间产物是 docker image,此时你的开发团队可能也要感知 容器架构引入而带来的上层应用架构的变化,所以容器也会有很多固有的问题。而容器大部分的问题可以用通过 DevOps 工具来解决,比如原本镜像打包的操作是在本地实现的,但现在可以通过 Jenkins 或 GitLab CI 来自动化解决,提高交付质量和交付速度。
所以我们看这三个概念,其实在各自的这个优势上面是相互互补的,而他们之间相互整合起来可以变成一个非常有机的可以高质量和快速的一个软件交付方式。所以这也是为什么,这三个概念最近经常被大家一起提到、一起出现的根本原因。
上节到后半部分,我们也给大家介绍了一些典型的容器交付流程,一个标准的容器交付流程大致就如下图所示:
开发者将代码提交到源代码管理中心,可能是 GitHub、GitLab 或 SVN。而云代码管理中心我会通过 Webhook 的方式通知 CI-Server,CI-Server 会运行客户在源代码里面包含的单元测试或其他测试的脚本,如果测试通过,它接下来会进行代码编译打包、镜像构建和推送的流程,然后进入到应用的部署的阶段,在不同的环境里边部署这个应用。最后这个环境里边会通过编排引擎来拉取镜像来实现一个容器化的一个部署。
2. 容器化交付流程阶段划分
如果我们将一个容器化交付流程进行阶段划分的话,其实可以划分成如下四个阶段:
- 本地开发阶段。主要是代码编写、测试编写、DockerFile 编写、容器化测试、编排模板编写
- 持续集成和持续交付阶段。主要进行代码构建、打包成构建产物、构建 docker image、推送 docker image 、部署编排模版
- 环境部署阶段。主要是调整一些应用拓扑关系、调整接入层的配置、调整弹性扩缩容的配置等
- 运维监控和调优阶段。一个就是基础监控资源的设置和告警;二是有些客户可能有更细腻度的监控或告警需求,会采用APM的方式内置一些 agent 来采集一些更细致的一些指标(比如网络的一些指标:类似TCP connection 数目、各种网络协议栈的状态);三是安装一些调优工具进行调优,例如大家可能接触的比较多的 top 、htop 等等。
本节课程主要关心的是怎么帮助大家进行本地开发阶段这个部分,主要会介绍怎么去编写高质量 DockerFile 以及容器编排引擎的一些内置的机制和原理,帮助大家快速入门编排模板的编写。
3. DockerFile入门及基本语法
下图是从 Docker 官网中摘录的最简化的一个 DockerFile
这四行简单的配置就把的一个 python 应用,从基础的环境到代码的准备、编译、运行完整的表达出来了,这就是一个最基本的 DockerFile,然后我们要做的就是通过按照 DockerFile 定义的规则编译打包生成一个 Docker image,命令如下所示:
# 通过DockerFile打包成镜像
docker build -t image:tag .
-t
表示用于指定镜像名和 tag id,.
是需要打包的代码文件路径,这里表示当前路径(也就是在当前路径下有 DockerFile 以及对应需要打包的文件,如 jar包/tar包)
📄 Docker 和 Git 的相似点
Docker 和 Git 有两点是非常相似的:
- Docker 和 Git 有两点是非常相似的一是打标签,这里 -t 来设置镜像名和 tag id,在 git 里边我们也有一个远程仓库名以及对应的branch分支,我们有时可能会给这个分支是打一个 tag 标签表示说这是xxx版本,然后这个tag 可以在发布时作为一个可直接回滚的版本。比如说今天我的 branch dev 变成一个可发布的版本时,我打一个 tag:0.1,然后再过一段时间发布第二个版本时打一个 tag:0.2,如果当0.2版本出现问题时我可以回滚到0.1的这个代码版本进行发布。其实对于 Docker image 来讲也给予了这种 tag 机制,比如说今天我打了一个0.1版本的image , 那明天我可能发布的就是0.2版本的image,档0.2这个image 有问题可以直接选择0.1版本的进行发布
- 二是信息的差量增加。git 它的存储是差量存储,当我们看到 git log 看到的实际上当前代码和之前代码比对的差异,然后每次的这个差异是增量往上 append 附加的。Docker 也是一样,它基于这种机制,也是不断的在往一个 base 镜像基础上去 append 需要的环境依赖等信息,而这个信息也是差量的去增加的。
通过这条命令之后,我们就可以构建出一个带有 tag 的一个 docker image。有了这样的一个镜像之后,我们可以在本地通过 docker run -d image:tag
将镜像跑起来。
📃 Docker 的单一进程原则
在 Docker 里有一个原则叫做单一进程原则,就是需要给 docker image 一个唯一进程作为这个 container 的主进程让他在前台运行,这样该 container 才永远的运行下去,而不会直接退出。什么是前台运行的进程呢?比如说我们平时在 linux 系统中可能会运用这个top 命令查看一些监控信息,此时整个 std:out 里边是不断的有数据在输出,此时没有办法操作任何其他的命令,除非执行了control+C 来强制跳出这条命令,这样的一个命令其实就是前台运行的一个命令。那什么是后台命令或者是说什么是立马结束的命令呢?比如说我 cat 一个标准的文件可以立马看到这个文件的内容,但是 std:out 立马又出了一个新光标可以让你输入下一条命令,也就说明上一条命令已经直接执行完退出了,这种场景对于容器来讲就不合适了,所以建议开发者给容器里面内置一个前台运行的命令,然后让这个命令一直耗在那儿比如说一直在打印着日志,这样 container 可以很好的运行起来。
DockerFile 的目标,其实就是将这个应用进行抽象打包,然后把这个构建出来的 docker image 作为标准化交付的一个中间产物。提到了这个标准化交付,我们要来更深入的来讲一下这个概念:
DockerFile 的这种设计思路和设计方法并不是它独创的。最早的标准化交付的这个概念是由一些 PaaS 平台提出的,其中有一个比较有代表性的软件叫做 Cloud Foundry
:它是 VMware 推出的业界的第一款开源的平台,它支持多种框架语言运行时环境、云平台以及应用服务,可以使开发者能够在几秒钟之内对于应用程序进行部署和扩展,而并不需要担心任何的架构。这是 Cloud Foundry 的一个官方的介绍,举个简单的例子,比如有个 java 的 spring 应用,使用 Cloud Foundry 后,我们只需要把代码提交到代码仓库,然后再把这个代码仓库配置到 Cloud Foundry 系统里,它就会自动拉取你的代码,然后并尝试着通过探测机制或者代码识别的方式来生成你的运行时环境。注意 Cloud Foundry 本身并不是使用 docker 的方式,它是应用了一个内置的叫 build pack 机制,然后它通过这种方式就探测出来这个是一个 Java 应用,使用的是 spring 的框架,继而判断还需要什么环境依赖:比如系统依赖ubuntu/centos,环境依赖jdk、spring配置文件的位置及启动方式等等。通过这种探测机制 Cloud Foundry 就可以把你这个spring 的应用在一个环境里边运行起来。
那么 Cloud Foundry 是怎么进行探测?或者是说他都生成了什么内容来把将一个应用从代码转换成线上的一个运行时的服务呢?其实 Cloud Foundry 把任何一个运行时的应用切分成了三个层次:
- 第一层是
application layer
,就是应用所需要的这些逻辑代码。刚才java 应用例子中我们 spring 相关代码这一部分对于 Cloud Foundry 来讲就是 application layer, - 第二层是
runtime layer
。比如 spring 依赖于 Java 环境,可能还依赖于一些系统包,这一部分其实就是放在这个runtime layer。 - 第三层是
OS image layer
,它表示的是说应用依赖的系统的环境是什么。
Cloud Foundry 认为我有了以上三个层级之后,就可以把一个应用代码转换成一个线上应用。
实际上 docker 的 DockerFile 的设计思路和 Cloud Foundry 对于这种标准化交付的一个抽象是非常相似的。
我们再看一下上述那个 DockerFile,对比 Cloud Foundry 的三个层次,其中第一行对应了其 OS image layer 层,然后第二行第三行和 runtime layer 是非常相似的,而最后一行定义了这个软件的一个运行方式,和application layer 保持一致。因此 DockerFile 使用这种 DSL 语言来去定义这个模板是有的一定理论依据的,而这个理论依据其实可以去看一下 Cloud Foundry 的的一个build pack机制来进行更深入的了解。
4. DockerFile语法
我们接下来从更高的一个维度来全览一下 DockerFile 里边的 这些命令和标签。
其中每个标签的功能如下图所示:比较常用的可能是from、run、expose、env、copy、cmd、user、workdir 这几个命令,其他的可能并不是很常用。
-
FROM
标签就是用来设置镜像使用的基础镜像 -
MAINTAINER
标签就是用来标注 DockerFile 的作者,但是该标签有一个问题,就是它只在 DockerFile 里面可以看得到。但是当容器运行时作者信息就丢失了。所以在新版本的 DockerFile 里面,MAINTAINER 标签已经逐渐的被弃用了,Docker 推荐的另一种方式就是打LABEL
标签的方式,我们可以在标签中写出容器的相关信息,这样的好处在于标签中的内容其实都会在运行起来的容器中里边看得到,因此就可以让这个 container 运行出错时追责的信息的链路更顺畅,我们可以直接使用 docker inspect containerID 命令就可以看到 container 的label标签信息。 -
RUN
标签就是是定义一些要里边执行的命令,这些命令其实只在 DockerFile 构建成 Docker image 时候生效。 -
LABEL
标签所标注的内容最终会在这个容器启动时能看到,该标签表面上看上去使用方式非常简单,但实际上它有很多用处:比如说我可以给一个image 打上特殊的标签,而这个标签会随着这个container 运行起来的时候也存在。所以特别是对于一些监控系统或探测系统,它可以通过这个container 的 label 来去识别这个 container 里面的一些信息,比如说很多的监控方案是通过这个 label 来去做上层概念的一个聚合。举个简单的例子,比如说在K8S里面,我们有很多的概念,类似像 development 等等,而这些概念其实和真正的 container 的之间是没有严格的物理的关系的,所以很多监控系统会把这些 label 打到 container 之上,表示这个container 是属于一个development,然后通过这种方式把一些原数据信息进行下发,以此来进行标识。采集系统也类似如此,它会把 label 采集上来,然后再做上述的一个聚合。所以 label 的用法是很灵活的,可以通过 label 打上很多特征的一个信息。 -
EXPOSE
标签表示的是说我要暴露哪些端口,DockerFile 默认支持的是TCP和UDP两种协议,如果你什么都不写的话,那默认情况下指的是TCP的协议。但是值得大家注意的一点是这个 expose 只是起到一种文档描述的作用,并没有什么实际作用,只是说让这个DockerFile 变得更可读,帮助你更好的去使用 DockerFile ,更好的去把这个容器启动起来。比如你写一条expose 80/tcp的话,其实对于使用这个 DockerFile 的开发者他就能知道说这个 container 里边运行的程序需要暴露80端口,因此在run这个container的时候就可以做一些端口映射等等 -
ENV
标签我们在这个 linux 命令行里面使用的 export 比较类似,就是把一个key value 的环境变量暴露到 container 里边,然后是在 container 运行时生效的。该标签的使用是非常推荐大家使用的一种方式。因为我们比较建议的是说能够尽可能的去抽象 docker image,比如说我们有的时候会把一些业务逻辑的代码放到这个 container 里边。但是我们可能会有测试环境、预发环境或者是线上环境,每个环节里边会有一些差异性。我们这个时候并不希望每一个开发者都去打三个镜像,而是说尽可能的把一些变化的信息抽离出来,以配置文件或者是以环境变量的方式进行注入。在docker 里边我们比较推荐大家的是把这些变的东西放到环境变量里面,然后通过这种环境变量注入的方式来去实现。 -
ADD
和COPY
这两个标签非常相似。add 的出现是比较早的,copy 命令是后来才有的,现在官方推荐使用 copy 命令,而 add 的命令实际上是 copy 命令的一个超集,它还支持了两种额外的场景:一种场景是 add 一个压缩包并且自解压的一个场景。第二个场景是add的一个远程地址,然后并下载到这个image 里边。除了这两种场景,默认情况下使用copy 就已经是足够用的了。 -
CMD
、ENTRYPOINT
、SHELL
三个标签中,对于大部分的开发者来讲。使用 cmd 和 entrypoint 这两条命令就已经足够用了,对于这两个命令的选择:如果需要继承自父 DockerFile 的一些命令的话,使用 entrypoint,否则使用cmd。shell 不常用,shell 最大的一个作用就是它可以在默认的终端环境。你比如说你想用 bush 或者是你想用这个ipad 的ASH等等就这个地方是可以通过shell 这条标签来去进行替换。注意:entrypoint 有一个最大的弊端是它默认的情况下是以/bin/sh -c的方式进行执行的,所以在 entrypoint 里设置的一些命令,它最终不会是以容器里边的PID为1的进程来运行,这样会导致就是如果说我在外层去杀container 时,我的容器里面只有PID=1的进程可以收到这个信号量,但是如果你是以 entrypoint 的方式执行的话,那实际上你自己的这个应用程序是没有办法收到这个信号量的,所以这个时候会导致你的这个容器是直接被杀掉,而不能够做很优雅的停止。如果对于没有捕获信号量的一个场景,那你的container 会自动在超时过后被强制删掉,所以这种场景也会触发一些小小的一个bug。比如说很多客户使用entrypoint 的方式来去运行自己的一个 DockerFile 然后发现说我在这个容器服务上面点击了删除。但是我很久我的这个container 还没有删除掉,我觉得可能是网络慢,或者说容器服务有问题,但是很多时候其实是由于PID不为1的这个进程无法收到停止的信号量,所以只能等到容器超时之后才被强制删除掉。
-
VOLUME
标签表示的在容器里边定义一个挂载点,这个挂载点是可以类似文档一样的告诉后续的开发者怎么去使用这个volume,更多是一个文档的作用并没有实际的一个价值。特别是对于一些依赖外部配置的,可以通过这种方式让你的 DockerFile 变得更好的能够自描述。 -
USER
标签表示 countainer 运行起来的时候使用的这个用户身份是什么。对于很多开发者而言,使用 docker 有一定的安全性考虑。我们以设置容器里边不通的 user 权限,然后来保证容器的主进程是在一个降权的user 的这个环节之下来去执行,其他进程也可控的赋予不同的 user 权限。 -
WORKDIR
标签表示的是当前执行的目录环境。比如说我可以定义当前执行的这个路径是在 /root 下,剩下每一条命令其实都以 root 这个路径来作为根路径来执行的。 -
ONBUILD
、STOPSIGNAL
和HEALTHCHECK
标签只需要了解使用方式就好,并不常用。unbuild 主要是指在这个构建时要执行的动作;stopsignal 表示的是在什么样的信号到来的时候这个容器会被杀掉;最后这个healthcheck 表示的是说,如果health是不通过的话,这个容器就起不起来。
5. DockerFile到Docker Image的底层原理
接下来我们来看一个常见语言 Node.JS 的 DockerFile 实例:
要想编写出一个很精简高效的 DockerFile,然后生成一个高质量的 docker image。我们首先要知道这个 DockerFile 是怎么变成 docker image 的以及 docker image 的原理是什么?
DockerFile 中的每一条指令都是一个 layer,而 docker image 就是一个由多个 layer 组成的文件。相比普通的 ISO 系统镜像来说,分层存储会带来以下优点:
- 第一个比较容易扩展,比如说我们刚才from unbuntu 16.04 构建了我们自己的这个python 应用,其实这就是一个分层的一个体现。ubuntu 是一层,然后我的上层的python 又是一层,而这种方式就可以使镜像变成是一个组装的一个方式。首先拿一个base 镜像,然后再往上去组你自己的这个 layer 层就可以。
- 第二个可以优化存储空间。因为相同的 layer 在 docker 的镜像仓库或是在本地环中只存储一份。比如说你有两个镜像都依赖于 ubuntu 的这个标准镜像且是同样的tag,这样第一个镜像下载一遍之后,第二个镜像就无需下载,因为本地已经有了一份,所以这个是镜像层机制节省空间的优势。
分层机制就是大致原理和上图右侧的比较相像,就是差异性的部分会在独立的一层,而相同的部分会以相同的方式进行存储,这样可以降低很多的存储空间,也可以降低网络的拉取事件。
我们再看一下这个docker image 底层的实现:
我们刚才已经提到了一个 docker image 是由多层组成,比如上图是一个三层的镜像,其中每层里面都会有自己的内容,当我们真正把一个镜像跑成 container 的时候,实际上 container 会增加一个容器的读写层。如果从客户的视角来看的话,实际上是把每一层的这个内容用联合文件系统的方式,用写式复制的一个机制进行拷贝。比如说我对这个 layer1 里面的 file A 进行了一个写,实际上layer 1 做了一件事情就是把这个 file A 拷贝到了这个容器的这个读写层,看上去像我有了这个file A,但实际上他只是拷贝了一个副本。同样,如果我要建立一个新的文件,实际上是在视图层里面先建立了一个文件,再扔到这个容器读写层,然后增加了这一层在该层里面来去实现镜像的内容。所以这个是很多容器存储驱动实现的一个机制,就是 copy on wright,写的时候我再把这个文件复制上来然后进行修改。
常见的存储驱动主要包含了两种:
- 第一种是 AUFS 或者是 OLFS,这种是基于文件的这种存储驱动,特点是启动快,但是效率会稍微差一点。
- 第二种是 Device Mapper,这种更为底层,是基于快驱动的,它的启动会相对来讲比较慢,而且会有很多奇怪的bug,但是效率特别高
以上就是 docker image 的底层实现,总结就是基于多层且遵循 copy on wright 机制。
6. DockerFile的优化实例
接下来我们要看说如何去编写一个高质量的 DockerFile:
比如说在这个例子里面是一个 java 应用。首先依赖基础景象 jdk:8-alpine,然后安装 maven,通过 maven 进行了一个构建,再就是添加了 pom.xml 以及源代码,然后进行打包,把jar包拷贝到相应个目录下,最后启动该应用。
以上 DockerFile 很具有代表性,我们很多开发者刚开始写的 DockerFile 都是这样的类型。那这个有什么问题呢?我们可以做什么样的一个优化呢?
1️⃣ 减少 layer 层数
DockerFile 中的每一个标签所代表的一行都会成为在镜像仓库里边的一个 layer,layer 越来越多会导致这个镜像越来越臃肿,所以降低 layer 是我们优化 docker image 的第一条。方法就是把不同层如果可以放到一层那就把它们放到一层,如下所示:在这个优化里边,我们就将一个14层的 layer 转变成了7层的 layer。
2️⃣ 清理镜像构建的中间产物
我们刚才通过wget的方式安装了maven,此外我们还可能采用 apt 或者是 yum 对方式来安装,安装完成后都会在本地系统里边会存一份安装包的 cache,如果以 apt-get 的方式安装的话实际上它会在 var/lib/apt/lists 下边放一份cache,如果我们不需要的话直接移除掉可以减少存储空间。
所以我们在这里面加了命令 mvn verify package 实际上是把很多我们不需要的maven压缩包进行了删除,降低了存储空间。
3️⃣ 充分利用镜像构建缓存
利用构建的缓存来加快镜像构建速度,Docker构建默认会开启缓存,缓存生效有三个关键点:镜像父层没有发生变化、构建指令不变、添加文件校验和一致。只要一个构建指令满足这三个条件,这一层镜像构建就不会再执行,它会直接利用之前构建的结果。需要注意的是,某一层的镜像缓存失效之后,它之后的镜像层缓存都会失效,所以我们应该把变化最少的部分放在Dockerfile的前面,这样可以充分利用镜像缓存。
4️⃣ 优化网络请求
我们使用一些镜像源或者在 DockerFile 中使用互联网的url时,可以去用一些网络比较好的开源站点来节约时间、减少失败率
例如在国内的maven源一直都不稳定,然后阿里云提供了maven镜像源,我们可以使用该镜像源
5️⃣ 多阶段构建
Docker 从17.05版本提出了一个叫多阶段构建multi- stage builds
的概念,将构建过程分为多个阶段,每个阶段都可以指定一个基础镜像,这样在一个Dockerfile就能将多个镜像的特性同时用到。
如下图所示,看起来好像将两个 DockerFile 合并成了一个 DockerFile,但是上面的FROM标签里多了AS builder
标识,下面的COPY标签里多了--from=builder
标识。作用就是它是把一些流程放到了 DockerFile 里面,例如下图中上半部分做的一件事情就是构建了一个编译环境,最终的产出是一个jar包,而第下半部分的作用就是定义了一个生产环境,它从第一个构建环境里面把这个jar包拷贝一份来进行启动。
这样会有什么好处呢?由于 docker image 它有分层机制,其中也有缓存的一个机制,而缓存机制的原理就是如果没有变化就可以使用缓存,因此这种方式多阶段构建的一个好处就是:如果你的构建没有变化,那实际上对于下半部分的 DockerFile 来讲不需要做任何构建的,所以能够极大的优化构建时间。
比如上述例子的构建时间:第一次优化前构建用了102秒,优化后用了55秒;第二次优化前构建用了86秒,优化后只用了8秒。这正是因为上半部分的 DockerFile 在第二次执行的时候根本不需要做任何的构建过程,只需要执行了下半部分里边的拷贝动作并执行。
那么多阶段构建在存储空间上面有什么优化呢?第一个传统的构建需要137MB的空间,而优化后的只需要81MB的空间;第二次构建的时候,由于是一个增量的构建,所以优化前只增加了9.73MB,但优化之后只增加了1.93KB,所以从存储空间上多阶段构建也有很大的优化。
总结:为了去构建一个高质量的DockerFile,我们有以下几条原则:
- 减少镜像层数。尽量的把一些功能上统一的命令合并到一起。
- 注意清理镜像的中间构建产物。比如说一些 apt 或者是 yum 的安装包,或者是 wget 下载下来的一些内容,只要不需要的就移除掉,这样可以降低很多的镜像空间。
- 注意优化网络请求。我们可以去配置一些镜像源来提高构建速度,可以优化这个镜像的构建时间,减少失败率。
- 尽量去使用构建缓存。我们把一些不变的或者是比较少变化的东西放在 DockerFile 的前面,因为 docker 缓存机制原理是一旦出现有差异,那后面的东西就都是新的需要重新进行,因此如果把不变的东西放在前面就可以更多的使用缓存。
- 使用多阶段进行构建。把代码的编译的流程和真正运行时的流程拆分开,这样可以避免将一些不需要的东西添加到真正运行的 DockerFile 里面,比如说 java 的JDK只是在开发阶段有用,而真正运行时只需要JRE即可。所以这种方式可以很好的利用缓存的机制,也可以更好更快的精简我们的真正的 DockerFile。
7. 编排系统 k8s 入门
在了解 DockerFile 之后,我们接下来要讲的是如何进行编排模板的编写。在提到编排模板之前,我们首先了解一下编排模板所承载的这个编排系统。
对于容器场景比较熟悉的开发者,可能对 Docker Swarm、k8s、Amazon ECS 都有所耳闻,这三个可以说是这几年来厮杀比较激烈的容器编排系统,接下来我们以其中最为主流的 k8s 为例进行讲解。
我们要想写好就是 k8s 里面的 yaml 文件,首先我们要理解就是 yaml 文件的设计原理,以及它在 k8s 中的实现原理。
下图是 k8s 的架构图:
从物理的角度来讲,k8s network 会分为master
节点和 worker
节点。上图中蓝色部分就是 master 节点,而黄色部分是 worker 节点。
- 在 master 节点上面会部署 k8s 里的几个主要管控的组件,比如说
kube-apiserver
,它是 k8s 中核心的驱动机,主要提供的是API的服务,任何其他的 k8s 里的组件都会和它打交道进行数据交换。kube-apiserver 之下是etcd
,etcd 是一个分布式存储,k8s 集群中的所有数据都存储在当中,并且 kube-apiserver 也会暴露ETCD的一些接口给其他的组件进行数据的存储和查询的功能。再往下是kube-scheduler
,它是 k8s 里边进行调度的组件。然后再往下是cloud-controller-manager
,它是 k8s 里面负责这个云资源管理的组件。再往下是kube-controller-manager
,它是 k8s 里负责一些抽象逻辑的管理组件。 - 在 worker 节点上面主要会运行
kubelet
和kube proxy
,kubelet 主要会进行 pod 创建的这一部分,而 kube-proxy 主要是负责网络的部分。
上图中我们已经大体的了解 k8s 组件的分布情况以及每一个组件的基本功能,接下来我们要看我们要以一个实例来看看各个组件之间是怎么进行通信的,以及是怎么完成一个pod 创建流程的。
我们首先来看一个实际中 pod 创建的这个流程,来看一下这个数据流是怎么走的:
在 K8S 里面创建一个pod,首先需要对应的 yaml 文件,然后把这个 yaml 文件提交给 kube-apiserver。kube-apiserver 做的事情非常简单,它只是把这个 yaml 文件的内容传给后边的底层存储 ETCD 并写到 ETCD 中。然后接下来 kube-schedule 会通过 watch 机制定期的轮询 kube-apiserver 中关于一些资源的变化,这里是 pod 的创建,schedule 会去 watch pod 这个资源,然后它发现 pod 有一个新的 yaml 文件产生,他就会把这个 pod 的相关信息拉下来,就可以看到这个 pod 的申请的资源,然后 kube-schedule 会经过一系列的计算选择其中的一个 worker 节点,然后再把这个 worker 节点的信息 append 到刚才生成的这个文件上,然后再把这个 yaml 文件回写给 kube-apiserver ,kube-apiserver 又把这个信息回写到 ETCD,然后 kubelet 也监听 kube-apiserver 里这个pod的变化,它发现有一个 pod 上面的 yaml 文件发生变化,而且绑定了一个节点,然后 kubelet 发现这个节点和我自己所在的这个节点相同,那它就会把这个 yaml 文件拉下来。然后在自己的节点上去运行,于是通过docker run 这条命令来运行起一个 container,再把这个 container 的状态回写给ETCD。
这就是 pod 创建一个流程,在其中我们可以看到 kube-apiserver、ETCD、kube-schedule、kubelet 和docker 之间相互交互的流程,可以发现其中除了kube-apiserver 和其他组件有直接的交互,其他组件之间是没有直接交互的。
这个其实是 k8s 的设计原则和设计概念,接下来我们再深入来看一下 k8s 的设计思路是什么样子的:
-
一是面向资源简化模型。在 k8s 中有各种各样的抽象,比如对交付对象的抽象 pod。在 Docker 中,我们通常称容器为 container,两者的区别在于 pod 是 container 之上的一层抽象,一个 pod 可能包含着多个 container。此外,k8s 里还有其他的抽象例如 Deployment、StatefulSet、DaemonSet 等等,这就是 k8s 的一种设计方式,它将很多交互中涉及到的名词或实体都抽象成了一种资源,并通过 go-restful 框架在 kube-apiserver 中实现了对应资源各种各样的 restful API,也就是说每一个在 k8s 里面定义的抽象在 kube-apiserver 里面都能够找到与之对应的 restful API 用于操作。
-
二是异步动作保障性能。上述在创建一个 pod 的流程中我们提到,k8s 组件之间都通过了一种 watch 监听机制来实现,就是一种生产者消费者模式来进行远程监听的思路,通俗来讲就是你有变化就通知我来进行操作,这其实是一个异步的动作,所有依赖于资源的组件都通过这种异步监听的方式来实现,可以很好的来控制自己执行命令的频度及时机来保证性能。
在 k8s 实现这种异步监听的原理是通过 informer 机制实现对,informer 可以说是 golang 的一个类库,专门用来去做 k8s 中这种资源监听的场景,其中主要会做两件事情:一是定义的各种监控的端点,比如说创建更新、删除等等这样的一个监控端点,你可以通过在这个监控端点里面注册一些方法来实现你自己的逻辑;二是 informer 里边实现了一个 reset 方法,在 k8s 中是命令是不可靠的,意思就是并不是说我下发一个创建pod 的命令就一定能够执行成功,还必须要有一种机制能够以反馈的方式去验证是否执行成功,或者不成功的时候能有一种方式能够去订正。reset 就是以一个补偿的方式来去实现失败订正,比如说我现在 watch pod 的变化,那可能我第一次的时候 miss 掉了没有 watch,通过 reset 可以定期的轮询刚才的 pod list 来查看这个pod 是否变化,如果有变化就再去全量更新一次,以此来帮我们协助进行订正这个动作。
-
三是 k8s 是基于状态机来实现这个状态基线。所有的信息都通过期望、实施、反馈的机制存储在 etcd 中,数据即状态。这个地方怎么理解呢?上述我们看到了在整个数据流中流转的实际上是 k8s 的 yaml 文件,它不断的被各种各样的组件 append 各种各样的信息,其中包括有一些状态实际上是由组件进行修改的。这样我们只要根据 k8s 中流转的 yaml 文件就知道当前 pod 的状态,这其实就是状态机的一种机制,就是我先设定我期望它能够达到的状态,然后我再通过一些机制去实现它,如果这个状态没有达到,我立马退回到另一个状态。但是我在任何时刻都能通过一个明确的配置文件来知道当前所处的状态,这样可以很好的协助我们来进行一些状态的迁移,甚至一些 back up、restore 等等。
-
四是反馈机制保证状态。刚才我们也提到 informer 里边有这种定期的 reset 方法来处理中间态,来保证这个状态机能够到达一个期望的一个状态。
-
五是组件之间的可插拔。组件之间的通信要么通过 kube-apiserver 进行中转,要么通过这个API-group 进行解耦。组件之间没有强依赖关系,部分的组件还自带熔断器机制,比如说像 dashboard 组件里边就包含了类似像 JHipster 之类的熔断器可以保证有一些组件不存在的时候,依然可以有一个降级的显示。
在 k8s 里面最重要的一个概念就是抽象
,它抽象了很多交互上面的一些内容。比如说下图实际上是 k8s 里面抽象出的一些逻辑概念:
k8s 对常见的应用场景里都有对应的抽象,比如说一般的普通无状态应用都可以用 Deployment 来表示,对于一些有状态的应用可以会抽象成 StatefulSet,对于像在每台机器上都要运行的守护类型的应用,它会用这个 DaemonSet 进行抽象。对于一些生命周期比较短、需要定期执行/定时执行的一些应用,会使用这个 CornJob 或者 Job 来去定义。
此外,对于接入层 k8s 也有抽象 service,还有对配置文件的抽象 ConfigMap,对敏感信息的抽象 Secret。
所以在 k8s 将所有能想到的交付内容都有对应的抽象,而每一种抽象都可以用 yaml 文件进行表达也有对应的 API 进行操作,所以 k8s 可以很好的将这个交付流程进行自动化,因为每一个抽象已经可以用代码来表示,所以就可以很好的进行交付。
再接下来我们来看一下在 k8s 里面的两个非常典型的 yaml 示例。第一个是 Deployment 的 yaml,第二个是 service 的 yaml。
然后我们首先来看这个deployment 的yaml:
apiVersion
表示资源的API版本。在 k8s 里所有抽象都是一种资源,而资源 api 的设计的方式是 restful 的形式,所以为了保证版本的兼容,它给每一个 api 都定义了一个版本。在这个例子里面是 apps/v1 的这个版本。kind
表示的就是抽象的类型,在这个例子里是 deployment。metadata
表示的是对于 kind 为 Deployment 的 yaml 里边的 meta 信息,包括一个名字和一个label标签。spec
是真正的定义的参数。比如说这个 replicas 为3表示的是有三个副本,然后 selector 下面有一个 match labels,表示是和哪个label 进行匹配。然后在 template 里面实际上是相应的 pod 的相关的一些配置,比如说他设置了这个 meta data 里边有这个label,然后 spec 里面有 containers 指定使用的镜像和暴露的端口。
service 负责的是接入层,它与相应的 Deployment 之间挂载的方式是通过 label、selector 来实现的。像本地里边要定义的这个selector,然后设置的这个label 和 deployment 的这个label 是一致的,都是nginx
上图最右侧是通过 kubectl descrive 这个nginx的Deployment ,然后可以获取上图的一些信息,其实这些信息大部分情况下都是从在 k8s 的 yaml 文件获取过来的,除了底下的 events 是一个独立的部分。
8. 总结
k8s 的设计思路就是通过抽象来去定义标准化交付,掌握了这个设计思路之后可以协助这个开发者入门k8s。这样开发者只需要了解 k8s 定义了哪些抽象以及相应面向的场景,然后剩下的就是查文档根据不同场景选择合适的抽象进行使用。
9. 工具推荐
1️⃣ Kompose
现在依然有很多的开发者在使用 Swarm,而且有客户正在将 Swarm 的一些应用向 k8s 进行迁移。这里给大家介绍一个工具叫 Kompose,这个工具就是为了解决 Swarm 应用向 k8s 迁移问题,它可以它有两种方式:一种方式是直接将一个docker compose 在 k8s 集群里边部署运行起来;第二种方式是将一个 docker compose 文件转换成 k8s 的 yaml 文件。
下图所示就是通过一个标准的一个 Swarm 的一个 yaml 转换成了 k8s 的多个 yaml。因为 k8s 的yaml 其实和swarm 的 yaml再表达的内容上面包括表达的形象利益上来讲是有很多的相似性的。所以可以Kompose 的一个目标就是将这个docker composed swarm 转换成K8S的yaml。
2️⃣ Derrick
最后给大家介绍一个阿里云独立开发的一个工具叫做 Derrick
。Derrick 是一个容器迁移工具,是由 python 编写的一个命令行工具,它可以帮助你动态的生成 DockerFile / docker compose,然后在 k8s 里面也可以生成 k8s 的 yaml 文件,以及 Jenkins file。它目前支持 Swarm 和 k8s 两种编排系统,支持 Java、Node.JS、Golang、Python、php 5种语言,三十余个框架。
它的大致的一个实现原理就是你有一个源代码,然后在这个源代码根目下执行 derrick init 命令,derrick 就会探测源代码使用的语言版本、框架类型以及一些特征信息(比如说包管理器的一些特征信息),然后调用自己内置的一些插件或者是开发者自己定义的插件,来实现相应的 build pack 机制。比如说生成 DockerFile、生成 docker compose 的yaml、 K8s的yaml、Jenkins file、publines as code 的CI/CD的配置。然后当生成完这些配置之后我们再执行 derrik up 时他会将你的这个 container 进行本地一个编译和打包并进行本地的一个验证,没有问题后,你再把这些生成的配置推送到远程的代码仓库,然后触发CI/CD的一个流程来实现完整的一个本地交互交互链的过程。
今天我们回顾一下我们的这个课程,我们首先讲了关于 DokcerFile 该怎么去编写,然后介绍在K8S里面的一些内置组件之间的相互依赖,以及K8S是怎么通过抽象的方式使用压迫文件来定义各种交付的一个产物。
最后也介绍了类似 Kompose 和 Derrick 的两个工具,在下一节中,我们会主要针对于容器化的标准交付里面的CI/CD的部分给大家讲解一下,就是一些现成的一些解决方案,以及怎么快速的搭建一个CI/CD server。
以上是关于容器云原生DevOps学习笔记——第二期:如何快速高质量的应用容器化迁移的主要内容,如果未能解决你的问题,请参考以下文章
容器云原生DevOps——第二期:如何快速高质量的应用容器化迁移
容器云原生DevOps学习笔记——第一期:DevOps微服务容器服务
容器云原生DevOps——第一期:DevOps微服务容器服务(学习笔记)
容器云原生DevOps学习笔记——第一期:DevOps微服务容器服务