3W - 타임아웃, 재시도를 활용한 네트워크 복원력

개요

이번 주차 마지막 문서도 네트워크 복원력을 높이기 위한 세팅을 알아본다.
이번에는 Virtual Service 단에서 할 수 있는 설정을 알아보자.

사전 지식

타임아웃 전략

네트워크의 신뢰성을 확보하기 위한 방법 중 하나는 타임아웃을 설정하는 것이다.
요청을 보냈는데, 응답이 돌아오지 않는다고 해서 무한정 기다리는 건 어마무시한 비효율이며 그 자체로 장애이다.
이건 생각보다 심각한 장애인데, 사람은 이걸 장애라고 인식할 수 있지만 기계 입장에서는 그저 응답이 늦게 오는 것일 뿐 장애라고 인식하지 않기 때문이다.
그래서 일찌감치 어느 정도의 시간이 지나면 타임아웃 에러로 처리해버리는 게 서비스 입장에서 네트워크 신뢰성을 확보하는 방법이 되는 것이다.

이때 여러 홉을 거치는 트래픽일 경우 각 서비스의 타임아웃을 잘 설정하는 것이 중요하다.
통상적으로는 처음 트래픽을 받는 쪽일 수록 타임아웃 기간을 길게 책정하는 것이 좋다.
A - B - C 로 흐르는 트래픽이 있다고 쳐보자.
이때 A에 타임아웃을 1초, B에 타임아웃을 2초로 걸어버리면 B가 C로부터 트래픽을 2초동안 기다리는 동안에 A는 진즉에 타임아웃으로 처리하고 에러를 반환해버린다.
이상적인 방식은 A를 2초, B를 1초로 설정하는 것이겠다.

재시도 전략

간헐적으로 에러가 발생하는 서비스가 있다면 에러를 에러라고 끝내기보다는 차라리 에러가 날 때 재시도를 날려서 정상 응답이 돌아오게 만드는 것이 네트워크의 안정성에 더욱 바람직할 것이다.
물론 이것도 트레이드 오프가 있는데,

그래서 재시도 전략은 매우 신중하게 설정하는 게 좋다.
책에서는 3가지 정도의 전략을 제시한다.

request hedging

미실습

실습이 제대로 되지 않는 마당에 시간까지 부족해 이 부분은 추후에 더 정리되면 실습 내용을 추가하겠다.


재시도와 타임아웃을 적절히 사용하여 짬뽕해서 나온 테크닉이 바로 리퀘스트 헤징이다.[1]
A로 가는 요청이 타임아웃 리밋에 걸렸을 때, 이걸 에러로 치지 않고 재시도를 B에 해버리는 것이다.
이제 클라는 A나 B나 어느 응답이 빨리 오는지만 따지면 된다.
어느 쪽이든 먼저 들어온 사람이 승자가 되는 방식이다!
이 방식도 이스티오가 현재 공식적으로 지원하고 있지 않지만, 엔보이에는 이 기능이 있다.
그래서 이걸 설정하고 싶다면 EnvoyFilter를 만들면 된다.

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: simple-backend-retry-hedge
  namespace: istioinaction
spec:
  workloadSelector:
    labels:
      app: simple-web
  configPatches:
  - applyTo: VIRTUAL_HOST
    match:
      context: SIDECAR_OUTBOUND
      routeConfiguration:
        vhost:
          name: "simple-backend.istioinaction.svc.cluster.local:80"          
    patch:
      operation: MERGE
      value:
        hedge_policy:
          hedge_on_per_try_timeout: true

서킷 브레이커

미실습

실습이 제대로 되지 않는 마당에 시간까지 부족해 이 부분은 추후에 더 정리되면 실습 내용을 추가하겠다.

서킷 브레이커는 장애가 나는 포인트로 트래픽을 보내는 것을 차단해서 전체 서비스의 장애율을 낮추는 기법이다.
가장 간단한 예시는 쿠버네티스 서비스에서 readiness probe가 failed된 파드를 엔드포인트로 두지 않는 것을 들 수 있다.

