本文最初发表于Elastisys的技术博客,经原作者Lars Larsson授权由 InfoQ 中文站翻译分享。
设计可扩展的云原生应用需要深思熟虑,因为我们需要克服很多的挑战。即便我们现在有了伟大云产品来部署应用,但著名的分布式计算谬误依然存在。的确,网络会造成延迟和错误。云原生应用通常是微服务,必须进行专门的设计和部署,以克服这些挑战。
为了帮助解决这些问题,我们有一个针对Kubernetes的庞大生态系统,包含了大量的优秀软件。从传统分布式系统的角度来看,Kubernetes 并不是一个“中间件”,但是它的确提供了一个平台,能够让这些优秀的软件组件帮助我们编写有韧性、高性能且设计良好的软件。通过有意识地设计软件来利用这些特性,并且按照相同的方式部署软件,我们就能创建出真正以云原生方式扩展的软件。
在本文中,我将会展示在 Kubernetes 上设计和部署云原生应用的 15 条原则。为了达到最好的效果,你还应该阅读其他的三篇文章。第一篇关于如何设计通用可扩展应用的,即可扩展性设计原则。另外两篇分别是关于如何部署应用以及它们之间如何以云原生方式进行协作的,分别是12-Factor应用宣言和以及研究论文“基于容器的分布式系统的设计原则”。不要介意它们是几年前的内容,它们都经历了时间的考验,依然极具价值。
导论:到底什么是云原生应用?
云原生计算基金会对“云原生”的含义有一个正式的定义。其主要内容如下:
这些技术能够构建具备韧性、易于管理和便于观测的松耦合系统。结合可靠的自动化手段,云原生技术使工程师能够轻松地对系统作出频繁和可预测的重大变更。
如果将上述内容翻译成可行的具体特征和属性,那么云原生软件需要满足如下条件:
能够运行某个实例的多个组件,以确保高可用性和扩展性。
由依赖于底层平台和基础设施的组件组成,以实现扩展、自动化、容量分配、故障处理、重启、服务发现等。
另外,云原生软件主要是围绕着微服务架构构建的,在这种架构中,组件会处理明确定义的任务。这种方式使得组件的扩展和运维变得非常容易,其带来的影响之一就是组件大致上被分为有状态和无状态的。大规模架构中的主要组件都是无状态的,并且会依赖几个数据存储来管理应用的状态。
在 Kubernetes 上设计和部署可扩展应用的原则
Kubernetes 使得部署和运维应用变得更容易。基于给定的容器镜像,我们只需一条命令就能部署,即便要部署多个实例也可以实现(kubectl create deployment nginx –image=nginx –replicas=3)。但是,这种简便性是一把双刃剑。因为这种方式部署的应用无法充分利用 Kubernetes 的高级特性,因此平台本身也没有达到最优的效果。
原则 1:单个 Pod 基本上没有任何用武之地
因为Kubernetes可能随时终止Pod,所以我们几乎总要使用某个控制器(Controller)来管理 Pod。除了一次性的调试之外,单个 Pod 几乎没有任何用武之地。
ReplicaSet 也很少会直接使用。
相反,我们应该使用Deployment或StatefulSet 来处理 Pod。不管我们是不是要运行一个以上的实例,均是如此。我们想要进行自动化的原因在于,Kubernetes 不会保证 Pod 持续的生命周期,以防 Pod 中的容器出现故障。实际上,它明确表明,Pod随时会被终止。
原则 2:清晰划分有状态和无状态的组件
Kubernetes 定义了很多不同的资源以及管理它们的控制器。每种资源都有自己的语义。我曾经见过有人困惑于 Deployment、StatefulSet 和 DaemonSet 分别是什么,以及它们能够做什么,不能做什么。要选择正确方案就意味着你需要清晰地表达自己的意图,Kubernetes 将会帮助你实现目标。
如果使用得当的话,Kubernetes 会强制我们按照它的规则行事,但是现在存在很多错综复杂的解决方案。最简单的规则就是将有状态的服务放到 StatefulSet 中,将无状态的服务放到 Deployment 中,因为这就是 Kubernetes 的方式。但是,需要记住,还有其他的 Kubernetes 控制器,请了解它们各自的特点和差异。
原则 3:区分 secret 配置与非 secret 配置,确保清晰和安全性
ConfigMap和Secret的技术差异很小。在 Kubernetes 内部的表示方式以及使用方式都很相似。但是,正确地使用它们能够表达我们的意图,另外,如果使用基于角色的访问控制,也更易于操作。比如,应用配置存储到 ConfigMap 中,而带有凭证的数据库连接字符串则属于 Secret,这样会更加清晰。
原则 4:启用自动扩展来实现容量管理
就像所有的 Pod 都是由 Deployment 管理,并且其前端都要使用 Service 一样,我们还需要为 Deploymen 考虑使用Horizontal Pod Autoscaler(HPA)。
没有人希望在生产环境出现容量耗尽的情况。类似的,也没有人希望终端用户因为 Pod 的容量分配不当而受到影响。我们从一开始就要考虑好这个问题,这意味着我们必须要认识到,扩展是必然发生的事情。这比容量耗尽要好得多。
根据通用的可扩展性原则,我们需要为运行每个应用组件的多个实例做好准备。对于可用性和可扩展性,这至关重要。
需要注意,借助 HPA,我们也可以很容易地扩展 StatefulSet。但是,一般来讲,有状态组件仅在绝对必要的时候才会进行扩展。
例如,扩展数据库会导致大量的数据复制和额外的事务管理,如果数据库已经处于高负载的状态,这绝对不是我们想要的行为。如果你确实要对有状态组件进行自动扩展,请考虑禁用自动收缩功能。如果有状态组件需要与其他实例以某种方式进行同步时,更要如此。手动触发这种行为是更为安全的。
原则 5:通过为容器生命周期管理添加钩子来增强和实现自动化
容器可以定义PostStart和PreStop钩子(hook),它们都可以用来执行重要的工作,通知应用的其他组件即将创建新的实例或终止现有实例。PreStop 钩子会在Pod终止前调用,并且有一个(可配置的)完成时间。使用它能够确保即将终止的实例能够完成它的任务,将文件提交至 Persistent Volume,或者其他需要完成的事项,以实现有序和自动的关闭。
原则 6:正确使用探针实现故障探查以及自动恢复
与单进程系统相比,分布式系统的故障不那么直观。网络连接问题造成了一大类新的故障。我们探测故障的能力越强,就越有机会从故障中自动恢复。
因此,Kubernetes 为我们提供了探针功能。其中,就绪性探针(readiness probe)特别有用,因为发送给 Kubernetes 的失败信号能够表明我们的容器(也就是 Pod)还没有为接收请求做好准备。
尽管有明确的文档,但存活性探针(liveness probe)经常会被误解。存活性探针的失败表明组件已经永久陷入糟糕的境地,需要通过强行重启来解决。这并不能说明它处于“活跃”状态(正如就绪性探针所指示的那样),因为在分布式系统中,存活性实际上有其他的意义。
Kubernetes 添加了启动探针(startup probe),表示何时能够使用其他的探针进行探测。因此,这是一种延迟其他探针执行的方式。
原则 7:让组件快速、严重地失败,并使其众所周知
如果应用组件失败的话,请确保它足够严重(崩溃)、快速(出现问题时马上失败),并且能够众所周知(在日志中包含翔实的信息)。这种方式能够避免应用中的数据处于一种奇怪的状态,将流量路由至健康的实例,并且能够为问题原因分析提供所需的全部信息。本文中提到的所有自动化原则都能帮助我们使应用处于良好的状态,同时又能找到问题发生的根本原因。
应用中的组件必须能够处理重启。故障迟早都会发生,不管是组件还是集群,均是如此。因为故障难以避免,所以我们必须要有能力处理它们。
原则 8:让应用为可观测性做好准备
监控、日志和跟踪是可观测性的三大支柱。将自定义指标提供给监控系统(如 Prometheus)、编写结构化的日志(如 JSON 格式)并有意识地保留 HTTP 头信息(比如包含 correlation ID 的头信息)并将其作为日志的一部分,这些措施都能为应用提供必要的可观测性。
如果你想要获取更详细的跟踪信息,那么可以将应用与 Open Telemetry API 集成。但是,不管是对于人类运维人员还是自动化,前文所述的操作足以让应用易于观测了。基于对应用有意义的指标进行自动扩展,始终要比使用原始指标(如 CPU 使用率)更好一些。
站点可靠性工程的“四个黄金信号”是延迟、流量、错误和饱和度。用应用特定的指标来跟踪这些信号,比通用的资源消耗测量手段获得的原始指标要有用得多。
原则 9:恰当地设置 Pod 的资源请求和限制
通过恰当地设置Pod的资源请求和限制, Horizontal Pod Autoscaler和Cluster Autoscaler都能更好地完成自己的任务。如果它们知道需要多少容量以及有多少可用的容量,那么当它们确定 Pod 和整个集群需要多少容量的时候就容易多了。
不要将资源请求和限制设置地太低。这种方式起初可能会比较诱人,因为这样能够在集群中运行更多的容器。但是,除非请求和限制设置的值是相等的(即“Guaranteed” QoS类的Pod),否则 Pod 在正常(常规流量)操作中可能会被赋予更多的资源。这是因为,它们可能会给予一些宽裕的资源。因此,起初一切都能很好地运行。但是在高峰期,它们会被限制到我们声明的容量。当然,此时发生这样的事情是最糟糕的时间点,此时进行扩展我们可能会得到性能更差的 Pod。虽然是无意的,但这可能确实是我们要求调度器所做的事情。
原则 10:保留容量并设置 Pod 的优先级
在容量管理方面,命名空间资源配额、在节点上预留计算资源以及设置Pod的优先级有助于确保集群容量和可扩展性免受影响。
我曾经亲眼看到过一个不堪重负的集群,以至于网络插件的 Pod 都会移除掉了。对于故障排查来说,这是一件很糟糕的事情(但是却很有教育意义)。
原则 11:根据需要将 Pod 放到一起或分散部署
在表达要将 Pod 放到一起(为了高效的网络流量)还是跨云 region 与可用区分散部署(为了冗余)方面,Pod的拓扑传播限制以及亲和与反亲和规则是一种很好的方式。
原则 12:确保 Pod 在可能导致停机的运维操作中的可用性
Pod干扰预算(Pod Disruption Budget)声明了在一组 Pod 中(如一个 Deployment 中的 Pod),允许每次有多少进行自愿干扰(voluntarily disrupted,也就是由我们的命令触发的,而不是故障)。即便是在集群节点被管理员排空(drain)时,这也能够确保高可用性。这种情况可能发生在集群升级的时候,通常这种行为都是按月进行的,因为 Kubernetes 的演进非常快。
需要注意,如果没有正确设置 Pod 干扰预算,那我们可能会限制管理员进行升级的能力。这方面很容易出现错误配置,从而阻碍排空节点,这会影响自动化操作系统补丁升级,并损害环境的安全性。
如果你能为应用设计如何处理干扰,并且在无法实现的时候通过 PDB(PodDisruptionBudget)让 Kubernetes 来提供帮助,那么这是非常不错的。
原则 13:选择蓝/绿或金丝雀部署,而不是全停机方式的部署
在现在这个时代,为了进行维护,而将整个应用关闭是难以让人接受的。目前,这种方式被称为“全停机(stop-the-world )部署”,在这个过程中,应用会在一段时间内无法访问。通过更复杂的部署策略,可以实现更平稳和渐进式的变更。终端用户根本不需要感知应用已经发生了变化。
蓝/绿或金丝雀部署曾经是一种黑科技,但 Kubernetes 让所有人都能使用它们。我们可以上线组件的新版本,并在 Service 中通过标签和选择器将流量路由到它们上面,这一切使得即便脚本中存在或多或少的手动操作也能实现这种高级的部署策略,当然也能通过更好的部署工具来实现,如 ArgoCD(蓝/绿或金丝雀部署)
请注意,大多数的部署策略都可以在技术层面上归结为同时部署相同组价的两个版本,并按照不同的方式将请求转发给它们。我们可以通过 Service 本身做到这一点,比如为新版本的 Pod 打上特定的标签,以便于 Service 将流量路由到它们。即将推出的Kubernetes Gateway资源将会提供开箱即用的特性(但是Ingress并没有该特性)。
原则 14:避免赋予 Pod 不需要的权限
Kubernetes 本身并不安全,其默认就是如此。但是,我们可以对其进行配置,以强化安全的最佳实践,比如限制容器在节点上都能做些什么。
以非 root 用户运行容器。在 Docker 中构建容器镜像时,容器默认是以 root 身份来运行的,这恐怕是近十年来黑客们最兴奋的事情。在容器构建的过程中,只使用 root 用户来安装依赖,然后切换至非 root 用户,并使用该用户运行应用。
如果应用确实需要提升权限,我们依然要使用非 root 用户,为其移除所有的 Linux 功能,然后将最小的功能集添加回来。
在 2022 年 1 月份,一个存在 3 年之久的容器漏洞被披露出来(CVE-2022-0185)。如果容器没有所需的 Linux 功能会怎样呢?那将完全无法进行攻击。
原则 15:限制 Pod 在集群中的行为
禁用默认的服务账号,防止将其暴露到应用中。除非明确需要与 Kubernetes API 进行交互,否则不要将默认的服务账号 token 放入应用中。但是,这是 Kubernetes 中的默认做法。
尽可能设置最安全的Pod安全策略和Pod安全标准,确保在默认情况下,避免出现不安全的操作。
使用网络策略来限制哪些 Pod 可以访问你的 Pod。Kubernetes 内默认的自由网络流量是一个安全噩梦,因为在这种情况下,攻击者只要进入一个 Pod,就可以直接访问其他所有的 Pod。
CVSS 评分为 10 分的 Log4J 漏洞(CVE-2021-44228)被幽默地称为 Log4Shell,它对于设置锁定网络策略的容器完全无效,除了允许列表中内容之外,所有的出站流量都会被禁用(漏洞中的模拟 LDAP 服务器并没有设置策略)。
总结
本文介绍了设计云原生应用并将其部署在 Kubernetes 上的 15 条原则。通过遵循这些原则,云原生应用可以很好地与 Kubernetes 工作负载编排器协同工作。这样,我们可以获得 Kubernetes 平台以及按照云原生方式设计和运维软件提供的所有收益。
你已经学会了如何正确地使用 Kubernetes 资源,为自动化做好准备,如何处理故障,利用 Kubernetes 探针功能来提高稳定性,为应用程序的可观测性做好准备,让 Kubernetes 调度器按照我们希望的方式运行,利用高级策略进行部署,以及如何限制应用程序的攻击面。
请将所有的这些因素都纳入软件架构的工作中,使你的日常 DevOps 流程更加顺畅、更加可靠。可以说,要使这种顺畅几乎达到无聊的程度。这是非常好的事情,因为软件的顺利部署和管理意味着一切都在按计划进行。正如常言所说,“没有消息就是好消息”。
评论