OKD 노드 디스크를 저널이 다 먹었습니다 — Podman·Quadlet 로그 관리 실전

|Operation Risk|19분 읽기

컨테이너가 안 뜰 때 저는 한동안 LLM 창부터 열었습니다. 에러 메시지를 복붙하고, 그럴듯한 답을 받고, 그 답대로 만지다가 두 시간을 버린 적도 있습니다. 정작 journalctl -u 한 줄이면 첫 화면에 이유가 적혀 있었고요. 답은 거의 항상 이미 로그에 있습니다. 모자란 건 정보가 아니라 로그를 먼저 열어보는 습관 쪽이었습니다.

그런데 그 습관이 붙고 나면 다음 문제가 옵니다. 로그가 너무 잘 남아서 노드가 죽습니다.

새벽 2시 40분, 노드가 밀려났습니다

작년 12월이었습니다. OKD 클러스터 워커 노드 한 대가 NotReady로 빠졌고, 알림에는 DiskPressure가 찍혀 있었습니다. 새벽 2시 40분. 침대에서 노트북을 열고 SSH로 들어가 df -h를 쳤더니 루트 파일시스템이 96%였습니다. 120GB짜리 디스크에서 /var/log/journal 하나가 41GB를 쥐고 있었습니다.

범인은 배포된 앱이 아니었습니다. 노드에서 Quadlet으로 돌리던 내부 수집 데몬 하나가 헬스체크 실패 로그를 초당 수십 줄씩 뱉고 있었고, 그게 journald로 그대로 흘러들어갔습니다. 3주쯤 아무도 안 봤습니다. 보름 전 배포 때 로그 레벨을 debug로 올려놓고 되돌리는 걸 잊었던 게 시작이었습니다. 제가 올렸습니다. 인정합니다.

다음 날 아침에 같이 운영하는 김 책임이 슬랙에 한 줄 남겼습니다. "우리 클러스터는 앱이 아니라 로그 때문에 죽네요." 웃을 상황이 아니었는데 웃었습니다.

OKD 노드는 CoreOS 계열입니다. FCOS에서 SCOS로 베이스가 넘어온 뒤에도 성격은 같습니다. /usr가 읽기 전용이고, 패키지를 마음대로 못 얹고, 노드에서 도는 거의 모든 것이 systemd 유닛이며, 그 유닛들의 출력은 전부 journald 한 군데로 모입니다. 컨테이너 워크로드 로그야 CRI-O가 /var/log/pods 아래에 따로 쓰지만, kubelet·crio·NetworkManager 같은 노드 자체 유닛과 Podman/Quadlet으로 얹은 보조 서비스들은 저널을 공유합니다. 그러니까 저널 보존 정책을 안 잡아둔 OKD 노드는 시한폭탄을 하나 달고 도는 셈입니다. 시한이 언제인지만 모를 뿐입니다.

Podman이 로그를 다루는 방식

구조 자체는 간단합니다. Podman은 컨테이너의 stdout과 stderr 스트림을 잡아서, 로깅 드라이버가 지정한 곳으로 넘깁니다. 어디에 쌓이고 어떻게 꺼내 보는지를 드라이버가 결정합니다. 실무에서 만나는 건 사실상 둘입니다.

드라이버 저장 위치 조회 방법 순환 관리 주체
journald systemd 저널 journalctl -u <unit> journald (전역)
k8s-file 디스크 파일 podman logs, 파일 직접 읽기 Podman (컨테이너별)

journald를 고르면 컨테이너 로그가 systemd가 관리하는 로깅 서비스로 바로 갑니다. OS의 나머지 로그와 같은 인프라에 얹히니까, 커널 메시지·네트워크 이벤트·컨테이너 출력을 한 타임라인 위에서 같이 봅니다. 장애 원인이 컨테이너 안이 아니라 노드 쪽에 있을 때 이게 시간을 줄여줍니다. 새벽에 화면 두 개를 왔다갔다하지 않아도 되거든요.

k8s-file은 디스크 파일에 씁니다. Kubernetes의 컨테이너 로깅과 비슷한 형식이라, 기존 파일 기반 도구로 그대로 읽고 처리합니다. 그냥 텍스트 파일이니까 grep도 awk도 붙습니다.

