새 레플리카의 첫 요청이 7초 걸리던 이유

Doosan published on
14 min, 2712 words

Categories: post

두 편 중 1편이다. 여기서 한 일은 새 레플리카가 처음 한 번 치르는 준비 비용을, 사용자 대신 앱이 소켓을 열기 전에 치르게 한 것이다. 비용은 없어지지 않았다. 레플리카가 업무시간 내내 늘었다 줄었다 하는 것도 그대로다. 그 회전을 멈추는 것이 근본적인 해결이고, 2편에서 다룬다.

Omni API는 평소 20ms대에 응답하지만, 새로 뜬 인스턴스의 첫 요청만은 7초가 걸렸다. 원인은 코드 경로가 아니라 프로세스가 처음 한 번 치르는 준비 비용이었다. prd에서 새 인스턴스의 첫 사용자 요청 p50은 7.5초에서 1.3초로 줄었다.

1. 증상: 첫 요청만 300배 느리다

처음 보고는 "대화방 여는 게 느리다"였다. 서버 응답 시간 자체는 멀쩡했다. p50이 16~21ms로 평소와 같았다. 느린 것은 새로 뜬 인스턴스가 받은 첫 요청뿐이었다.

prd의 HTTP 요청 14일치(2026-09-08~09-22, 워밍 전, 인스턴스 847개, 표본 비율을 되돌리면 약 25만 건)를 인스턴스가 받은 순서별로 나누면 모양이 분명해진다.

인스턴스가 받은 순서p502초 초과 비율
1번째7,316ms94.2%
2번째4,560ms64.9%
3번째1,601ms43.7%
6~10번째202ms6.8%
11~20번째95ms1.6%
100번째 이후23ms1.3%

평균이 느린 것이 아니다. 인스턴스 하나가 데워지기까지의 콜드스타트 곡선이다. 메시지 목록 목표인 2초를, 첫 요청만 세어도 14일간 798번 어겼다.

보존 기간(90일) 안의 완전한 주(07-12~09-27)를 봐도 첫 요청 p50은 매주 7.2~7.7초, 2초 초과는 94~100%로 평평했다. 어느 배포에서 생긴 회귀가 아니라 처음부터 이랬다. 평소 p50(20ms대)에 묻혀 보이지 않았을 뿐이다.

2. 왜 하루 종일 일어나나: 배포가 아니라 오토스케일링

"새 인스턴스의 첫 요청"이라고 하면 배포 때 한 번 겪는 일처럼 들린다. 하지만 prd에는 14일 동안 인스턴스가 851개, 하루 약 60개가 떴다. 배포는 하루 한두 번뿐이다.

배포라면실제 (오토스케일링)
새 인스턴스가 뜨는 빈도하루 1~2번업무시간 시간당 4~7개, 새벽 0개
인스턴스 수명다음 배포까지p50 7.6분, 59%가 10분 안에 사라짐
피해 빈도를 정하는 것배포 주기트래픽 주기

Azure Container Apps는 동시 요청 수를 보고 레플리카를 늘리고 줄인다. prd 설정은 최소 1개, 최대 10개, 축소 대기 300초, 레플리카당 0.5 vCPU와 1GiB다. 트래픽이 늘면 새 레플리카가 뜨고, 5분이 지나 한가해지면 사라진다. 시스템 로그에는 3일 동안 scaled to N by the scaler: http-scaler가 249번 찍혔다.

그래서 이 문제는 "배포하는 날의 불편"이 아니다. 업무시간 내내 시간당 몇 번씩, 누군가는 7초를 기다린다.

3. 범인 좁히기: 가설을 하나씩 지웠다

INT에서 5.4초짜리 첫 요청 하나를 스팬으로 쪼개 보니, 앞의 3.3초 동안 밖으로 나가는 호출(데이터베이스, 캐시, 인증 서버)이 하나도 없었다. prd에서도 같았다. 첫 요청 7.2초 중 첫 외부 호출은 5.7초에야 시작됐고, 외부 호출을 다 합쳐도 0.18초였다. 범인은 프로세스 안에 있었다.

프로세스 안에서 시간을 쓸 만한 후보는 많다. 하나씩 측정으로 지웠다.

