从头开始使用 Go 构建 Orchestrator(第九部分:故障恢复)

22 阅读32分钟

9 可能会出什么问题呢?

本章内容涵盖:

  • 列举潜在的故障情况

  • 探索从故障中恢复的方法

  • 实现任务健康检查以从任务崩溃中恢复

在第 4 章开头,我们开始着手实现工作节点,当时我们讨论了运行一个提供静态页面服务的 Web 服务器的场景。在那个场景中,我们思考了如何应对网站日益受到欢迎所带来的问题,即需要具备应对故障的能力,以确保能够为不断增长的用户群体提供服务。我们提出的解决方案是运行多个 Web 服务器实例。换句话说,我们决定进行水平扩展,这是一种常见的扩展模式。通过增加 Web 服务器的数量,我们可以确保任何一个 Web 服务器实例出现故障时,不会导致整个网站完全瘫痪,进而使用户无法访问网站。

在本章中,我们将对这个场景稍作修改。我们不再提供静态网页服务,而是提供一个 API 服务。这个 API 非常简单:它接收一个带有请求体的 POST 请求,并返回一个包含相同请求体的响应。也就是说,它只是在响应中回显请求内容。基于对场景的这一微小改变,本章将回顾我们到目前为止所构建的内容,并讨论编排器以及在其上运行的任务可能出现的多种故障场景。然后,我们将实现几种机制来处理部分故障场景。

9.1 新场景概述

我们的新场景涉及一个 API,该 API 接收请求体,并简单地在响应中把该请求体返回给用户。请求体的格式为 JSON,并且我们的 API 在 Message 结构体中定义了这种格式:

typ Message struct {
    Msg string
}

因此,向该应用程序接口发出的 curl 请求是这样的:

$ curl -X POST localhost:7777/ -d '{"Msg":"Hello, world!"}' 
{"Msg":"Hello, world!"}

正如你在响应中看到的,我们得到了与发送时相同的正文。为了让这个场景更简单易用,我提前构建了一个 Docker 镜像,我们可以在本章的其余部分重复使用它。因此,要在本地运行 API,你只需这样做:

$ docker run -d -p 7777:7777 sun4965485/echo-smy:v1

$ curl -X POST -H "Content-Type: application/json" http://127.0.0.1:7777/ -d '{"Msg":"Hello, world!"}'
{"Msg":"Hello, world!"}

(如果你对这个 API 的源代码感兴趣,可以在本章可下载的源代码中的 echo 目录里找到它。)

现在,让我们接着来讨论一下,如果我们在编排系统中把这个 API 作为一个任务来运行,它可能会出现故障的几种情况。

9.2 故障场景

故障无时无刻不在发生!作为工程师,我们应该对此有所预期。故障是常态,而非意外情况。更重要的是,故障会在多个层面上发生:

  • 应用程序层面的故障

  • 单个任务层面的故障

  • 编排系统层面的故障

让我们来逐一了解一下在这些层面上可能出现的故障情形。

9.2.1 应用程序启动故障

一个任务可能无法启动,原因是任务所对应的应用程序在其启动例程中存在缺陷。例如,我们可能决定存储我们的回显服务接收到的每一个请求,为了实现这一点,我们添加了一个数据库作为依赖项。现在,当我们的回显服务的一个实例启动时,它会尝试连接到数据库。然而,如果我们无法连接到数据库会发生什么呢?也许是由于某些网络问题导致数据库无法访问。或者,如果我们使用的是托管服务,也许管理员决定需要进行一些维护工作,结果导致数据库服务在一段时间内不可用。又或者,数据库对它的 “人类主人” 不满,决定 “罢工” 了。

数据库不可用的原因其实并不重要。关键在于我们的应用程序依赖于这个数据库,并且当数据库不可用时,应用程序需要采取一些措施。一般来说,我们的应用程序有两种应对方式:

  • 它可以直接崩溃。

  • 它可以尝试重新连接数据库。

以我的经验来看,前一种选择经常被采用,它是默认的做法。作为一名工程师,我有一个需要数据库的应用程序,并且在应用程序启动时尝试连接数据库。也许我会检查错误,记录下来,然后优雅地退出。

后一种选择可能是更好的办法,但它会增加一些复杂性。如今,大多数编程语言至少都有一个第三方库,该库提供了使用指数退避算法来进行重试的框架。采用这种方式,我可以让我的应用程序在一个单独的协程中尝试连接数据库,并且在成功连接之前,也许我的应用程序会返回一个 503 响应,并附带一条有用的消息,解释说应用程序正在启动过程中。

9.2.2 应用程序缺陷

一个任务在成功启动之后也可能会失败。例如,我们的回显服务可能会成功启动并正常运行一段时间。但是我们最近添加了一个新功能,由于我们认为这个功能很重要,并且将其投入生产环境后我们就可以在 Hacker News 上发布相关内容,所以我们没有对其进行全面测试。有用户以一种意想不到的方式调用了我们的服务,触发了我们的新代码,从而导致 API 崩溃。哎呀!

9.2.3 由于资源问题导致的任务启动故障

一个任务可能无法启动,原因是工作节点机器没有足够的资源(即内存、CPU 或磁盘空间)。从理论上讲,这种情况不应该发生。像 Kubernetes 和 Nomad 这样的编排系统实现了复杂的调度器,这些调度器在将任务调度到工作节点时会考虑内存和 CPU 的需求。

然而,关于磁盘空间的情况则稍微复杂一些。容器镜像会占用磁盘空间。在你的机器上运行 docker images 命令,注意输出中的 SIZE 列。在我的机器上,我们在本章中使用的 timboring/echo-server 镜像大小为 12.3 MB。如果我拉取 postgres:14 镜像,我可以看到它的大小是 376 MB:

image.png