엔보이에는 서킷 브레이커 관련 설정이 존재하는데, 이스티오에서는 이를 명시적으로 명명해 사용하진 않지만 해당 기능들을 제공하긴 한다.
여기에는 크게 두 가지 방법이 있다.

데스티네이션 룰 connectionPool

  trafficPolicy:
    connectionPool:
      tcp:
        maxConnections: 100
        connectTimeout: 30ms # 커넥션 맺을 때 기다리는 시간
        tcpKeepalive: # idle 상태 유지 위해 주기적으로 작은 패킷 보내기
          time: 7200s
          interval: 75s
        maxConnectionDuration: 1h # 한 커넥션 최대 유지 시간
        idleTimeout: 1h # 트래픽 오가지 않는 상태에서 유효(idle)한 상태로 유지되는 최대 시간

업스트림 클러스터 각각의 커넥션 풀을 설정하는 필드이다.
즉 해당 클러스터를 대상으로 삼는 엔보이들은 각각 이 설정을 받게 된다.
idleTimeout 필드는 사실 리스너 쪽에 설정되는 필드라 가중치 관련 세팅이 된 환경에서 개별 적용될 수 없다.

  trafficPolicy:
    connectionPool:
      http:
        http1MaxPendingRequests: 100 # 풀이 꽉 찼을 때 큐에 저장해둘 최대 요청 수
        http2MaxRequests: 1000 # 풀 크기
        maxRequestsPerConnection: 10 # 한 tcp 커넥션에서 처리할 요청 개수(풀의 한 원소)
        maxRetries: 3
        idleTimeout: 30s
        h2UpgradePolicy: UPGRADE # http1에서 http2로의 업그레이드 정책
        useClientProtocol: false # 클라의 프로토콜을 그대로 쓸지
        maxConcurrentStreams: 256 # http2에서 최대 동시 스트림 수

http에 대해서도 설정할 수 있다.
여기에서 커넥션 풀의 개별 단위는 tcp인 것은 동일하다.
http2MaxRequests 필드가 나타내는 것이 전체 풀 크기를 나타내는 것이라고 했는데, 이름만 보면 HTTP/2로 한정짓는 것처럼 보인다.
그러나 이건 잘못 이름 지어진 것으로, 엔보이의 설정 이름을 따라했다가 수정사항은 팔로업하지 않아서 생긴 이슈이다.[2]

실습 진행

타임아웃

네트워크 복원력에서 또 중대한 전략인 타임아웃과 재시도를 해본다.
이번 장은 대체로 데스티네이션 룰에 적용하는 거였지만, 이것들은 RDS 쪽 설정이기 때문에 버츄얼서비스에 설정해야 한다.

kubectl apply -f ch6/simple-web.yaml -n istioinaction
kubectl apply -f ch6/simple-backend.yaml -n istioinaction
kubectl delete destinationrule simple-backend-dr -n istioinaction
kubectl apply -f ch6/simple-backend-delayed.yaml -n istioinaction

기존 환경을 없애고 다시 세팅한다.
이때 똑같이 버전 1은 지연 시간이 1초가 되도록 적용한다.

SIMPLEWEB="simple-web.istioinaction.io"
for in in {1..10}; do time curl -s http://$SIMPLEWEB:30000 --resolve "$SIMPLEWEB:30000:127.0.0.1"| jq .code; done

time을 이용해 걸리는 시간을 측정하고, 이걸 보기 쉽게 한 줄로 출력한다.
image.png
curl 요청 자체야 유저, 커널 네임스페이스 어디든 시간이 걸릴리가 만무하다.
하지만 결과적으로 요청이 갔다가 돌아오는 시간은 적나라하게 반영되어 출력되고 있다.

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: simple-backend-vs
spec:
  hosts:
  - simple-backend
  http:
  - route:
    - destination:
        host: simple-backend
    timeout: 0.5s

그렇다면 이제 이 1초는 에러라고 치부해버리자.
사용자 경험에서 웹 페이지가 1초 이상 로딩 걸린다?
나라면 바로 나간다.
나같은 허겁지겁쟁이들을 고려하는 착실한 엔지니어로서, 0.5초 이상 지나는 트래픽은 에러라고 치부하자.