가설확인 방법결과
Entity Framework 모델 빌드데이터베이스를 전혀 쓰지 않는 정적 파일 요청도 콜드일 때 약 6초였다기각
JIT 컴파일ReadyToRun으로 미리 컴파일해 A/B: 첫 요청 변화 없음, 이미지 +39%기각
인증(토큰 검증) 경로로컬 가짜 발급자로 서명한 토큰: 인증만의 추가 비용 약 55ms기각
Azure Monitor 텔레메트리 내보내기켜고 끄기 A/B: +5~9ms기각
0.5 vCPU 자체prd와 같은 CPU 할당의 컨테이너에서도 첫 요청 2~3초일부만 설명
기동 직후 백그라운드 작업과의 CPU 경합기동 2분 뒤에 온 첫 요청도 p50 6,985ms기각

여기서 배울 점 하나: 매번 새 계측 코드를 짜기 전에 이미 쌓이고 있던 텔레메트리부터 읽었다. 가장 비싼 가설 세 개는 코드를 한 줄도 바꾸지 않고 기각됐다.

4. 핵심 발견: 프로세스당 한 번 치르는 비용이다

범인을 하나로 짚지 못했는데도 해결책을 고를 수 있었던 이유가 이 발견이다. 같은 인스턴스의 두 번째 요청을 "첫 요청과 겹쳤나"로 나눠 보았다(prd 한 리비전, 2026-09-24~10-02, HTTP 요청만).

새 인스턴스의 요청건수p50
첫 요청7057,457ms
두 번째, 첫 요청이 끝난 뒤 시작258760ms
두 번째, 첫 요청과 겹침4347,856ms

첫 요청이 끝난 뒤에 온 두 번째 요청은 대개 다른 엔드포인트인데도 약 0.8초다. 겹친 요청은 첫 요청과 같은 준비를 함께 기다린다. 즉 비용은 "엔드포인트마다 한 번"이 아니라 "프로세스마다 한 번"이다.

로컬에서도 같은 모양이 나왔다. 무엇이 첫 요청이든 약 700ms를 물었고, 매칭되는 엔드포인트가 없는 /nope-404 같은 경로도 마찬가지였다. 그다음 요청은 엔드포인트가 달라도 5ms였다. 라우팅 초기화와 미들웨어 첫 실행 같은 일회성 준비가 첫 요청에 몰려 있다는 뜻이다.

이 발견이 중요한 이유는 이렇다. 어느 요청이든 한 번만 파이프라인을 통과하면 비용 대부분이 치러진다. 그 한 번을 사용자가 아니라 앱이 치르게 하면 된다.

참고로 겹침은 요청이 한꺼번에 쏟아져서 생기는 것이 아니다. 첫 10초의 유입은 초당 0.266건으로 평소(0.135건)의 약 2배뿐이다. 첫 요청이 7초나 걸리니 평범한 유입에도 대부분 겹친다.

5. 헬스 프로브는 왜 막아 주지 못했나

앱에는 /health와 /alive가 있다. 그런데도 첫 요청이 차가웠던 이유는, 플랫폼의 헬스 프로브가 HTTP 요청을 보내지 않기 때문이다.

Container Apps는 레플리카마다 프로브 세 개를 돌린다.

프로브언제실패하면
스타트업기동 직후, 성공할 때까지만아직 기동 중으로 보고 트래픽을 보내지 않음
레디니스그 뒤로 계속그 레플리카로 트래픽을 보내지 않음
라이브니스그 뒤로 계속컨테이너를 재시작

확인 방식에는 두 가지가 있다. TCP는 포트에 연결이 되는지만 보고 끊는다. HTTP는 정해진 경로로 실제 요청을 보내 응답 코드를 본다. prd 설정에는 프로브가 하나도 적혀 있지 않아서(probes: null), 플랫폼이 기본값인 TCP 프로브를 돌린다. 가게 문이 열려 있는지만 보고 돌아가는 셈이다.

