K8S 应用开发指北

前言

  在周志明的『凤凰架构』中需要思考这样一个问题,如何用不可靠的部件来构造一个可靠的系统?对于程序员来说,写的代码从某种程度上来说都是不可靠的,但这些代码组成的一些系统却可以是可靠的。程序员对于错误的处理可以分为两派,一派是必须对错误进行处理,以保证系统的稳定行;另一派不对错误进行处理,任由程序 crash,只要有兜底方案,后面再不断完善。这两派并无孰优孰劣,只是两种不同的思维方式,甚至在同一个程序中,有些错误会处理,有些错误不会处理,这都是可能的。K8S 作为事实上的云原生操作系统,其目的就是为了将程序员写的各个程序组装成一个稳定的系统,并减少运维成本。

基础篇

  K8S 调度的基本单元是 Pod,Pod 也是 K8S 自带的一个资源对象,其可以简单理解为是一个容器集合体,程序员可控的容器有两类(Pause 容器除外),一类是 InitContainer,另一类是普通业务容器,InitContainer 按数组顺序创建,顺序执行,若一个失败,则整个 Pod 创建失败,普通业务容器同样按数组顺序创建,但异步执行,所以执行顺序不可控(可以通过 postStart Hook 简单控制一下)。由于 InitContainer 先于 Pod 其他容器执行,所以一般用来做普通业务容器执行前置条件的一些事情,比如:下载文件,初始化配置,状态消息通知等。

  同一 Pod 中存储卷和网络可以共享。存储卷共享是指 Pod 内各容器可以挂载相同存储卷,从而数据共享。K8S 目前支持的存储卷共有三种:第一种是 emptyDir,这种存储是临时的,只能在 Pod 内使用,当 Pod 被销毁时,该存储的内容也会消失,只能在同一 Pod 内共享数据;第二种是 hostPath,这种存储会直接和集群中物理机存储相关联,是一种跨 Pod 持久化存储,但仅限该物理机,当 pod 被调度到其他物理机时就无法实现跨 Pod 共享数据;最后一种是外部存储(NFS,Ceph,GlusterFS,AWS EBS 等),这种方式可以真正实现数据持久化并共享,而且可以支持存储与计算分离,对系统会更友好一些,当然运维的成本也会更大。当然除了 K8S 自身提供的存储卷挂载可以实现数据共享,从程序的角度上,使用传统的方式一样也能数据共享,如数据库,DFS,OSS 等。

  而网络共享是指 Pod 内各容器直接可以使用 localhost 以及容器暴露的端口进行相互通信,K8S 的端口有三种,分别为:容器端口(containerPort,容器中对外暴露的端口),集群内端口(port,集群内 pod 相互通信的端口),集群外端口(nodePort,集群外请求集群内的端口),其中容器端口和集群内是正常的动态端口,取值范围为 [1024, 65535],集群外端口只能设置为 [30000, 32767],若集群中服务不与集群外通信,则只需要设置集群内端口就行。K8S 中 IP 也同样有三种,分别为:Pod IP(两不同 Pod 资源对象相互通信的地址,集群外不可访问),Cluster IP(Service 资源对象的通信地址,集群外不可访问),Node IP(K8S 物理节点的 IP 地址,是真实的物理网络,集群外配合 nodePort 即可访问)。集群内端口和集群外端口由 K8S 的 Service 资源提供设置。在创建 Service 时需要注意,一个 Pod 资源对应一个 Service 资源,不要想着一个 Service 管理两个 Pod 暴露的端口,这样做会使 Service 提供服务的能力异常,经常会接口超时

  K8S 编程可以简单称之为面向 config 编程,一切需要动态变化的程序初始化变量,都应该以 config 的形式提供,然后交给运维就行,这样可以避免程序员频繁的修改程序,减少运维负担,K8S 的 config 有三种形式,第一种是程序启动参数,通过创建容器时的 args 参数配置;第二种是系统环境变量,通过创建容器时的 env 参数配置;最后一种是 K8S 提供的 ConfigMap 资源,该资源可以从文件,目录或 key-value 字符串创建,创建后的 ConfinMap 被全集群同命名空间所共享,可以通过 volumes 参数挂载到 pod 中,进而 mount 进容器中,被程序读取。前两种 config 方式对于配置变量少的可以使用,当配置变量很多或配置参数很长时,还是使用 ConfigMap 比较合适。

