컴퓨터공학 300 주제 시리즈의 239번째 글이다. 전체 지도는 여기.

한 줄 요약

서비스 메시는 서비스 간 통신 경로에 프록시를 끼워 넣어(데이터 플레인), 암호화(mTLS)·재시도·타임아웃·트래픽 분할·관측을 애플리케이션 코드 대신 플랫폼이 일관되게 처리하게 하고, 그 프록시들을 컨트롤 플레인이 중앙에서 설정하는 인프라 계층이다.

왜 필요한가

마이크로서비스가 수십 개가 되면 모든 서비스가 같은 문제를 반복해서 푼다.

  • 서비스 간 통신을 암호화하고, 상대가 정말 그 서비스인지 확인해야 한다.
  • 상대가 느릴 때 타임아웃·재시도·서킷 브레이커를 적용해야 한다.
  • 새 버전에 트래픽을 5% 만 보내 보고 싶다.
  • 누가 누구를 얼마나 부르고 얼마나 실패하는지 지표가 필요하다.

이것을 서비스마다 라이브러리로 구현하면 언어마다, 팀마다, 버전마다 동작이 달라진다. 자바 서비스와 Go 서비스의 재시도 정책이 다르고, 라이브러리를 업그레이드하려면 모든 서비스를 다시 배포해야 한다. 서비스 메시는 이 공통 기능을 네트워크 계층으로 내린다.

핵심 개념

데이터 플레인과 컨트롤 플레인

         +---------------- 컨트롤 플레인 ----------------+
         |  설정 배포 · 인증서 발급 · 서비스 디스커버리     |
         +-----------+--------------------+--------------+
                     | 설정(xDS 등)        |
   +--- 파드 A ------v---+        +--- 파드 B v---------+
   | [앱 A] <-> [프록시] | ==mTLS==> | [프록시] <-> [앱 B] |
   +---------------------+        +---------------------+
              데이터 플레인: 모든 요청이 프록시를 지난다
  • 데이터 플레인: 실제 트래픽을 처리하는 프록시들. 많은 메시가 Envoy 를 쓰고, Linkerd 는 자체 경량 프록시를 쓴다.
  • 컨트롤 플레인: 사용자가 선언한 정책을 프록시 설정으로 바꿔 내려보내고, 워크로드 신원(identity)에 대한 인증서를 발급·갱신한다.

사이드카와 사이드카리스

전통적인 방식은 파드마다 프록시 컨테이너를 하나 붙이는 사이드카 모델이다. 파드의 네트워크 규칙(iptables 등)을 조정해 앱의 송수신 트래픽이 투명하게 프록시를 거치게 한다. 앱은 프록시가 있는지 모른다.

사이드카는 파드마다 메모리·CPU 를 쓰고, 프록시를 업그레이드하려면 파드를 재시작해야 한다. 그래서 노드 단위 프록시로 L4(mTLS, 기본 텔레메트리)를 처리하고 L7 기능이 필요한 곳에만 별도 프록시를 두는 사이드카리스 방식도 등장했다. Istio 의 ambient 모드가 대표적이다.

주요 기능

영역 기능
보안 서비스 간 상호 TLS(mTLS) 자동화, 워크로드 신원 기반 인가 정책
트래픽 관리 가중치 분할(카나리), 헤더 기반 라우팅, 타임아웃, 재시도, 서킷 브레이킹, 장애 주입
관측 서비스 간 요청 수·에러·지연 지표, 접근 로그, 추적 span 생성

mTLS 는 양쪽이 모두 인증서를 제시하는 TLS 다. 메시는 각 워크로드에 신원(예: 쿠버네티스 서비스어카운트 기반)을 담은 짧은 수명의 인증서를 발급하고 자동으로 갱신한다. 그 결과 “네트워크 위치(IP)”가 아니라 “누구인가(신원)”로 접근을 통제할 수 있다. 제로 트러스트 원칙과 맞닿아 있다.

트래픽 분할 예 (Istio)

apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: reviews
spec:
  hosts: ["reviews"]
  http:
    - route:
        - destination: { host: reviews, subset: v1 }
          weight: 95
        - destination: { host: reviews, subset: v2 }
          weight: 5
      timeout: 2s
      retries:
        attempts: 2
        perTryTimeout: 500ms

디플로이먼트 두 개(v1, v2)를 띄워 두고 이 설정만 바꾸면 5% → 25% → 100% 로 점진 배포할 수 있다. 레플리카 수와 트래픽 비율이 분리된다는 점이 핵심이다. 쿠버네티스 서비스만으로는 파드 수 비율로만 나눌 수 있다.

재시도는 공짜가 아니다

재시도는 일시적 실패를 가려 주지만, 하위 서비스가 이미 과부하인 상황에서는 부하를 몇 배로 키운다. 호출 체인의 각 단계가 독립적으로 재시도하면 증폭은 곱셈이 된다. 그래서 재시도는 체인의 한 곳(보통 가장 바깥이나 가장 안쪽)에서만, 횟수와 전체 시간 예산을 정해서 쓴다. 멱등하지 않은 요청(결제 POST 등)은 재시도하지 않는다.

비용과 판단

서비스 메시는 운영할 구성 요소를 하나 더 늘린다. 요청마다 프록시를 한두 번 더 거치므로 지연과 자원 사용이 늘고, 장애가 나면 앱 문제인지 메시 문제인지 가리는 일이 어려워진다. 서비스가 몇 개뿐이고 mTLS 요구가 없다면 메시 없이 라이브러리와 쿠버네티스 기본 기능으로 충분할 수 있다. 도입 근거로는 보통 “서비스 간 mTLS 의무화”, “언어가 섞인 다수 서비스의 일관된 트래픽 정책”, “서비스 간 관측성”이 꼽힌다.