虽然有一些策略可以尽量减小镜像大小,但镜像仍然会驻留在磁盘上并占用空间。除了容器镜像之外,工作节点上运行的其他进程也会使用磁盘空间 —— 例如,它们可能会将日志存储在磁盘上。而且,在一个节点上运行的其他容器可能会为了存储自身数据而使用磁盘空间。所以,编排器有可能会将一个任务调度到某个工作节点上,而当该工作节点启动这个任务时,任务却因为没有足够的磁盘空间来下载容器镜像而失败。

9.2.4 由于 Docker 守护进程崩溃和重启导致的任务失败

正在运行的任务也可能会受到 Docker 守护进程问题的影响。例如,如果 Docker 守护进程崩溃,那么我们的任务将会被终止。同样地,如果我们停止或重启 Docker 守护进程,守护进程也会停止我们的任务。这是 Docker 守护进程的默认行为。可以使用一种名为 “实时恢复” 的功能在守护进程停止运行时保持容器处于运行状态,但该功能的使用超出了本书的范围。就我们的目的而言,我们将处理默认行为。

9.2.5 由于机器崩溃和重启导致的任务失败

最极端的故障场景是工作节点机器崩溃。对于编排系统来说,如果一台工作节点机器崩溃,那么 Docker 守护进程显然会停止运行,机器上正在运行的任何任务也会随之停止。

不太极端的情况是机器重启。也许管理员在更新了某些软件之后重启了机器,在这种情况下,Docker 守护进程将会在机器重启后先停止再重新启动。然而,在此过程中,任何任务都会被终止并且需要重新启动。

9.2.6 工作节点故障

除了应用程序和任务故障之外,当我们在编排系统上运行回显服务时,运行任务的工作节点也可能会出现故障。但在这个层面上情况会稍微复杂一些。当我们提到工作节点时,我们需要明确我们具体在说什么。首先,是我们编写的工作节点组件。其次,是运行我们工作节点组件的机器。

所以当我们谈到工作节点故障时,在我们编排系统的这一层面存在两种不同类型的故障。我们编写的工作节点组件可能会因为代码中的缺陷而崩溃。它也可能会因为它所运行的机器崩溃或因其他原因变得不可用而崩溃。

我们已经提到过机器故障,但让我们简要谈谈工作节点组件的故障。如果它因为某种原因而出现故障,正在运行的任务会怎样呢?与 Docker 守护进程不同,我们的工作节点出现故障或重启并不会终止正在运行的容器。这确实意味着管理器无法向工作节点发送新任务,并且管理器也无法查询工作节点以获取正在运行任务的当前状态。

所以虽然我们的工作节点出现故障会带来不便,但它不会对正在运行的任务产生直接影响。(比如说,如果大量工作节点崩溃,导致你的团队无法部署一个关键任务的错误修复程序,那就不只是不便的问题了。但这是另一本书的话题了。)

9.2.7 管理器故障

最后要考虑的故障场景涉及管理器组件。记住,我们说过管理器承担着管理功能。它接收用户运行任务的请求,并将这些任务调度到工作节点上。除非管理器和工作节点组件运行在同一台机器上(而我们在生产环境中不会这么做,对吧?),否则管理器的任何问题只会影响那些管理功能。所以如果管理器组件或运行它的机器崩溃,其影响可能是最小的。正在运行的任务将会继续运行。然而,用户将无法向系统提交新任务,因为管理器将无法接收这些请求并采取相应行动。同样,这会带来不便,但不一定是世界末日。

9.3 恢复选项

由于编排系统中的故障可能会在多个层面发生,并且会产生不同程度的影响,恢复选项也是如此。

9.3.1 从应用程序故障中恢复

正如我们之前所讨论的,应用程序可能会在启动时由于外部依赖项不可用而失败。在这里,唯一真正的自动化恢复选项是使用指数退避或其他一些机制进行重试。编排系统无法像变魔术一样解决外部依赖项的问题(当然,除非该外部依赖项也在编排系统上运行)。编排器为我们提供了一些工具来自动从这类情况中恢复,但如果数据库不可用,不断重启应用程序并不能改变这种情况。

同样地,编排系统也无法帮助我们解决应用程序中引入的缺陷。真正的解决方案是像自动化测试这样的工具,它们可以在缺陷被部署到生产环境之前帮助识别出这些缺陷。

9.3.2 从环境故障中恢复

编排系统提供了许多工具来处理非特定于应用程序的故障。我们可以将其余的故障场景归为一类,并称之为环境故障:

  • Docker 故障

  • 机器故障

  • 编排器工作节点故障

  • 编排器管理器故障

让我们来介绍一下编排系统可以帮助从这些类型的故障中恢复的一些方法。

9.3.3 从任务层面的故障中恢复

Docker 有一个内置机制,用于在容器退出时重启容器。这个机制被称为重启策略,可以在命令行中使用 -restart 标志来指定。在下面的示例命令行中,我们使用 sun4965485/echo-smy:v1 镜像运行一个容器,并告诉 Docker 如果容器因为故障而退出,就重启它一次:

$ docker run --restart=on-failure:1 --name echo -p 7777:7777 -it sun4965485/echo-smy:v1

Docker 支持四种重启策略:

  • no —— 当容器退出时不做任何操作(这是默认策略)。

  • on-failure —— 如果容器以非零状态码退出,则重启该容器。

  • always —— 无论退出码是多少,始终重启容器。

  • unless-stopped —— 始终重启容器,除非容器已被停止。

你可以在网址 mng.bz/VRWN 的文档中了解更多关于 Docker 重启策略的信息。

当处理在编排系统之外运行的单个容器时,这种重启策略效果很好。在大多数生产环境中,我们将 Docker 本身作为一个 systemd 单元来运行。systemd 作为大多数 Linux 发行版的初始化系统,能够确保本应运行的应用程序实际上正在运行,尤其是在重启之后。

然而,对于作为编排系统一部分运行的容器来说,使用 Docker 的重启策略可能会带来问题。主要问题在于,这会让处理故障的责任变得模糊不清。最终是 Docker 守护进程负责处理故障吗?还是编排系统呢?此外,如果 Docker 守护进程参与了故障处理,这会给编排器增加复杂性,因为编排器需要与 Docker 守护进程进行核对,以确定它是否正在重启一个容器。

