Tech Wiki

[Rust 실전 로드맵 31] Rust API 관측성과 보안: tracing·입력 제한·비밀 관리

HTTP handler가 정상 응답한다고 해서 운영 가능한 API가 되는 것은 아닙니다. 요청 하나를 로그에서 끝까지 찾을 수 있어야 하고, 비밀값은 그 로그에 남지 않아야 합니다. 큰 body와 오래 걸리는 handler에는 경계가 필요합니다. 잘못된 설정은 서버가 뜬 뒤가 아니라 시작할 때 거부해야 합니다.

이 글의 fixture는 Article 30과 32 사이에 끼울 수 있도록 middleware에 초점을 맞춥니다. Rust 2024 edition과 고정된 Axum 0.8.6, tower-http 0.6.8, tracing 0.1.41, Tokio 1.53.1을 사용합니다. 외부 네트워크나 wall clock에 기대지 않고 여섯 개의 결정적 테스트로 계약을 확인합니다.

1. 설정을 router보다 먼저 검증합니다

AppConfig::try_from_pairs는 환경 변수 대신 key-value map을 받습니다. 운영 코드에서는 환경을 읽은 뒤 같은 경계로 넘기면 됩니다. 이렇게 나누면 테스트가 process-wide 환경 변수를 바꾸지 않아도 됩니다.

pub struct AppConfig {
    api_secret: String,
    pub max_body_bytes: usize,
    pub request_timeout: Duration,
}