실행할 때 지정하는 건 한 줄입니다.

podman run --log-driver journald nginx

저는 노드에 상주하는 서비스면 journald 쪽으로 붙입니다. 일회성으로 돌려보는 컨테이너나, 로그를 파일째로 어딘가 넘겨야 하는 경우에만 k8s-file을 씁니다. 이건 취향이 좀 섞인 판단이라 반대로 하는 팀도 봤습니다.

Quadlet이면 로깅도 서비스 정의 안으로

Quadlet은 컨테이너를 systemd 유닛으로 관리하는 방식입니다. 그러니까 로깅 설정도 명령줄이 아니라 유닛 파일 안에 들어갑니다.

[Container]
Image=nginx:latest
LogDriver=journald

장기 실행 서비스에는 이쪽이 훨씬 낫습니다. 로깅 동작이 서비스 정의의 일부가 되니까, 누가 어떤 옵션으로 띄웠는지 추적할 일이 없어집니다. 조회도 다른 유닛과 완전히 같은 명령입니다.

journalctl -u my-container.service

컨테이너가 이미 systemd 서비스이기 때문에 이 통합에 이음매가 없습니다. 여기까지는 좋습니다. 문제는 저널 자체입니다. 로그 순환의 책임이 Podman에서 systemd로 넘어갔다는 말은, Podman 쪽 옵션을 아무리 잘 잡아도 저널 정책이 비어 있으면 소용이 없다는 뜻이기도 합니다. 저는 이걸 41GB를 보고 나서 이해했습니다.

광고

파일 드라이버를 쓴다면 고삐부터

k8s-file은 컨테이너별로 파일 크기와 개수를 직접 묶습니다.

podman run -d \
  --log-driver=k8s-file \
  --log-opt max-size=10mb \
  --log-opt max-file=3 \
  nginx

최대 10MB짜리 파일 3개를 유지하고, 넘어가면 가장 오래된 파일을 지웁니다. 컨테이너 하나가 30MB를 넘기지 못한다는 상한이 생기는 겁니다. 컨테이너가 수십 개면 이 상한을 곱해서 노드 디스크와 비교해보는 계산을 한 번은 해두는 게 좋습니다. 저희는 그 계산을 사고 이후에 처음 했습니다.

저널 보존 설정 — 여기가 진짜입니다

/etc/systemd/journald.conf에서 저널이 얼마나 자랄지 묶습니다. 손대는 키는 네 개면 충분합니다.

키 하는 일 예시
SystemMaxUse 저널이 쓸 최대 디스크 공간 SystemMaxUse=2G
SystemKeepFree 파일시스템에 남겨둘 최소 여유 공간 SystemKeepFree=10G
MaxRetentionSec 로그 최대 보존 기간 MaxRetentionSec=1month
MaxFileSec 새 저널 파일로 넘어가는 주기 MaxFileSec=1week

흔히 도는 구성 예시는 이런 모양입니다.

[Journal]
Storage=persistent
Compress=yes
SystemMaxUse=1G
SystemKeepFree=100G
MaxRetentionSec=2week
RateLimitIntervalSec=30s
RateLimitBurst=10000

이 예시를 그대로 복붙하기 전에 SystemKeepFree=100G를 한 번 보시길 권합니다. 여유 공간을 100GB 남기라는 뜻인데, 제가 사고 낸 그 노드는 디스크 전체가 120GB였습니다. 이 값을 그대로 넣었으면 저널이 사실상 아무것도 못 쓰고 계속 잘려나갔을 겁니다. 로그가 안 쌓이는 노드는 디스크는 안 차지만 장애 때 아무것도 못 봅니다. 그건 그것대로 사고입니다. 노드 디스크의 10~20% 선에서 잡는 게 무난했습니다.

적용은 재시작 한 번, 확인은 명령어 하나입니다.

sudo systemctl restart systemd-journald
journalctl --disk-usage

마음에 드는 지점이 하나 있습니다. journald가 순환이나 용량 부족으로 로그를 지우면 그 사실도 journald 서비스 로그에 남습니다. 로그를 지웠다는 로그가 남는 겁니다. "3주치 있어야 할 로그가 왜 4일치밖에 없지"를 추적할 때 이 기록이 답을 줍니다. 가장 오래된 항목이 언제인지 보려면 이렇게 봅니다.