对于我们的 Cube 系统,我们将自行处理任务故障,而不是依赖 Docker 的重启策略。不过,这个决定又引出了另一个问题:应该由管理器还是工作节点来负责处理故障呢?

工作节点离任务最近,所以让工作节点来处理故障似乎是很自然的。但工作节点只了解自身的存在情况。因此,它只能在自己的上下文中尝试处理故障。如果它负载过重,它无法决定将任务发送给另一个工作节点,因为它不知道其他工作节点的存在。

而管理器虽然离控制任务执行的实际机制较远,但它对整个系统有更全面的了解。它知道集群中的所有工作节点,也知道在每个工作节点上运行的各个任务。因此,与单个工作节点相比,管理器在从故障中恢复方面有更多的选择。它可以要求运行失败任务的工作节点尝试重启该任务。或者,如果该工作节点负载过重或不可用(也许它崩溃了),管理器可以找到另一个有能力运行该任务的工作节点。

9.3.4 从工作节点故障中恢复

正如我之前在讨论工作节点故障类型时提到的,当涉及到工作节点时,存在两种不同类型的故障。一种是工作节点组件本身的故障,另一种是运行工作节点的机器的故障。

在第一种情况下,当我们的工作节点组件出现故障时,我们有最大的灵活性。工作节点本身对于现有任务来说并非至关重要。一旦一个任务启动并运行起来,工作节点就不再参与该任务的持续操作。所以,如果工作节点出现故障,对正在运行的任务不会有太大影响。然而,在这种状态下,工作节点处于降级状态。管理器将无法与工作节点进行通信,这意味着它无法收集任务状态,也无法在该工作节点上放置新任务。它同样也无法停止正在运行的任务。

在这种情况下,我们可以让管理器尝试修复工作节点组件。怎么做呢?首先想到的明显办法是让管理器认为该工作节点已失效,并将所有任务转移到另一个工作节点上。然而,这是一种相当强硬的策略,可能会造成更多的混乱。如果管理器只是简单地认为那些任务已失效,并尝试在另一个工作节点机器上重启它们,那么如果这些任务仍在工作节点崩溃的那台机器上运行,会发生什么情况呢?通过盲目地认为工作节点及其所有任务都已失效,管理器可能会使应用程序处于意外状态。当任务只有单个实例时,这种情况尤其如此。

第二种情况,即工作节点机器崩溃,也很棘手。我们如何定义 “崩溃” 呢?这是意味着管理器无法通过其 API 与工作节点通信吗?还是意味着管理器要执行一些其他操作来验证工作节点是否正常运行 —— 例如,通过尝试进行 ICMP ping 操作?此外,即使管理器确实尝试了 ICMP ping 操作但没有收到响应,它能确定工作节点机器实际上已停机吗?如果问题是工作节点机器的网卡坏了,但机器在其他方面正常运行,那该怎么办呢?同样地,如果管理器和工作节点机器位于不同的网络段,而路由器、交换机或其他网络设备出现故障,从而将两个网络分隔开,导致管理器无法与工作节点机器通信,又该怎么办呢?

正如我们所看到的,试图使我们的编排系统能够抵御工作节点组件的故障,比最初看起来要复杂得多。很难确定一台机器是否已停机 —— 也就是说它已经崩溃或已断电,并且在其他方面没有运行任何任务,在这种情况下 Docker 守护进程也没有运行,其控制下的任何任务也都没有运行。

9.3.5 从管理器故障中恢复

最后,让我们考虑一下针对管理器组件故障的应对方案。和工作节点一样,存在两种故障场景。一种是管理器组件本身可能出现故障,另一种是运行管理器组件的机器可能出现故障。虽然这些场景与工作节点的情况相同,但它们的影响以及我们处理它们的方式略有不同。

首先,如果管理器出现故障,无论故障是管理器组件本身还是运行它的机器,对正在运行的任务都没有影响。任务和工作节点独立于管理器运行。在我们的编排系统中,如果管理器出现故障,工作节点及其任务会继续正常运行。唯一的区别是工作节点将不会收到任何新任务。

其次,在我们的编排系统中,从管理器故障中恢复可能比从工作节点层的故障中恢复要简单一些。记住,为了简单起见,我们说过我们将只运行单个管理器。所以如果它出现故障,我们只需要尝试恢复单个实例。如果管理器组件崩溃,我们可以重启它。(理想情况下,我们会使用像 Systemd 或 supervisord 这样的初始化系统来运行它。)如果它的数据存储损坏,我们可以从备份中恢复它。

虽然不理想,但管理器故障并不会使我们的整个系统瘫痪。它确实会给开发人员带来一些困扰,因为在管理器停机期间,他们将无法启动新任务或停止现有任务。所以新功能的部署或错误修复将被推迟,直到管理器重新上线。

显然,关于管理器的理想状态是运行多个管理器实例。这就是像 Borg、Kubernetes 和 Nomad 这样的编排器所做的。就像运行多个工作节点一样,运行多个管理器实例会增加整个系统的弹性。然而,这也增加了复杂性。

当运行多个管理器时,我们必须考虑在所有实例之间同步状态。可能会有一个主实例负责处理用户请求并对其采取行动。这个实例还将负责在其他管理器之间分发系统中所有任务的状态。如果主实例出现故障,那么另一个实例可以接管它的角色。在这一点上,我们开始进入共识领域以及系统如何就世界状态达成一致的概念。这就是像 Raft 协议这样的东西发挥作用的地方,但深入探讨这个话题超出了本书的范围。

9.4 实现健康检查

