原文:architecting-applications-for-kubernetes
作者:Justin Ellingwood
译者:朱雷(piglei)
简介
设计并运行一个兼顾可扩展性、可移植性和健壮性的应用是一件很有挑战的事情,尤其是当系统复杂度在不断增长时。应用或系统本身的架构极大的影响着其运行方式、对环境的依赖性,以及与相关组件的耦合强弱。当应用在一个高度分布式的环境中运行时,如果能在设计阶段遵循特定模式,在运维阶段恪守特定实践,就可以帮助我们更好的应对那些最常出现的问题。
尽管软件设计模式和开发方法论可以帮助我们生产出满足恰当扩展性指标的应用,基础设施与运行环境也在影响着已部署系统的运维操作。像 Docker、Kubernetes 这些技术可以帮助团队打包、分发、部署以及在分布式环境中扩展应用。学习如何最好的驾驭这些工具,可以帮助你在管理应用时拥有更好的机动性、控制性和响应能力。
在这份指南里,我们将探讨一些你可能想采用的准则和模式,它们可以帮助你在 Kubernetes 上更好的扩展和管理你的工作集(workloads)。尽管在 Kubernetes 上可以运行各种各样的工作集,但是你的不同选择会影响运维难度和部署时的可选项。你如何架构和构建应用、如何将服务用容器打包、如何配置生命周期管理以及在 kubernetes 上如何操作,每一个点都会影响你的体验。
为可扩展性做应用设计
当开发软件时,你所选用的模式与架构会被很多需求所影响。对于 Kubernetes 来说,它最重要的特征之一就是要求应用拥有水平扩展能力 - 通过调整应用副本数来分担负载以及提升可用性。这与垂直扩展不同,垂直扩展尝试使用同样的参数将应用部署到性能更强或更弱的服务器上。
比如,微服务架构是一种适合在集群中运行多个可扩展应用的软件设计模式。开发者们创建一些可组合的简单应用,它们通过良好定义的 REST 接口进行网络通信,而不是像更复杂的单体式应用那样通过程序内部机制通信。将单体式应用拆分为多个独立的单一功能组件后,我们可以独立的扩展每个功能组件。很多之前通常存在于应用层的组合与复杂度被转移到了运维领域,而它们刚好可以被像 Kubernetes 这样的平台搞定。
比特定的软件模式更进一步,云原生(cloud native)应用在设计之初就有一些额外的考量。云原生应用是遵循了微服务架构模式的程序,拥有内置的可恢复性、可观测性和可管理性,专门用于适应云集群平台提供的环境。
举例来说,云原生应用在被创造出时都带有健康度指标数据,当某个实例变得不健康时,平台可以根据指标数据来管理实例的生命周期。这些指标产生(也可以被导出)稳定的遥控数据来给运维人员告警,让他们可以依据这些数据做决策。应用被设计成可以应付常规的重启、失败、后端可用性变化以及高负载等各种情况,而不会损坏数据或者变得无法响应。
遵循 “12 法则应用”应用理论
在创建准备跑在云上的 web 应用时,有一个流行的方法论可以帮你关注到那些最重要的特征:“12 法则应用理论”( Twelve-Factor App)。它最初被编写出来,是为了帮助开发者和运维团队了解所有被设计成在云环境运行的 web 服务的共有核心特征,而对于那些将在 Kubernetes 这种集群环境中运行的应用,这个理论也非常适用。尽管单体式应用可以从这些建议中获益,围绕这些原则设计的微服务架构应用也会工作的非常好。
“12 法则”的一份简单摘要:
- 基准代码(Codebase): 将你的所有代码都放在版本控制系统中(比如 Git 或者 Mercurial)。被部署的内容完全由基准代码决定。
- 依赖(Dependencies):应用依赖应该由基准代码全部显式管理起来,无论是用 vendor(指依赖代码和应用代码保存在一起),还是通过可由包管理软件解析安装的依赖配置文件的方式。
- 配置(Config):把应用配置参数与应用本身分开来,配置应该在部署环境中定义,而不是被嵌入到应用本身。
- 后端服务(Backing Services):本地或远程的依赖服务都应该被抽象为可通过网络访问的资源,连接细节应该在配置中定义。
- 构建、发布、运行(Build, release, run):应用的构建阶段应该完全与发布、运维阶段区分开来。构建阶段从应用源码创建出一个可执行包,发布阶段负责把可执行包和配置组合起来,然后在运行阶段执行这个发布版本。
- 进程(Processes):应用应该由不依赖任何本地状态存储的进程实现。状态应该被存储在第 4 个法则描述的后端服务中。
- 端口绑定(Port binding):应用应该原生绑定端口和监听连接。所有的路由和请求转发工作应该由外部处理。
- 并发(Concurrency):应用应该依赖于进程模型扩展。只需同时运行多份应用(可能分布在不同服务器上),就能实现不调整应用代码扩展的目的。
- 易处理(Disposability):进程应该可以被快速启动、优雅停止,而不产生任何严重的副作用。
- 开发环境与线上环境等价(Dev/prod parity):你的测试、预发布以及线上环境应该尽可能一致而且保持同步。环境间的差异有可能会导致兼容性问题和未经测试的配置突然出现。
- 日志(Logs):应用应该将日志输出到标准输出(stdout),然后由外部服务来决定最佳的处理方式。
- 管理进程(Admin processes):一次性管理进程应该和主进程代码一起发布,基于某个特定的发布版本运行。
依照“12 法则”所提供的指南,你可以使用完全适用于 Kubernetes 运行环境的模型来创建和运行应用。“12 法则”鼓励开发者们专注于他们应用的首要职责,考虑运维条件以及组件间的接口设计,并使用输入、输出和标准进程管理功能,最终以可被预见的方式将应用在 Kubernetes 中跑起来。
容器化应用组件
Kubernetes 使用容器在集群节点上运行隔离的打包应用程序。要在 Kubernetes 上运行,你的应用必须被封装在一个或者多个容器镜像中,并使用 Docker 这样的容器运行时执行。尽管容器化你的组件是 Kubernetes 的要求之一,但其实这个过程也帮助强化了刚刚谈到的“12法则应用”里的很多准则,从而让我们可以简单的扩展和管理应用。
举例来说,容器提供了应用环境与外部宿主机环境之间的隔离,提供了一个基于网络、面向服务的应用间通信方式,并且通常都是从环境变量读取配置、将日志写到标准输出与标准错误输出中。容器本身鼓励基于进程的并发策略,并且可以通过保持独立扩展性和捆绑运行时环境来帮助保持开发/线上环境一致性(#10 Dev/prod parity)。这些特性让你可以顺利打包应用,从而顺利的在 Kubernetes 上运行起来。
容器优化准则
因为容器技术的灵活性,我们有很多不同种封装应用的方式。但是在 Kubernetes 环境中,其中一些方式比其他方式工作的更好。
镜像构建(image building),是指你定义应用将如何在容器里被设置与运行的过程,绝大多数关于“如何容器化应用”的最佳实践都与镜像构建过程有关。通常来说,保持镜像尺寸小以及可组合会带来很多好处。在镜像升级时,通过保持构建步骤可管理以及复用现有镜像层,被优化过尺寸的的镜像可以减少在集群中启动一个新容器所需要的时间与资源。
当构建容器镜像时,尽最大努力将构建步骤与最终在生产环境运行的镜像区分开来是一个好的开始。构建软件通常需要额外的工具、花费更多时间,并且会生产出在不同容器里表现不同、或是在最终运行时环境里根本不需要的内容。将构建过程与运行时环境清晰分开的办法之一是使用 Docker 的“多阶段构建(multi-stage builds)” 特性。多阶段构建配置允许你为构建阶段和运行阶段设置不同的基础镜像。也就是说,你可以使用一个安装了所有构建工具的镜像来构建软件,然后将结果可执行软件包复制到一个精简过的、之后每次都会用到的镜像中。
有了这类功能后,基于最小化的父镜像来构建生产环境镜像通常会是个好主意。如果你想完全避免由 ubuntu:16.04
(该镜像包含了一个完整的 Ubuntu 16.04 环境)这类 “Linux 发行版” 风格父镜像带来的臃肿,你可以尝试用 scratch
- Docker 的最简基础镜像 - 来构建你的镜像。不过 scratch
基础镜像缺了一些核心工具,所以部分软件可能会因为环境问题而无法运行。另外一个方案是使用 Alpine Linux 的 alpine
镜像,该镜像提供了一个轻量但是拥有完整特性的 Linux 发行版。它作为一个稳定的最小基础环境获得了广泛的使用。
对于像 Python 或 Ruby 这种解释型编程语言来说,上面的例子会稍有变化。因为它们不存在“编译”阶段,而且在生产环境运行代码时一定需要有解释器。不过因为大家仍然追求精简的镜像,所以 Docker Hub 上还是有很多基于 Alpine Linux 构建的各语言优化版镜像。对于解释型语言来说,使用更小镜像带来的好处和编译型语言差不多:在开始正式工作前,Kubernetes 能够在新节点上快速拉取到所有必须的容器镜像。
在 Pod 和“容器”之间做选择
虽然你的应用必须被“容器”化后才能在 Kubernetes 上跑起来,但 pods(译注:因为 pod、service、ingress 这类资源名称不适合翻译为中文,此处及后面均使用英文原文) 才是 Kubernetes 能直接管理的最小抽象单位。pod 是由一个或更多紧密关联的容器组合在一起的 Kubernetes 对象。同一个 pod 里的所有容器共享同一生命周期且作为一个独立单位被管理。比如,这些容器总是被调度到同一个节点上、一起被启动或停止,同时共享 IP 和文件系统这类资源。
一开始,找到将应用拆分为 pods 和容器的最佳方式会比较困难。所以,了解 Kubernetes 是如何处理这些对象,以及每个抽象层为你的系统带来了什么变得非常重要。下面这些事项可以帮助你在使用这些抽象概念封装应用时,找到一些自然的边界点。
寻找自然开发边界是为你的容器决定有效范围的手段之一。如果你的系统采用了微服务架构,所有容器都经过良好设计、被频繁构建,各自负责不同的独立功能,并且可以被经常用到不同场景中。这个程度的抽象可以让你的团队通过容器镜像来发布变更,然后将这个新功能发布到所有使用了这个镜像的环境中去。应用可以通过组合很多容器来构建,这些容器里的每一个都实现了特定的功能,但是又不能独立成事。
与上面相反,当考虑的是系统中的哪些部分可以从独立管理中获益最多时,我们常常会用 pods。Kubernetes 使用 pods 作为它面向用户的最小抽象,因此它们是 Kubernetes API 和工具可以直接交互与控制的最原生单位。你可以启动、停止或者重启 pods,或者使用基于 pods 建立的更高级别抽象来引入副本集和生命周期管理这些特性。Kubernetes 不允许你单独管理一个 Pod 里的不同容器,所以如果某些容器可以从独立管理中获得好处,那么你就不应该把它们分到到一个组里。
因为 Kubernetes 的很多特性和抽象概念都直接和 pods 打交道,所以把那些应该被一起扩缩容的东西捆绑到一个 pod 里、应该被分开扩缩容的分到不同 pod 中是很有道理的。举例来说,将前端 web 服务器和应用服务放到不同 pods 里让你可以根据需求单独对每一层进行扩缩容。不过,有时候把 web 服务器和数据库适配层放在同一个 pod 里也说得过去,如果那个适配器为 web 服务器提供了它正常运行所需的基本功能的话。
通过和支撑性容器捆绑到一起来增强 Pod 功能
了解了上面这点后,到底什么类型的容器应该被捆绑到同一个 pod 里呢?通常来说,pod 里的主容器负责提供 pod 的核心功能,但是我们可以定义附加容器来修改或者扩展那个主容器,或者帮助它适配到某个特定的部署环境中。
比如,在一个 web 服务器 pod 中,可能会存在一个 Nginx 容器来监听请求和托管静态内容,而这些静态内容则是由另外一个容器来监听项目变动并更新的。虽然把这两个组件打包到同一个容器里的主意听上去不错,但是把它们作为独立的容器来实现是有很多好处的。nginx 容器和内容拉取容器都可以独立的在不同情景中使用。它们可以由不同的团队维护并分别开发,达到将行为通用化来与不同的容器协同工作的目的。
Brendan Burns 和 David Oppenheimer 在他们关于“基于容器的分布式系统设计模式”的论文中定义了三种打包支撑性容器的主要模式。它们代表了一些最常见的将容器打包到 pod 里的用例:
-
Sidecar(边车模式):在这个模式中,次要容器扩展和增强了主容器的核心功能。这个模式涉及在一个独立容器里执行非标准或工具功能。举例来说,某个转发日志或者监听配置值改动的容器可以扩展某个 pod 的功能,而不会改动它的主要关注点。
-
Ambassador(大使模式):Ambassador 模式使用一个支援性容器来为主容器完成远程资源的抽象。主容器直接连接到 Ambassador 容器,而 Ambassador 容器反过来连接到可能很复杂的外部资源池 - 比如说一个分布式 Redis 集群 - 并完成资源抽象。主容器可以完成连接外部服务,而不必知道或者关心它们实际的部署环境。
-
Adaptor(适配器模式):Adaptor 模式被用来翻译主容器的数据、协议或是所使用的接口,来与外部用户的期望标准对齐。Adaptor 容器也可以统一化中心服务的访问入口,即便它们服务的用户原本只支持互不兼容的接口规范。
使用 Configmaps 和 Secrets 来保存配置
尽管应用配置可以被一起打包进容器镜像里,但是让你的组件在运行时保持可被配置能更好支持多环境部署以及提供更多管理灵活性。为了管理运行时的配置参数,Kubernetes 提供了两个对象:ConfigMaps 与 Secrets。
ConfigMaps 是一种用于保存可在运行时暴露给 pods 和其他对象的数据的机制。保存在 ConfigMaps 里的数据可以通过环境变量使用,或是作为文件挂载到 pod 中。通过将应用设计成从这些位置读取配置后,你可以在应用运行时使用 ConfigMaps 注入配置,并以此来修改组件行为而不用重新构建整个容器镜像。
Secrets 是一种类似的 Kubernetes 对象类型,它主要被用来安全的保存敏感数据,并根据需要选择性的的允许 pods 或是其他组件访问。Secrets 是一种方便的往应用传递敏感内容的方式,它不必像普通配置一样将这些内容用纯文本存储在可以被轻易访问到的地方。从功能性上讲,它们的工作方式和 ConfigMaps 几乎完全一致,所以应用可以用完全一样的方式从二者中获取数据。
ConfigMaps 和 Secrets 可以帮你避免将配置内容直接放在 Kubernetes 对象定义中。你可以只映射配置的键名而不是值,这样可以允许你通过修改 CongfigMap 或 Secret 来动态更新配置。这使你可以修改线上 pod 和其他 kubernetes 对象的运行时行为,而不用修改这些资源本身的定义。
实现“就绪检测(Readiness)”与“存活检测(Liveness)”探针
Kubernetes 包含了非常多用来管理组件生命周期的开箱即用功能,它们可以确保你的应用始终保持健康和可用状态。不过,为了利用好这些特性,Kubernetes 必须要理解它应该如何监控和解释你的应用健康情况。为此,Kubernetes 允许你定义“就绪检测探针(Readiness Probe)”与“存活检测探针(Liveness Probe)”。
“存活检测探针”允许 Kubernetes 来确定某个容器里的应用是否处于存活与运行状态。Kubernetes 可以在容器内周期性的执行一些命令来检查基本的应用行为,或者可以往特定地址发送 HTTP / TCP 网络请求来判断进程是否可用、响应是否符合预期。如果某个“存活探测指针”失败了,Kubernetes 将会重启容器来尝试恢复整个 pod 的功能。
“就绪检测探针”是一个类似的工具,它主要用来判断某个 Pod 是否已经准备好接受请求流量了。在容器应用完全就绪,可以接受客户端请求前,它们可能需要执行一些初始化过程,或者当接到新配置时需要重新加载进程。当一个“就绪检测探针”失败后,Kubernetes 会暂停往这个 Pod 发送请求,而不是重启它。这使得 Pod 可以完成自身的初始化或者维护任务,而不会影响到整个组的整体健康状况。
通过结合使用“存活检测探针”与“就绪检测探针”,你可以控制 Kubernetes 自动重启 pods 或是将它们从后端服务组里剔除。通过配置基础设施来利用好这些特性,你可以让 Kubernetes 来管理应用的可用性和健康状况,而无需执行额外的运维工作。
使用 Deployments 来管理扩展性与可用性
在早些时候讨论 Pod 设计基础时,我们提到其他 Kubernetes 对象会建立在 Pod 的基础上来提供更高级的功能。而 deployment 这个复合对象,可能是被定义和操作的最多次的 Kubernetes 对象。
Deployments 是一种复合对象,它通过建立在其他 Kubernetes 基础对象之上来提供额外功能。它们为一类名为 replicasets 的中间对象添加了生命周期管理功能,比如可以实施“滚动升级(Rolling updates)”、回滚到旧版本、以及在不同状态间转换的能力。这些 replicasets 允许你定义 pod 模板并根据它快速拉起和管理多份基于这个模板的副本。这可以帮助你方便的扩展基础设施、管理可用性要求,并在故障发生时自动重启 Pods。
这些额外特性为相对简单的 pod 抽象提供了一个管理框架和自我修复能力。尽管你定义的工作集最终还是由 pods 单元来承载,但是它们却不是你通常应该最多配置和管理的单位。相反,当 pods 由 deployments 这种更高级对象配置时,应该把它们当做可以稳定运行应用的基础构建块来考虑。
创建 Services 与 Ingress 规则来管理到应用层的访问
Deployment 允许你配置和管理可互换的 Pod 集合,以扩展应用以及满足用户需求。但是,如何将请求流量路由到这些 pods 则是例外一码事了。鉴于 pods 会在滚动升级的过程中被换出、重启,或者因为机器故障发生转移,之前被分配给这个运行组的网络地址也会发生变化。Kubernetes services 通过维护动态 pods 资源池以及管理各基础设施层的访问权限,来帮助你管理这部分复杂性。
在 Kuberntes 里,services 是控制流量如何被路由到多批 pods 的机制。无论是为外部客户转发流量,还是管理多个内部组件之间的连接,services 允许你来控制流量该如何流动。然后,Kubernetes 将更新和维护将连接转发到相关 pods 的所有必需信息,即使环境或网络条件发生变化也一样。
从内部访问 Services
为了有效的使用 services,你首先应该确定每组 pods 服务的目标用户是谁。如果你的 service 只会被部署在同一个 Kubernetes 集群的其他应用所使用,那么 ClusterIP 类型允许你使用一个仅在集群内部可路由的固定 IP 地址来访问一组 pods。所有部署在集群上的对象都可以通过直接往这个 service IP 地址发送请求来与这组 pod 副本通信。这是最简单的 service 类型,很适合在内部应用层使用。
Kubernetes 提供了可选的 DNS 插件来为 services 提供名字解析服务。这允许 pods 和其他对象可以使用域名来代替 IP 地址进行通信。这套机制不会显著改动 service 的用法,但基于域名的标识符可以使连接组件和定义服务间交互变得更简单,而不需要提前知道 service IP 地址。
将 Services 向公网开放
如果你的应用需要被公网访问,那么 “负载均衡器(load balancer)”类型的 service 通常会是你的最佳选择。它会使用应用所在的特定云提供商 API 来配置一个负载均衡器,由这个负载均衡器通过一个公网 IP 来服务所有到 service pods 的流量。这种方式提供了一个到集群内部网络的可控网络通道,从而将外部流量引入到你的 service pods 中。
由于“负载均衡器”类型会为每一个 service 都创建一个负载均衡器,因此用这种方式来暴露 Kubernetes 服务可能会有些昂贵。为了帮助缓解这个问题,我们可以使用 Kubernetes ingress 对象来描述如何基于预定规则集来将不同类型的请求路由到不同 services。例如,发往 “example.com” 的请求可能会被指向到 service A,而往 “sammytheshark.com” 的请求可能会被路由到 service B。Ingress 对象提供了一种描述如何基于预定义模式将混合请求流分别路由到它们的目标 services 的方式。
Ingress 规则必须由一个 ingress controller 来解析,它通常是某种负载均衡器(比如 Nginx),以 pod 的方式部署在集群中,它实现了 ingress 规则并基于规则将流量分发到 Kubernetes serices 上。目前,ingress 资源对象定义仍然处于 beta 阶段,但是市面上已经有好几个能工作的具体实现了,它们可以帮助集群所有者最小化需要运行的外部负载均衡器数量。
使用声明式语法来管理 Kubernetes 状态
Kubernetes 在定义和管理部署到集群的资源方面提供了很大灵活性。使用 kubectl
这样的工具,你可以命令式的定义一次性资源并将其快速部署到集群中。虽然在学习 Kubernetes 阶段,这个方法对于快速部署资源可能很有用,但这种方式也存在很多缺点,不适合长周期的生产环境管理。
命令式管理方式的最大问题之一就是它不保存你往集群部署过的变更记录。这使得故障时恢复和跟踪系统内运维变更操作变得非常困难,甚至不可能。
幸运的是,Kubernetes 提供了另外一种声明式的语法,它允许你使用文本文件来完整定义资源,并随后使用 kubectl
命令应用这些配置或更改。将这些配置文件保存在版本控制系统里,是监控变更以及与你的公司内其他部分的审阅过程集成的一种简单方式。基于文件的管理方式也让将已有模式适配到新资源时变得简单,只需要复制然后修改现有资源定义即可。将 Kubernetes 对象定义保存在版本化目录里允许你维护集群在每个时间节点的期望集群状态快照。当你需要进行故障恢复、迁移,或是追踪系统里某些意料之外的变更时,这些内容的价值是不可估量的。
总结
管理运行应用的基础设施,并学习如何最好的利用这些现代化编排系统提供的特性,这些事情可能会令人望而生畏。但是,只有当你的开发与运维过程与这些工具的构建概念一致时,Kubernetes 系统、容器技术提供的优势才能更好的体现出来。遵循 Kubernetes 最擅长的那些模式来架构你的系统,以及了解特定功能如何能缓解由高度复杂的部署带来的挑战,可以帮助改善你运行平台时的体验。
😊 如果你喜欢这篇文章,也欢迎了解我的书: 《Python 工匠:案例、技巧与工程实践》 。它专注于编程基础素养与 Python 高级技巧的结合,是一本广受好评、适合许多人的 Python 进阶书。