근거는 두 가지다.

  • 시스템 로그(3일): 스타트업 프로브의 연결 거부가 1,745건, 컨테이너 시작은 295건이다. 기동할 때마다 포트가 열리기까지 약 6번 거부당한다.
  • 요청 텔레메트리(14일): HTTP 프로브였다면 하루 수만 건이 남아야 한다. 실제로는 GET / 하루 8.9건, /health 5건, /alive 3건뿐이다.

여기서 나오는 사실이 설계의 열쇠가 됐다. 포트가 열리는 순간이 곧 트래픽이 들어오기 시작하는 순간이다. 포트가 닫혀 있는 동안에는 트래픽이 오지 않고, 그동안 요청은 이미 떠 있던 레플리카가 처리한다.

왜 기본값이 TCP일까. /health와 /alive는 업계 표준이 아니라 .NET Aspire 템플릿이 정한 이름이다. 쿠버네티스 관례는 /healthz·/livez·/readyz이고, Spring Boot는 /actuator/health다. 플랫폼은 앱 안에 어떤 경로가 있는지 알아내지 않는다. HTTP 프로브를 쓰려면 사람이 프로브 설정에 경로와 포트를 직접 적어야 하고, 경로를 모르는 플랫폼이 고를 수 있는 기본값은 경로가 필요 없는 TCP뿐이다. 우리 배포 설정에는 그런 경로가 어디에도 없다. 앱은 두 경로를 열어 두었지만, 그것을 부르도록 설정된 곳이 없었던 것이다.

반대로 프로브를 HTTP로 바꾸면 이야기가 달라진다. 스타트업이나 레디니스 프로브가 /alive를 부르게 하면, 그 프로브가 성공할 때까지 레플리카는 트래픽을 받지 않는다. 그러니 프로브의 첫 요청이 곧 워밍이 된다. 첫 요청이 느려 프로브가 시간 초과로 실패해도, 요청은 앱 안에서 끝까지 돌며 준비 비용을 치른다. 이 방식을 고르지 않은 이유는 효과가 아니라 다른 조건 때문이고, 6번에서 다룬다. 단, 준비 여부를 가르지 않는 라이브니스 프로브만 HTTP로 바꾸면 워밍이 되지 않는다. 그 요청은 사용자 트래픽과 동시에 도착하기 때문이다.

우리에게 필요한 프로브

세 프로브 모두 필요하다. 다만 지금처럼 플랫폼 기본 TCP면 충분하고, HTTP로 바꿀 이유는 없다. 우리가 따로 설정하지 않아도 이미 돌고 있다.

프로브지금(TCP)이 해 주는 일HTTP로 바꾸면판단
스타트업포트가 열릴 때까지 트래픽을 막고, 너무 오래 뜨지 못하는 컨테이너는 재시작한다프로브의 첫 요청이 워밍이 된다. 코드 워밍을 하므로 필요 없다TCP 유지. 워밍이 여기에 기댄다
레디니스포트가 응답하지 않는 레플리카를 트래픽에서 뺀다/health를 쓰면 Blob 저장소가 흔들릴 때 모든 레플리카가 한꺼번에 빠진다. 앱에는 "떠 있지만 받으면 안 되는" 상태가 따로 없어서 알릴 것도 없다TCP 유지
라이브니스프로세스는 살아 있는데 연결을 못 받는 컨테이너를 재시작한다부하가 몰려 /alive가 늦어지는 순간 멀쩡한 컨테이너를 재시작해 상황을 악화시킬 수 있다TCP 유지

스타트업 프로브는 코드 워밍을 해도 필요하다. 오히려 워밍이 이 프로브에 기댄다. 워밍은 포트를 닫아 둔 채 진행되고, 그동안 트래픽이 오지 않는 것은 TCP 스타트업 프로브가 "아직 준비 안 됨"이라고 판정해 주기 때문이다. 문서상 기본값은 1초 간격에 실패 한도 240회라, 기동 약 7초와 워밍 상한 30초가 넉넉히 들어간다. 필요 없는 것은 HTTP 스타트업 프로브다.

함정 하나: 나중에 누군가 프로브를 직접 설정하면서 스타트업이나 레디니스를 빼면, 빠진 종류를 플랫폼이 기본값으로 채워 주는지는 문서에 없다. 그러면 포트가 닫힌 레플리카에도 트래픽이 갈 수 있다. 프로브를 설정한다면 인그레스 포트에 스타트업이나 레디니스 프로브를 반드시 남겨야 한다.