考虑到这种复杂性,为了便于说明,我们将实现一个简单的解决方案。我们将在任务级别实现健康检查。这里的基本思想有两个方面:

  1. 应用程序实现一个健康检查,并在其 API 上以 /health 的形式公开它。(端点的名称可以是任何名称,只要定义明确且不改变即可。)

  2. 当用户提交任务时,他们将健康检查端点定义为任务配置的一部分。管理器会定期调用任务的健康检查,如果收到的响应不是 200 状态码,就会尝试为该任务启动一个新任务。

有了这个解决方案,我们就不必担心工作节点机器是否可达。我们也不必弄清楚工作节点组件是否正常工作。我们只需调用任务的健康检查,如果它响应说它正在按预期运行,我们就知道管理器可以继续它的工作。

注意:在实际操作中,我们仍然关心工作节点组件是否按预期运行。但我们可以将这个问题与任务健康以及我们需要在何时尝试重启任务分开处理。

健康检查有两个组成部分。首先,工作节点必须定期检查其任务的状态并相应地更新它们。为此,它可以调用 Docker API 上的 ContainerInspect() 方法。如果任务处于除运行之外的任何状态,那么工作节点就会将任务的状态更新为 “失败”。

其次,管理器必须定期调用任务的健康检查。如果检查不通过(即返回的响应码不是 200),它就会向相应的工作节点发送一个任务事件以重启该任务。

9.4.1 在工作节点上检查任务

让我们从重构我们的工作节点开始,以便它能够检查任务的 Docker 容器的状态。如果我们回想一下第 3 章,我们实现了以下 Docker 结构体。这个结构体的目的是保存对 Docker 客户端实例的引用,通过这个引用我们可以调用 Docker API 并执行各种容器操作。它还保存了任务的 Config

type Docker struct {
    Client *client.Client
    Config Config
}

为了处理来自 ContainerInspect API 调用的响应,让我们在 task/task.go 文件中创建一个名为 DockerInspectResponse 的新结构体。 正如我们在清单 9.1 中看到的,这个结构体将包含两个字段。如果在调用 ContainerInspect 时遇到错误,Error 字段将保存该错误。Container 字段是一个指向 types.ContainerJSON 结构体的指针。这个结构体是在 Docker 的 Go 软件开发工具包(SDK)(mng.bz/ orjD)中定义的。它包含了关于容器的各种详细信息,但对于我们的目的来说最重要的是,它包含了 State 字段。这是 Docker 所看到的容器的当前状态。

type DockerInspectResponse struct {
    Error error
    Container *types.ContainerJSON
}

关于 Docker 容器状态的说明

Docker 容器状态的概念可能会让人感到困惑。例如,docker ps 命令的文档(mng.bz/n19d)提到了按容器… 之一。

type State struct {

Running bool

Paused bool

Restarting bool

OOMKilled

RemovalInProgress

Dead

...

}

正如我们所看到的,从技术层面来讲,并不存在名为 “已创建(created)” 的状态,也没有 “已退出(exited)” 状态。那么这是怎么回事呢?原来在 State 结构体上有一个名为 StateString 的方法(mng.bz/QRj4),正是这个方法执行了一些逻辑,从而产生了我们在 docker ps 命令文档中看到的那些状态。

除了添加 DockerInspectResponse 结构体之外,我们还打算在现有的 Docker 结构体中添加一个新方法。我们将这个方法命名为 Inspect。它应该接受一个字符串作为参数,该字符串表示我们想要检查的容器 ID。然后,它应该返回一个 DockerInspectResponse 类型的值。这个方法的主体很简单直接。它会创建一个名为 dc 的 Docker 客户端实例。接着,我们调用客户端的 ContainerInspect 方法,传入一个上下文 ctx 和一个容器 ID。我们检查是否有错误,如果发现错误就返回它。否则,我们就返回一个 DockerInspectResponse

code 9.2 The Inspect method calling the Docker API

func (d *Dcoker) Inspect(containerID string) DockerInspectResponse {
    dc, _ := client.NewClientWithOpts(client.FromEnv)
    ctx := context.Background()
    resp, err := dc.ContainerInspect(ctx, containerID)
    if err != nil {
        log.Printf("Error inspecting container: %s\n", err)
        return DockerInspectResponse{Error: err}
    }
    
    return DockerInspectResponse{Container: &resp}
}

既然我们已经实现了检查任务的方法,那就继续在 Worker 中使用它吧。

9.4.2 在工作节点上实现任务更新

为了让工作节点能够更新其任务的状态,我们需要对它进行重构,以便使用我们在 Docker 结构体上创建的新 Inspect 方法。首先,让我们打开 worker/worker.go 文件,并按照清单 9.3 所示添加 InspectTask 方法。这个方法接受一个类型为 task.Task 的单个参数 t。它创建一个任务配置 config,然后设置一个 Docker 类型的实例,通过这个实例我们能够与运行在工作节点上的 Docker 守护进程进行交互。最后,它调用 Inspect 方法,并传入容器 ID。

code 9.3 The InspectTask method

func (w *Worker) InspectTask(t task.Task) task.DockerInspectResposne {
    config := task.NewConfig(&t)
    d := task.NewDocker(config)
    return d.Inpect(t.ContainerID)
}

接下来,工作节点将需要调用它新创建的 InspectTask 方法。为了实现这一点,让我们采用我们过去一直使用的相同模式。我们将创建一个名为 UpdateTasks 的公共方法,这样我们就可以在一个单独的协程中运行它。这个方法不过是一个包装器,它运行一个持续的循环,并调用负责完成所有主要工作的私有方法 updateTasks

func (w *Worker) UpdateTasks() {
    for {
        log.Println("Checking status of tasks")
        w.updateTasks()
        log.Println("Task updates completed")
        log.Println("Sleeping for 15 seconds")
        time.Sleep(15 * time.Second)
    }
}

updateTasks 方法执行一个非常简单的算法。对于工作节点数据存储中的每个任务,它会执行以下操作:

  1. 调用 InspectTask 方法,从 Docker 守护进程获取任务的状态。

  2. 验证任务是否处于运行状态。

  3. 如果任务不处于运行状态,或者根本没有运行,就将任务的状态设置为失败。

