优先启动 Sidecar:如何避免障碍

通过《Kubernetes 多容器 Pod:概述》这篇博文,你已经了解了它们的工作内容、主要架构模式以及它们在 Kubernetes 中的实现方式。本文将重点介绍如何确保你的 Sidecar 容器在主应用之前启动。这比你想象的要复杂!

温和回顾

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

initContainers:
  - name: logshipper
    image: alpine:latest
    restartPolicy: Always # this is what makes it a sidecar container
    command: ['sh', '-c', 'tail -F /opt/logs.txt']
    volumeMounts:
    - name: data
        mountPath: /opt

与使用多个 .spec.containers 的传统多容器 Pod 相比,使用 .spec.initContainers 块定义 Sidecar 有什么特殊之处呢?嗯,所有 .spec.initContainers 总是在主应用之前启动。如果你定义了 Kubernetes 原生 Sidecar,它们会在主应用之后终止。此外,与作业(Jobs)一起使用时,Sidecar 容器应该仍然存活,甚至可能在所属的 Job 完成后重启;Kubernetes 原生 Sidecar 容器不会阻塞 Pod 的完成。

要了解更多信息,你还可以阅读官方的Pod Sidecar 容器教程

问题所在

现在你知道了,使用这种原生方法定义 Sidecar 将始终使其在主应用之前启动。从 kubelet 源代码中可以看出,这通常意味着几乎是并行启动的,而这并非工程师总是希望实现的效果。我真正感兴趣的是,我是否可以延迟主应用的启动,直到 Sidecar 不仅已经启动,而且完全运行并准备好提供服务。这可能有点棘手,因为 Sidecar 的问题在于没有明显的成功信号,这与 Init 容器相反——Init 容器被设计为只运行指定的时间段。对于 Init 容器,退出状态 0 明确表示“我成功了”。而对于 Sidecar,有很多时间点可以说“某个东西正在运行”。只有一个容器就绪后再启动下一个容器是平滑部署策略的一部分,确保启动期间的正确顺序和稳定性。实际上,我也希望 Sidecar 容器也能这样工作,以覆盖主应用依赖于 Sidecar 的场景。例如,如果 Sidecar 无法为请求提供服务(例如,使用 DataDog 进行日志记录),应用可能会出错。当然,可以更改应用代码(这实际上是“最佳实践”解决方案),但有时他们无法这样做——而本文正专注于这种用例。

我将解释一些你可能尝试的方法,并向你展示哪些方法会真正有效。

就绪探针(Readiness Probe)

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

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

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 # this command always fails, keeping the container "Not Ready"
            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:

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

也许用启动探针(Startup Probe)?

为了确保 Sidecar 在主应用容器启动前就绪,我可以定义一个 startupProbe。它将延迟主容器的启动,直到命令成功执行(返回 0 退出状态)。如果你想知道我为什么将其添加到我的 initContainer 中,让我们分析一下如果我将其添加到 myapp 容器中会发生什么。我无法保证探针会在主应用代码之前运行——而主应用代码在 Sidecar 未启动并运行时可能会出错。

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 已经启动后才启动。但为了确认它是否等待 Sidecar 就绪,让我们将 startupProbe 更改为 exec 类型的命令。

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

并运行 kubectl get pods -w 来实时观察两个容器的就绪状态是否仅在 15 秒延迟后才改变。同样,事件证实主应用在 Sidecar 之后启动。这意味着使用带有正确 startupProbe.httpGet 请求的 startupProbe 有助于延迟主应用的启动,直到 Sidecar 就绪。这并非最优,但它有效。

那么 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 https://:80"
            until curl -sf https://:80; do
              echo "Still waiting for https://:80..."
              sleep 5
            done
            echo "Service is ready at https://:80"            

存活探针(Liveness Probe)

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

livenessProbe:
  exec:
    command:
    - /bin/sh
    - -c
    - exit 1 # this command always fails, keeping the container "Not Ready"
  periodSeconds: 5

在添加了与之前就绪探针配置相同的存活探针后,通过 kubectl describe pod 检查 Pod 的事件,可以看到 Sidecar 的重启次数大于 0。然而,主应用既没有被重启,也没有受到任何影响,尽管我知道(在我们假想的最坏情况下)当 Sidecar 不存在并提供服务时它可能会出错。如果我使用没有生命周期 postStartlivenessProbe 会怎么样?两个容器将立即就绪:起初,这种行为与没有任何额外探针的行为没有区别,因为存活探针根本不影响就绪状态。过了一段时间,Sidecar 将开始自行重启,但不会影响主容器。

发现总结

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

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

总结一下:由于 Sidecar 通常是主应用的依赖项,你可能希望延迟后者的启动,直到 Sidecar 健康为止。理想的模式是同时启动两个容器,并让应用容器的逻辑在所有层面上进行延迟,但这并不总是可行。如果你需要这样做,就必须对 Pod 定义使用正确的自定义方式。幸运的是,这很方便快捷,而且你已经有了上面的现成方案。

部署愉快!