6. 해결책 후보들

문제를 "프로세스가 처음 한 번 치르는 비용을 누가, 언제 치르나"로 바꾸어 보면 후보가 세 묶음으로 나뉜다. 비용 자체를 줄이기, 비용을 미리 치르기, 그리고 아예 새 인스턴스를 덜 띄우기다.

후보무엇을 하나판정과 이유
ReadyToRun코드를 미리 컴파일해 JIT를 줄임기각. 첫 요청은 그대로고 이미지만 39% 커짐
Entity Framework 컴파일된 모델데이터 모델을 빌드 때 만듦보류. 들어갈 자리가 약 0.3초뿐
로그인 메타데이터·캐시 연결 미리 열기첫 요청의 외부 호출을 기동 때 처리기각. 겨냥하는 것이 0.18초
주기적으로 요청을 보내 데워 두기인스턴스를 계속 따뜻하게 유지기각. 데워진 프로세스는 식지 않는다. 문제는 새로 태어난 인스턴스
기동 후 백그라운드 서비스가 요청 보내기포트가 열린 뒤 앱이 스스로 요청기각. 첫 사용자 요청과 경주하고, 겹치면 같이 기다린다
라우팅 내부를 직접 초기화라우트 매처만 미리 만듦기각. 프레임워크 내부에 기대고 라우팅만 데움
HTTP 스타트업 프로브플랫폼이 기동 때 /alive를 불러 그 요청이 워밍이 됨기각. 코드는 필요 없지만 배포 설정을 바꿔야 하고 로컬에서 검증할 수 없으며, 프로브 요청이 텔레메트리에 남는다
레디니스 전용 엔드포인트 + 프로브"워밍 끝났음"만 답하게 하고 프로브로 확인기각. 워밍 코드가 어차피 필요하고 프로브 설정까지 더해진다
최소 레플리카·CPU 올리기인스턴스를 덜 띄우고 더 빠르게이 글에서는 범위 밖. 인프라 설정이고 비용이 늘어난다
소켓을 열기 전에 앱이 스스로 한 번 요청포트를 열기 직전에 앱 안에서 가짜 요청 하나를 처리선택. 비용을 없애지 않고, 누가 치르는지만 바꾼다

두 가지가 워밍 쪽 선택을 갈랐다. 하나는 비용을 없애는 것보다 누가 치르느냐를 바꾸는 쪽이 훨씬 쉽다는 점이다. 다른 하나는 새 레플리카가 뜨는 동안 기존 레플리카가 이미 서빙 중이라는 점이다. 새 레플리카가 조금 더 시간을 써도, 그 몇 초 동안 사용자는 기존 레플리카를 쓴다.

이 표는 그 준비 비용만 다룬다. 레플리카를 덜 만드는 일은 당시 인프라 설정이라 범위 밖으로 두었다. 그 판단은 절반만 맞았다. 레플리카를 더 사 두는 것은 답이 아니다. 기본 스케일 규칙이 부하가 아닌 회전을 만들고 있었고, 그걸 멈추는 일이 2편이다. 1편의 워밍은 그 회전이 있는 동안 사용자의 7초를 걷어 내는 임시 조치다.

비용은 어떻게 달라지나

비용으로는 방식을 가르기 어렵다. prd는 Container Apps의 Consumption 플랜이고, 요금은 레플리카가 떠 있는 동안의 vCPU-초·GiB-초와 환경 밖에서 들어온 HTTP 요청 수(구독당 월 200만 건 무료)로 매겨진다. Billing in Azure Container Apps 문서는 "Health probe requests aren't billable"이라고 적고 있다. 늘어날 수 있는 쪽은 Application Insights의 텔레메트리 수집량이다. 프로브 요청도 앱 입장에서는 일반 요청이라 기록되기 때문이다.

