현재 개발중인 서비스가 akka를 사용중인데 외부와 http 통신할 때 akka-http-client를 사용하고 있습니다.
이전까지는 크게 문제 없이 서비스에서 사용중이었는데, 점점 서비스가 성장하면서 TPS가 늘은 이후로 어느 순간부터 이상한 에러들이 자주 발생하고 서비스를 배포한 직후에 요청들이 대량으로 RequestTimeout 에러가 발생하는 현상이 나타났습니다.
관련된 문제들과 수정 방법들을 정리합니다.
AKKA http client
akka http client의 기본 동작을 먼저 설명하자면 akka http client는 모든 http 요청과 이에 대한 응답이 stream 방식으로 동작하게 설계된 모듈입니다.
기본적으로 back-pressure 동작이 tcp layer, http layer를 넘어 HttpRequest와 HttpResponse, 그리고 HttpEntity API 에서도 동작합니다. Spring의 WebClient를 사용해 보셨다면 이해하기 쉬울 수 있습니다.
akka http client의 동작 방식은 세 가지 방식이 존재하는데 request-level, host-level 그리고 마지막으로 connection-level이 있습니다. 각 방식이 필요한 상황이 달라 각 서비스의 상황에 따라 사용하면 됩니다.
akka http client를 사용할 예정이라면 그냥 코드를 베껴서 적용하면 문제가 발생할 가능성이 매우 높아서 반드시 document에서도 읽기를 강력히 권장하는 akka http client concpet 문서를 읽어보시기를 추천드립니다.
Request level akka http client
host-level api를 사용자가 조금 더 편히 사용할 수 있게 wrapping한 스타일로 akka http client를 사용하는 대부분의 상황에서 사용하기에 적합한 방식입니다.
http.singleRequest(), http.superPool()을 통해서 사용할 경우 request level api를 사용하는 것입니다.
이를 사용한 예제는 document의 코드를 보시면 됩니다.
http.singleRequest()는 Future 기반의 동작으로 그 결과값이 Future[HttpResponse]입니다.
만약 actor 내부에서 http.singleRequest()를 사용한다면 이는 Future를 동작시키는 것과 같으므로 그 결과를 직접 조작하지 말고 pipeTo를 통해 자신 actor의 메시지로 변환해주어야 합니다.
왜 직접 future의 andThen, catch 같은 함수로 연결하지 않고 메시지로 변환해야 하냐면 thread starvation을 피하기 위함입니다. 이는 akka-http-client가 아닌 actor의 주의사항이므로 document를 반드시 읽어보시길 추천드립니다.
http.singleRequest() 외에도 http.superPool() 방식을 사용할 수 있는데 특수한 상황에서만 사용하므로 이후에 설명하겠습니다.
Host level akka http client
Host level api는 scala의 source를 활용하는 법과 비슷한데 Http.cachedHostConnectionPool를 사용해 특정 도메인을 지정한 connection pool을 만들고 해당 pool을 통해 stream 방식으로 요청하고 결과를 소비하면 됩니다.
자세한 사용법은 document의 코드를 보시면 됩니다.
Connection level akka http client
가장 low level의 http client connection pool을 만드는 방법으로 일반적으로는 사용할 이유가 없습니다.
http connection의 생성과 종료, 요청과 결과값의 소비를 모두 스스로 코드적으로 조절할 수 있는데 문제가 발생할 가능성이 높으므로 추천하지 않습니다.
자세한 사용법은 document의 코드를 보시면 됩니다.
request, host, connection level의 사용처
request level의 사용법으로 http.singleRequest(), http.superPool()이 있고 host level의 사용법으로는 http.cachedHostConnectionPool()이 존재합니다. connection level은 가장 low level 사용법이므로 사용을 권장하지 않습니다.
http.singleRequest()는 url의 host 주소별로 커넥션 풀을 가집니다.
즉, max-connection이 4라면 url1에 4, url2에 4처럼 최대 커넥션 수가 도메인 별로 정해집니다.
짧은 response time에 적은 buffering같이 가장 일반적으로 사용하는 패턴에 사용하면 좋습니다.
즉, 대부분의 상황에서는 request level api인 singleRequest() 방식을 사용하면 됩니다.
http.cachedHostConnectionPool()은 생성할 때 부터 host 주소를 주게 되어있고, 생성된 것마다 각자 풀을 가집니다.
동일한 host로 cachedHostConnectionPool을 생성해도 전부 동일한 connection pool을 공유합니다.
다만 생성자에서 config를 건네줄 수 있어서 이 기능을 사용하면 동일한 host여도 다른 풀을 가지게 됩니다.
주로 Streaming 데이터같이 큰 데이터를 조금도 누락하지 않고 느리게 보내도 될 때 사용하면 됩니다.
http.superPool()은 host 레벨이 아니라 전체 Pool 레벨에서 max-connections를 처리합니다.
달리 말하자면 host가 달라도 모두 하나의 connection pool을 공유해서 사용하게 되어 max-connection이 4이면 전체 합쳐서 4개의 connection만 가지게 됩니다.
모든 host를 동일하게 하나의 pool로 관리하고 싶을 때 사용하면 되지만 언제 사용하면 될 지는 잘 모르겠습니다.
정리하자면 대부분은 그냥 http.singleRequest()를 사용하면 되고, 만약 Streaming 전송이 필요하다면 http.cachedHostConnectionPool()을 사용하면 됩니다.
akka http stream 방식의 주의점
해당 문제는 document에서도 주의깊게 설명해주니 반드시 읽어보시길 추천드립니다.
akka-http-client가 Streaming 방식으로 동작하면서 주의해야 할 점이 생기는데 성공이든 실패든 어떤 형식으로든 응답이 온다면 이를 반드시 소비해줘야 한다는 것입니다.
응답을 특정 entity로 변환한다면 Unmarshaling을 통해 특정 entity로 변환하고, 만약 값이 필요없다면 반드시 discardEntityByte() 함수를 통해 반환값을 버려줘야 합니다.
akka-http-client 발생한 문제들과 해결법
TimeoutException: Response entity was not subscribed after ... 오류
해당 문제는 stream 방식의 주의점으로 얘기한 http 요청 이후 response를 소비하지 않아서 발생하는 오류입니다.
akka http client는 response가 도착하면 akka configuration에 설정한 akka.http.host-connection-pool.response-entity-subscription-timeout 시간만큼 대기하고, 해당 대기시간 이후에도 response를 소비하지 않으면 해당 오류를 발생시킵니다.
만약 필요없는 결과여서 의도적으로 소비하지 않는 경우라면 response의 함수인 discardEntityByte() 함수를 사용해 결과값을 버려야 합니다.
akka http client는 back pressure 메커니즘이 동작하게 되어있어 만약 response를 소비하지 않는다면 해당 response가 계속 남아있는 상태로 유지되고, 이후에 도착하는 response들 역시 아직 소비되지 않은 response가 소비될 때까지 계속 대기하는 상태가 유지됩니다. 이는 서비스의 성능에도 악영향을 끼치고 예측할 수 없는 결과를 가져오므로 반드시 결과를 소비하여야합니다.
connection reset by peer ... 오류
서버에서 일방적으로 connection을 끊어서 발생하게 되는 오류입니다.
일반적으로 서버와 클라이언트가 conneciton을 잡아두면 서버는 유휴시간까지만 대기하고 더 이상 요청이 없으면 연결을 종료합니다.
이 때 connection 입장에서는 어떻게 graceful shutdown을 처리할 지 알 수 없기 때문에 메시지를 보내는 중이든 아니든 상관없이 그냥 연결이 끊어지고 새로운 요청을 보내게 된다면 이 때 연결을 다시 맺게 됩니다.
이 때 connection reset by peer ... 오류 메시지가 발생하게 됩니다. 말 그대로 연결을 다시 맺었다는 오류입니다.
이를 해결하려면 akka.http.host-connection-pool.keep-alive-timeout 설정의 시간을 짧게 줄이면 됩니다.
일반적으로 spring 서버를 사용할경우 keep-alive-timeout을 5초 정도로 설정하게 되니 이보다 짧은 시간인 4초로 설정하면 됩니다.
단 이는 절대적인 정답이 아니므로 각 서버마다 keep-alive-timeout 설정이 다를 수 있으니 반드시 경험에 의거해 수정해줘야 합니다.
Buffer overflow exception 오류
Http.singleRequest() 또는 Http.cachedConnectionPool()을 이용하여 http request를 전송할 때 설정으로 허용한 최대 갯수를 넘어서 요청을 보낼경우 발생하는 오류입니다.
akka.http.host-connection-pool.max-open-requests의 숫자를 최대 숫자보다 높게 가져가면 됩니다.
max-open-requests 수는 반드시 0보다 크고 2의 승수여야 하므로 이 제약을 지켜서 가져가야 합니다.
예를들어 최대 요청 갯수가 108개라면 108보다 크고 가장 가까운 2의 승수인 128로 설정하시면 됩니다.
이와 연관된 설정으로 akka.http.host-connection-pool.max-connections가 존재하는데 요청 갯수는 늘렸지만 요청이 종료되는 수보다 요청이 추가되는 수가 더 많아서 지속적으로 buffer overflow exception이 발생한다면 max-connections의 수를 늘려서 병렬적으로 더 많이 요청을 보내게 수정하시면 됩니다.
배포시 발생하는 Request timeout 오류
제가 개발하는 서비스는 Future 기반의 http.singleRequest를 사용해 http 요청을 처리하고 있습니다.
이 request timeout 오류는 항상 초기 배포를 시작하고 http 요청을 받을 때 발생하게 되고, 1분정도 지나면 오류가 잦아들어서 서비스가 정상적으로 작동하는 상황이었습니다.
문제가 생각보다 복잡해서 쉽게 해결할 수는 없었지만 akka document와 akka-http-client-config document, 해당 blog 글을 통해 문제점을 알게되어 해결할 수 있었습니다.
처음에는 단순히 warm-up을 하지 않아 처음 요청이 오래걸려 발생하는 것이라 생각했습니다.
Document에 직접적으로 나오지는 않았지만 아래 글을 통해 Request level api는 실제로 요청을 보내기 전까지 connection pool을 설정하지 않고, connection을 open하지도 않음을 유추할 수 있었습니다.
When you request a pool client flow with Http().cachedHostConnectionPool(...), Akka HTTP will immediately start the pool, even before the first client flow materialization. However, this running pool will not actually open the first connection to the target endpoint until the first request has arrived.
따라서 웜업용 로직을 삽입하여서 자기 자신에게 http 요청을 보내게 수정하면 connection pool을 생성할 것이고 connection도 open 상태로 유지되어 문제가 해결될 것이라 추측할 수 있었습니다.
또, 웜업이 끝나지 않았는데 외부 요청을 받으면 안되니 http 요청이 성공한 이후에 akka http 서버를 띄워서 서비스를 동작하게 하였습니다.
이렇게 웜업 로직을 삽입한 후에 새롭게 배포를 진행하였는데, 이전에는 만 건 정도 발생하던 Request timeout 오류가 천 건으로 줄어들어 확실히 효과는 있었지만 완벽하게 해결되지는 않았습니다.
해결되지 않은 이유는 http.singleRequest()의 구체적인 동작을 이해하지 못하고 웜업을 수행하였고, 실제 웜업이 끝나기 전에 http 요청을 여전히 받고 있는 것이었습니다.
위에서 설명하였지만 http.singleRequest()는 url의 host 주소별로 커넥션 풀을 가집니다. 따라서 자기 자신에게 http 요청을 수행하면 connection pool은 localhost 호스트에만 생성되고 실제 요청을 보낼 host의 connection pool은 생성되지 않는 것입니다.
Request timeout 오류가 줄어든 이유는 비록 타겟 host의 connection pool은 생성되지 않았지만 http는 미리 생성되면서 어느정도 warmup이 수행되었고, 또 웜업 로직이 실제 비즈니스 로직을 타게 만들어서 JVM이 실제로 사용되는 코드를 메모리에 올리고 최적화된 것이 그 이유였습니다.
따라서 http warmup 호출을 자기 자신에게 호출하지 않고 실제 요청할 서버의 host로 바꾸면 문제는 해결될 수도 있을거라 생각했습니다.
다만 여기서 문제는 상대 서버에 웜업용으로 request 요청을 보내면 실제 비즈니스 로직이 수행된다는 점이었습니다.
이에 대한 해결책으로 실제 url을 쓰지않고 health check용 url인 ping url을 호출하는 것으로 connection pool 웜업을 수행했습니다.
또 새롭게 만든 웜업 요청은 akka-http 서버가 뜬 후에 웜업을 수행하였는데, 일단 http 서버가 뜨면 요청을 받을 수 있다고 가정하기 때문에 웜업은 아직 다 수행되지 않았는데 외부의 요청을 받고 있었습니다.
이를 해결하기 위해 kubernetes의 startup_probe에 웜업 요청 추가하여 웜업이 끝난 이후에 요청을 받게 수정하였습니다.
이외에도 평소에 요청이 많다면 akka.http.host-connection-pool.min-connections를 수정하여서 connection에서 최소로 유지하는 갯수를 조정하여 connection을 생성하고 삭제할 때 드는 비용을 줄일 수도 있습니다.
min-connections를 수정하면 서버가 올라간 후 초기에 대량으로 몰리는 요청과 중간에 갑작스레 몰리는 요청을 timeout 없이 처리가 가능해지는데, 이에대한 trade-off로 gc 시간이 늘어나는 문제가 있습니다. 어떻게 처리해야 할 것인지는 각 서비스에서 전체 결과를 모니터링하면서 결정하는게 좋습니다. 해당 옵션의 default 값은 0입니다.
또한 현재 서비스에서는 http 요청을 굉장히 다양한 서비스들에게 호출하는데 특정한 하나의 서비스가 전체 요청의 대부분을 차지하고 있습니다. 따라서 connection pool을 나누는게 굉장히 유의미할 수 있었고, 해당 도메인만 더욱 많은 connection과 request를 할당하고 나머지는 이보다 적게 줄여서 connection pool을 최적화했습니다.
특정 host의 connection pool 설정은 document의 per-host-override 설정을 보면 됩니다.
특별히 수정할 것은 없고 max-connections, min-connections, max-open-requests를 늘려주었습니다.
max-open-requests 역시 서버의 TPS와 max-connections를 통해서 계산하여 수정하여야 합니다.
이렇게 수정한 결과가 실제로 유효했는지를 검증하기 위해 성능테스트를 진행하였는데, 수정하지 않은 원본 소스는 기존과 동일하게 시간이 굉장히 오래걸리는 요청들이 많이 발생하였는데 수정한 이후의 소스는 크게 시간이 밀리는 요청 없이 모두 안정적으로 수행되는 것을 확인할 수 있었습니다.
Akka는 document가 정말 상세하게 적혀있으니 만약 akka를 사용하신다면 무조건 document를 읽고 사용하시기를 추천드립니다.