직접 해 보기

두 가지를 계산해 본다. 가중치 기반 트래픽 분할이 실제로 비율대로 나뉘는지, 그리고 단계마다 재시도할 때 부하가 얼마나 증폭되는지.

import random
from collections import Counter

random.seed(1)

def pick(routes):
    r = random.uniform(0, sum(w for _, w in routes))
    for dest, w in routes:
        r -= w
        if r <= 0:
            return dest
    return routes[-1][0]

routes = [("v1", 95), ("v2", 5)]
c = Counter(pick(routes) for _ in range(100_000))
print("traffic split:", {k: f"{v / 1000:.1f}%" for k, v in sorted(c.items())})

def amplification(depth, attempts):
    """각 단계가 실패 시 최대 attempts 번 시도. 최하위가 완전히 죽었을 때 최하위가 받는 요청 수"""
    return attempts ** depth

print("\nworst-case load on the deepest service per 1 user request:")
for depth in (1, 2, 3, 4):
    print(f"  depth={depth}: retry at every hop (3 tries) -> {amplification(depth, 3):3}x, "
          f"retry only at edge -> {3}x")

def success_rate(p_fail, attempts):
    return 1 - p_fail ** attempts

print("\nsuccess with independent transient failures (p_fail=0.05):")
for a in (1, 2, 3):
    print(f"  attempts={a}: {success_rate(0.05, a):.4%}")

결과는 다음과 같다.

traffic split: {'v1': '94.9%', 'v2': '5.1%'}

worst-case load on the deepest service per 1 user request:
  depth=1: retry at every hop (3 tries) ->   3x, retry only at edge -> 3x
  depth=2: retry at every hop (3 tries) ->   9x, retry only at edge -> 3x
  depth=3: retry at every hop (3 tries) ->  27x, retry only at edge -> 3x
  depth=4: retry at every hop (3 tries) ->  81x, retry only at edge -> 3x

success with independent transient failures (p_fail=0.05):
  attempts=1: 95.0000%
  attempts=2: 99.7500%
  attempts=3: 99.9875%

요청 단위 무작위 분할이므로 비율은 정확히 95:5 가 아니라 그 근처로 수렴한다. 요청 수가 적은 초기 카나리에서는 이 오차가 상대적으로 크다는 점도 기억해 둔다. 실패가 독립적이고 일시적이라면 재시도 두세 번으로 성공률이 크게 오른다. 하지만 실패의 원인이 과부하라면 실패는 독립적이지 않다. 그때 단계마다 걸린 재시도는 네 단계 체인에서 최하위 서비스에 81배의 부하를 퍼붓는다. 메시는 재시도를 설정 한 줄로 켜게 해 주므로, 이 곱셈을 의식하고 써야 한다.

현업에서는

  • 점진 배포와 자동 롤백. 메시의 가중치 라우팅과 지표를 결합한 도구(Argo Rollouts, Flagger 등)는 카나리 비율을 단계적으로 올리면서 에러율·지연을 확인하고, 기준을 넘으면 자동으로 되돌린다.
  • mTLS 전환은 단계적으로. 메시를 처음 도입할 때 바로 “mTLS 만 허용”으로 바꾸면 메시 밖 클라이언트(모니터링, 레거시 서비스)가 끊긴다. 평문과 mTLS 를 모두 받는 허용 모드로 시작해 트래픽을 확인한 뒤 엄격 모드로 바꾼다.
  • 추적은 앱의 협조가 필요하다. 사이드카가 span 은 만들어 주지만, 들어온 요청의 trace 헤더를 나가는 요청에 옮겨 싣는 일은 앱이 해야 한다(234번).
  • 작은 클러스터라면. 노드 몇 대짜리 홈랩 클러스터에서 사이드카 메시는 자원 부담이 상대적으로 크다. 경량 메시나 사이드카리스 모드를 고르거나, 꼭 필요한 기능(예: mTLS)만 쓰는 편이 현실적이다.
  • 장애 진단. 503 이 났을 때 앱이 낸 것인지 프록시가 낸 것인지 구분해야 한다. Envoy 접근 로그의 응답 플래그(예: 업스트림 연결 실패, 타임아웃)를 보면 프록시 쪽 원인이 드러난다.

확인 문제

  1. 서비스 메시의 데이터 플레인과 컨트롤 플레인은 각각 무엇을 하는가?
  2. 쿠버네티스 서비스만으로 카나리 5% 를 하기 어려운 이유와 메시가 해결하는 방식은?
  3. mTLS 가 IP 기반 방화벽 규칙보다 나은 점은?
  4. 세 단계 호출 체인에서 각 단계가 최대 3번 시도하면, 최하위 서비스가 완전히 실패할 때 받는 최대 요청 수는 사용자 요청 하나당 몇 개인가?
  5. 사이드카 모델의 단점 두 가지는?

풀이

  1. 데이터 플레인(프록시)은 실제 트래픽을 처리하며 mTLS·재시도·라우팅·지표 수집을 한다. 컨트롤 플레인은 정책을 프록시 설정으로 배포하고 인증서를 발급·갱신한다.
  2. 서비스는 파드 수 비율로만 나뉘므로 5% 를 하려면 파드 20개 중 1개 식으로 맞춰야 한다. 메시는 레플리카 수와 무관하게 가중치로 요청을 나눈다.
  3. IP 는 파드마다 바뀌고 위조·재사용될 수 있다. mTLS 는 암호학적으로 검증된 워크로드 신원으로 통제하고 트래픽도 암호화한다.
  4. 3³ = 27개.
  5. 파드마다 프록시 자원(메모리·CPU) 소비, 프록시 업그레이드 시 파드 재시작 필요, 요청 경로에 홉 추가로 지연 증가(이 중 두 가지).

더 읽을거리 (References)