updateTasks 方法还会执行另一项操作。它会设置任务上的 HostPorts 字段。这使我们能够查看 Docker 守护进程为任务正在运行的容器分配了哪些端口。因此,工作节点新的 updateTasks 方法负责调用新的 InspectTask 方法,根据任务的 Docker 容器的状态来更新任务的状态。

code 9.5 The worker’s new updateTasks method

func (w *Worker) updateTasks() {
    for id, t := range w.Db {
        if t.State == task.Running {
            resp := w.InspectTask(*t)
            if resp.Error != nil {
                fmt.Printf("Error: %v\n", resp.Error)
            }
            if resp.Container == nil {
                log.Printf("No container for running task %s\n", id)
                w.Db[id].State = task.Failed
            }
            if resp.Container.State.Status == "exited" {
                log.Printf("Container for task %s in non-running state %s", id, resp.Container.State.Status)
                w.Db[id].State = task.Failed
            }
            
            w.Db[id].HostPorts = resp.Container.NetworkSettings.NetworkSettingsBase.Ports
        }
    }
}

9.4.3 健康检查与重启

我们已经提到过,我们将让管理器对在我们编排系统中运行的任务执行健康检查。但是我们要如何标识这些健康检查,以便管理器能够调用它们呢?实现这一点的一个简单方法是在 Task 结构体中添加一个名为 HealthCheck 的字段,按照惯例,我们可以使用这个新字段来包含一个 URL,管理器可以调用该 URL 来执行健康检查。

除了 HealthCheck 字段之外,我们还在 Tasks 结构体中添加一个名为 RestartCount 的字段。正如我们将在本章后面看到的,每次任务重启时,这个字段的值都会增加。

code 9.6 Adding the HealthCheck and RestartCount fields

type Task struct {
    // ...
    HealthCheck string
    RestartCount int
}

这种健康检查方式的好处在于,它将定义任务 “健康” 的含义的责任交给了任务本身。实际上,不同任务对 “健康” 的定义可能大相径庭。所以,通过让任务将其健康检查定义为一个可由管理器调用的 URL,管理器只需调用该 URL 即可。调用任务健康检查 URL 的结果将决定任务是否健康:如果调用返回 200 状态码,则任务是健康的;否则,任务不健康。

既然我们已经实现了支持健康检查策略所需的部分,接下来就编写管理器利用这些工作成果所需的代码。我们先从最底层的代码开始。在编辑器中打开 manager/manager.go 文件,按照清单 9.7 所示添加 checkTaskHealth 方法。该方法实现了让管理器检查单个任务健康状况的必要步骤。它接受一个类型为 task.Task 的参数 t,如果健康检查未成功,则返回一个错误。

关于这个方法,有几点需要注意。首先,请记住,当管理器将一个任务调度到某个工作节点时,它会在其 TaskWorkerMap 字段中添加一个条目,该条目将任务的 ID 映射到被调度到的工作节点。这个条目是一个字符串,代表工作节点的 IP 地址和端口(例如 192.168.1.100:5555),也就是工作节点 API 的地址。当然,任务监听的端口与工作节点 API 的端口不同。因此,有必要获取 Docker 守护进程在任务启动时为其分配的端口,我们通过调用 getHostPort 辅助方法来实现这一点。然后,利用工作节点的 IP 地址、任务监听的端口以及任务定义中定义的健康检查,管理器可以构建一个类似 http://192.168.1.100:49847/health 的 URL。

code 9.7 The manager’s new checkTaskHealth method

func (m *Manager) checkTaskHealth(t task.Task) error {
    log.Printf("Calling health check for task %s: %s\n", t.ID, t.HealthCheck)
    
    w := m.TaskWorkerMap[t.ID]
    hostPort := getHostPort(t.HostPorts)
    worker := strings.Split(w, ":")
    url := fmt.Sprintf("http://%s:%s%s", worker[0], *hostPort, t.HealthCheck)
    log.Printf("Calling health check for task %s: %s\n", t.ID, url)
    
    resp, err := http.Get(url)
    if err != nil {
        msg := fmt.Sprintf("Error connecting to health check %s", url) 
        log.Println(msg)
        return errors.New(msg)
    }
    
    if resp.StatusCode != http.StatusOK {
        msg := fmt.Sprintf("Error health check for task %s did not return 200\n", t.ID) 
        log.Println(msg) 
        return errors.New(msg)
    }
    
    log.Printf("Task %s health check response: %v\n", t.ID, resp.StatusCode)
    
    return nil
}

getHostPort 方法是一个辅助方法,用于返回任务正在监听的主机端口。

func getHostPort(ports nat.PortMap) *string {
    for k, _ := range ports {
        return &ports[k][0].HostPort
    }
    return nil
}

既然我们的管理器已经知道如何调用单个任务的健康检查,接下来我们创建一个新方法,利用这个能力对系统中的所有任务进行操作。需要注意的是,我们希望管理器仅对处于 “运行中(Running)” 或 “失败(Failed)” 状态的任务进行健康检查。处于 “待处理(Pending)” 或 “已调度(Scheduled)” 状态的任务正在启动过程中,此时我们不想尝试调用它们的健康检查。而 “已完成(Completed)” 状态是最终状态,意味着任务已正常停止且处于预期状态。

我们检查单个任务健康状况的流程如下:遍历管理器 TaskDb 中的任务。如果任务处于 “运行中” 状态,调用该任务的健康检查端点;若健康检查失败,则尝试重启任务。如果任务处于 “失败” 状态,无需调用其健康检查,直接尝试重启任务。我们可以将这个流程总结如下:

  • 如果任务处于 “运行中” 状态,调用管理器的 checkTaskHealth 方法,该方法会进而调用任务的健康检查端点。若任务的健康检查失败,尝试重启任务。

  • 如果任务处于 “失败” 状态,尝试重启任务。