调度篇

  调度,广义上的调度可指一切管理安排,CPU 的指令执行就涉及到三级缓存的调度,程序运行时的 GC 可认为是运行时对内存资源的调度,操作系统的进程轮转可认为是系统对进程的调度,而 K8S 中的调度可简单理解为是对操作系统的调度。

  K8S 的调度可简单分为两个层面上的调度,最底层的调度自然是 K8S 自身的调度策略,根据不同的资源用度和调度策略将 Pod 分配到不同的物理节点之上执行,根据指定的重启或恢复策略启动相应的 Pod,这个层面上的调度,K8S 有一套默认的调度器,对于特殊的调度需求,K8S 也支持自定义调度器,使用外部调度器代替默认调度器,这个层面的调度器 Shaun 没做太多研究,所以在这篇里对这层面的调度器不做过多描述。Shaun 接触过的是更上层的调度器,业务层面的调度服务,业务调度服务一般与业务紧密相关,但最核心的一点就是能够从业务入手,负责 Pod 的创建和销毁,并能掌握其运行状态,就算是完成了一个基础的业务调度服务器。

  在设计业务调度服务时,有一种通用的模式,可以称之为 master-worker 模式,与同名的并发模式细节上有所不同,这里的 master 是指调度服务本体,只负责对外服务,资源监控,以及任务分发,任务状态感知等,不负责做具体的任务,一般也不关心任务的输入输出。在部署 master 时,一般会创建一个 Service 资源对象,毕竟其主要功能就是对外服务,master 一般由运维进行部署创建销毁。而 worker 是指真正做任务的 Pod,该 Pod 中可能会有多个容器,主容器负责真正执行任务,其他一些容器可能会负责保障任务的前置条件(输入,配置等),以及向 master 汇报任务执行状态信息(执行任务的主容器可能并不知道 master 的存在)等。worker 对应的 Pod 一般由 master 进行创建销毁,worker 的一些配置信息则可能会由运维管理。

  由于 K8S 并没有在整个集群物理资源之上抽象出一层集群资源,所以 K8S 分配的节点实际还是在物理机上,若所有物理机剩余资源(是单个剩余资源,而不是所有剩余资源之和)都不满足 Pod 所需资源,则该 Pod 无法调度,类比内存碎片化,可以称之为资源碎片化。所以在创建 Pod 时,所需资源最好不要太多,以免调度失败。

