利用毒素氧化和黄瓜设计容错微服务


设计容错微服务。


你可能还喜欢:Making Your Microservices Resilient and Fault Tolerant

从第0天开始思考容错

容错性——以及安全性和其他特征——在服务已经建立之后很难考虑进去。它是通过在你的服务诞生的同时做出精心的设计决定而创造出来的。

“当数据库不存在时会发生什么?如果所有连接都超时,会发生什么情况?” 

这些都是有效的问题,你最好在你的服务上线前做好准备!

关于毒性

Toxiproxy是一个很酷的可编程代理,由Shopify制作,用于测试目的。我们将在测试场景中使用它来预定义网络特征。这些场景负责描述服务的故障模式。

为什么是黄瓜?

故障模式很难理解,但仍然在我们的服务生命周期中占有重要地位。通常,您只能通过查看源代码来理解特定服务的故障模式。这些实现通常都是低级的,因此仅仅依靠代码阅读就更难理解它们。最好使用活文档来理解服务的故障模式,这样团队就可以轻松地维护和支持服务。此外,在步骤定义实现确定之后,它为实验提供了更多的机会。

我们要建造什么?

该图显示了示例中容器连接的概述docker compose 文件。该应用程序连接到一个缓存集群和一个由Redis主/从和MySQL提供的数据库实例。它是通过Toxiproxy间接实现的,Toxiproxy提供了一个REST应用编程接口来控制网络特性。测试将能够分别改变应用程序及其每个依赖项之间的网络连接。黄瓜为测试场景提供了一种易读的格式,并允许在特性文件中声明我们的应用程序的失败模式。

如何运行该示例?

GitHub example看看README.md关于如何启动服务以及所有依赖关系。它还包含运行黄瓜测试的命令。

Before,BeforeAll,AfterAll实现,以更好地理解步骤定义如何与Toxpiproxy API

我将展示一个想象的发展过程,就像我自己做练习时经历的那样。读者可以通读这篇文章并查看示例代码库,以了解开发过程是什么样子的。

当事情变糟时

我们要实现的第一件事是无法访问数据库的情况。

Feature: ...
  ...
  Background:
    Given user 'u-12345abde234' with name 'Jack' is cached
    And user 'u-12345abde234' with name 'Jack' is stored

  Scenario: Read-only mode without MySQL
    Given 'MySQL' is down
    When user 'u-12345abde234' is requested
    Then the user with id 'u-12345abde234' is returned from 'Redis'


storage-availability.feature

在执行测试后不久,我们将意识到,当MySQL不存在时,我们的应用程序就会崩溃。如果是有意的,快速失败绝对是件好事。如果我们只是想在缓存中有数据时谦逊地发回数据,会怎么样?

似乎修复应用程序崩溃相对容易。我们只需要修改我们的代码来使用connection pools

function connect({host, port, user, password}, callback) {
  const pool = mysql.createPool({
    host: host,
    port: port,
    user: user,
    password: password
  });
  callback(null, pool);
}


dao.js

在下一步中,我们将添加另外两个场景,确保正确处理写入问题,并且应用程序能够在数据库恢复在线后恢复。

Feature: ...
  ...
  Scenario: Write not allowed without MySQL
    Given 'MySQL' is down
    When new user created with id 'u-1123' and name 'Joe'
    Then HTTP 503 is returned

  Scenario: Write is restored with MySQL
    Given 'MySQL' is down
    When 'MySQL' is up
    And new user created with id 'u-1123' and name 'Joe'
    And user 'u-1123' is requested
    Then the user with id 'u-1123' is returned from 'MySQL'


storage-availability.feature

第二个场景没有代码变化,因为数据库驱动程序在为我们工作。我们只需要确保写错误得到正确处理,并且我们也能够通过第一个场景。

app.put('/users/:userId', function (req, res) {
  ...
  dao.saveUser(app.daoConnection, user, (err) => {
    if (err) {
      console.error("MySQL error, when saving user - ", err);
      return res.sendStatus(503);
    }
  ...
  });
});


main.js

缓存问题

现在,让我们定义与缓存相关的故障模式的场景。如果Redis主服务器不可用,该怎么办?在我们运行Redis从机之前,我们仍然应该使用它来返回缓存的结果。

Feature: Cache availability scenarios for user service
  User service should survive all possible failure scenarios

  Background:
    Given user 'u-12345abde234' with name 'Jack' is cached
    And user 'u-12345abde234' with name 'Jack' is stored

  Scenario: Cache read-only mode without Redis master
    Given 'redis-master' is down
    When user 'u-12345abde234' is requested
    Then the user with id 'u-12345abde234' is returned from 'Redis'


