根据我的个人经验,大多数人似乎倾向于通过 Helm 或者手动方式将应用程序甩给 Kubernetes,之后就坐等每天轻松调用的美好生活。但在 GumGum 公司的实践当中,我们体会到 Kubernetes 应用的一系列“陷阱”,也希望把这些陷阱与大家分享,给您的 Kubernetes 探索之旅带来一点启发。
1. 配置 Pod 请求与限制
我们从配置一套可以运行 Pod 的简单环境开始。Kubernetes 在处理 Pod 调度与故障状态方面确实表现出色,但我们也意识到,如果 Kubernetes 调度程序无法衡量 Pod 的成功运行究竟需要多少资源,那么有时候部署工作可能面临挑战。而这一挑战,也正是资源请求与限制机制的设计根源。目前,设置应用程序请求与限制方面的最佳实践仍然存在不少争议。实际上,这项工作更像是一门艺术,而非单纯的科学。下面,我们聊聊 GumGum 公司内部对这个问题的看法:
Pod 请求: 这是调度程序用于衡量 Pod 最佳部署方法的主要指标。
下面来看 Kubernetes 说明文档中的相关描述:
过滤步骤会在可行的情况下找到一组 Pod。例如,PodFitsResources 过滤器会检查候选节点是否具备充足的可用资源,以满足 Pod 提出的特定资源请求。
在内部,我们通过这样一种方式使用应用程序请求:通过设置,我们对应用程序正常运行实际工作负载时的资源需求做出估计。以此为基础,调度程序即可更合理地放置节点。最初,我们希望将请求设置得更高一些,保证各个 Pod 都拥有充足的资源。但我们很快发现,这种方式会大大增加调度时间,并导致部分 Pod 无法完全调度。这样的结果实际上与我们完全不指定资源请求时看到的情况类似:在后一种情况下,由于控制平面并不清楚应用程序需要多少资源,因此调度程序经常会“逐出”Pod 且不再重新加以调度。正是这一调度算法中的关键组成部分,导致我们无法得到符合预期的调度效果。
Pod 限制: 即对于 Pod 的直接限制,代表着集群允许各容器所使用的最大资源量。
同样来看官方说明文档中的描述:
如果您为容器设置了 4GiB 的内存限制,则 kubelet(与容器运行时)将强制执行此限制。运行时将防止容器使用超出所配置上限的资源容量。例如,当容器中的进程所消耗的内存量超过获准数量时,系统内核将终止该资源分配尝试,并提示内存不足(OOM)错误。
容器所使用的实际资源量可以高于其请求,但永远不能高于配置上限。很明显,对限制指标的正确设置相当困难,但也非常重要。在理想情况下,我们希望让 Pod 的资源需求在整个流程生命周期内发生变化,而又不致干扰到系统上的其他流程——这也正是限制机制的意义所在。遗憾的是,我们无法明确给出最合适的设置值,只能遵循以下过程进行调整:
- 使用负载测试工具,我们可以模拟基准流量水平,并观察 Pod 的资源使用情况(包括内存与 CPU)。
- 我们将 Pod 请求设置在极低水平,同时将 Pod 资源限制保持在请求值的约 5 倍,而后观察其行为。当请求过低时,进程将无法启动,并时常引发神秘的 Go 运行时错误。
这里需要强调的一点在于,资源限制越严格,Pod 的调度难度也就越大。这是因为 Pod 调度要求目标节点拥有充足的资源。例如,如果您的资源非常有限(内存只有 4GB),那么即使是运行轻量级 Web 服务器进程都很可能非常困难。在这种情况下,大家需要进行横向扩展,而且各个新容器也应运行在同样拥有至少 4GB 可用内存的节点之上。如果不存在这样的节点,您需要在集群中引入新节点以处理该 Pod,这无疑会令启动时间有所增加。总之,请务必在资源请求与限制之间找到最小“边界”,保证快速、平衡实现扩展。
2. 配置 Liveness 与 Readiness 探针
Kubernetes 社区中经常讨论的另一个有趣话题,就是如何配置 Linvess 与 Readiness 探针。合理使用这两种探针,能够为我们带来一种运行容错软件、并最大程度减少停机时间的机制。但如果配置不正确,它们也可能对应用程序造成严重的性能影响。下面来看这两种探针的基本情况,以及如何进行使用判断:
Liveness 探针:“用于指示容器是否正在运行。如果 Liveness 探针失败,则 kubelet 将关闭容器,且容器将开始执行重新启动策略。如果容器并不提供 Liveness 探针,则其默认状态被视为成功。”—Kubernetes说明文档
Liveness 探针的资源需求必须很低,因为它们需要频繁运行,并需要在应用程序运行时向 Kubernetes 发出通知。请注意,如果将其设置为每秒运行一次,则系统将需要承担每秒 1 次的额外请求处理量。因此,请务必认真考虑如何处理这些额外请求及相应资源。在 GumGum,我们将 Liveness 探针设置为在应用程序主组件运行时进行响应,且不考虑数据是否已经完全可用(例如来自远程数据库或缓存的数据)。举例来说,我们会在应用当中设置一个特定的“health”端点,单纯负责返回 200 响应代码。只要仍在返回响应,就表明该进程已经启动并可以处理请求(但尚未正式产生流量)。
Readiness 探针:“指示容器是否准备好处理请求。如果 Readiness 探针失败,则端点控制器将从与该 Pod 相匹配的所有服务端点中,删除该 Pod 的 IP 地址。”
Readiness 探针的运行成本要高得多,因为其作用在于持续告知后端,整个应用程序正处于运行状态且准备好接收请求。关于此探针是否应该访问数据库,社区中存在诸多争论。考虑到 Readiness 探针造成的开销(需要经常运行,但频繁可以灵活调整),我们决定在某些应用程序中只在从数据库返回记录后,才开始“提供流量”。通过对 Readiness 探针的精心设计,我们已经能够实现更高的可用性水平以及零停机时间部署。
但如果大家确实有必要通过应用程序的 Readiness 探针随时检查数据库请求的就绪状态,请尽可能控制查询操作的资源用量,例如……
- SELECT small_item FROM table LIMIT 1
以下,是我们在 Kubernetes 中为这两种探针指定的配置值:
- livenessProbe:
- httpGet:
- path: /api/liveness
- port: http
- readinessProbe:
- httpGet:
- path: /api/readiness
- port: http periodSeconds: 2
您还可以添加其他一些配置选项:
- initialDelaySeconds- 容器启动的多少秒后,探针开始实际运行
- periodSeconds- 两次探测之间的等待间隔
- timeoutSeconds- 需要经过多少秒,才能判定某一 Pod 处于故障状态。相当于传统意义上的超时指标
- failureThreshold- 探针失败多少次后,才向 Pod 发出重启信号
- successThreshold- 探针成功多少次后,才能判定 Pod 进入就绪状态(通常使用在 Pod 启动或者故障恢复之后)
3. 设置默认 Pod 网络策略
Kubernetes 使用一种“扁平”网络拓扑;在默认情况下,所有 Pod 之间都可以直接相互通信。但结合实际用例,这种通信能力往往不必要甚至不可接受。由此带来的一大潜在安全隐患在于,如果某一易受攻击的应用程序遭到利用,则攻击者即可由此获取完全访问权限,进而将流量发送至网络上的所有 Pod 当中。因此我们也有必要在 Pod 网络中应用最低访问原则,在理想情况下通过网络策略明确指定哪些容器之间允许建立相互连接。
以下列简单策略为例,可以看到其将拒绝特定命名空间中的所有入口流量:
- ---
- apiVersion: networking.k8s.io/v1
- kind: NetworkPolicy
- metadata:
- name: default-deny-ingress
- spec:
- podSelector: {}
- policyTypes:
- - Ingress
4. 通过 Hooks 与 Init 容器执行自定义行为
我们希望在 Kubernetes 系统中实现的核心目标之一,在于尝试为现有开发人员提供近乎零停机时间的部署支持。但不同应用程序往往拥有不同的关闭方式与资源清理过程,因此整体零停机目标很难实现。首先横亘在我们面前的,就是 Nginx 这道难关。我们注意到在启动 Pod 的滚动部署时,活动连接在成功终止之前就会被丢弃。经过广泛的在线研究,事实证明 Kubernetes 在终止 Pod 之前,并不会等待 Nginx 用尽其连接资源。使用预停止 hook,我们得以注入此项功能,并由此实现了零停机时间。
- lifecycle:
- preStop:
- exec:
- command: ["/usr/local/bin/nginx-killer.sh"]
- isnginx-killer.sh:
- #!/bin/bashsleep 3
- PID=$(cat /run/nginx.pid)
- nginx -s quitwhile [ -d /proc/$PID ]; do
- echo "Waiting while shutting down nginx..."
- sleep 10
- done
另一个实用范例,是通过 Init 容器处理特定应用程序的启动任务。部分高人气 Kubernetes 项目还会使用 Istio 等 init-containers 将 Envoy 处理代码注入 Pod 当中。如果您在应用程序启动之前,需要首先完成繁重的数据库迁移过程,那么 Init 容器特别适用。您也可以为此过程设定更高的资源上限,保证其不受主应用程序的限制设定影响。
另一种常见模式是向 init-conatiner 提供 secrets 访问权,并由该容器将这些凭证公布给主 Pod,从而防止通过主应用 Pod 本体对 secret 发出示授权访问。同样来看说明文档中的表述:
Init 容器能够安全运行实用程序或自定义代码,避免其破坏应用程序容器镜像的安全性。通过剥离这些不必要的工具,您可以限制应用程序容器镜像的攻击面。
5. 内核调优
最后,我们来聊聊一项最先进的技术。Kubernetes 本身是一套高度灵活的平台,可帮助您以最适合的方式运行工作负载。在 GumGum,我们拥有多种高性能应用程序,其对运行资源有着极为苛刻的要求。在进行了广泛的负载测试之后,我们发现有某一款应用程序难以在使用 Kubernetes 默认设置的前提下处理必要的流量负载。但 Kubernetes 允许我们运行一个高权限容器,通过修改为其配置适用于特定 Pod 的内核运行参数。通过以下示例代码,我们修改了 Pod 中的最大开启连接数量:
- initContainers:
- - name: sysctl
- image: alpine:3.10
- securityContext:
- privileged: true
- command: ['sh', '-c', "sysctl -w net.core.somaxconn=32768"]
这是一种使用频率较低的高级技术。如果您的应用程序难以在高负载场景下健康运行,大家可能需要调整其中的部分参数。这里建议各位在官方说明文档中参阅参数调优与可选值的相关细节信息。
6. 总结
虽然 Kubernetes 已经算是一种几乎“开箱即用”的解决方案,但大家仍然需要采取一系列关键步骤以保证应用程序的平衡运行。在将应用程序迁移至 Kubernetes 之上的整个过程中,请务必重视负载测试“循环”——运行应用程序,对其进行负载测试,观察指标与扩展行为,基于结果调整您的配置,而后重复。请尽量客观地设定预期流量,并尝试将流量增加至超限水平,借此查看哪些组件会最先陷入瘫痪。通过这种迭代方法,大家也许只需要采取本文中介绍的部分步骤即可获得理想的应用程序运行效果。总之,请永远关注以下几个核心问题:
- 我的应用程序的资源占用量是多少?占用量会如何变化?
- 服务的实际扩展要求是什么?预计需要处理怎样的平均流量?峰值流量处于怎样的水平?
- 服务可能多久需要进行一次横向扩展?新的 Pod 要过多久才能正式开始接收流量?
- 我们的 Pod 终止过程优雅可控吗?是否需要这种优雅性与可控性?我们能否实现零停机时间部署?
- 该如何尽可能降低安全风险,并限制 Pod 入侵状况的“爆炸半径”(影响范围)?服务中是否存在某些不必要的权限或访问能力?
Kubernetes 是一套令人印象深刻的强大平台,您可以在这里运用最佳实践为整个集群部署数千项服务。但不同的软件之间总是有所差别,有时候您的应用程序可能需要进一步调整,好在 Kubernetes 为我们提供不少调整“旋钮”,尽可能让用户轻松达成与预期相符的技术目标。将资源请求与限制、Livenss 与 Readiness 检查、init-containers、网络策略以及自定义内核调优等方法相结合,相信大家能够在 Kubernetes 平台之上实现更出色的基准性能、弹性与快速规模扩展能力。