这个流程在清单 9.9 的 doHealthChecks 方法中实现。注意,我们仅在任务的 RestartCount 字段小于 3 时才尝试重启失败的任务。这里我们随意选择只对失败任务尝试重启三次。如果要编写一个具备生产质量的系统,我们可能会采用更智能、更复杂的处理方式。

code 9.9 The manager’s doHealthChecks method

func (m *Manager) doHealthChecks() {
    for _, t := range m.GetTasks() {
        if t.State == task.Running && t.RestartCount < 3 {
            err := m.checkTaskHealth(*t)
            if err != nil {
                if t.RestartCount < 3 {
                    m.restartTask(t)
                }
            }
        } else if t.State == task.Failed && t.RestartCount < 3 {
            m.restartTask(t)
        }
    }
}

doHealthChecks 方法会调用 restartTask 方法,该方法负责重启失败的任务,具体实现如清单 9.10 所示。尽管代码行数较多,但逻辑相当简单。由于我们的管理器只是简单地尝试在任务最初被调度的同一个工作节点上重启任务,所以它会使用任务的 task.ID 字段在 TaskWorkerMap 中查找该工作节点。接下来,它会将任务的状态更改为 “已调度(Scheduled)”,并增加任务的 RestartCount。然后,它会覆盖 TaskDb 数据存储中现有的任务,以确保管理器拥有任务的正确状态。到这一步,剩下的代码应该看起来很熟悉了。它会创建一个 task.TaskEvent,将任务添加到其中,然后将 TaskEvent 序列化为 JSON 格式。使用经过 JSON 编码的 TaskEvent,它会向工作节点的 API 发送一个 POST 请求来重启任务。

code 9.10 The manager’s new restartTask method

