乐趣区

使用Istio和Spring-Boot在Kubernetes上进行熔断和重试

对于每个服务网格框架来说,处理服务间通信中的通信故障的能力是绝对必要的。它包括超时和 HTTP 错误代码的处理。在本文中,我将展示如何使用 Istio 配置重试和熔断机制。与之前 Kubernetes 上使用 Istio Service Mesh 的文章相同,我们将分析在 Kubernetes 上部署的两个简单 Spring Boot 应用程序之间的通信。但是,我们将讨论更高级的主题,而不是非常基本的示例。

示例

为了演示 Istio 和 Spring Boot 的用法,我在 GitHub 上创建了带有两个示例应用程序的存储库:callme-service 和 caller-service。该存储库的地址为https://github.com/piomin/sample-istio-services.git。前言中已经提到与 Istio 有关服务网格的第一篇文章使用了相同的存储库。

架构

我们的示例系统的体系结构与上一篇文章非常相似。但是,存在一些差异。我们不是在使用 Istio 组件注入故障或延迟,而是直接在源代码内部的应用程序上注入错误或延迟。为什么?现在,我们将能够直接按照为 callme-service 创建的规则进行处理,而不必像以前那样在客户端进行处理。另外,我们正在运行两个 callme-service 应用程序版本 v2 实例,以测试熔断器对相同服务(或相同 Deployment)实例的作用如何。下图说明了当前描述的体系结构。

Spring Boot 应用

我们从示例应用程序的实现开始。应用程序 callme-service 公开了两个端点,这些端点返回有关版本和实例 ID 的信息。端点 GET /ping-with-random-errorHTTP 504错误代码设置为对约 50%请求的响应。端点 GET /ping-with-random-delay 返回的响应具有 0s 和 3s 之间的随机延迟。这是 callme-service 端 @RestController 的实现。

@RestController
@RequestMapping("/callme")
public class CallmeController {private static final Logger LOGGER = LoggerFactory.getLogger(CallmeController.class);
    private static final String INSTANCE_ID = UUID.randomUUID().toString();
    private Random random = new Random();

    @Autowired
    BuildProperties buildProperties;
    @Value("${VERSION}")
    private String version;

    @GetMapping("/ping-with-random-error")
    public ResponseEntity<String> pingWithRandomError() {int r = random.nextInt(100);
        if (r % 2 == 0) {LOGGER.info("Ping with random error: name={}, version={}, random={}, httpCode={}",
                    buildProperties.getName(), version, r, HttpStatus.GATEWAY_TIMEOUT);
            return new ResponseEntity<>("Surprise" + INSTANCE_ID + " " + version, HttpStatus.GATEWAY_TIMEOUT);
        } else {LOGGER.info("Ping with random error: name={}, version={}, random={}, httpCode={}",
                    buildProperties.getName(), version, r, HttpStatus.OK);
            return new ResponseEntity<>("I'm callme-service"+ INSTANCE_ID +" " + version, HttpStatus.OK);
        }
    }

    @GetMapping("/ping-with-random-delay")
    public String pingWithRandomDelay() throws InterruptedException {int r = new Random().nextInt(3000);
        LOGGER.info("Ping with random delay: name={}, version={}, delay={}", buildProperties.getName(), version, r);
        Thread.sleep(r);
        return "I'm callme-service " + version;
    }

}

应用程序 caller-service 公开了两个 GET 端点。它使用 RestTemplate 来调用 callme-service 公开的相应 GET 端点。它还返回 caller-service 的版本,但是只有一个标有 version = v1 的应用程序的部署。

@RestController
@RequestMapping("/caller")
public class CallerController {private static final Logger LOGGER = LoggerFactory.getLogger(CallerController.class);

    @Autowired
    BuildProperties buildProperties;
    @Autowired
    RestTemplate restTemplate;
    @Value("${VERSION}")
    private String version;