방식Container Apps 요금Application Insights에 쌓이는 요청
코드 워밍 (선택한 방식)기동마다 약 2초의 CPU를 더 쓴다(로컬 측정). 외부 요청이 아니라 요청 요금은 없다남지 않는다
HTTP 스타트업 프로브늘지 않는다. 프로브 요청은 과금 대상이 아니다기동당 1~3건, prd 기준 하루 60~180건
레디니스·라이브니스까지 HTTP (5초 간격)요청 요금은 그대로다. 다만 최소 레플리카 상태의 유휴 감액을 덜 받을 수 있다(문서에 없는 추정)레플리카 하나당 하루 약 34,560건. 현재 prd 실제 요청은 하루 약 1.5만 건이다

유휴 감액은 레플리카가 "HTTP 요청을 처리하고 있지 않을 것" 등의 조건을 모두 만족할 때 적용된다. 5초마다 오는 HTTP 프로브가 그 조건을 깨는지는 문서가 말하지 않는다.

7. 고른 것: 소켓을 열기 전에 스스로 한 번 요청하기

ASP.NET Core가 기동할 때는 순서가 정해져 있다. 요청 처리 파이프라인을 다 만든 뒤에 서버(Kestrel)의 StartAsync를 불러 포트를 연다. 파이프라인은 있고 포트는 아직 닫힌 순간은 그 사이 하나뿐이다. 그 자리에 끼어들었다.

  1. 서버(IServer) 등록을 감싼다. 감싼 서버의 StartAsync가 불리면 바로 포트를 열지 않는다.
  2. 앱 안에서 가짜 GET /alive 요청을 만들어 파이프라인에 직접 넣는다. 네트워크는 타지 않는다.
  3. 이 요청이 라우팅 초기화와 미들웨어 첫 실행을 치른다.
  4. 그다음에 진짜 서버의 StartAsync를 불러 포트를 연다. TCP 프로브가 성공하고 트래픽이 들어오기 시작한다.

핵심 부분은 이렇다.

public async Task StartAsync<TContext>(IHttpApplication<TContext> application, CancellationToken cancellationToken)
{
    try
    {
        // 동기 구간(생성자·JIT)에서 막혀도 상한이 걸리도록 스레드 풀에서 돌린다.
        await Task.Run(() => WarmAsync(application, results), CancellationToken.None)
                  .WaitAsync(timeout, cancellationToken);
    }
    catch (TimeoutException exception)
    {
        logger.LogWarning(exception, "... starting the server anyway", timeout);
    }

    mark("warmed");
    await inner.StartAsync(application, cancellationToken);   // 포트는 워밍 뒤에 열린다
}

// WarmAsync 안쪽: 요청 하나를 직접 만들어 파이프라인에 넣는다
DefaultHttpContext httpContext = new();
httpContext.Request.Method = HttpMethods.Get;
httpContext.Request.Scheme = Uri.UriSchemeHttps;
httpContext.Request.Path = "/alive";

using (SuppressInstrumentationScope.Begin())
{
    context = application.CreateContext(httpContext.Features);   // 텔레메트리 억제는 여기에만
}
await application.ProcessRequestAsync(context);

설계 원칙은 네 가지다.

  • 실패해도 기동은 막지 않는다. 요청이 예외를 던지면 경고만 남기고, 30초가 지나면 그냥 포트를 연다. 워밍은 최적화일 뿐 가용성 요건이 아니다.
  • 텔레메트리에 남기지 않는다. 남기면 모든 새 인스턴스의 "첫 요청"이 가짜 요청이 되어, 효과를 판정하는 지표가 거짓말을 한다.
  • 요청은 하나만. 인증 경로를 타는 두 번째 요청을 더해 봤지만, 측정한 차이가 잡음 안이라 뺐다.
  • 프로브는 그대로 TCP. 배포 설정을 하나도 바꾸지 않는다.

8. 전후 비교: 바뀐 것은 단계 하나

워밍 전
  컨테이너 시작
  파이프라인 조립
  소켓 개방            ← 여기서부터 트래픽
  사용자 요청          ← 약 7초

워밍 후
  컨테이너 시작
  파이프라인 조립
  프로세스 안 GET /alive   ← 앱이 약 2초를 씀. 소켓은 아직 닫힘
  소켓 개방            ← 여기서부터 트래픽
  사용자 요청          ← prd p50 1.3초

