Java - AWSのNLB越しに長時間かかるリクエストを送ると応答待ちのまま終わらなくなる場合の対策

EC2上のWebアプリから別のEC2上にあるWebアプリのAPIをリクエストした際、応答までに350秒以上経過するとリクエストした側が応答待ちのまま終わらなくなる、という事象があった。
調べた時に具体的な対策サンプルが少なそうだったので書く。

構成

だいたいこんな感じ

  • EC2: Linux
  • Web Application: Java17 (Spring Boot 2.7.3)

原因

Network Load Balancer - Elastic Load Balancing

NLBを経由するリクエストがアイドル状態のまま350秒以上経過すると、NLBはその接続を切断する。
この時通知などは行われないため、リクエストした側は切断されたことを検知できず永久に応答を待ち続けることになる。
また、応答した側もエラーにはならないため何が起きたのか分かりづらい。

流れはこんな感じ

対策

TCP KeepAliveで継続的にパケットを流すことで、350秒のカウンターをリセットし続けることができる。
リクエストする側で以下2点を修正する。

  1. リクエスト時のソケット設定でTCP KeepAliveを有効にする
  2. OSの設定でTCP KeepAliveパケット送出開始までの待機時間を350秒未満にする
    TCP KeepAliveパケットの送出開始までの待機時間はOSが制御している。
    デフォルトでは7200秒(2時間)になっているようなので、350秒未満に変更する。

サンプルコード

http://localhost:8080/greetingで待機し、リクエストを受け付けると外部APITCP KeepAliveを有効にしたリクエストを送るサンプル。
Spring Bootでプロジェクトを新規作成し、以下の2ファイルをコピペすれば動くはず。

1. ソース

build.gradle
plugins {
  id 'org.springframework.boot' version '2.7.3'
  id 'io.spring.dependency-management' version '1.0.13.RELEASE'
  id 'java'
  id 'war'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-web'
  // ソケットでTCP KeepAliveを設定するために使用
  implementation 'org.apache.httpcomponents:httpclient:4.5.13'
  providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
GreetingController.java
package com.example.demo;

import org.apache.http.config.SocketConfig;
import org.apache.http.impl.client.HttpClients;
import org.slf4j.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.*;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.scheduling.annotation.*;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;

@EnableAsync
@RestController
public class GreetingController {
  private Logger logger = LoggerFactory.getLogger(GreetingController.class);

  @Lazy
  @Autowired
  private ExternalApiAccessor api;

  @GetMapping("/greeting")
  public String greeting() throws Exception {
    logger.info("greeting");
    api.access();
    return "Hello, World!";
  }

  @Bean
  public RestTemplate restTemplate() {
    // ソケットのTCP KeepAliveを有効にする
    var socketCfg = SocketConfig.custom().setSoKeepAlive(true).build();
    var httpclient = HttpClients.custom().setDefaultSocketConfig(socketCfg).build();
    var reqFactory = new HttpComponentsClientHttpRequestFactory(httpclient);
    var template = new RestTemplateBuilder().requestFactory(() -> reqFactory).build();
    return template;
  }

  @Service
  public class ExternalApiAccessor {
    @Autowired
    private RestTemplate restTemplate;

    @Async
    public void access() throws Exception {
      logger.info("API access");
      // NLBの向こう側にいて350秒以上かかるAPI
      var apiUrl = "https://example.com/external_api/heavy_work";
      var response = restTemplate.getForEntity(apiUrl, String.class);
      logger.info(response.getBody());
    }
  }
}

2. OS設定変更

EC2
## 現状確認
# sysctl -a | grep tcp_keepalive
net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_time = 7200

## 修正 (このファイルがない場合は新規作成する)
# vi /etc/sysctl.conf
EC2 /etc/sysctl.conf
 :
## TCP KeepAliveパケット送出開始までの待機時間 / 350秒未満にする
net.ipv4.tcp_keepalive_time = 120
 :

これで大丈夫になるはず。