    @GetMapping("/ping-with-random-error")
    public ResponseEntity<String> pingWithRandomError() {LOGGER.info("Ping with random error: name={}, version={}", buildProperties.getName(), version);
        ResponseEntity<String> responseEntity =
                restTemplate.getForEntity("http://callme-service:8080/callme/ping-with-random-error", String.class);
        LOGGER.info("Calling: responseCode={}, response={}", responseEntity.getStatusCode(), responseEntity.getBody());
        return new ResponseEntity<>("I'm caller-service "+ version +". Calling... " + responseEntity.getBody(), responseEntity.getStatusCode());
    }

    @GetMapping("/ping-with-random-delay")
    public String pingWithRandomDelay() {LOGGER.info("Ping with random delay: name={}, version={}", buildProperties.getName(), version);
        String response = restTemplate.getForObject("http://callme-service:8080/callme/ping-with-random-delay", String.class);
        LOGGER.info("Calling: response={}", response);
        return "I'm caller-service "+ version +". Calling... " + response;
    }

}

Istio 中处理重试

Istio DestinationRule的定义与我的文章使用 Istio 和 Spring Boot 在 Kubernetes 上的服务网格中的定义相同。为标记为 version = v1 和 version = v2 的实例创建了两个子集。重试和超时可以在 VirtualService 上配置。我们可以设置重试次数和重试条件(枚举字符串列表)。以下配置还为整个请求设置了 3s 超时。这两个设置都可以在 HTTPRoute 对象中使用。我们还需要为每次尝试设置超时。在这种情况下,我设置为 1。在实践中如何运作?我们将通过简单的示例对其进行分析。

apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: callme-service-destination
spec:
  host: callme-service
  subsets:
    - name: v1
      labels:
        version: v1
    - name: v2
      labels:
        version: v2
---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: callme-service-route
spec:
  hosts:
    - callme-service
  http:
    - route:
      - destination:
          host: callme-service
          subset: v2
        weight: 80
      - destination:
          host: callme-service
          subset: v1
        weight: 20
      retries:
        attempts: 3
        perTryTimeout: 1s
        retryOn: 5xx
      timeout: 3s

在部署示例应用程序之前,我们应该增加日志记录级别。我们可以轻松启用 Istio 访问日志记录。Envoy 代理打印访问日志,并将所有传入请求和传出响应发送到它们的标准输出。日志记录条目的分析将特别用于检测重试尝试。

$ istioctl manifest apply --set profile=default --set meshConfig.accessLogFile="/dev/stdout"

现在,让我们向 HTTP 端点 GET /caller/ping-with-random-delay 发送测试请求。它调用随机延迟的 callme-service 端点GET /callme/ping-with-random-delay。这是该操作的请求和响应。

看来情况非常清楚。但是,让我们检查一下背后发生了什么。我已经强调了重试的顺序。如您所见,Istio 执行了两次重试,因为前两次尝试的时间比设置为 1s 的 perTryTimoeut 更长。两次尝试均被 Istio 超时,可以在其访问日志中进行验证。第三次尝试成功了,因为它花费了大约 400 毫秒。

重试超时不是 Istio 中唯一可用的重试选项。实际上,我们可以重试所有 5XX 甚至 4XX 代码。用于测试错误代码的 VirtualService 更加简单,因为我们没有配置任何超时。

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: callme-service-route
spec:
  hosts:
    - callme-service
  http:
    - route:
      - destination:
          host: callme-service
          subset: v2
        weight: 80
      - destination:
          host: callme-service
          subset: v1
        weight: 20
      retries:
        attempts: 3
        retryOn: gateway-error,connect-failure,refused-stream

我们将使用 GET /caller/ping-with-random-error 调用 HTTP 端点,即调用 callme-service 公开的端点GET /callme/ping-with-random-error。它为大约 50%的传入请求返回 HTTP 504。这是带有 200 OK HTTP 代码的请求和成功的响应。

以下是日志,这些日志说明了 callme-service 发生的情况。由于两次第一次尝试均导致 HTTP 错误代码,因此请求已重试了 2 次。

Istio 中处理熔断

