← 문제로

22. 서비스 디스커버리 등록과 헬스체크의 준비 상태·해제 순서 (C#)

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 서비스 디스커버리 등록과 헬스체크의 준비 상태·해제 순서 (C#)

난이도: 중상

요약

이 코드는 "프로세스가 살아 있다(liveness)"와 "요청을 처리할 준비가 됐다(readiness)"를 구분하지 않는다. 리스너를 열고 워밍업 전에 디스커버리에 등록해 버리므로, 최소 연결 수 기반 LB 를 쓰는 게이트웨이가 새 인스턴스를 "가장 한가한 서버"로 보고 신규 접속을 전량 몰아준다.

종료 경로는 반대로 순서가 뒤집혀 있다. 리스너를 먼저 닫고 나중에 디스커버리에서 지우기 때문에 그 사이 구간에서 게이트웨이는 연결을 받지 못하는 인스턴스로 계속 라우팅한다. 게이트웨이 쪽 캐시도 TTL 만 볼 뿐 갱신 실패를 구분하지 않아서, 디스커버리 조회가 한 번 실패하면 빈 목록으로 캐시를 통째로 갈아엎어 클러스터 전체를 라우팅 대상에서 지운다.

문제점

(A) 준비 상태를 반영하지 않는 등록과 헬스체크

  • 분류: readiness/liveness 미분리 · 콜드 스타트 · 연쇄 장애(cascading failure)
  • 증상:
    • 배포 직후 새 인스턴스에 접속이 몰리고 로그인/맵 진입이 대량 타임아웃된다.
    • 오케스트레이터가 그 인스턴스를 unhealthy 로 보고 죽였다가 다시 띄우고, 다시 뜨자마자 또 트래픽이 몰려 또 죽는 재시작 루프가 된다. 그 사이 살아 있던 인스턴스의 부하가 올라가 같은 일이 옆 인스턴스로 번진다.
    • 하트비트 루프에서 예외가 나면 아무 로그 없이 조용히 멈춘다. 하트비트가 끊기면 인스턴스는 "죽은 것"으로 간주되어 정상 서비스 중인 서버가 라우팅 대상에서 빠진다.
  • 재현 조건: 워밍업(맵 데이터 + 스폰 테이블 + DB 풀 예열)이 수십 초 걸리는 인스턴스를 트래픽이 있는 시간대에 롤링 배포. ActiveConnections == 0 이므로 OrderBy 가 항상 새 인스턴스를 최우선으로 고른다.
  • 근본 원인:
    1. StartAsync 가 RegisterAsync 를 워밍업 3종보다 먼저 호출한다. 등록되는 순간 게이트웨이 목록에 들어가고, 실제 준비는 그 뒤에 이뤄진다.
    2. HealthCheck() 가 무조건 200 을 반환한다. 맵 데이터가 없든 DB 풀이 비었든 항상 통과하므로 헬스체크가 아무 정보도 주지 못한다. 이 엔드포인트는 liveness 로도, readiness 로도 쓸 수 없다.
    3. _listener.Start() 가 등록보다 먼저 호출되는 것 자체는 문제가 아니지만, 리스너가 열려 있으니 TCP 레벨 헬스체크(포트 열림 확인)도 통과해 버린다. "포트가 열렸다 = 준비됐다" 라는 가정이 깨진다.
    4. Task.Run(() => HeartbeatLoop(...)) 는 반환된 Task 를 버린다. HeartbeatAsync 안에서 HttpRequestException 이나 TaskCanceledException 이 나면 그 예외는 아무도 관측하지 않는 Task 에 담긴 채 사라지고 루프는 끝난다. await 도 ContinueWith 도 없다.
    5. DiscoveryClient 는 호출마다 new HttpClient() 를 만들고 using 으로 버린다. 소켓이 TIME_WAIT 로 쌓여 포트 고갈(SocketException: Only one usage of each socket address)로 이어지고, Timeout 을 지정하지 않아 기본 100 초 동안 하트비트 한 번이 매달린다. 5 초 주기 하트비트가 100 초 블로킹되면 사실상 하트비트가 죽는다.

(B) 해제 순서 역전과 라우팅 캐시의 통째 교체

  • 분류: 그레이스풀 셧다운 순서 위반 · 빈 결과 방어 부재 · 캐시 일관성
  • 증상:
    • 롤링 배포 중 "서버가 응답하지 않습니다" 접속 실패가 인스턴스당 최대 TTL(20 초) + 갱신 주기(10 초) 만큼 발생한다. 이미 리스너를 닫은 인스턴스로 게이트웨이가 계속 신규 접속을 보낸다.
    • 디스커버리가 잠깐 500 을 뱉거나 네트워크가 끊기면 전체 서비스가 라우팅 불가가 된다 (PickForNewConnection 이 null 반환).
    • 라우팅 캐시가 갱신되는 순간순간 게이트웨이가 인스턴스를 못 찾는 접속 실패가 산발한다.
  • 재현 조건:
    • SIGTERM → ApplicationStopping → ShutdownAsync. _listener.Stop() 직후부터 DeregisterAsync 완료 + 게이트웨이 캐시 갱신까지의 구간에 신규 접속을 시도.
    • 디스커버리 앞단 로드밸런서를 재시작해 ListAsync 가 한 번 실패하게 만든다.
  • 근본 원인:
    1. 순서 역전. 그레이스풀 셧다운의 올바른 순서는 ① 디스커버리에서 먼저 빠지고 ② 게이트웨이가 그 사실을 반영할 만큼 기다린 다음 ③ 리스너를 닫고 ④ 세션을 정리하는 것이다. 현재 코드는 ③ → ④ → ① 이라 ①이 끝나기 전까지 "받을 수 없는데 목록에는 있는" 상태가 유지된다.
    2. ListAsync 는 실패 시 new List<InstanceInfo>() 를 반환하고, RefreshLoop 는 그 빈 목록을 정상 결과와 똑같이 취급해 캐시에 반영한다. "조회 실패"와 "인스턴스가 하나도 없음"이 구분되지 않는다. 디스커버리는 가용성보다 일관성을 택하는 시스템이라 일시적 조회 실패가 정상이며, 클라이언트는 마지막으로 성공한 목록을 유지(stale-if-error)해야 한다.
    3. _routeCache.Clear() 후 foreach 로 다시 채우는 동안 캐시는 중간의 빈/부분 상태를 노출한다. ConcurrentDictionary 는 개별 연산만 원자적이고 "Clear + N 개 삽입"은 원자적이지 않다. 이 사이에 PickForNewConnection 이 호출되면 null 또는 일부 인스턴스만 보인다.
    4. 등록/해제 이벤트에 세대(generation) 정보가 없다. 하트비트나 재등록이 네트워크 지연으로 늦게 도착하면, 방금 지운 인스턴스가 오래된 등록으로 되살아난다. InstanceId 만으로는 "이 등록이 언제 것인가"를 판단할 수 없다.
    5. _cachedAtUtc 는 TTL 판정에 쓰이지만 로그만 찍고 아무 조치도 하지 않는다. stale 캐시로 계속 라우팅한다. 또 DateTime 필드를 락 없이 다른 스레드에서 읽는다 (DateTime 은 8 바이트라 x64 에서 tearing 은 실질적으로 없지만 가시성 보장은 없다).
    6. ApplicationStopping.Register 안에서 GetAwaiter().GetResult() 로 비동기를 동기 대기한다. StopAsync 도 같은 ShutdownAsync 를 부르므로 셧다운 경로가 두 번 실행될 수 있고, DeregisterAsync 가 두 번 나가거나 _cts.Cancel() 이 중복 호출된다.

수정안

핵심은 세 가지다. ① 등록을 워밍업 이후로 미루고 헬스체크를 실제 의존성 상태에 연결한다. ② 종료는 디스커버리에서 먼저 빠지고 전파 지연만큼 기다린 뒤 리스너를 닫는다. ③ 라우팅 캐시는 원자 교체 + 빈 결과 방어로 갱신한다.

(A) readiness 게이트와 상태 기반 헬스체크

public enum ReadyState { Starting, Ready, Draining }

public sealed class ZoneServerHost : IHostedService
{
    private int _state = (int)ReadyState.Starting;
    private Task _heartbeatTask;

    public ReadyState State => (ReadyState)Volatile.Read(ref _state);

    public async Task StartAsync(CancellationToken ct)
    {
        // 1) 워밍업을 먼저 끝낸다
        await MapDataLoader.LoadAsync(_self.InstanceId);
        await SpawnTableCache.WarmUpAsync();
        await DbPool.PrewarmAsync(minConnections: 32);

        // 2) 그 다음 리스너를 열고
        _listener.Start(_self.Port);
        Volatile.Write(ref _state, (int)ReadyState.Ready);

        // 3) 마지막에 디스커버리에 등록한다
        await _discovery.RegisterAsync(_self, ct);
        _heartbeatTask = Task.Run(() => HeartbeatLoopAsync(_cts.Token));
    }

    // liveness: 프로세스가 데드락 없이 돌고 있는가 (의존성은 보지 않는다)
    public int LivenessCheck() => _lastLoopTickUtc > DateTime.UtcNow.AddSeconds(-30) ? 200 : 503;

    // readiness: 지금 트래픽을 받아도 되는가
    public int ReadinessCheck()
    {
        if (State != ReadyState.Ready) return 503;
        if (!DbPool.IsHealthy) return 503;
        if (!SpawnTableCache.IsLoaded) return 503;
        return 200;
    }
}

하트비트는 예외를 삼키지 말고 잡아서 로그를 남기고 루프를 유지해야 한다.

private async Task HeartbeatLoopAsync(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        try
        {
            if (ReadinessCheck() == 200)
                await _discovery.HeartbeatAsync(_self.InstanceId, ct);
            _lastLoopTickUtc = DateTime.UtcNow;
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
        catch (Exception ex)
        {
            _logger.LogError(ex, "heartbeat failed for {Id}", _self.InstanceId);
        }
        try { await Task.Delay(TimeSpan.FromSeconds(5), ct); }
        catch (OperationCanceledException) { break; }
    }
}

HttpClient 는 IHttpClientFactory 로 받거나 최소한 정적 단일 인스턴스로 재사용하고, 반드시 타임아웃을 준다. 하트비트 주기(5 초)보다 짧아야 요청이 겹치지 않는다.

services.AddHttpClient<DiscoveryClient>(c =>
{
    c.BaseAddress = new Uri(discoveryUrl);
    c.Timeout = TimeSpan.FromSeconds(2);
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5));   // DNS 변경 반영

(B) 종료 순서 정상화와 캐시 원자 교체

private int _shutdownOnce;

private async Task ShutdownAsync()
{
    if (Interlocked.Exchange(ref _shutdownOnce, 1) != 0) return;   // 멱등

    // 1) 먼저 "받을 수 없음"을 알린다 — readiness 를 내리고 디스커버리에서 뺀다
    Volatile.Write(ref _state, (int)ReadyState.Draining);
    try { await _discovery.DeregisterAsync(_self.InstanceId); }
    catch (Exception ex) { _logger.LogWarning(ex, "deregister failed"); }

    // 2) 게이트웨이 캐시가 갱신될 시간을 준다 (갱신주기 + 여유)
    await Task.Delay(_gatewayPropagationDelay);   // 예: 15초

    // 3) 그제야 리스너를 닫고 기존 세션을 정리한다
    _listener.Stop();
    await _sessions.DrainAsync(TimeSpan.FromSeconds(30));

    // 4) 마지막에 하트비트를 멈추고 회수한다
    _cts.Cancel();
    if (_heartbeatTask != null)
        await Task.WhenAny(_heartbeatTask, Task.Delay(TimeSpan.FromSeconds(5)));
}

라우팅 캐시는 Clear + Add 대신 새 스냅샷을 만들어 참조 하나만 교체한다. 읽기 쪽은 락 없이 항상 일관된 목록을 본다.

public sealed class GatewayRouter
{
    private IReadOnlyList<InstanceInfo> _snapshot = Array.Empty<InstanceInfo>();
    private DateTime _lastSuccessUtc = DateTime.MinValue;
    private static readonly TimeSpan MaxStale = TimeSpan.FromMinutes(2);

    public async Task RefreshLoop(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try
            {
                var list = await _discovery.ListAsync("zone-server", ct);

                // 빈 결과 방어: 조회는 성공했지만 목록이 비었다면 즉시 반영하지 않는다
                if (list.Count == 0 && DateTime.UtcNow - _lastSuccessUtc < MaxStale)
                {
                    _logger.LogWarning("discovery returned empty list; keeping previous snapshot");
                }
                else
                {
                    Volatile.Write(ref _snapshot, list.ToArray());   // 원자 교체
                    _lastSuccessUtc = DateTime.UtcNow;
                }
            }
            catch (Exception ex)
            {
                // 조회 실패는 "인스턴스 없음"이 아니다 — 이전 스냅샷을 유지한다
                _logger.LogError(ex, "discovery list failed; serving stale snapshot");
            }
            await Task.Delay(TimeSpan.FromSeconds(10), ct);
        }
    }

    public InstanceInfo PickForNewConnection()
    {
        var snap = Volatile.Read(ref _snapshot);
        if (snap.Count == 0) throw new NoHealthyInstanceException();
        // 최소 연결 수만 보면 새 인스턴스에 몰린다 — 후보 2개 중 적은 쪽(P2C)
        return PowerOfTwoChoices(snap);
    }
}

ListAsync 는 실패를 삼키지 말고 던져야 호출자가 "실패"와 "빈 목록"을 구분할 수 있다.

public async Task<IReadOnlyList<InstanceInfo>> ListAsync(string svc, CancellationToken ct)
{
    var res = await _http.GetAsync($"/v1/health/service/{svc}", ct);
    res.EnsureSuccessStatusCode();          // 실패는 예외로
    return Parse(await res.Content.ReadAsStringAsync(ct));
}

세대(generation) 문제는 등록 페이로드에 모노토닉 증가값을 넣어 해결한다. 프로세스 시작 시각(또는 etcd lease ID / Consul 세션)을 함께 저장하고, 캐시 반영 시 같은 InstanceId 의 더 낮은 세대는 무시한다.

public sealed class InstanceInfo
{
    public string InstanceId;
    public long Generation;   // 프로세스 시작 시각(Ticks) 또는 lease ID
}

// 반영 시
if (existing != null && incoming.Generation < existing.Generation) continue;  // 늦게 온 옛 등록 무시

더 나은 설계

(1) 디스커버리 등록을 애플리케이션 코드에서 빼고 플랫폼에 위임한다. Kubernetes 의 readinessProbe / livenessProbe + Service Endpoints, 또는 Consul 의 sidecar 등록(consul-template, Nomad)에 맡기면 애플리케이션은 /ready, /live 두 엔드포인트만 정확히 구현하면 된다. 등록/해제 순서와 전파 지연은 플랫폼이 검증된 방식으로 처리한다.

  • 트레이드오프: 플랫폼 종속이 생기고, 게임 서버 특유의 "이 존은 만석" 같은 도메인 신호를 표준 probe 로 표현하기 어렵다. readinessProbe 를 만석 시 503 으로 쓰면 기존 세션까지 Endpoints 에서 빠져 끊긴다(readiness 는 신규 라우팅만 막는 게 아니라 Endpoints 에서 제거한다). 그래서 실무에서는 probe 는 순수 기술 상태만 보고, 만석 여부는 별도 라우팅 메타데이터로 뺀다.

(2) 푸시 기반 감시(watch)로 바꾼다. Consul blocking query / etcd watch 를 쓰면 폴링 TTL 이 사라지고 변경이 수백 ms 안에 전파된다. 게이트웨이는 초기 목록을 한 번 받고 이후 델타만 적용한다.

  • 트레이드오프: 연결 유지 비용과 재연결 시 재동기화 로직이 필요하다. watch 가 끊긴 동안의 이벤트 유실을 revision/index 기반으로 따라잡아야 하며, 이 재동기화를 잘못 짜면 지금 코드와 같은 "빈 목록 반영" 사고가 다시 난다. 실무에서는 watch + 저빈도 전체 재동기화(예: 60 초)를 함께 쓴다.

(3) 트래픽 램프업(slow start)을 LB 에 넣는다. 새 인스턴스가 목록에 들어와도 처음 30~60 초는 가중치를 선형으로 올린다(Envoy slow_start_config, NGINX slow_start). readiness 를 통과했더라도 JIT 컴파일, CPU 캐시, 커넥션 풀이 실제 부하로 데워지는 데는 시간이 더 걸린다.

  • 트레이드오프: LB 설정 복잡도가 오르고, 램프업 중에는 기존 인스턴스 부하가 잠깐 더 높다. 급증 트래픽 상황에서는 램프업이 오히려 확장을 늦춘다. 그래도 재시작 루프로 클러스터가 통째로 무너지는 것보다는 훨씬 낫다.

(4) 최소 연결 수 대신 P2C(power of two choices) 를 쓴다. 후보 2 개를 무작위로 뽑아 그중 연결 수가 적은 쪽을 고르면, 새 인스턴스로 전량 쏠리는 "herd" 현상이 사라지면서도 최소 연결 수와 거의 같은 균형을 얻는다.

  • 트레이드오프: 완벽한 최적 분배는 아니다. 다만 분산 환경에서 각 게이트웨이가 보는 ActiveConnections 는 어차피 stale 이라 "정확한 최소"라는 전제 자체가 성립하지 않는다.

면접 포인트

  • "liveness 와 readiness 를 왜 나누는가?" — liveness 실패는 재시작, readiness 실패는 트래픽 차단이다. 의존성(DB, 캐시) 상태를 liveness 에 넣으면 DB 가 흔들릴 때 모든 인스턴스가 동시에 재시작되어 장애를 증폭시킨다. 의존성은 readiness 에만 넣는다.
  • "그레이스풀 셧다운의 올바른 순서는?" — ① readiness 내리기/디스커버리 해제 → ② 전파 지연 대기 → ③ 리스너 닫기 → ④ 기존 요청 드레인 → ⑤ 종료. ②를 빼면 반드시 접속 실패 구간이 생긴다.
  • "디스커버리 조회가 실패했을 때 인스턴스 목록을 비워야 하는가?" — 절대 아니다. 마지막 성공 스냅샷을 유지한다(stale-if-error). "조회 실패"와 "인스턴스 0 개"를 타입 수준에서 구분해야 하며, 실패를 빈 컬렉션으로 뭉개는 API 시그니처 자체가 사고의 원인이다.
  • "Task.Run 의 반환값을 버리면?" — 예외가 관측되지 않은 채 사라진다. .NET Core 부터는 TaskScheduler.UnobservedTaskException 이 떠도 프로세스를 죽이지 않으므로 더 조용하다. 백그라운드 루프는 BackgroundService 로 감싸거나 반드시 Task 를 보관해 회수한다.
  • "new HttpClient() 를 매번 만들면?" — TIME_WAIT 소켓 누적으로 포트가 고갈된다. 반대로 정적 싱글턴만 쓰면 DNS 변경이 반영되지 않으므로 IHttpClientFactory 나 SocketsHttpHandler.PooledConnectionLifetime 으로 핸들러 수명을 제한한다.
  • "늦게 도착한 등록 이벤트가 죽은 인스턴스를 되살리는 문제는?" — 등록에 세대/lease 를 붙이고 더 낮은 세대는 무시한다. 이것이 분산 시스템의 fencing token 과 같은 원리다.