HTTP idle timeout 튜닝하기
본 글에서는 서버 운영 중 발생하는 간헐적인 503 Bad Gateway 오류의 원인을 HTTP Idle Timeout 설정 문제로 진단하고 해결 방법을 제시합니다.
Contents
회사에서 서버 운영을 하다, 서버측에서 간헐적으로 503 Bad Gateway 가 발생하는 이슈를 발견하였다. 별도의 이상 징후가 없는 상황에서, 간헐적으로 UnHealthy 에러가 발생하여 이상하다고 생각하였다. 이슈를 트래킹해보니, HTTP 의 Idle Timeout 설정으로 인해 발생한 이슈라는 사실을 발견하였다.
HTTP Keep-Alive
  웹 개발을 하다 보면 자주 마주치게 되는 HTTP Header 이다 (공식문서). 다수의 HTTP 요청에서 연결을 유지하고 재사용하는 매커니즘이다. HTTP Keep-Alive 를 통해, 핸드셰이크를 비롯한 Connection 에 수반되는 여러 비용들을 절약할 수 있다. HTTP/1.0 에서는 Connection: Keep-Alive 를 직접적으로 명시하여야 했지만, HTTP/1.1 이후부터는 디폴트로 적용되며, Connection: Close 헤더가 있으면 Keep-Alive 설정이 해제된다.
HTTP Keep-Alive 를 활용하여, HTTP Idle Timeout 을 설정할 수 있다. HTTP Idle Timeout 보다 짧은 주기로 요청이 계속 온다면, 지속적으로 연결을 유지시킨다. HTTP Idle Timeout 동안 상대방과 통신이 없다면, 상대방과와의 연결을 끊는다. 연결이 끊어진 이후에 상대방이 요청을 보내면, 새로운 연결을 체결한다. 단, HTTP Keep-Idle Timeout 은 아래에 나오는 TCP Keep-Alive 패킷은 제외하고 계산한다. HTTP Idle Timeout 은 Client, Server 가 각각 상대방에 대해 설정할 수 있다.
TCP Keep-Alive
TCP Keep-Alive 는 이번 이슈와는 직접적인 관련이 없지만(HTTP Keep-Alive 와는 무관한 개념이다), HTTP Keep-Alive 와 자주 혼동되는 개념이어서 정리해 보았다. TCP Keep-Alive 는 TCP(L4) 레이어에서 연결 상태를 확인하고 유지하는 매커니즘이다. TCP Keep-Alive 는 정해진 주기마다 패킷을 보내, 상대방이 연결을 끊었는지 확인한다. 주로 방화벽 등에서 Sleep 상태로 판단하고 연결을 끊어내는 상황을 방지한다.
Race Condition
  위의 개념을 이해한 상태에서, 다음과 같은 상황을 가정해 보자.

프록시 서버의 HTTP Idle Timeout 은 20초, 어플리케이션 서버의 HTTP Idle Timeout 은 10초로 설정되어 있다. 위의 상황에서, 아래와 같은 상황이 발생할 수 있다.

1. 약 10초가 흘렀을 때에, 클라이언트가 요청을 보낸다.
2. 프록시는 해당 요청을 어플리케이션 서버측에 보낸다.
3. 어플리케이션 서버 입장에서는 HTTP Idle Timeout 이 지났으므로, 프록시에 FIN 패킷을 전송한다.
4. 프록시는 어플리케이션 서버로부터 정상 응답을 받지 못했으므로(FIN), 503 응답을 내보낸다.
...
이후에 새로운 요청이 들어오면, 새로운 HTTP 연결을 수립한다.
위의 상황에서 알 수 있듯이, 프록시가 끼어있는 상황에서 HTTP Idle Timeout 은 min(프록시 HTTP Idle Timeout, 어플리케이션 서버 HTTP Idle Timeout) 이다. 특히, 어플리케이션 서버의 HTTP Idle Timeout 이 프록시의 HTTP Idle Timeout 보다 짧게 설정되면, 간헐적으로 503 에러가 발생할 수 있다. 트래픽이 많은 상황에서는 타임아웃 자체가 날 확률이 적으므로 문제가 되지 않지만, 트래픽이 적은 상황(새벽 시간대, 개발환경 등) 에서는 위의 현상이 발생 가능하다. 위의 예시의 상황을 가장 흔하게 볼 수 있는 상황은 리버스 프록시 이다. 
Solution
  해결방법을 간단히 요약하면, 리버스 프록시의 HTTP Idle Timeout 보다 어플리케이션 서버의 HTTP Idle Timeout 을 길게 설정 하면 된다. 

위의 상황에서는, Idle Timeout 이슈가 발생하지 않는다.
1. 약 20초가 흘렀을 때에, 클라이언트가 요청을 보낸다.
2. 프록시는 다음 중 하나의 방식으로 동작한다.
  2-1. 프록시의 타임아웃이 지나지 않은 경우, 프록시가 지니고 있는 Connection Pool 중 하나에서 연결을 골라, 어플리케이션 서버와 통신한다.
  2-2. 프록시의 타임아웃이 지난 경우, 어플리케이션 서버와 새로운 연결을 수립한 후 통신한다.
어떠한 경우에도, 프록시와 어플리케이션 서버는 정상적으로 통신할 수 있다. 그렇다면 이 해결책을 어떻게 서버의 코드로 구현할 수 있을까 ?
Nest.js 에서는 다음과 같이 간단하게 해결할 수 있다.
export class AppModule implements OnApplicationBootstrap {
  constructor(private readonly host: HttpAdapterHost<FastifyAdapter>) {}
  onApplicationBootstrap(): void {
    // 리버스 프록시의 타임아웃이 1시간인 경우, 1시간 + 5초의 시간으로 HTTP Idle Timeout 을 설정한다.
    const httpIdleTimeoutMs = 3605 * 1000;
    const httpServer = this.host.httpAdapter.getHttpServer();
    server.keepAliveTimeout = httpIdleTimeoutMs;
  }
}
            
            이것도 읽어보세요