달라진 것은 포트를 열기 전에 끼워 넣은 단계 하나다. 준비 비용을 치르는 주체가 사용자에서 앱으로 바뀌고, 그동안 트래픽은 기존 레플리카가 받는다.

9. 구현하며 밟은 함정 다섯 가지

가짜 요청 하나 보내는 코드는 짧다. 하지만 "진짜 요청과 똑같이, 그러나 아무 흔적 없이"를 지키는 데 함정이 많았다. 앞의 세 개는 코드 리뷰에서, 뒤의 두 개는 실제 실행에서 나왔다.

  1. 텔레메트리 억제를 너무 넓게 걸었다. OpenTelemetry의 SuppressInstrumentationScope는 AsyncLocal이다. 요청 처리 전체를 감싸면, 그 요청 안에서 처음 만들어지는 싱글턴의 타이머·백그라운드 작업이 억제 상태를 영구히 물려받는다. 요청 기록 여부는 CreateContext에서 정해지므로, 억제는 그 한 줄에만 건다.
  2. http로 보내면 데워지지 않는 환경이 있다. https 주소가 설정된 환경(모든 로컬 실행 프로필)에서는 UseHttpsRedirection이 307로 끊어 인증과 엔드포인트까지 가지 않는다. prd의 실제 요청은 전달 헤더(forwarded headers)로 https가 되므로 워밍도 https로 보낸다.
  3. 시간 상한이 비동기 부분에만 걸려 있었다. WaitAsync는 메서드가 처음 await에 도달한 뒤에야 붙는다. 생성자나 JIT에서 동기적으로 막히면 포트가 영원히 열리지 않는다. Task.Run으로 감싸서 해결했다.
  4. 메트릭은 억제되지 않는다. 로컬 수집 서버로 확인해 보니 요청 트레이스는 억제됐지만, http.server.request.duration 메트릭에는 약 2초짜리 샘플이 레플리카마다 하나 들어갔다. 테스트만 보고 "억제된다"고 믿었다면 몰랐을 사실이다.
  5. 측정 환경이 거짓말을 한다. 첫 로컬 측정에서는 죽인 줄 알았던 프로세스가 살아 있어서 모든 요청이 1ms 미만으로 나왔다. Apple Silicon에서 amd64 이미지를 에뮬레이션으로 돌리면 JIT이 부풀려 보인다. 측정용 데이터베이스의 스키마가 뒤처져 500이 났다. 세 경우 모두 결과가 이상하면 시스템보다 측정 방법을 먼저 의심했다.

테스트는 7개다. 테스트가 정말 무언가를 지키는지 보려고, 운영 코드를 일부러 여덟 가지로 망가뜨리고(마크 순서, 억제 제거·확대, catch, 시간 상한, Task.Run, scheme, 스코프 해제) 각각 테스트가 실패하는지 확인했다.

10. 결과: 로컬 측정과 prd 판정

prd와 같은 할당(0.5 vCPU, 1GiB)의 로컬 컨테이너에 실제 SQL Server·Redis·RabbitMQ와 가짜 토큰 발급자를 붙여 재었다. 첫 요청은 실제 토큰을 가진 세션 목록 조회다.

첫 요청 (3회)기동 완료
워밍 없음2,703 / 2,177 / 2,633ms9.0~9.9초
워밍 있음479 / 373 / 397ms11.7~11.8초

첫 요청은 약 6배 빨라졌고, 늘어난 약 2초는 포트가 열리기 전에 쓰여 사용자가 기다리지 않는다. 비용은 없어지지 않았다. 사용자 대신 앱이 치르게 됐을 뿐이다.

로컬은 prd 첫 요청 7.2초 중 약 3분의 1만 재현했다. 그래서 효과는 prd에서 판정하기로 하고, 배포 전에 기준을 정해 두었다. 평일 새 인스턴스 100개 이상에서 첫 사용자 요청 p50이 2초 미만이면 합격이다.

prd 판정: 합격

워밍은 2026-10-02 08:30Z부터 prd에서 돌았다. 6일 동안 397번 기동했고 모두 /alive=200이었으며, 워밍에 걸린 시간은 p50 약 2.3초로 로컬(약 2.2초)과 거의 같았다.