SIMPLEWEB="simple-web.istioinaction.io"
for in in {1..50}; do curl -s http://$SIMPLEWEB:30000 --resolve "$SIMPLEWEB:30000:127.0.0.1"| jq ".upstream_calls[0].body"; done | sort | uniq -c | sort -nr

image.png
일단 반복 접속을 해두면, 위와 같이 에러가 발생하는 것을 볼 수 있다!
에러로 돌아온 응답시간도 볼 수 있는데, 최소 0.5초가 지난 뒤에 에러로 처리되어 그 정도의 시간이 소요된다.
image.png
키알리에서 빠르게 디버깅할 때 선을 눌러서 코드를 볼 수 있다.
여기에 플래그 정보가 같이 들어있는데, 이것은 엔보이의 플래그 정보이다.[3]
로그로 뜯어보면 이거 잘 안 보여서 좀 귀찮은데, 키알리 갓이다.
image.png
아무튼 UT는 UpstreamRequestTimeout, DC는 DownstreamConnectionTerminatioin을 뜻한다.
개인적으로는 조금 재밌는 결과라고 생각하는데, 버츄얼 서비스의 설정은 전에 말했다시피 다운스트림 호스트의 라우팅 필드에 설정을 가한다.
어차피 두 에러 모두 결국 다운스트림 측에서 낸 에러이긴 할 텐데 퍼센티지가 다르게 분할됐다는 것이 무얼 뜻하는 건지 궁금하다.

k logs simple-backend-1-7f79b4688d-hwclj --container istio-proxy

image.png
백엔드의 로그를 구체적으로 뜯어보니, DC는 업스트림 측에서 내뱉는 에러로 응답을 돌려보내지 못했으니 반환값도 없다.
image.png
반대로 웹의 로그를 뜯어보면 UT가 발생하고 504 에러 처리를 해버린다.
그 이후 클라이언트에 처음 들어왔던 요청을 반환할 때는 500 에러로 반환하고 있다.

image.png
아까처럼 다시 걸리는 시간과 응답 코드를 보자.
500에러가 몇 번 나지만 최소한 해당 요청에 대해서는 0.5초의 시간이 지나고 바로 응답이 돌아온다.
여기에서 500에러로 표시되는 이유는 위에서 봤듯 백엔드로부터(라고 생각하지만 자신의 프록시인..) 504 에러를 받은 웹 서버가 500을 반환하기 때문이다.
이제 나같은 허겁지겁쟁이들이 빠르게 서비스 이용을 포기할 수 있게 됐다!
image.png
버츄얼 서비스에서 이뤄지는 설정이다보니 해당 설정은 라우터 쪽에서 볼 수 있다.

재시도

맨 처음 이스티오를 접할 때 실습해봤던 재시도를 마지막으로 다룬다.

kubectl apply -f ch6/simple-web.yaml -n istioinaction
kubectl apply -f ch6/simple-backend.yaml -n istioinaction

다시 초기 상태로 되돌리자.
image.png
어떤 엔보이를 붙잡고 들어가도 일단 재시도 정책은 이스티오 전역적으로 2회로 잡혀있다.
즉, 요청이 실패하더라도 기본적으로 최대 3번까지 요청을 날린다는 뜻이다(2번까지 재시도하고 마지막 요청까지 날리므로).
그러나 이런 방식은 위에서 봤듯이 불필요한 thundering herd 문제를 야기한다.
그래도 기본으로 재시도를 하는 에러의 종류가 정해져있는데, 이로 인해 아무 에러에 대해 전부 재시도를 하는 것은 아니긴 하다.

기본 세팅에서는 이 케이스가 아니면 재시도를 하진 않는다.

  meshConfig:
    defaultHttpRetryPolicy:
      attempts: 0

아무튼 이스티오 오퍼레이터 양식 파일에서 재시도 정책을 0으로 설정해서 전역 재시도 정책을 비활성화하자.

kubectl apply -f ch6/simple-backend-periodic-failure-503.yaml -n istioinaction