func (m *Manager) restartTask(t *task.Task) {
    w := m.TaskWorkerMap[t.ID]
    t.State = tsak.Scheduled
    t.RestartCount++
    m.TaskDb[t.ID] = t
    
    te := task.TaskEvent{
        ID: uuid.New(),
        State: task.Running,
        Timestamp: time.Now(),
        Task: *t,
    }
    data, err := json.Marshal(te)
    if err != nil {
        log.Printf("Unable to marshal task object: %v.", t)
        return
    }
    
    url := fmt.Sprintf("http://%s/tasks", w)
    resp, err := http.Port(url, "application/json", bytes.NewBuffer(data))
    if err != nil {
        log.Printf("error connecting to %v: %v", w, err)
        m.Pending.Enqueue(t)
        return
    }
    
    d := json.NewDecoder(resp.Body)
    if resp.StatusCode != http.StatusCreated {
        e := worker.ErrResponse{}
        err := d.Decode(&e)
        if err != nil {
            fmt.Printf("Error decoding response: %s\n", err.Error())
            return
        }
        log.Printf("Response error (%d): %s, e.HTTPStatusCode, e.Message)
        return
    }
    
    newTask := task.Task{}
    err = d.Decode(&newTask)
    if err != nil {
        fmt.Printf("Error decoding response: %s\n", err.Error())
        return
    }
    log.Printf("%#v\n", t)
}

9.11 The DoHealthChecks method wrapping the doHealthChecks method

func (m *Manager) DoHealthChecks() {
    for {
        log.Println("Performing task health check")
        m.doHealthChecks()
        log.Println("Task health checks completed")
        log.Println("Sleeping 60 seconds")
        time.Sleep(60 * time.Second)
    }
}

9.5 整合所有内容

为了测试我们的代码并查看其运行效果,我们需要一个实现了健康检查的任务。此外,我们还需要一种方法来触发它失败,这样我们的管理器就会尝试重启它。为此,我们可以使用本章开头提到的回显服务。

要运行它,请使用以下命令:

docker run -p 7777:7777 --name echo sun4965485/echo-smy:v1

echo 服务有三个端点。使用 POST 和 JSON 编码的请求体调用根端点时,只会在响应体中回传 JSON 请求体:

$ curl -X POST http://localhost:7777/ -d '{"Msg": "hello world"}'
{"Msg":"hello world"}

以 GET 方式调用健康状况端点将返回一个空的正文和 200 OK 响应:

$ curl -v http://localhost:7777/health
* Host localhost:7777 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:7777...
* Connected to localhost (::1) port 7777
> GET /health HTTP/1.1
> Host: localhost:7777
> User-Agent: curl/8.7.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 200 OK
< Date: Thu, 13 Mar 2025 07:33:00 GMT
< Content-Length: 2
< Content-Type: text/plain; charset=utf-8
< 
* Connection #0 to host localhost left intact
OK%

最后,以 GET 方式调用 healthfail 端点将返回一个空的正文,并显示 500 Internal Server Error(内部服务器错误):

$ curl -v http://localhost:7777/healthfail
* Host localhost:7777 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:7777...
* Connected to localhost (::1) port 7777
> GET /healthfail HTTP/1.1
> Host: localhost:7777
> User-Agent: curl/8.7.1
> Accept: */*
> 
* Request completely sent off
< HTTP/1.1 500 Internal Server Error
< Date: Thu, 13 Mar 2025 07:33:42 GMT
< Content-Length: 21
< Content-Type: text/plain; charset=utf-8
< 
* Connection #0 to host localhost left intact
Internal server error%

此时,我们可以在本地启动工作节点和管理器。我们仅需对第 8 章main.go文件中的代码做两处修改。第一处是在工作节点上调用新的UpdateTasks方法,这能让工作节点定期检查任务状态并更新。第二处是在管理器上调用新的DoHealthChecks方法,使得管理器可以对任务执行健康检查并处理失败任务的重启。代码的其余部分保持不变,完成这些修改后就能启动工作节点和管理器。

9.12 Adding UpdateTasks and DoHealthChecks methods to main.go

func main() {
    ...
    m := manager.New(workers) 
    mapi := manager.Api{Address: mhost, Port: mport, Manager: m}
    
    go m.ProcessTasks() 
    go m.UpdateTasks() 
    go m.DoHealthChecks()
    
    mapi.Start()
}

当我们启动 Worker 和管理器时,应该会看到如下熟悉的输出:

$ CUBE_WORKER_HOST=localhost CUBE_WORKER_PORT=5555 CUBE_MANAGER_HOST=localhost  CUBE_MANAGER_PORT=5556 go run main.go

Starting Cube Worker
Starting Cube Manager
2025/03/14 12:09:14 No tasks in the queue
2025/03/14 12:09:14 Sleeping for 10 second
2025/03/14 12:09:14 Checking for task updates from workers
2025/03/14 12:09:14 Checking worker localhost:5555 for task updates
2025/03/14 12:09:14 Processing any tasks in the queue
2025/03/14 12:09:14 No work in the queue
2025/03/14 12:09:14 Sleeping for 10 seconds
2025/03/14 12:09:14 Collecting stats
2025/03/14 12:09:14 No tasks in the database
2025/03/14 12:09:14 Task update completed
2025/03/14 12:09:14 Sleeping for 15 second

我们可以通过向 Worker 和管理器的 API 发送请求来验证它们是否按预期运行:

$ curl localhost:5555/tasks
null

$ curl localhost:5556/tasks
[]

正如我们所预期的,工作节点和管理器对于它们各自的 /tasks 端点都返回空列表。

让我们创建一个任务,以便管理器可以启动它。为了简化这个过程,创建一个名为 task1.json 的文件,并添加以下清单中的 JSON 内容。我们可以将一个以 JSON 格式存储的任务保存在一个文件中,然后将该文件传递给 curl 命令,这样既节省时间又能避免混淆。

code 9.13 Storing a task in a file

{
	"ID": "a7aa1d44-08f6-443e-9378-f5884311019e",
	"State": 2,
	"Task": {
		"State": 1,
		"ID": "bb1d59ef-9fc1-4e4b-a44d-db571eeed203",
		"Name": "test-chapter-9.1",
		"Image": "timboring/echo-server:latest",
		"ExposedPorts": {
			"7777/tcp": {}
		},
		"PortBindings": {
			"7777/tcp": "7777"
		},
		"HealthCheck": "/health"
	}
}

接下来,让我们使用此 JSON 作为请求体,向管理器发出 POST 请求:

curl -v -X POST localhost:5556/tasks -d @task1.json
...
< HTTP/1.1 201 Created
< Date: Fri, 14 Mar 2025 04:12:36 GMT
< Content-Length: 380
...
{
	"ID": "bb1d59ef-9fc1-4e4b-a44d-db571eeed203",
	"ContainerID": "",
	"Name": "test-chapter-9.1",
	"State": 1,
	"CPU": 0,
	"Memory": 0,
	"Disk": 0,
	"Image": "timboring/echo-server:latest",
	"RestartPolicy": "",
	"ExposedPorts": {
		"7777/tcp": {}
	},
	"HostPorts": null,
	"PortBindings": {
		"7777/tcp": "7777"
	},
	"StartTime": "0001-01-01T00:00:00Z",
	"FinishTime": "0001-01-01T00:00:00Z",
	"HealthCheck": "/health",
	"RestartCount": 0
}
* Connection #0 to host localhost left intact

当我们将任务提交给管理器时,我们应该能看到管理器和工作节点按照它们的正常流程来创建该任务。最终,如果我们向管理器查询任务状态,我们应该会看到任务处于运行状态:

$ curl http://localhost:5556/tasks | jq

[
  {
    "ID": "bb1d59ef-9fc1-4e4b-a44d-db571eeed203",
    "ContainerID": "7ce955b187917b5e8620d4f8e40f7a195f821b6f6a24d1e02af3d9527f5ea75b",
    "Name": "test-chapter-9.1",
    "State": 2,
    "CPU": 0,
    "Memory": 0,
    "Disk": 0,
    "Image": "docker.io/strm/helloworld-http",
    "RestartPolicy": "",
    "ExposedPorts": {
      "7777/tcp": {}
    },
    "HostPorts": null,
    "PortBindings": {
      "7777/tcp": "7777"
    },
    "StartTime": "2025-03-14T04:15:04.327334Z",
    "FinishTime": "0001-01-01T00:00:00Z",
    "HealthCheck": "/health",
    "RestartCount": 0
  }
]

最终,我们应该能看到管理器的输出结果,显示它调用了任务的健康检查:

2025/03/14 12:17:14 Checking port bindings for 7777/tcp: [{0.0.0.0 7777}]
2025/03/14 12:17:09 Found host port: 7777
2025/03/14 12:17:09 Attempting health check with URL: http://127.0.0.1:7777/health
2025/03/14 12:17:09 Health check response body: OK
2025/03/14 12:17:09 Task bb1d59ef-9fc1-4e4b-a44d-db571eeed203 health check successful with URL http://127.0.0.1:7777/health, response: 200
2025/03/14 12:17:09 Checked health for task bb1d59ef-9fc1-4e4b-a44d-db571eeed203 (Manager Current State: 2, RestartCount: 0)
2025/03/14 12:17:09 Task health check completed

这是个好消息。由此可见,我们的健康检查策略生效了。至少在任务运行正常的情况下是如此!那如果任务运行不正常,会发生什么呢?

为了探究任务健康检查失败时的情况,我们向管理器提交另一个任务。这次,我们将把任务的健康检查端点设置为 /healthfail。第二个任务的 JSON 定义里包含的健康检查会返回非 200 状态码。

code 9.14 The JSON definition for our second task

{
	"ID": "a7aa1d44-08f6-443e-9378-f5884311019e",
	"State": 2,
	"Task": {
		"State": 1,
		"ID": "bb1d59ef-9fc1-4e4b-a44d-db571eeed203",
		"Name": "test-chapter-9.1",
		"Image": "docker.io/sun4965485/echo-smy:v1",
		"ExposedPorts": {
			"7777/tcp": {}
		},
		"PortBindings": {
			"7777/tcp": "7777"
		},
		"HealthCheck": "/healthfail"
	}
}


如果我们在 Worker 和管理器运行的终端中观察输出,最终应该会看到调用该任务的 healthfail 端点会返回非 200 响应:

2025/03/17 14:08:09 Attempting health check with URL: http://127.0.0.1:7777/healthfail
2025/03/17 14:08:09 Health check response body: Internal server error
2025/03/17 14:08:09 Health check for task bb1d59ef-9fc1-4e4b-a44d-db571eeed203 did not return 200, got 500

由于健康检查失败,我们应该看到管理器尝试重新启动任务:

2025/03/17 14:08:09 Restarting failed task bb1d59ef-9fc1-4e4b-a44d-db571eeed203 (attempt 1)
2025/03/17 14:08:09 Added task {bb1d59ef-9fc1-4e4b-a44d-db571eeed203 8629425af9799dc2ccd06bfd3bdc185da3251e796defcbfbb64bd9e495c13c15 test-chapter-9.1 1 0 0 0 docker.io/sun4965485/echo-smy:v1  map[7777/tcp:{}] map[7777/tcp:[{0.0.0.0 7777}]] map[7777/tcp:7777] 2025-03-17 06:07:38.482953 +0000 UTC 0001-01-01 00:00:00 +0000 UTC /healthfail 1}
2025/03/17 14:08:09 Task bb1d59ef-9fc1-4e4b-a44d-db571eeed203 restarted successfully with container ID: 8629425af9799dc2ccd06bfd3bdc185da3251e796defcbfbb64bd9e495c13c15

这个过程会一直持续,直到任务被重启了三次。三次之后,管理器将停止尝试重启该任务,此时任务会保持在 “运行(Running)” 状态。我们可以通过查询管理器的 API 并查看任务的 State(状态)和 RetryCount(重试次数)字段来验证这一点:

[  {    "ID": "bb1d59ef-9fc1-4e4b-a44d-db571eeed203",    "ContainerID": "6a384ee05b8d17e91cf9133d2458a8d1307f1d45dfb69243c1425829c704e2d8",    "Name": "test-chapter-9.1",    "State": 2,    "CPU": 0,    "Memory": 0,    "Disk": 0,    "Image": "docker.io/sun4965485/echo-smy:v1",    "RestartPolicy": "",    "ExposedPorts": {      "7777/tcp": {}    },    "HostPorts": {      "7777/tcp": [        {          "HostIp": "0.0.0.0",          "HostPort": "7777"        }      ]
    },
    "PortBindings": {
      "7777/tcp": "7777"
    },
    "StartTime": "2025-03-17T06:10:12.072027Z",
    "FinishTime": "0001-01-01T00:00:00Z",
    "HealthCheck": "/healthfail",
    "RestartCount": 1
  }
]

除了在健康检查失败时重启任务外,这一策略还适用于任务死亡的情况。例如,我们可以使用 docker stop 命令手动停止任务的容器,模拟任务死亡的情况。让我们为我们创建的第一个任务做这件事,它应该还在运行:

    "ID": "bb1d59ef-9fc1-4e4b-a44d-db571eeed203",
    "ContainerID": "6a384ee05b8d17e91cf9133d2458a8d1307f1d45dfb69243c1425829c704e2d8",
    "Name": "test-chapter-9.1",
    "State": 2,

然后,我们就能看到容器再次运行了:

2025/03/17 14:33:56 Performing task health check
2025/03/17 14:33:56 Checking health for task bb1d59ef-9fc1-4e4b-a44d-db571eeed203 (Manager Current State: 2, RestartCount: 1)
2025/03/17 14:33:56 Calling health check for task bb1d59ef-9fc1-4e4b-a44d-db571eeed203: /health
2025/03/17 14:33:56 Successfully inspected container 653194223b5fa8277e49382a3972518bedc23d3a6023431d3d396fe32a95377c for task bb1d59ef-9fc1-4e4b-a44d-db571eeed203
2025/03/17 14:33:56 Container 653194223b5fa8277e49382a3972518bedc23d3a6023431d3d396fe32a95377c port mappings: map[7777/tcp:[{0.0.0.0 7777}]]
2025/03/17 14:33:56 Updated port mappings for task bb1d59ef-9fc1-4e4b-a44d-db571eeed203: map[7777/tcp:[{0.0.0.0 7777}]]
2025/03/17 14:33:56 Task bb1d59ef-9fc1-4e4b-a44d-db571eeed203 port mappings: map[7777/tcp:[{0.0.0.0 7777}]]
2025/03/17 14:33:56 Available host ports: map[7777/tcp:[{0.0.0.0 7777}]]
2025/03/17 14:33:56 Checking port bindings for 7777/tcp: [{0.0.0.0 7777}]
2025/03/17 14:33:56 Found host port: 7777
2025/03/17 14:33:56 Attempting health check with URL: http://127.0.0.1:7777/health
2025/03/17 14:33:56 Health check response body: OK
2025/03/17 14:33:56 Task bb1d59ef-9fc1-4e4b-a44d-db571eeed203 health check successful with URL http://127.0.0.1:7777/health, response: 200
2025/03/17 14:33:56 Task health check completed

如果我们查询管理器的 API,就会发现该任务的重启次数为 1:

2025/03/17 14:33:56 Checked health for task bb1d59ef-9fc1-4e4b-a44d-db571eeed203 (Manager Current State: 2, RestartCount: 1)

总结

故障总是会发生,而且其原因可能多种多样。

在编排系统中处理故障是一件复杂的事情。我们可以将任务级别的健康检查作为一种策略来处理少量的故障场景。编排系统在一定程度上可以自动化从任务故障中恢复的过程。然而,超过一定程度后,它最能做到的是提供有用的调试信息,以帮助管理员和应用程序所有者进一步排查问题。(请注意,我们的编排系统并没有做到这一点。如果你对此感到好奇,可以尝试实现类似任务日志记录这样的功能。 )