先启动边车:如何避免障碍

"Kubernetes 多容器 Pod:概述"博客中, 你了解了 Pod 的工作方式,Pod 的主要架构模式,以及 Pod 在 Kubernetes 中是如何实现的。 本文主要介绍的是如何确保你的边车容器在主应用之前启动。这比你想象的要复杂得多!

简要回顾

我想提醒读者的是,Kubernetes v1.29.0 版本增加了对 边车容器的原生支持, 现在可以在 .spec.initContainers 字段中定义,但带有 restartPolicy: Always。 你可以在下面的示例 Pod 清单片段中看到这一点:

initContainers:
  - name: logshipper
    image: alpine:latest
    restartPolicy: Always # 这就是它成为边车容器的原因
    command: ['sh', '-c', 'tail -F /opt/logs.txt']
    volumeMounts:
    - name: data
        mountPath: /opt

使用 .spec.initContainers 块定义边车与使用多个 .spec.containers 定义传统的多容器 Pod 相比,具体有什么不同? 其实,所有 .spec.initContainers 总是主应用之前启动。 如果你定义了 Kubernetes 原生的边车容器,这些边车容器将在主应用之后终止。 此外,当与 Job 一起使用时, 边车容器仍然保持运行,并且在拥有它的 Job 完成后甚至可能重启; Kubernetes 原生边车容器不会阻止 Pod 的完成。

要了解更多,你也可以阅读官方的 Pod 边车容器教程

问题

现在你知道使用这种原生方法定义边车总是会在主应用之前启动它。 从 kubelet 源代码 可以看出,这通常意味着几乎是并行启动的,而这并不总是工程师想要的结果。 我们真正感兴趣的是,是否可以延迟主应用的启动,直到边车不仅启动而且完全运行并准备好服务。 这可能有点棘手,因为与 Init 容器不同(设计为仅运行指定的时间段),边车没有明显的成功信号。 对于一个 Init 容器,退出状态 0 明确表示“我成功了”。而对于边车容器, 在很多情况下你可以说“某个东西正在运行”。 仅在前一个容器准备好之后才启动另一个容器,这是优雅部署策略的一部分, 确保启动期间的正确排序和稳定性。实际上,这也是我希望边车容器工作的方式, 以覆盖主应用依赖于边车的场景。例如,如果边车不可用于服务请求(例如,使用 DataDog 进行日志记录), 应用程序可能会报错。当然,可以更改应用程序代码(这实际上是“最佳实践”解决方案), 但有时他们不能这样做 - 而本文档关注的就是这种情况。

我会解释一些你可能尝试的方法,并告诉你哪些方法真的有效。

就绪性检测

要检查 Kubernetes 原生边车是否会延迟主应用的启动直到边车准备就绪, 让我们模拟一个简短的调查。首先,我将通过实现一个永远不会成功的就绪探针来模拟一个永远不会准备就绪的边车容器。 提醒一下,就绪性探针检查容器是否准备好开始接受流量, 由此判断 Pod 是否可以用于服务的后端。

(与标准的 Init 容器不同,边车容器可以拥有探针 , 以便 kubelet 可以监督边车,并在出现问题时进行干预。例如, 如果边车容器未通过健康检查,则重启它。)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  labels:
    app: myapp
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: alpine:latest
          command: ["sh", "-c", "sleep 3600"]
      initContainers:
        - name: nginx
          image: nginx:latest
          restartPolicy: Always
          ports:
            - containerPort: 80
              protocol: TCP
          readinessProbe:
            exec:
              command:
              - /bin/sh
              - -c
              - exit 1 # 此命令总是失败,导致容器处于"未就绪"状态
            periodSeconds: 5
      volumes:
        - name: data
          emptyDir: {}

结果是:

controlplane $ kubectl get pods -w
NAME                    READY   STATUS    RESTARTS   AGE
myapp-db5474f45-htgw5   1/2     Running   0          9m28s

controlplane $ kubectl describe pod myapp-db5474f45-htgw5 
Name:             myapp-db5474f45-htgw5
Namespace:        default
(...)
Events:
  Type     Reason     Age               From               Message
  ----     ------     ----              ----               -------
  Normal   Scheduled  17s               default-scheduler  Successfully assigned default/myapp-db5474f45-htgw5 to node01
  Normal   Pulling    16s               kubelet            Pulling image "nginx:latest"
  Normal   Pulled     16s               kubelet            Successfully pulled image "nginx:latest" in 163ms (163ms including waiting). Image size: 72080558 bytes.
  Normal   Created    16s               kubelet            Created container nginx
  Normal   Started    16s               kubelet            Started container nginx
  Normal   Pulling    15s               kubelet            Pulling image "alpine:latest"
  Normal   Pulled     15s               kubelet            Successfully pulled image "alpine:latest" in 159ms (160ms including waiting). Image size: 3652536 bytes.
  Normal   Created    15s               kubelet            Created container myapp
  Normal   Started    15s               kubelet            Started container myapp
  Warning  Unhealthy  1s (x6 over 15s)  kubelet            Readiness probe failed:

从这些日志中可以明显看出只有一个容器准备就绪 - 我知道这不可能是边车, 因为我将其定义为永远不会准备就绪(你也可以在 kubectl get pod -o json 中检查容器状态)。 我还看到 myapp 在边车准备就绪之前已经启动。这不是我希望达到的结果; 在这种情况下,主应用容器对它边车有硬依赖。

或许是一个启动探针?

为了确保边车准备就绪后再启动主应用容器,我可以定义一个 startupProbe。 这将延迟主容器的启动,直到命令成功执行(返回 0 退出状态)。 如果你想知道为什么我将其添加到我的 initContainer 中, 让我们分析一下如果我将其添加到 myapp 容器会发生什么。 我不能保证探针会在主应用代码之前运行 - 而这可能会导致错误,尤其是在边车尚未启动和运行时。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  labels:
    app: myapp
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: alpine:latest
          command: ["sh", "-c", "sleep 3600"]
      initContainers:
        - name: nginx
          image: nginx:latest
          ports:
            - containerPort: 80
              protocol: TCP
          restartPolicy: Always
          startupProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 5
            periodSeconds: 30
            failureThreshold: 10
            timeoutSeconds: 20
      volumes:
        - name: data
          emptyDir: {}

这导致 2/2 个容器已就绪并运行,从事件中可以推断主应用仅在 nginx 已启动后才开始启动。 但为了确认它是否等待了边车的就绪状态,让我们将 startupProbe 更改为执行类型命令:

startupProbe:
  exec:
    command:
    - /bin/sh
    - -c
    - sleep 15

并运行 kubectl get pods -w 以实时观察两个容器的就绪状态是否仅在 15 秒延迟后更改。 再次确认,事件显示主应用在边车之后启动。 这意味着使用带有正确 startupProbe.httpGet 请求的 startupProbe 有助于延迟主应用的启动,直到边车准备就绪。这不理想,但它有效。

关于 postStart 生命周期钩子?

趣闻:使用 postStart 生命周期钩子块也可以完成任务, 但我要编写自己的迷你 Shell 脚本,这甚至更低效。

initContainers:
  - name: nginx
    image: nginx:latest
    restartPolicy: Always
    ports:
      - containerPort: 80
        protocol: TCP
    lifecycle:
      postStart:
        exec:
          command:
          - /bin/sh
          - -c
          - |
            echo "Waiting for readiness at http://localhost:80"
            until curl -sf http://localhost:80; do
              echo "Still waiting for http://localhost:80..."
              sleep 5
            done
            echo "Service is ready at http://localhost:80"            

存活探针

一个有趣的练习是使用存活探针检查边车容器的行为。 存活探针的配置和行为与就绪探针相似——唯一的区别是它不会影响容器的就绪状态,而是在探针失败时重启容器。

livenessProbe:
  exec:
    command:
    - /bin/sh
    - -c
    - exit 1 # 该命令总是失败,导致容器处于"未就绪"状态
  periodSeconds: 5

在添加了配置与之前的就绪探针相同的存活探针,并通过 kubectl describe pod 检查 Pod 的事件后,可以看到边车的重启次数超过 0。尽管如此,主应用并未受到任何影响或重启, 即使我知道(在我们假想的最坏情况下)当边车不处理请求时,主应用可能会出错。 如果我在没有生命周期 postStart 的情况下使用 livenessProbe 会怎样? 两个容器将立即准备就绪:一开始,这种行为不会与没有任何额外探针的情况有任何不同, 因为存活探针完全不影响就绪状态。一段时间后,边车将开始重启自己,但这不会影响主容器。

调研总结

我将在下表中总结启动行为:

探针/钩子边车在主应用之前启动?主应用是否等待边车准备就绪?如果检查不通过会发生什么?
readinessProbe,但几乎是并行的(实际上为 边车未就绪;主应用继续运行
livenessProbe是,但几乎是并行的(实际上为 边车被重启,主应用继续运行
startupProbe主应用不会启动
postStart,主应用容器在 postStart 完成后启动,但你必须为此提供自定义逻辑主应用不会启动

总结:由于边车经常是主应用的依赖项,你可能希望延迟后者启动直到边车健康。

理想模式是同时启动两个容器,并让应用容器逻辑在所有层面上延迟,但这并不总是可行。 如果你需要这样做,就必须对 Pod 定义使用适当的自定义设置。 值得庆幸的是,这既简单又快速,并且你已经有了上面的解决方案。

祝部署顺利!