이제 버전 1에서 간헐적으로 503에러를 내뱉도록 만들어본다.
image.png
해당 예제는 75퍼센트 확률로 에러를 내뱉도록 돼있다.
image.png
얼추 그 정도 에러를 내뱉는 것이 확인된다.

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: simple-backend-vs
spec:
  hosts:
  - simple-backend
  http:
  - route:
    - destination:
        host: simple-backend
    retries:
      attempts: 2

재시도 정책을 설정하는 것 자체는 매우 간단하다.
image.png
그러나 이스티오 1.21 현 버전에서는 재시도 정책을 제대로 명시하지 않으면 기본적으로 503에 대해 재시도를 진행하지 않는다.[4]
image.png
재시도 설정도 이전과 동일한 모습을 보인다.

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: simple-backend-vs
spec:
  hosts:
  - simple-backend
  http:
  - route:
    - destination:
        host: simple-backend
    retries:
      attempts: 2
      retryOn: 5xx

image.png
어떤 요청에 재시도를 할지 명확하게 지정한다.

image.png
이렇게 적용하면 키알리 상으로는 에러율이 동일해 보인다.
그러나 이 부분은 1주차에서 봤듯이, 메트릭 수집 설정에서 서버 측 메트릭을 비활성화해야 비로소 재시도를 포함하지 않고 시각화를 할 수 있다.
귀찮아서 이 부분은 생략.
image.png
대신 웹쪽 상태를 보면 에러가 확실하게 줄어드는 것을 확인할 수 있다.

결론

재시도 전략은 양날의 검이라고 생각한다.
커넥션, 부하 등의 이유로 발생하는 간헐적인 에러 상황에 대해서 재시도를 하는 것은 의미가 있을 수 있지만, 되려 재시도는 네트워크 부하를 지나치게 유발할 수 있다.
운영하는 입장에서 서비스 메시의 전체 아키텍쳐가 명확하게 정리돼있다면 장애가 날 만한 지점에 각각 이 전략을 커스텀해서 사용하는 것도 가능하겠으나, 총체적인 관리를 용이하게 하면서 실리를 챙기려면 사용자와 맞닿게 되는 프론트 부근에 재시도 전략을 적용하는 게 차선이지 않을까 싶다.

번외 - kubectl 버그

이건 스터디를 하면서 가시다님이 언급하신 문제를 탐구하면서 정리한 글이다.
실습하면서 워크로드 환경변수 값을 변경해서 적용했는데, 막상 해당 파드의 정보를 보면 값이 제대로 반영이 안 되는 이슈가 있었다.
결론적으로 말하자면 세팅에 문제가 있었던 건 아니고, kubectl의 동작에 문제가 있었던 것으로 추정된다.

kubectl exec -it deploy/simple-backend-1 -n istioinaction -- env | grep TIMING

버전 1에 대해 지연이 걸리도록 세팅을 한 후 exec을 해서 확인을 하는 상황이다.
image.png
막상 디플로이먼트가 관리하는 파드를 뜯어보면 원래 1000ms로 나와야할 환경변수가 적용되지 않은 것처럼 보인다.
image.png
조금 더 보니, 버전 1 디플로이먼트의 하위로 잡힌 파드가 버전2인 것처럼 보인다!

keti simple-backend-1-7f79b4688d-hwclj -- env

image.png
이상하다 싶어서 아예 대놓고 버전 1의 파드를 대상으로 exec을 실행했더니, 이번에는 값이 제대로 반영된 것이 확인됐다.
이 문제가 왜 일어나는가 잠시 고민했는데, 아무래도 이건 kubectl이 오동작하는 게 아닐까 하는 생각이 들었다.
디플로이먼트 리소스를 기반으로 exec을 하려할 때, kubectl은 디플로이먼트의 라벨을 기반으로 임의의 파드를 찾아 해당 파드에 exec을 날리는 것은 아닐까?

kubectl exec -v 8 -it deploy/simple-backend-1 -n istioinaction -- printenv | grep M