journalctl | head -n 1

CoreOS에서는 이 파일을 직접 못 고칩니다

OKD 노드에서 SSH로 들어가 journald.conf를 vi로 고치는 건 임시방편입니다. 노드가 재부팅되거나 MCO(Machine Config Operator)가 설정을 되감으면 사라집니다. 노드가 열 대면 열 번 해야 하고, 오토스케일로 새로 뜬 노드에는 당연히 없습니다.

MachineConfig로 drop-in 파일을 배포하는 게 맞습니다. Butane 기준으로 이런 모양입니다.

variant: openshift
version: 4.16.0
metadata:
  name: 50-worker-journald-retention
  labels:
    machineconfiguration.openshift.io/role: worker
storage:
  files:
    - path: /etc/systemd/journald.conf.d/10-retention.conf
      mode: 0644
      overwrite: true
      contents:
        inline: |
          [Journal]
          Storage=persistent
          Compress=yes
          SystemMaxUse=4G
          SystemKeepFree=10G
          MaxRetentionSec=2week
          MaxFileSec=1week
          RateLimitIntervalSec=30s
          RateLimitBurst=10000

version은 클러스터 버전에 맞춰 바꿔 넣으시고요. 적용되면 MCO가 롤링으로 노드를 재부팅합니다. 워커 열 대짜리 클러스터에서 이 작업 하는 데 40분쯤 걸렸습니다. 새 노드가 뜰 때 이 설정이 자동으로 따라온다는 게 이 방식의 값입니다. 사람 손을 거치는 설정은 언제든 빠집니다. 제 손이 특히 그렇습니다.

광고
광고

사용자 저널이라는 사각지대

Quadlet은 rootless로, 사용자 서비스로 돌리는 경우가 꽤 많습니다. 이때 로그는 시스템 저널이 아니라 사용자 저널에 쌓입니다. journalctl --disk-usage만 보고 "괜찮네" 하고 넘어가면 놓치는 지점이 여기입니다. 저는 이걸 두 번 놓쳤습니다.

정리는 두 줄입니다.

journalctl --user --vacuum-time=7d
journalctl --user --vacuum-size=200M

앞은 7일보다 오래된 항목을, 뒤는 200MB를 넘는 만큼을 오래된 순으로 걷어냅니다. 사용자 서비스로 뭔가를 돌리고 있다면 이 두 줄을 타이머에 걸어두는 편이 낫습니다.

유닛 상태를 아침에 훑는 습관

Quadlet 컨테이너는 systemd 서비스라서, 상태 점검이 특별할 게 없습니다. systemctl list-units로 podman/quadlet 관련 유닛을 뽑고, 각 유닛에 대해 최근 24시간의 오류 등급 로그 수를 세면 됩니다.

for unit in $(systemctl list-units --plain --no-legend '*.service' \
  | awk '/podman|quadlet/ {print $1}'); do
  state=$(systemctl is-active "$unit")
  errs=$(journalctl -u "$unit" --since -24h -p 3..0 --no-pager | wc -l)
  printf '%-40s %-10s errors:%s\n' "$unit" "$state" "$errs"
done

-p 3..0이 emerg부터 err까지를 잡습니다. 저는 이걸 매일 아침 8시에 돌려서 슬랙 채널에 던지게 해뒀습니다. 화려한 대시보드보다 이 열 줄짜리 출력이 더 자주 문제를 먼저 알려줬습니다. 어떤 유닛의 에러 카운트가 어제 3이었는데 오늘 900이면, 그 숫자만으로 오전이 결정됩니다.

Vector로 밖으로 빼기

노드 한 대면 journalctl로 버팁니다. 노드가 열 대를 넘어가고 컨테이너가 수십 개가 되면, 어느 노드에 붙어야 할지부터 고민하게 됩니다. 그 시점이 중앙화를 붙일 때입니다.

Vector가 journald와 붙는 궁합이 좋습니다. 파이프라인은 이렇게 흐릅니다. 컨테이너에서 나온 출력이 journald로 들어가고, Vector가 저널을 읽어 가공한 뒤, OpenObserve 같은 로깅 플랫폼으로 밀어 넣습니다.

