로깅이 동작하는 방식
로깅이 동작하는 방식 (How Logging Works)
출처: Caddy 공식 문서
본문
Caddy는 강력하고 유연한 로깅 기능을 갖고 있지만, 특히 구식 공유 호스팅이나 다른 레거시 웹 서버에서 왔다면 익숙한 것과 다를 수 있어요.
개요
로깅에는 발행(emission)과 소비(consumption)라는 두 가지 주요 측면이 있어요.
발행은 메시지를 생성하는 것을 의미해요. 세 단계로 구성돼요:
-
관련 정보(컨텍스트) 수집
-
유용한 표현(인코딩) 만들기
-
그 표현을 출력으로 보내기(쓰기)
이 기능은 Caddy의 핵심에 내장되어 있어서, Caddy 코드베이스나 모듈(플러그인)의 어떤 부분이든 로그를 발행할 수 있어요.
소비는 메시지의 수집과 처리를 의미해요. 유용하려면 발행된 로그가 소비되어야 해요. 단지 기록만 되고 읽히지 않는 로그는 가치가 없어요. 로그 소비는 관리자가 콘솔 출력을 읽는 것처럼 간단할 수도, 로그 메시지를 필터링·집계·인덱싱하는 로그 집계 도구나 클라우드 서비스를 연결하는 것처럼 고도화될 수도 있어요.
Caddy의 역할
Caddy는 로그 발행자예요. 로그를 인코딩하고 쓰는 데 필요한 최소한의 처리 외에는 로그를 소비하지 않아요. 이는 Caddy의 코어를 더 단순하게 유지해서 버그와 엣지 케이스를 줄이고 유지보수 부담을 낮추기 때문에 중요해요. 궁극적으로 로그 처리는 Caddy 코어의 범위를 벗어나요.
하지만 로그를 소비하는 Caddy 앱 모듈이 존재할 가능성은 항상 있어요. (우리가 아는 한 아직 존재하지 않을 뿐이에요.)
구조화된 로그
대부분의 현대 애플리케이션과 마찬가지로 Caddy의 로그는 구조화되어 있어요. 즉 메시지 안의 정보가 단순한 불투명 문자열이나 바이트 조각이 아니에요. 대신 데이터는 메시지를 인코딩해 쓰기 전까지 강한 타입을 유지하고 개별 필드 이름으로 키가 지정돼요.
전통적인 HTTP 서버에서 흔히 쓰이는 구식 Common Log Format(CLF) 같은 전통적인 비구조화 로그와 비교해 봐요:
127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.1" 200 2326
이 형식은 "구조가 있지만" "구조화되지는" 않아요. HTTP 요청을 로그로 남기는 데만 사용할 수 있어요. 불투명한 바이트 문자열이기 때문에 다르게 인코딩할 (효율적인) 방법이 없어요. 또한 많은 정보가 빠져 있어요. 요청의 Host 헤더조차 포함하지 않아요! 이 로그 형식은 단일 사이트를 호스팅하고 요청에 대한 가장 기본적인 정보를 얻을 때만 유용해요.
CLF에 호스트 정보가 없는 것이, 둘 이상의 사이트를 호스팅할 때 이 로그를 보통 별도의 파일로 나눠 써야 하는 이유예요. 그렇지 않으면 요청에서 Host 헤더를 알 방법이 없기 때문이에요!
이제 Caddy의 동등한 구조화 로그 메시지를 JSON으로 인코딩하고 표시용으로 예쁘게 만든 것과 비교해 봐요:
{
"level": "info",
"ts": 1646861401.5241024,
"logger": "http.log.access",
"msg": "handled request",
"request": {
"remote_ip": "127.0.0.1",
"remote_port": "41342",
"client_ip": "127.0.0.1",
"proto": "HTTP/2.0",
"method": "GET",
"host": "localhost",
"uri": "/",
"headers": {
"User-Agent": ["curl/7.82.0"],
"Accept": ["*/*"],
"Accept-Encoding": ["gzip, deflate, br"],
},
"tls": {
"resumed": false,
"version": 772,
"cipher_suite": 4865,
"proto": "h2",
"server_name": "example.com"
}
},
"bytes_read": 0,
"user_id": "",
"duration": 0.000929675,
"size": 10900,
"status": 200,
"resp_headers": {
"Server": ["Caddy"],
"Content-Encoding": ["gzip"],
"Content-Type": ["text/html; charset=utf-8"],
"Vary": ["Accept-Encoding"]
}
}
구조화된 로그가 훨씬 유용하고 훨씬 많은 정보를 담고 있음을 알 수 있어요. 이 로그 메시지의 풍부한 정보는 유용할 뿐만 아니라 성능 오버헤드도 거의 없어요. Caddy의 로그는 zero-allocation이에요. 구조화된 로그는 데이터 타입이나 컨텍스트에 제한이 없어서, 어떤 코드 경로에서든 사용하고 어떤 종류의 정보든 담을 수 있어요.
로그가 구조화되고 강한 타입이기 때문에 어떤 형식으로든 인코딩할 수 있어요. JSON으로 작업하고 싶지 않다면 로그를 다른 어떤 표현으로도 인코딩할 수 있어요. Caddy는 로그 인코더 모듈을 통해 다른 형식을 지원하고, 더 추가될 수도 있어요.
구조화된 로그와 레거시 형식의 구분에서 가장 중요한 점은, 성능 비용을 들여서 구조화된 로그는 레거시 Common Log Format으로 변환할 수 있지만, 그 반대는 불가능하다는 거예요. CLF에서 구조화된 형식으로 가는 것은 사소하지 않고(적어도 비효율적이며), 정보 부족을 고려하면 불가능해요.
본질적으로 효율적인 구조화된 로깅은 일반적으로 이런 철학을 지향해요:
-
너무 적은 로그보다 너무 많은 로그가 낫다
-
버리는 것보다 필터링하는 것이 낫다
-
유연성과 상호운용성을 위해 인코딩을 미루라
발행
코드에서 로그 발행은 다음과 비슷해요:
logger.Debug("proxy roundtrip",
zap.String("upstream", di.Upstream.String()),
zap.Object("request", caddyhttp.LoggableHTTPRequest{Request: req}),
zap.Object("headers", caddyhttp.LoggableHTTPHeader(res.Header)),
zap.Duration("duration", duration),
zap.Int("status", res.StatusCode),
)
이것은 Caddy의 리버스 프록시에서 실제로 나온 코드 한 줄이에요. 이 줄 덕분에 디버그 로깅을 활성화했을 때 구성된 업스트림에 대한 요청을 검사할 수 있어요. 문제 해결 시 매우 귀중한 데이터예요!
이 한 번의 함수 호출에 로그 레벨, 메시지, 여러 데이터 필드가 들어 있다는 걸 볼 수 있어요. 이 모든 것이 강한 타입이며, Caddy는 zero-allocation 로깅 라이브러리를 사용하므로 로그 발행은 거의 오버헤드 없이 빠르고 효율적이에요.
logger 변수는 이름과 데이터 필드를 모두 포함하는 어떤 양의 컨텍스트와도 연관될 수 있는 zap.Logger예요. 덕분에 로거가 부모 컨텍스트에서 아주 잘 "상속"되어 고급 트레이싱과 메트릭을 가능하게 해요.
거기에서 메시지는 인코딩되고 쓰여지는 매우 효율적인 처리 파이프라인으로 보내져요.
로깅 파이프라인
위에서 보았듯이 메시지는 **로거(logger)**에 의해 발행돼요. 그런 다음 메시지는 처리를 위해 **로그(log)**로 보내져요.
Caddy는 처리를 할 여러 로그를 구성할 수 있게 해줘요. 로그는 인코더, 작성기, 최소 레벨, 샘플링 비율, 포함하거나 제외할 로거 목록으로 구성돼요. Caddy에는 항상 default라는 기본 로그가 있어요. 설정의 이 객체에 "default" 키로 로그를 지정해 커스터마이즈할 수 있어요.
지금 Caddy의 로깅 문서를 탐구해 우리가 말하는 구조와 매개변수에 익숙해지는 것이 좋겠어요.
-
인코더: 로그의 형식이에요. 인메모리 데이터 표현을 바이트 조각으로 변환해요. 인코더는 로그 메시지의 모든 필드에 접근할 수 있어요.
-
작성기: 로그 출력이에요. 파일이나 네트워크 소켓 같은 어떤 로그 작성기 모듈이든 될 수 있어요. 단순히 바이트를 써요.
-
레벨: 로그에는 DEBUG에서 FATAL까지 다양한 레벨이 있어요. 지정된 레벨보다 낮은 메시지는 로그에서 무시돼요.
-
샘플링: 극도로 핫한 경로는 효과적으로 처리할 수 있는 것보다 더 많은 로그를 발행할 수 있어요. 샘플링을 활성화하는 것은 대표적인 메시지 샘플을 유지하면서 부하를 줄이는 방법이에요.
-
포함/제외: 각 메시지는 이름(보통 모듈 ID에서 유래)이 있는 로거에 의해 발행돼요. 로그는 특정 로거의 메시지를 포함하거나 제외할 수 있어요.
Caddy에서 로그 메시지가 발행되면:
-
발행한 로거의 이름이 각 로그의 포함/제외 목록과 대조돼요. 포함되어 있으면(또는 제외되지 않았으면) 그 로그로 받아들여져요.
-
샘플링이 활성화되어 있으면 빠른 계산으로 로그 메시지를 유지할지 결정해요.
-
메시지는 로그에 구성된 인코더로 인코딩돼요.
-
인코딩된 바이트는 로그에 구성된 작성기로 쓰여져요.
기본적으로 모든 메시지는 모든 구성된 로그로 전달돼요. 이는 위에서 설명한 구조화된 로깅의 가치를 따르는 거예요. 포함/제외 목록을 설정해 어떤 메시지가 어떤 로그로 가는지 제한할 수 있지만, 이는 주로 서로 다른 모듈의 메시지를 필터링하기 위한 것이에요. 로그 집계 서비스처럼 사용하려는 게 아니에요. Caddy의 로깅 파이프라인을 간결하고 효율적으로 유지하기 위해, 로그 메시지의 고급 처리는 소비 단계로 미뤄져요.
소비
메시지가 출력으로 보내진 후에는 소비자가 메시지를 읽어 들여 파싱하고 그에 따라 처리해요.
이는 로그 발행과는 매우 다른 문제 영역이며, Caddy의 핵심은 소비를 다루지 않아요(물론 Caddy 앱 모듈이 할 수는 있어요). JSON 메시지(또는 다른 형식) 스트림을 처리하고 로그를 보고, 필터링하고, 인덱싱하고, 쿼리하는 데 사용할 수 있는 도구는 무수히 많아요. 직접 작성하거나 구현할 수도 있어요.
예를 들어 특정 필드(예: 호스트 이름)를 기준으로 분리된 CLF를 요구하는 레거시 소프트웨어를 실행한다면, JSON을 읽어 sprintf()로 CLF 문자열을 만들고 request.host 필드의 값에 따라 파일에 쓰는 간단한 도구를 사용하거나 작성할 수 있어요.
Caddy의 로깅 기능으로 메트릭과 트레이싱도 구현할 수 있어요. 메트릭은 기본적으로 특정 특성을 가진 메시지를 세는 것이고, 트레이싱은 메시지 간의 공통점을 기반으로 여러 메시지를 연결해요.
Caddy의 로그를 소비해서 할 수 있는 일은 무궁무진해요!