cache-availability.feature

这是事情变得有趣的时刻。如果我们打算用简单的master/slave模式—不使用哨兵或带有碎片的集群—使用传统的节点JS驱动程序不可能故障转移到只读从机。我不得不寻找一个更聪明的客户,并最终使用thunk-redis。幸运的是,这两个应用编程接口几乎是相同的,我可以选择在连接初始化期间定义主和从的IP地址。您可以在中看到所有编程语言的客户端列表official website

function connect({
    hosts, ...
  }, callback) {
  const client = redis.createClient(hosts, {onlyMaster: false});
  ...
  client.on("error", function (err) {
    console.error("Redis error caught on callback - ", err);
  });
  client.on("warn", function (err) {
    console.warn("Redis warning caught on callback - ", err);
  });
  ...
  callback(null, client);
}


cache.js

{
    "default": {
      "config_id": "default",
      ...
      "redis": {
        "hosts": ["192.168.99.106:6379", "192.168.99.106:16379"],
        ...
      },
      ...
    }
}


config.json

我们应该如何处理缓存写入的任何错误?下一步是为这些用例开拓另外两个场景。

Feature: Cache availability scenarios for user service
  ...
  Background:
    Given user 'u-12345abde234' with name 'Jack' is cached
    And user 'u-12345abde234' with name 'Jack' is stored
  ...
  Scenario: Write cache fails without Redis master
    Given 'redis-master' is down
    When user is updated with id 'u-12345abde234' and name 'Joe'
    And user 'u-12345abde234' is requested
    Then the user with id 'u-12345abde234' and name 'Jack' is returned from 'Redis'
...


cache-availability.feature

第一种情况是表示应用程序应该在写入失败后从只读缓存返回陈旧的结果。请注意,这在您的情况下可能是不可接受的。即使写入缓存失败,写入仍应到达数据库。这意味着在缓存驱逐之后,我们必须从MySQL中获取最新的用户。

下一个场景是处理当Redis主机恢复业务时的情况。

Feature: ...
  ...
  Scenario: Write cache connection is restored after Redis master is up
    Given 'redis-master' is down
    And 'redis-master' is up
    When user is updated with id 'u-12345abde234' and name 'Joe'
    And user 'u-12345abde234' is requested
    Then the user with id 'u-12345abde234' and name 'Joe' is returned from 'Redis'
  ...


cache-availability.feature

让我们开始在我们的应用程序中实现它。好消息是thunk-redis当主机不可用时,自动连接到从机。不幸的是,当主服务器再次可用时,情况并非如此。司机会把READONLY每次写入从节点失败时的错误代码。它不会自动重新连接到主机。我们必须检测这些故障并启动重新连接。这就是它的样子。

function reconnectOnReadOnlyError(client, err) {
  if (!err.code || err.code !== 'READONLY') {
    return;
  }
  setTimeout(() => {
    reconnect(client);
  }, client.reconnectToMasterMs);
  console.debug("Reconnecting to master after %d ms", client.reconnectToMasterMs);
}
...
function storeUser(client, user) {
  client.hmset(user.id, 'id', user.id, 'name', user.name)(err => {
    if (err) {
      console.error("Storing user in REDIS failed", err);
      reconnectOnReadOnlyError(client, err);
    }
  });
}
...
function evictUser(client, userId) {
  client.del(userId)(err => {
    if (err) {
      console.error("Deleting user in REDIS failed", err);
      reconnectOnReadOnlyError(client, err);
    }
  });
}


cache.js

你注意到上面代码中的问题了吗?我们将只读模式的检测与缓存应用编程接口调用紧密结合在一起。这有什么问题?如果没有流量,就不会检测到故障,也不会启动恢复。甚至重试的次数也取决于试图写入缓存的调用(只是一个小提示:一次重新连接的尝试通常是不够的)。

我们的应用程序能够通过新编写的场景吗?不幸的是,不是第一次尝试。应用程序需要检测多个失败的操作,并且需要一些时间来恢复。我不得不多次重写这个场景。经过这么多努力,它看起来还是很难看,而且它的格式可能会伤害你的眼睛:

Feature: ...
  ...
  @fragile
  Scenario: Write cache connection is restored after Redis master is up
    ...
    Given 'redis-master' is up
    And we wait a bit
    When user is updated with id 'u-12345abde234' and name 'Joe'
    And we wait a bit
    When user is updated with id 'u-12345abde234' and name 'Joe'
    And user 'u-12345abde234' is requested
    # Should be cached now
    And user 'u-12345abde234' is requested
    Then the user with id 'u-12345abde234' and name 'Joe' is returned from 'Redis'
    ...


cache-availability.feature