sources:
  journal_logs:
    type: "journald"
    include_units:
      - "my-quadlet-service.service"

transforms:
  parse_logs:
    type: "remap"
    inputs: ["journal_logs"]
    source: |
      .message = parse_json(.message) ?? .message
      .container_name = .CONTAINER_NAME

sinks:
  openobserve_out:
    type: "http"
    inputs: ["parse_logs"]
    uri: "https://your-openobserve/api/default/ingest/_json"
    method: "post"
    auth:
      strategy: "basic"
      user: "${OPENOBSERVE_USER}"
      password: "${OPENOBSERVE_PASSWORD}"
    encoding:
      codec: "json"

include_units로 읽을 유닛을 좁히는 게 첫 번째 방어선입니다. 노드 전체 저널을 통째로 밀어 보내면 중앙 플랫폼 쪽 비용이 먼저 터집니다. 저는 처음에 필터를 안 걸고 하루 돌렸다가 수집량 그래프를 보고 바로 껐습니다.

전처리를 앞단에서 끝낼 수 있다는 게 Vector의 값입니다. 컨테이너명과 타임스탬프를 필드로 뽑고, 200번대 상태코드는 버리고, 헬스체크 요청 같은 잡음은 아예 흘려보내지 않습니다. 하위 시스템이 받는 양이 줄고, 저장 비용도 줄고, 검색도 빨라집니다. 로그 파이프라인은 leak처럼 새는 지점이 항상 앞단에 있습니다.

로그가 방어선이 되는 지점

여기서 한 걸음 더 나갑니다. 이건 좀 다른 얘긴데, 저희 쪽에서 실제로 재미를 본 부분이라 적어둡니다.

로그를 정규식으로 긁어서 뭔가를 판단하는 스크립트를 다들 하나쯤 가지고 계실 겁니다. 그게 점점 커지면 아무도 못 고치는 물건이 됩니다. Vector에서 한 번 파싱해 구조화된 JSON으로 넘겨버리면, 뒷단의 Rust나 Go, Python 앱은 그냥 필드를 읽습니다. 정규식 지옥이 파이프라인 앞단으로 한 번 옮겨가고 거기서 끝납니다.

그 구조 위에서는 같은 IP가 30초 안에 몇 번 두드렸는지 집계하고, 의심스러운 접근을 잡아 nftables나 방화벽 API로 자동 차단하는 흐름을 붙이는 게 어렵지 않습니다. Traefik을 리버스 프록시로 쓰고 있다면 /wp-login 같은 엔드포인트에 반복적으로 들어오는 요청은 미들웨어 규칙으로 프록시 단에서 바로 끊어냅니다. WordPress를 쓰지도 않는 서버에 /wp-login.php 요청이 하루 4천 건씩 들어오던 걸 보고 규칙을 넣었는데, 그날 이후 로그가 조용해졌습니다. 로그를 보다가 방화벽 규칙이 나온 겁니다.

광고

그래서 지금은

노드 저널 보존 정책은 MachineConfig에 들어가 있고, 사용자 저널 vacuum은 타이머에 걸려 있고, 아침 8시에 유닛 에러 카운트가 슬랙에 뜹니다. 그 사고 이후로 DiskPressure 알림은 안 왔습니다.

다만 SystemMaxUse=4G가 맞는 숫자인지는 아직 잘 모르겠습니다. 큰 장애가 한 번 나서 노드가 로그를 쏟아내면 4GB는 몇 시간이면 찹니다. 정작 원인을 봐야 할 시간대의 로그가 잘려나갈 겁니다. 올리자니 디스크가 아깝고, 두자니 그 상황이 그려집니다. 아직 답을 못 냈습니다.

로그를 먼저 보는 습관 얘기로 시작했는데, 그 습관을 지키려면 로그가 거기 남아 있어야 한다는 게 이번에 남은 부분입니다. 지우는 정책과 남기는 정책 사이 어딘가에 적당한 지점이 있을 텐데, 저는 아직 그 지점을 못 찾았습니다.

이 글이 도움이 되셨나요?

버튼 하나가 다음 글을 쓰는 힘이 됩니다

광고
#Podman#journald#Quadlet#OKD#CoreOS