pub enum ConfigError {
    Missing(&'static str),
    NotUnsigned(&'static str),
    Zero(&'static str),
}

API_SECRET은 비어 있으면 안 됩니다. MAX_BODY_BYTESREQUEST_TIMEOUT_MS는 양의 usize여야 합니다. 파싱 실패와 0을 다른 typed error로 남기므로 배포 시스템은 어떤 설정이 잘못됐는지 구분할 수 있습니다. secret 자체는 Debug 출력이나 오류에 넣지 않습니다.

검증 시점도 계약입니다. body limit과 timeout을 만들기 전에 모든 값을 확인합니다. 유효하지 않은 설정으로 잠시라도 요청을 받는 상태를 만들지 않습니다.

2. 요청 ID를 먼저 만들고 구조화 span을 엽니다

tracing span에는 자유 형식 문장 대신 request_id, http.method, http.uri 필드를 기록합니다. 같은 이름을 계속 쓰면 JSON subscriber나 수집기가 문자열을 다시 파싱하지 않고 필드를 색인할 수 있습니다.

TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
    let request_id = request
        .headers()
        .get("x-request-id")
        .and_then(|value| value.to_str().ok())
        .unwrap_or("missing");
    tracing::info_span!(
        "http.request",
        request_id,
        http.method = %request.method(),
        http.uri = %request.uri(),
    )
})

middleware 순서는 코드 장식이 아닙니다. request ID를 trace보다 먼저 설정해야 span이 ID를 볼 수 있습니다. propagation은 inner service가 응답한 뒤 같은 ID를 response header에 복사합니다. fixture의 생성기는 원자 카운터로 req-0000000000000001 같은 값을 만듭니다. 테스트 출력을 재현하려고 택했습니다. 여러 process에서 전역 유일한 ID가 필요한 운영 환경이라면 UUID나 upstream ID 정책으로 바꿔야 합니다.

이미 유효한 x-request-id가 들어오면 tower-http의 setter와 propagator는 덮어쓰지 않습니다. 테스트는 새 ID 생성과 caller-42 보존을 각각 확인합니다. 신뢰하지 않는 외부 ID를 그대로 받아도 되는지는 proxy 경계에서 별도로 정해야 합니다.

3. 비밀은 사용하되 관측 데이터에서 제외합니다

예제의 /echoAuthorization: Bearer ...를 확인하지만 secret을 response에 넣지 않습니다. 더 중요한 규칙은 header 전체나 request body 전체를 span에 기록하지 않는 것입니다. allowlist 방식으로 method, URI, request ID만 남깁니다.

ServiceBuilder::new()
    .layer(RequestBodyLimitLayer::new(max_body_bytes))
    .layer(SetSensitiveRequestHeadersLayer::new([AUTHORIZATION]))
    .layer(SetRequestIdLayer::x_request_id(SequentialRequestId::default()))
    .layer(TraceLayer::new_for_http())
    .layer(PropagateRequestIdLayer::x_request_id())
    .layer(TimeoutLayer::with_status_code(
        StatusCode::GATEWAY_TIMEOUT,
        request_timeout,
    ))

SetSensitiveRequestHeadersLayerAuthorization 값을 sensitive로 표시합니다. 이 표시가 trace보다 먼저 적용되어야 header-aware formatter도 값을 숨길 수 있습니다. 다만 sensitive flag만 믿고 모든 header를 무차별 기록하는 설계는 피하는 편이 안전합니다. application event에도 token, password, cookie, JSON payload를 넘기지 않는 규칙이 필요합니다.

인증 실패는 401이고 성공은 입력 JSON을 돌려줍니다. 두 경로 모두 secret 문자열을 응답하지 않는 테스트가 있습니다. secret 비교 방식과 실제 인증 체계는 fixture 범위 밖입니다. 운영 서비스에서는 검증된 인증 middleware와 secret manager를 사용해야 합니다.

4. body 크기와 처리 시간을 서로 다른 경계로 둡니다

RequestBodyLimitLayer는 설정값보다 큰 요청을 handler 전에 413 Payload Too Large로 바꿉니다. Axum extractor에도 기본 제한이 있지만 직접 Body::poll_frame을 쓰는 extractor에는 적용되지 않습니다. service-wide limit을 별도로 둔 이유입니다.

TimeoutLayer::with_status_code는 handler future가 제한 시간을 넘으면 빈 body의 504 Gateway Timeout을 반환합니다. body 크기 제한과 처리 시간 제한은 같은 문제가 아닙니다. 느리게 도착하는 body의 idle timeout이나 body 전체 transfer deadline이 필요하면 tower-http의 body timeout 계층을 추가로 검토해야 합니다.

#[tokio::test(start_paused = true)]
async fn service_timeout_returns_504_without_wall_clock_waiting() {
    let task = tokio::spawn(async move { app.oneshot(slow_request()).await.unwrap() });
    tokio::task::yield_now().await;
    tokio::time::advance(Duration::from_millis(50)).await;
    assert_eq!(task.await.unwrap().status(), StatusCode::GATEWAY_TIMEOUT);
}

테스트는 Tokio 시간을 멈춘 뒤 50ms만 전진합니다. 실제로 50ms를 자지 않으므로 빠르고 재현 가능합니다. 정확한 경계에서는 timeout layer가 이기고 handler future는 drop됩니다. 이미 시작된 외부 side effect가 되돌아간다는 뜻은 아닙니다. database write나 원격 호출에는 별도의 취소 계약이 필요합니다.

5. liveness와 readiness를 분리합니다

/health/live는 process가 HTTP 요청을 처리할 수 있으면 200을 반환합니다. /health/ready는 공유 AtomicBool을 읽어 dependency 준비 전에는 503, 준비 뒤에는 200을 반환합니다.

async fn ready(State(state): State<AppState>) -> StatusCode {
    if state.runtime.ready.load(Ordering::Acquire) {
        StatusCode::OK
    } else {
        StatusCode::SERVICE_UNAVAILABLE
    }
}

두 endpoint를 합치면 일시적인 dependency 장애가 process 재시작으로 번질 수 있습니다. 반대로 readiness가 항상 200이면 아직 traffic을 받을 수 없는 instance로 요청이 들어갑니다. 실제 readiness에는 database migration, connection pool, worker startup처럼 요청 처리에 필요한 조건만 넣어야 합니다. 외부 서비스 하나가 느리다는 이유만으로 무조건 실패시키면 장애가 확산될 수 있습니다.

6. fixture와 검증 명령을 실행합니다

프로젝트 root에서 다음 명령을 실행합니다. 첫 네 명령은 format, 전체 target compile, warning 없는 Clippy, debug test를 확인합니다. release test와 binary 출력도 따로 실행합니다.

cd examples/article-31-api-observability-security
cargo fmt --all -- --check
cargo check --offline --all-targets --all-features
cargo clippy --offline --all-targets --all-features -- -D warnings
cargo test --offline --all-features
cargo test --offline --release --all-features
cargo run --offline --quiet

정확한 binary 출력은 다음과 같습니다.

ready_status=200
request_id=req-0000000000000001
max_body_bytes=1024
request_timeout_ms=100

여섯 테스트는 잘못된 config, liveness/readiness 분리, request ID 생성과 보존, 32-byte body 제한, paused-time 504, secret 비노출을 고정합니다. health endpoint는 process 생존과 traffic 수용 가능성을 같은 값으로 취급하지 않습니다.

이 fixture를 Article 30의 application에 붙일 때는 request ID 신뢰 경계, readiness 조건, timeout 이후 side effect를 먼저 결정하십시오. Article 32의 통합 테스트에서는 실제 listener를 거쳐 proxy header 정책과 graceful shutdown까지 확인할 수 있습니다. middleware가 있다고 운영 계약이 자동으로 생기지는 않습니다.

전체 소스 코드

이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 31 프로젝트에서 확인할 수 있습니다.

출처


2개 응답

  1. […] 이전 글Rust API 관측성과 보안: tracing·입력 제한·비밀 관리 […]

  2. […] 다음 글Rust API 관측성과 보안: tracing·입력 제한·비밀 관리 […]

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

Tech Wiki

Built with WordPress · Learn in public.