DestinationRule 对象上配置了断路器。我们为此使用了 TrafficPolicy。首先,我们不会设置用于先前示例的任何重试,因此我们需要将其从VirtualService 定义中删除。我们还应该在 TrafficPolicy 中的 connectionPool 上禁用任何重试。现在最重要。为了在 Istio 中配置断路器,我们使用 OutlierDetection 对象。Istion 断路器的实现基于下游服务返回的连续错误。后续错误的数量可以使用属性 continuous5xxErrorscontinuousGatewayErrors进行配置。它们之间的唯一区别在于它们能够处理的 HTTP 错误。尽管 ContinuousGatewayErrors 仅适用于 502、503 和 504,但 continuous5xxErrors 用于 5XX 代码。在以下 c allme-service-destination的配置中,我在 3 上使用了 set Continuous5xxErrors的设置。这意味着在发生 3 个错误行后,将从负载平衡中删除应用程序的一个实例(pod)1 分钟(baseEjectionTime = 1m)。因为我们在版本 v2 中运行两个 callme-service Pod,所以我们还需要将maxEjectionPercent 的默认值覆盖为 100%。该属性的默认值为 10%,它表示负载平衡池中可以弹出的最大主机百分比。

apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: callme-service-destination
spec:
  host: callme-service
  subsets:
    - name: v1
      labels:
        version: v1
    - name: v2
      labels:
        version: v2
  trafficPolicy:
    connectionPool:
      http:
        http1MaxPendingRequests: 1
        maxRequestsPerConnection: 1
        maxRetries: 0
    outlierDetection:
      consecutive5xxErrors: 3
      interval: 30s
      baseEjectionTime: 1m
      maxEjectionPercent: 100
---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: callme-service-route
spec:
  hosts:
    - callme-service
  http:
    - route:
      - destination:
          host: callme-service
          subset: v2
        weight: 80
      - destination:
          host: callme-service
          subset: v1
        weight: 20

部署两个应用程序的最快方法是使用 Jib 和 Skaffold。首先,您进入目录 callme-service 并使用可选的 --port-forward 参数执行 skaffold dev 命令。

$ cd callme-service
$ skaffold dev --port-forward

caller-service 做相同的操作:

$ cd caller-service
$ skaffold dev --port-forward

在发送一些测试请求之前,让我们运行callme-service v2 版本的第二个实例,因为 Deployment 将参数副本设置为 1。为此,我们需要运行以下命令。

$ kubectl scale --replicas=2 deployment/callme-service-v2

现在,让我们验证 Kubernetes 上的部署状态。有 3 个部署。

之后,我们准备发送一些测试请求。我们正在调用由 caller-service 暴露的端点 GET /caller/ping-with-random-error,即正在调用由callme-service 暴露的端点 GET /callme/ping-with-random-errorcallme-service 公开的端点针对 50%的请求返回 HTTP 504。我已经在 8080 上为 callme-service 设置了端口转发,因此用于调用应用程序的命令是:

curl http://localhost:8080/caller/ping-with-random-error

现在,让我们分析 caller-service 的响应。我已经突出显示了来自版本为 v2 的 callme-service 实例的 HTTP 504 错误代码响应,并生成了 ID 98c068bb-8d02-4d2a-9999-23951bbed6ad。在该实例的行中出现 3 次错误响应后,立即将其从负载平衡池中删除,这导致将所有其他请求发送到 ID 为 00653617-58e1-4d59-9e36-3f98f9d403b8 的 callme-service v2 的第二个实例。当然,仍然有一个 callme-service v1 实例,该实例正在接收 caller-service 发送的请求总数的 20%。

好的,让我们检查一下单个实例 callme-service v1 返回 3 个错误会发生什么情况。我还用以下可见的日志突出显示了这些错误响应。因为池中只有一个 callme-service v1 实例,所以没有机会将传入的流量重定向到其他实例。这就是为什么 Istio 为发送到 callme-service v1 的下一个请求返回 HTTP 503 的原因。自熔断后的 1 分钟内,将返回相同的响应。

PS:本文属于翻译,原文

退出移动版