哎呀!这是我们需要重新思考我们在做什么的地方。故障模式的描述对于人类来说应该是容易理解的。我将把它留给读者来修复测试用例和应用程序(可能我也会在接下来的文章中这样做)。以下是一些如何做到这一点的指南:

  • 故障检测应该定期进行,类似于下一节中如何实现超时检测。
  • 这个场景应该有一个定义好的时间间隔,在这个时间间隔内,我们期望得到正确的结果。

现在,我将使用@fragile注释。我认为把脆弱的测试分开是一个很好的做法。您可以选择不中断构建,但是如果其中一个失败了,仍然要生成一个测试报告,直到团队提出一个稳定的解决方案。

上面的场景可以用cucumber-js —tags @fragile

超时设定

我们有很多场景可以处理一种类型的toxic。也是时候尝试其他的了。让我们看看发生超时时的一些故障。

Feature: Cache availability scenarios for user service
...
  Scenario: Redis master/slave time out
    Given 'redis-master' times out
    And 'redis-slave' times out
    When user 'u-12345abde234' is requested
    Then the user with id 'u-12345abde234' is returned from 'MySQL'


cache-availability.feature

不幸的是,这是一个缺陷thunk-redis实现正在浮出水面。我们的应用程序一直在等待,直到最终放弃并拒绝对我们的Redis连接的所有请求。我必须引入一个合适的连接验证逻辑,以便能够有效地处理超时。简而言之,这就是每个客户库应该确保内部表示的所有连接都准备好接收请求:

  • 为每个阻塞呼叫添加一个超时间隔作为额外参数。
  • 定期检查已建立的连接是否仍然有效。

让我们从介绍一个包装方法开始,它允许超时间隔参数:

...
function callWithTimeout(method, timeout, callback) {
  let timeoutTriggered = false;
  const afterTimeout = setTimeout(() => {
    timeoutTriggered = true;
    callback(new Error(`Execution timed out after ${timeout}ms`));
  }, timeout);
  method((...args) => {
    clearTimeout(afterTimeout);
    // We avoid sending callbacks multiple times.
    if (!timeoutTriggered) {
      return callback(...args);
    }
  });
}
...


cache.js

好消息是JavaScript允许我们定义一个通用方法来包装任何类型的函数。这是我将用来实现我们的Redis连接的连接验证逻辑的方法:

function reconnect(client) {
  client.clientEnd();
  client.clientConnect();
}
...
function initiateScheduledPing(client) {
  setInterval(() => {
    callWithTimeout(client.ping(), client.pingTimeoutMs, (err) => {
      if (err) {
        console.error("Ping failed, reconnecting:", err);
        reconnect(client);
      }
    });
  }, client.pingIntervalMs);
}


cache.js

请注意client.ping()只是将函数传递给的“工厂方法”callWithTimeout。那是一种特殊的方言thunk-redis

启动Redis连接将通过配置参数并启动池验证:

function connect(
  {
    ...
    pingIntervalMs,
    pingTimeoutMs,
    ...
  },
  callback) {
  ...
  client.pingIntervalMs = pingIntervalMs;
  client.pingTimeoutMs = pingTimeoutMs;
  ...
  initiateScheduledPing(client);
  ...
}


cache.js

每次呼叫thunk-redis应该用callWithTimeout,这就是如何让用户具有预定义的超时间隔:

function getUser(client, userId, callback) {
  callWithTimeout(client.hgetall(userId), client.callTimeoutMs, callback);
}


cache.js

在实现了所有这些不那么简单的库扩展之后,我们的场景最终会通过。

结论

为什么我不能只使用集成测试和模拟?

驱动器是一个黑匣子。它们包含了许多你意想不到的惊喜。创建一个可编程的模拟包括很多关于驱动程序将如何运行的假设。上面的方法给了你很多探索的空间。需要完全理解您的驱动程序在处理连接故障方面的局限性。

毒性节点客户端是怎么回事?

不幸的是使用toxiproxy-node-client结果不太好,所以我决定实现我自己的客户端调用Before,BeforeAll,AfterAll

为什么我们不用断路器?

说得好。我计划在另一篇文章中继续展示如何用它们来解决上述问题。

缺点

  • 服务只能在一段时间后恢复。在不同的环境中,这一时期可能不同。这需要仔细微调所有与超时和宽限期相关的配置。
  • 如果你的流量没有稳定的特征,把故障检测和某种行为联系起来不是一个好主意。例如,检测写失败不应该与写尝试紧密联系在一起,除非有相关请求的适当流量。


进一步阅读

Microservices Architectures: What Is Fault Tolerance?

Improving the Reliability of Microservices

Challenges in Implementing Microservices