원인을 파악하는 방법은 매우 쉽다.
그냥 중간 과정을 추적해본다.
이를 기반으로 디플로이먼트를 이용해서 exec 요청을 날리는 원리를 알아내면 된다.
image.png
답도 매우 심플했다.
일단 처음에 kubectl에서는 디플로이먼트에 대해 get 요청을 때려버린다.
image.png
그리고 돌아온 양식 정보를 바탕으로 라벨 셀렉터 값을 읽은 뒤에 그냥 라벨 셀렉터로 파드를 또 get 요청 때린다;;
image.png
그리고 마지막으로 나온 파드 리스트 중 하나를 골라 기본 컨테이너에 exec 요청을 날리는 것이다.
image.png
몇 번이고 반복해서 테스트를 해본 결과, exec을 하기 위해 선택되는 파드는 항상 리스트 상에서 가장 마지막 파드이다.
로그를 계속 봐도 이전 명령에서 캐싱돼서 나온 결과도 아니었다.

내 입장에서 이러한 방식은 명백한 버그이다.
이런 식의 문제가 있는 게 당연한 거였다면 애초에 kubectl에서 파드 관련 명령(logs, exec)에 대해 워크로드 리소스를 기반으로 실행할 수 있게 하면 안 됐다.

이전 글, 다음 글

다른 글 보기

이름 index noteType created
1W - 서비스 메시와 이스티오 1 published 2025-04-10
1W - 간단한 장애 상황 구현 후 대응 실습 2 published 2025-04-10
1W - Gateway API를 활용한 설정 3 published 2025-04-10
1W - 네이티브 사이드카 컨테이너 이용 4 published 2025-04-10
2W - 엔보이 5 published 2025-04-19
2W - 인그레스 게이트웨이 실습 6 published 2025-04-17
3W - 버츄얼 서비스를 활용한 기본 트래픽 관리 7 published 2025-04-22
3W - 트래픽 가중치 - flagger와 argo rollout을 이용한 점진적 배포 8 published 2025-04-22
3W - 트래픽 미러링 패킷 캡쳐 9 published 2025-04-22
3W - 서비스 엔트리와 이그레스 게이트웨이 10 published 2025-04-22
3W - 데스티네이션 룰을 활용한 네트워크 복원력 11 published 2025-04-26
3W - 타임아웃, 재시도를 활용한 네트워크 복원력 12 published 2025-04-26
4W - 이스티오 메트릭 확인 13 published 2025-05-03
4W - 이스티오 메트릭 커스텀, 프로메테우스와 그라파나 14 published 2025-05-03
4W - 오픈텔레메트리 기반 트레이싱 예거 시각화, 키알리 시각화 15 published 2025-05-03
4W - 번외 - 트레이싱용 심플 메시 서버 개발 16 published 2025-05-03
5W - 이스티오 mTLS와 SPIFFE 17 published 2025-05-11
5W - 이스티오 JWT 인증 18 published 2025-05-11
5W - 이스티오 인가 정책 설정 19 published 2025-05-11
6W - 이스티오 설정 트러블슈팅 20 published 2025-05-18
6W - 이스티오 컨트롤 플레인 성능 최적화 21 published 2025-05-18
8W - 가상머신 통합하기 22 published 2025-06-01
8W - 엔보이와 iptables 뜯어먹기 23 published 2025-06-01
9W - 앰비언트 모드 구조, 원리 24 published 2025-06-07
9W - 앰비언트 헬름 설치, 각종 리소스 실습 25 published 2025-06-07
7W - 이스티오 메시 스케일링 26 published 2025-06-09
7W - 엔보이 필터를 통한 기능 확장 27 published 2025-06-09

관련 문서

이름 noteType created
Istio VirtualService knowledge 2025-04-21
3W - 버츄얼 서비스를 활용한 기본 트래픽 관리 published 2025-04-22
3W - 트래픽 가중치 - flagger와 argo rollout을 이용한 점진적 배포 published 2025-04-22
3W - 타임아웃, 재시도를 활용한 네트워크 복원력 published 2025-04-26

참고


  1. https://grpc.io/docs/guides/request-hedging/ ↩︎

  2. https://github.com/istio/istio/issues/27473 ↩︎

  3. https://www.envoyproxy.io/docs/envoy/v1.34.0/configuration/observability/access_log/usage ↩︎

  4. https://istio.io/latest/docs/reference/config/networking/virtual-service/#HTTPRetry ↩︎