实践篇

  Shaun 目前在 K8S 上开发的主要就是重计算(单机计算时间以小时计)调度服务。这类调度服务其实也分两种,一种是并发调度,一种是流水线(pipeline)式的串行调度,当然也可以将这两种混合起来,串行中有并行。在设计这类调度服务时,需要考虑集群上的资源(内存,CPU)是否足够,若不足,则可以考虑加入一个简单的等待机制,将任务放进一个队列中,当然加入这样一个等待机制,又会增加系统复杂性,需要考虑队列容量,队列优先级等。所以可执行的最小任务消耗的资源越少约好,否则集群中可能完全无法执行相关任务。

  由于 Shaun 是独立开发,能完全控制 master 和 worker 的编写,所以 worker 设计的比较简单,一个主容器即完成了前置数据处理,主任务执行,执行状态汇报等全部事情,这是从时间和性能上以及系统复杂度上等多方面权衡的结果,当然在时间足够人手够的情况,是应该把现有的 worker 进一步分离的,而 master 就是比较通用的设计,资源监控,任务队列,任务 Pod 创建与销毁,任务状态信息保存,服务接口等,其中常规的服务接口应该有添加任务,开始任务,停止任务,恢复任务,删除任务,任务状态查询,任务日志查询,任务状态汇报等接口,如果任务是并行且无依赖的,还应该支持开始指定子任务等接口。

  在工作中,Shaun 也接触到一个 pipeline 式的任务调度服务,pipeline 式的工作流有个特点就是下一个子任务的输入必定依赖上一个子任务的输出,在这个任务调度服务中,其子任务的输入输出都是文件态,并且 master 不关心子任务的输入输出,子任务的执行程序也不知道 master 的存在,尽量低耦合。在云上,文件态的存储载体比较好的自然是 OSS,但原本的子任务执行程序只支持本地读取文件,而且在原来的程序中引入 OSS 的读写逻辑并不十分合适,所以在 K8S 中引入了 NFS,由 master 负责将 NFS 挂载到各子任务的 Pod 中,并在挂载到主容器时使用 SubPath 完成 pipeline 之间的资源隔离,使用 emptyDir 完成各子任务之间的资源隔离,每条 pipeline 开始的子任务是从 OSS 中拉取文件到 NFS 中对应的 SubPath 目录中,结束的子任务是将 NFS 中对应的 SubPath 目录中约定好的生成物上传到 OSS 中,并清空该 SubPath 目录,从而使原来的程序在 IO 这块完全不用改动。在监听任务运行状态方面,有两种方案:一种是利用 K8S 的 InitContainer,另一种是借助 K8S 的 shareProcessNamespace。InitContainer 的方案比较简单,InitContainer 第一个容器只做汇报子任务开始这一件事, 第二个容器则是真正执行子任务的容器,而业务容器只做汇报子任务结束这一件事,该方案利用 InitContainer 顺序且先于业务容器执行这两特点,并且若执行子任务的容器失败,则 Pod 也会创建失败,查询 Pod 状态即可知道子任务是否正常运行。而 shareProcessNamespace 的方案稍微复杂一些,同样使用一个 InitContainer 做汇报子任务开始这件事,而业务容器中放两个容器:一个主容器和一个 sidecar 容器(希望 K8S 原生支持的 SideCar 早日做好 ╯△╰),sidecar 容器中以轮询的方式监听主容器的运行状态(查询是否存在主进程)以及是否正常退出(获取容器退出码),并向 master 推送状态信息,该方案借助进程空间共享,使 sidecar 容器能直接查询主容器中的进程,从而达到监听主容器运行状态的目的,该方案的执行还需要一个小 trick,就是要让主容器先执行,由两种方案:一种是借助 postStart Hook,另一种是直接让 sidecar 容器先休眠个 10s 钟。关于 sidecar 容器的另外一种应用方案可参考 Nginx容器配置文件如何热更新?

  虽然分布式任务调度框架有很多,eg:AirflowLuigi 以及 DolphinScheduler 等,但目前与 K8S 联系最紧密的应该就是 Argo 了,其利用 K8S 的自定义资源对 K8S 已有功能进行扩展,仅使用 YAML 即可完成整个 pipeline 的任务调度和部署,虽然在并发任务调度时有一定的缺陷,但仅使用 YAML 表示其对 K8S 运维的足够友好性,对于常规 pipeline 式任务,Argo 已足以应付,除特殊需求外,程序员可少写很多代码。

附录

  对于 Spring 编写的程序,在 K8S 中运行,在导出日志时可参考 k8s:获取pod的ip,通过 valueFrom 使用 Pod 的 metadata 作为环境变量,以区分日志的来源,不过挂载存储时最好还是用外部存储,用 hostPath 的话就需要保证每个物理节点都有相同的日志存储目录。

后记

  K8S 作为云原生时代的操作系统,不要求人人都完全掌握,但至少需要了解,知道什么该开发干,什么该运维干,这样才能充分发挥各个角色(包括 K8S)的价值。