Loki + Alloy 적용기: stdout 한 줄이 Grafana에 보이기까지
Loki를 붙이기 전에 남은 문제
이전 작업에서 백엔드 로그를 Pino 기반 JSON으로 구조화했다.
요청마다 reqId를 붙였고 라우트, 상태 코드, 처리 시간, 에러 정보도 같은 형식으로 남겼다.
stdout까지는 정리된 셈이다.
문제는 그다음이었다.
서버 터미널을 닫고 며칠이 지나면 로그를 다시 볼 방법이 없었다.
운영할 곳은 폐쇄망이고 사람이 계속 지켜보는 서버도 아니다.
당장 알림을 받는 기능보다 나중에 접속해 당시 상황을 재구성할 이력이 먼저 필요했다.
수집 경로는 이렇게 잡았다.
앱(stdout) → /var/log/[프로젝트명]/app.log → Alloy → Loki → Grafana
앱의 책임은 stdout에서 끝난다.
이후의 파일 적재와 회전, 수집, 저장, 조회는 인프라 몫이다.
이 글은 그중 Loki와 Alloy를 붙이며 내린 결정과 밟았던 함정에 관한 기록이다.
왜 파일을 거치기로 했나
처음엔 세 가지 방식을 고안했다.
앱에서 Loki로 직접 보내거나, Alloy가 journald를 읽거나, stdout을 파일로 적재한 뒤 Alloy가 읽는 방식이었다.
직접 push하면 중간 단계는 줄어든다.
대신 앱이 특정 관측 백엔드에 묶인다.
프로세스가 죽을 때 전송 중이던 배치도 같이 사라져, 정작 필요한 장애 순간의 로그를 잃을 수도 있었다.
journald를 쓰면 회전과 디스크 상한은 systemd가 관리한다.
다만 검토한 환경에서는 기본 rate limit, 사용자 저널의 재부팅 후 보존 여부, 한 줄 길이 상한까지 살펴야 했다.
서버에 접속해 tail, grep, jq로 바로 훑기 어렵고 조회 권한도 따로 맞춰야 한다.
무엇보다 팀원들이 로그를 파일로 남기길 바랐다.
이 요구도 파일 기반 수집을 고른 실무적인 배경이 됐다.
서버에 붙으면 바로 읽을 수 있고, Alloy나 Loki가 멈춘 동안에도 애플리케이션은 로그를 남긴다.
파일 회전과 권한을 직접 관리해야 한다는 대가가 있었고 copytruncate의 짧은 유실 구간도 감수하기로 했다.
Alloy보다 먼저 로그 파일을 만들기
Alloy를 설치하기 전에 읽을 파일부터 만들었다.
애플리케이션은 stdout만 사용하므로 systemd 유닛에 다음 설정을 넣었다.
StandardOutput=append:/var/log/[프로젝트명]/app.log
경로는 배포 트리 밖인 /var/log/[프로젝트명]/app.log로 정했다.
개발 서버 배포가 rsync --delete로 동작하니, 배포 디렉터리 안의 로그는 매번 지워질 수 있었다.
이 디렉터리를 먼저 만들지 않으면 systemd가 출력 파일을 열지 못해 앱 자체가 뜨지 않는다.
회전은 logrotate가 맡았다.
/var/log/[프로젝트명]/app.log {
su <앱 계정> <앱 계정>
daily
maxsize 100M
rotate 14
missingok
notifempty
compress
delaycompress
copytruncate
}
copytruncate는 선택이 아니었다.
StandardOutput=append:에서는 systemd가 연 파일 디스크립터를 프로세스에 넘긴다.
일반적인 mv 방식으로 회전하면 앱은 이름이 사라진 inode에 계속 쓴다.
새 app.log는 비어 버린다.
기존 내용을 복사한 뒤 같은 파일을 비우는 copytruncate를 써야 inode가 유지됐다.
물론 복사와 truncate 사이에 들어온 한두 줄이 빠질 수 있다.
실측 로그가 하루 수십 KB 수준이었고 I/O도 그리 빈번하지 않아 그 정도는 받아들였다.
강제 회전 뒤 새 로그가 같은 inode에 쌓이는지는 따로 확인했다.
받는 쪽인 Loki를 먼저 세우기
받는 쪽부터 세우기로 했다.
Alloy는 발신자라 수신처가 없으면 버퍼만 쌓다가 결국 로그를 잃을 수 있다.
Loki가 push를 받고 다시 조회하는 데까지 확인한 다음 Alloy를 연결했다.
Loki는 단일 인스턴스와 filesystem 저장으로 구성했다.
현재 규모에는 클러스터나 오브젝트 스토리지가 필요하지 않았다.
데이터는 /var/lib/loki에 두고 개발 서버는 30일, 운영은 1년을 보관하도록 했다.
공식 예제 설정은 그대로 쓸 수 없었다.
예제의 /tmp/loki를 사용하면 재부팅이나 임시 파일 정리 때 이력이 사라질 수 있다.
retention과 compactor가 빠져 있어 무인 서버의 디스크 상한도 보장하지 못한다.
폐쇄망에서 의미 없는 외부 사용량 보고 역시 껐다.
보관 설정은 세 값을 한 묶음으로 봤다.
- 앱 로그 파일은 logrotate로 14일 보관한다.
- Loki의
reject_old_samples_max_age는 Alloy가 밀린 파일을 다시 보낼 수 있는 범위를 정한다. retention_period는 Loki에서 실제로 조회할 기간이다.
개발 서버는 수집 거부 기준 14일, 조회 보관 30일이다.
운영에서는 둘 다 1년으로 올렸다.
retention_period만 1년으로 바꾸고 수집 거부 기준을 14일로 남기면 어떻게 될까.
보관 설정은 1년인데 재전송된 14일 이전 로그를 Loki가 거부하는 어긋난 상태가 된다.
compactor.retention_enabled: true도 빠뜨리면 안 된다.
기본값이 false라 retention_period만 적어두면 만료 로그가 삭제되지 않는다.
조회에서 사라지는 시점과 디스크에서 실제로 삭제되는 시점도 compaction_interval과 retention_delete_delay만큼 차이가 난다.
HTTP 포트는 127.0.0.1:3100에만 바인딩했다.
auth_enabled: false는 인증을 끄는 옵션처럼 보이지만 실제로는 멀티테넌시 사용 여부를 정한다.
Loki 자체에 인증 계층이 생기는 것은 아니다.
같은 호스트에 둔 Alloy와 Grafana만 루프백으로 접근하게 하고, LAN에는 로그인을 거치는 Grafana만 열었다.
Alloy에서 파싱할 것과 라벨로 올릴 것
Alloy 파이프라인은 파일 탐색, tail, 가공, 전송의 네 단계다.
| 단계 | 역할 |
|---|---|
local.file_match |
/var/log/[프로젝트명]/app.log를 수집 대상으로 지정 |
loki.source.file |
파일을 tail하고 읽은 위치를 저장 |
loki.process |
JSON 파싱, 타임스탬프 보정, 레벨 변환, 라벨 승격 |
loki.write |
Loki push 엔드포인트로 전송 |
Pino는 레벨을 30, 40, 50 같은 숫자로 기록한다.
대시보드에서 쓸 info, warn, error는 stage.template으로 만들었다.
여기서 한 번 막혔다.
JSON 파싱 결과는 숫자 타입이라 문자열과 바로 비교해도 매칭되지 않았다.
템플릿에서 printf "%v"로 문자열화한 뒤에야 제대로 바뀌었다.
라벨은 job, env, level만 올렸다.
Loki는 로그 본문이 아니라 라벨 조합에 인덱스를 만든다.
로그가 얼마나 많은지만 볼 일이 아니었다.
어떤 값을 라벨로 두는지가 더 중요할 수 있었다.
reqId는 요청마다 값이 달라 라벨로 올리지 않았다.
올리는 순간 요청 수만큼 스트림이 생긴다.
값 집합이 넓은 route, query, msg도 라인 본문에 남기고 필요할 때만 파싱한다.
{job="[프로젝트명]", level="error"}
{job="[프로젝트명]"} | json | reqId="<uuid>"
{job="[프로젝트명]"} | json | durationMs > 500
{job="[프로젝트명]"} | json | kind="audit"
값이 적은 kind는 라벨 후보처럼 보였다.
그래도 본문에 뒀다.
라벨은 수집 시점에만 붙어 이미 들어온 감사 로그에 소급되지 않고, 앱이 새 kind를 추가하더라도 수집 설정에서 값의 집합을 제한할 방법이 없었다.
지금은 | json 조회로 충분하다.
감사 로그만 더 오래 보관할 요구가 생기면 그때 다시 보기로 했다.
설정보다 먼저 막힌 것은 파일 권한이었다
Alloy는 전용 계정으로 실행하지만 앱 로그는 배포 계정 소유다.
설정이 맞아도 Alloy가 파일을 읽지 못하면 한 줄도 전송되지 않는다.
usermod -a -G <앱 계정> alloy
chmod 750 /var/log/[프로젝트명]
chmod 640 /var/log/[프로젝트명]/app.log
함정은 그룹 편입 시점에 있었다.
새 그룹은 이미 떠 있는 프로세스에 반영되지 않는다.
Alloy를 먼저 기동하고 그룹에 넣으면 설정과 서비스 상태가 모두 정상인데도 파일은 읽지 못한다.
설치 스크립트에서는 권한부터 설정했다.
이어서 runuser -u alloy -- test -r로 새 프로세스의 실제 읽기를 확인한 뒤 유닛을 배치했다.
파일 모드도 설치할 때 한 번 chmod하는 것으로 끝나지 않는다.
파일이 다시 만들어지면 umask 기본값으로 돌아가기 때문이다.
로그를 그룹 한정으로 읽게 하려면 앱 유닛의 UMask까지 같은 정책으로 관리해야 한다.
프로세스가 살아 있다는 판정을 버리기
처음 만든 설치 스크립트는 Loki의 /ready, Grafana의 /api/health, Alloy의 is-active를 확인했다.
이것만으로는 부족했다.
Alloy가 파일을 못 읽거나 Loki가 push를 거부해도 스크립트는 성공으로 끝날 수 있었다.
프로세스가 떠 있는 것과 로그가 도달하는 것은 별개였다.
설치 스크립트를 다시 만들면서 성공 기준을 바꿨다.
- 패키지를 설치한다.
- 설정과 유닛을 배치한다.
loki -verify-config,alloy validate로 문법을 확인한다.- Loki → Alloy → Grafana 순서로 기동한다.
- Alloy가 대상 파일을 잡았는지 확인한다.
- Loki에서 최근 로그가 실제로 조회되는지 확인한다.
설치와 기동도 갈랐다.
설치 단계에서 설정 오류를 먼저 거른다.
기동 단계는 서비스 상태와 파이프라인 도달을 확인한다.
같은 버전의 패키지는 건너뛰되 설정과 유닛은 다시 배치하게 해, 몇 번을 실행해도 같은 상태로 끝나도록 했다.
폐쇄망 VM에서 이 기준의 필요성이 바로 드러났다.
Loki, Alloy, Grafana가 모두 active인데 Grafana에는 로그가 하나도 나오지 않았다.
Alloy나 데이터소스가 원인이 아니었다.
앱을 npm run dev로 직접 띄운 것이 문제였다.
stdout을 파일에 적재하는 장치는 systemd 유닛의 StandardOutput=append:뿐이다.
터미널에서 띄운 로그는 터미널로만 흘렀다.
서비스 상태만 봤다면 수집기 쪽을 한참 뒤졌을 것이다.
폐쇄망 설치는 같은 절차를 반복할 수 있어야 했다
첫 버전은 zip 바이너리를 /usr/local/bin에 설치했다.
최종 구성에서는 네 구성요소를 RPM으로 통일했다.
Loki 3.7.4, logcli 3.7.4, Alloy 1.18.0, Grafana 13.1.1의 RPM에 설정·스크립트를 더해 tar.gz 하나로 묶었다.
전체 반입물은 약 415MB였다.
RPM을 택한 이유는 단순했다.
rpm -q로 설치 버전을 바로 확인할 수 있고, 현장에 필요한 해제 도구가 tar, rpm, curl로 줄었다.
Grafana만 RPM이고 나머지는 zip인 식으로 설치 절차가 갈리는 것도 피했다.
버전은 bundle/versions.env 한 곳에서 관리했다.
마지막 검증은 네트워크를 끊은 Rocky Linux 9.8 VM에서 했다.
번들을 옮겨 설치하고 재부팅 후 자동 기동부터 앱 로그가 Grafana에 보이는 지점까지 확인했다.
설치 스크립트는 일부러 여러 번 돌렸다.
이미 설치된 패키지를 건너뛰면서도 설정 검증과 배치는 반복되는지 봐야 했기 때문이다.
용량보다 먼저 봐야 했던 것
개발 서버에서 request completed 한 줄은 228바이트였다.
같은 형태 5,000줄은 gzip으로 압축하니 약 7.5배 줄었다.
페이지를 한 번 열 때 요청 3~4건이 몰리는 실제 패턴으로 다시 계산해도 현재 트래픽에서 저장 용량은 큰 제약이 아니었다.
더 주의해야 했던 것은 라벨 카디널리티였다.
로그가 하루 몇 KB밖에 안 되더라도 reqId를 라벨로 올리면 요청마다 스트림이 생긴다.
디스크에 저장하는 본문 양은 그대로인데 인덱스와 메모리 사용량, 잘게 쪼개진 청크가 문제가 된다.
운영 환경의 기본 로그 레벨을 warn으로 올린 뒤에는 정상 조회 요청도 남지 않는다.
운영 로그 양은 요청 수보다 에러율과 변경 요청 빈도에 가까워졌다.
성공한 변경 요청은 별도 감사 통로로 남겼다.
정상 요청 수와 폴링 주기는 로그가 아니라 Prometheus 같은 메트릭으로 옮길 과제로 남겨뒀다.
마무리
Loki나 Alloy의 설치 명령보다 오래 붙잡은 것은 각 단계의 경계였다.
stdout을 누가 파일로 옮기는지, 회전 뒤에도 같은 파일을 읽는지, 수집 계정이 실제로 읽을 수 있는지, 어떤 필드를 라벨로 올릴지 계속 고민했다.
서비스 세 개가 active라고 구축이 끝난 것은 아니었다.
앱에서 나온 한 줄이 파일에 남고 Alloy를 지나 Loki에서 원래 시각과 필요한 라벨로 조회돼야 비로소 끝이었다.
지금은 설치 성공 여부를 프로세스가 살아있는지가 아니라 로그 한 줄이 도달했는지로 판정한다.
이 기준을 세운 뒤에야 폐쇄망 재설치 절차와 권한 검사, 보관 설정에서 무엇을 확인해야 하는지도 분명해졌다.
댓글남기기