どこへ送るかと、着いた後どう扱うか
한국어 원문으로 표시합니다.
한 줄 요약
VirtualService 는 어디로 보낼까를, DestinationRule 은 도착한 뒤 어떻게 다룰까를 정합니다. 카나리 배포가 동작하지 않는 사고의 절반은 이 분업을 반쪽만 만들어서 생깁니다.
왜 이게 필요했나
배포와 릴리스를 분리하고 싶다는 요구가 출발점입니다. 새 버전을 클러스터에 올리는 일(배포)과 사용자 트래픽을 그리로 보내는 일(릴리스)을 따로 제어할 수 있으면, 문제가 생겨도 이미지를 되돌리는 대신 가중치만 0으로 내리면 됩니다.
그런데 이 두 가지는 성격이 다릅니다. "10%만 v2 로" 같은 분배는 요청 단위 결정이고, "v2 란 version=v2 라벨을 가진 파드들"이라는 정의는 목적지 단위 사실입니다. Istio 는 이것을 두 리소스로 쪼갰습니다.
여기서 시험에 그대로 나오는 비대칭이 생깁니다. subset 은 DestinationRule 에만 있고, weight 는 VirtualService 에만 있습니다. DestinationRule 없이 VirtualService 에서 subset: v2 를 참조하면 Envoy 에 그런 클러스터가 만들어지지 않아 요청은 503 을 받습니다. 액세스 로그의 응답 플래그는 UH(no healthy upstream)로 찍힙니다.
어떻게 동작하나
라우팅 규칙은 위에서 아래로 평가되고 첫 매치가 이깁니다. 그래서 구체적인 규칙을 위에, 포괄 규칙을 맨 아래에 둡니다. 마지막에 match 없는 규칙(catch-all)이 없으면 아무 규칙에도 걸리지 않은 요청은 NR(no route) 플래그와 함께 404 또는 503 이 됩니다.
매칭 조건의 결합 규칙도 자주 틀립니다.
# AND: 경로가 /admin 이면서 동시에 헤더도 맞아야 매칭
- match:
- uri:
prefix: /admin
headers:
x-role:
exact: admin
# OR: 경로가 /admin 이거나, 헤더가 맞으면 매칭
- match:
- uri:
prefix: /admin
- headers:
x-role:
exact: admin
한 match 블록 안의 조건들은 AND, match 배열의 항목 사이는 OR 입니다. 들여쓰기 한 칸 차이로 정책의 의미가 뒤집힙니다.
복원력 설정은 고정 배수를 외우기보다 시간 예산과 재시도 조건을 나누어 읽어야 합니다. attempts: 2는 원 요청을 포함한 두 번이 아니라 추가 재시도 최대 두 번, 즉 업스트림 요청 최대 세 번입니다. perTryTimeout은 원 요청과 각 재시도의 시간 상한이고, timeout은 재시도와 대기까지 포함한 전체 요청 제한입니다. 시도마다 상한만큼 반드시 기다리는 것은 아닙니다.
전체 제한과 시도별 제한이 모두 1초여도 빠른 실패는 다릅니다. retryOn: "503"을 명시하고 실제 업스트림이 즉시 503을 돌려주면, 전체 1초 안에서 재시도를 실행할 수 있습니다. 반면 첫 시도가 응답 없이 전체 예산을 소진하면 다음 시도에 쓸 시간이 없습니다. 따라서 perTryTimeout × (attempts + 1)보다 전체 제한이 작다는 사실만으로 재시도가 불가능하다고 단정하면 안 됩니다. 모든 시도에 각각 상한까지 시간을 주려는 예산이라면 그 곱뿐 아니라 시도 사이 backoff도 고려해야 합니다.
실제 횟수는 실패 조건·발생 시점·backoff·남은 예산에 따라 달라집니다. Istio 1.31.0의 별도 실측에서 같은 1초/1초/추가 2회 설정으로 빠른 503은 업스트림에 세 번, 1.5초 걸리는 응답은 한 번 도달했습니다. 최종 HTTP 코드로 횟수를 추측하지 말고 요청 식별자와 업스트림 수신 기록을 대조하세요. 프록시가 연결 오류를 503으로 바꾼 경우는 실제 업스트림의 503과 달라서 retryOn: "503"만으로 같은 재시도를 보장하지 않습니다.
시도별 제한은 정상 지연 분포와 호출자의 남은 예산을 보고 정하고, 재시도는 업무의 중복 처리 안전성을 확인한 뒤 켭니다. GET이라는 이름만으로 구현의 무부작용이 보장되지는 않고, POST에 재시도 한 번 정도는 안전하다는 규칙도 없습니다. 응답을 못 받았어도 서버는 이미 썼을 수 있습니다. 합성 주문 실측에서는 성공 응답 하나 뒤에 쓰기가 세 번 남았고, 같은 요청 키를 원자적으로 중복 제거한 대조군에서는 한 번만 남았습니다. 단일 프로세스 메모리 대조군을 운영용 exactly-once 보장으로 해석하지 마세요.
공식 정의는 VirtualService의 HTTPRetry에서 확인하세요. 아래 파일 작성 실습은 설정 구조를 연습하며, 이 설명의 HTTP 관측은 별도의 실제 사이드카 VM에서 수행한 것입니다.
재시도는 체인 전체 관점에서 봐야 합니다. A → B → C 에서 각 단계가 attempts 2 를 가지면 C 가 받는 요청은 최악의 경우 3 x 3 = 9배가 됩니다. 4단이면 27배입니다. 죽어가는 서비스에 재시도가 관에 못을 박는 셈이므로, 재시도는 체인의 한 계층(가급적 가장 바깥)에만 둡니다.
서킷브레이커는 두 리소스가 아니라 DestinationRule 한 곳에 모여 있습니다. connectionPool 은 "얼마나 쌓아 둘 것인가"를, outlierDetection 은 "어떤 인스턴스를 빼낼 것인가"를 정합니다. 인스턴스가 3개뿐인 서비스에서 maxEjectionPercent: 100 을 두면 세 개가 동시에 퇴출되어 서킷브레이커가 곧 전면 장애가 됩니다.
같은 DestinationRule 안에 localityLbSetting 도 있습니다. 같은 존의 엔드포인트를 먼저 쓰고 그 존이 죽으면 다음 존으로 넘기는 설정입니다. 존을 넘는 트래픽에는 요금과 지연이 붙으므로 규모가 커지면 반드시 켜게 됩니다.
여기에 시험에도 현장에도 나오는 함정이 있습니다. 페일오버는 outlierDetection 없이는 일어나지 않습니다. 다음 우선순위로 넘어가는 조건이 "지금 우선순위의 엔드포인트가 불건강하다" 인데, 그 판정을 만드는 장치가 outlierDetection 이기 때문입니다. 없으면 Envoy 는 같은 존의 엔드포인트가 죽어 가는 것을 알 방법이 없어 계속 그리로 보냅니다.
증상이 헷갈리는 이유는 평상시에는 멀쩡히 동작한다는 것입니다. 존 우선 라우팅 자체는 잘 되고 지연도 줄고 요금도 내려갑니다. 그러다 존 하나가 죽는 날에만 넘어가지 않습니다. 라벨(topology.kubernetes.io/zone)이 잘못됐다면 애초에 존 우선 라우팅부터 동작하지 않으므로 증상이 다릅니다 — 평소에 잘 되다가 장애 때만 안 되면 거의 언제나 outlierDetection 이 빠진 것입니다.
현장에서 만나는 모습
저자의 홈랩에서 얻은 교훈 하나를 그대로 옮깁니다. Cilium 으로 세운 그 클러스터에서 같은 IP, 같은 포트의 nginx 에 rules.http: [{method: GET}] 정책을 걸었더니 GET 은 200, POST 는 403 이 됐습니다. L3/L4 계층에서는 메서드가 보이지 않으므로 이 구분은 누군가 HTTP 를 파싱했다는 뜻입니다. Istio 에서 그 누군가가 사이드카 Envoy 이고, 헤더 매칭 라우팅과 메서드 매칭이 가능한 이유도 정확히 같습니다.
흥미로운 것은 그때 Hubble 로그의 모양이었습니다. 요청은 DROPPED, 응답은 FORWARDED 로 찍혔습니다. 프록시가 만들어 낸 403 이 정상 응답으로 흘러 나갔기 때문입니다. Istio 에서도 마찬가지로 연결은 성립했는데 403 이 돌아오는 상황과 연결 자체가 실패하는 상황은 로그 모양이 다릅니다. 전자는 L7 판정, 후자는 L4 문제입니다. 이 구분을 못 하면 엉뚱한 리소스를 몇 시간씩 들여다보게 됩니다.
다음 실습에서 할 것
두 개의 실습이 이어집니다. 첫 번째에서는 네임스페이스와 v1/v2 워크로드를 실제로 배포한 뒤 DestinationRule 과 VirtualService 매니페스트를 파일로 작성해 가중치·헤더 매칭·URI 재작성·규칙 순서를 손에 익힙니다. 두 번째에서는 재시도와 타임아웃의 관계, 헤더로 게이팅한 장애 주입, 서킷브레이커, 미러링을 다룹니다.