prd, HTTP 요청만워밍 전워밍 후
인스턴스 (평일)705 (591)363 (256)
첫 사용자 요청 p507,468ms1,285ms
첫 사용자 요청 p9010,109ms3,712ms
첫 요청 중 2초 초과95.5%34.7%
첫 요청과 겹친 요청 p507,539ms2,490ms
첫 요청이 끝난 뒤의 요청 p50304ms277ms
처음 10개 요청 중 2초 초과, 인스턴스당3.05건1.55건
  • 거짓 합격이 아니다. 워밍 후 인스턴스 369개에서 GET /alive 기록은 0건이다. 워밍 요청이 텔레메트리로 새었다면 인스턴스마다 한 건씩 "첫 요청" 자리를 차지해, 지표가 저절로 좋아 보였을 것이다. 9번의 함정 1을 피한 덕이다.
  • 겹침도 줄었다. 첫 요청이 짧아지자 겹칠 창이 좁아져, 인스턴스당 겹친 요청이 1.50건에서 0.90건으로 줄었다.
  • 엔드포인트별 잔여는 그대로다. 첫 요청이 끝난 뒤의 요청은 304ms에서 277ms로 거의 그대로다. 워밍이 덮지 않는 영역이라는 예상 그대로다.

판정하면서 밟은 함정도 하나 있다. 처음에는 표본 추출이 없다고 보고 itemCount(항상 1)만 확인했다. 실제로는 Azure Monitor가 요청을 표본 추출하고 있었고, 보존 비율은 요청의 사용자 지정 차원에 실려 있었다. 또 요청 테이블에는 메시지 소비자 스팬이 섞여 있어서, 첫 판정 숫자는 이를 걸러내지 않은 값이었다. 위 표는 HTTP 요청만으로 다시 낸 값이다. 판정은 인스턴스의 첫 요청을 보는데, 첫 기록이 100% 보존 구간에 있던 비율이 워밍 전후 모두 80%대라 비교는 유지된다. 교훈은 이렇다. 텔레메트리로 판정할 때는 다른 집계(인그레스 메트릭)와 총량을 맞춰 보고, 테이블에 무엇이 섞여 있는지부터 확인한다.

아직 남은 것

  • 첫 요청의 잔여: p50 1.3초로, 같은 조건의 로컬(0.4초)보다 길고 34.7%는 여전히 2초를 넘는다. 로컬에 없는 prd 전용 경로(App Configuration, Key Vault, Azure SignalR, TLS Redis, Azure SQL)의 몫으로 보인다. 측정 전 추정이다.
  • 스케일 회전: 워밍 뒤에도 평일 인그레스 요청 1,000건당 컨테이너 시작은 3.26회에서 3.72회로 줄지 않았다. 콜드스타트의 느린 요청이 동시 요청 수를 부풀려 스스로 스케일아웃을 부른다는 가설은 데이터가 뒷받침하지 않았다. 이어서 측정해 보니, 기본 HTTP 스케일 규칙이 문서의 계산식과 달리 적은 요청에도 레플리카를 늘리고 있었다. 준비 비용을 옮겨도 레플리카는 업무시간 내내 태어나고 죽는다. 그 회전을 멈추는 것이 2편이다.
  • 기동 예산: 요청 파이프라인 조립 2.8초와 Azure App Configuration 1.1초. 사용자가 기다리는 시간은 아니지만 레플리카가 쓸모 있어지는 시점을 늦춘다.

돌아보면 두 가지가 판정을 믿을 만하게 만들었다. 하나는 배포 전에 합격선과 표본 수를 정해 둔 것이고, 다른 하나는 숫자가 좋게 나왔을 때 거짓 합격의 경로부터 막혔는지 확인한 것이다. 그리고 이 합격은 "첫 요청이 빨라졌다"까지의 합격이다. 레플리카가 생기는 횟수는 이 글의 합격 조건에 없었다.

수치는 Application Insights의 요청·트레이스와 Container Apps 시스템 로그, 2026-09-11~2026-10-08이다. 프로브 종류와 기본값은 Health probes in Azure Container Apps를 봤다.