Fluentd OOM — 메모리 버퍼 vs 파일 버퍼

데이터·관측성4분조회 –

로그 파이프라인 중간에 있는 Fluentd 가 며칠에 한 번씩 죽었습니다. 죽어도 재시작되고 나면 Kafka 에서 다시 구독을 이어 가니 Kibana 로는 그 구간만 비어 있는 정도라, 죽었다는 사실을 파드 재시작 횟수로 뒤늦게 알게 되는 상태였습니다.

파이프라인은 이렇게 생겼습니다. 노드마다 뜬 Fluent Bit 이 컨테이너 로그 파일을 읽어 Kafka 로 넣고, Fluentd 가 Kafka 를 구독해서 파싱한 다음 Elasticsearch 로 넣으면, 사람은 그걸 Kibana 로 봅니다.

중간에 Kafka 를 둔 건 Elasticsearch 가 잠깐 느려지거나 재시작해도 로그를 잃지 않으려는 것이었고, Kafka 에 남아 있으니 뒤가 밀리면 그만큼 늦게 가져가면 됩니다.

메모리 버퍼 무제한 증가

설정을 열어 보니 버퍼가 메모리에만 있고 상한이 없었습니다.

@type memory        buffer가 메모리에만 있음
total_limit_size    미설정 → 무제한 증가
resources: {}       메모리 제한 없음 → OOM 대상
volumeClaimTemplates 없음 → 재시작하면 버퍼 유실

Elasticsearch 로 나가는 쪽이 잠깐 느려지면 버퍼가 쌓이고, 상한이 없으니 메모리를 계속 먹다가 결국 OOM 으로 죽습니다. 메모리에만 있는 버퍼라 죽는 순간 쌓여 있던 게 통째로 사라지고 파드 재시작과 로그 유실이 같이 옵니다. 로그를 잃지 않으려고 Kafka 를 중간에 뒀지만 바로 그다음 단계에서 잃고 있었습니다.

파일 버퍼 + PVC + 상한

버퍼를 디스크로 내리고 상한을 걸었습니다.

<buffer tag, app_name>
    @type file
    path /mnt/buffer/es/stdout
    flush_mode interval
    flush_interval 10s
    flush_at_shutdown true
    flush_thread_count 4
    chunk_limit_size 8m
    total_limit_size 5GB
    overflow_action drop_oldest_chunk
    retry_type exponential_backoff
    retry_wait 5s
    retry_max_interval 60s
    retry_timeout 24h
</buffer>

total_limit_size 와 overflow_action 은 짝으로 봐야 합니다. 상한만 걸고 넘쳤을 때 무엇을 할지 안 정하면 기본값으로 예외가 나면서 파이프라인 전체가 멈춥니다. drop_oldest_chunk 는 오래된 것부터 버리는 동작이라 로그는 유실되지만 파이프라인은 계속 돕니다. 여기에 flush_at_shutdown: true 를 붙이면 재시작할 때 남은 버퍼를 비우고 나갑니다.

버퍼가 디스크에 있어야 하니 StatefulSet 에 PVC 를 붙였습니다.

volumeClaimTemplates:
  - metadata:
      name: buffer-storage
    spec:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 30Gi
      storageClassName: gp2

파일 버퍼로 바꿔도 메모리 상한이 없으면 다른 경로로 다시 커질 수 있어서 requests 와 limits 도 같이 박았습니다.

resources:
  requests:
    memory: "512Mi"
    cpu: "100m"
  limits:
    memory: "1Gi"
    cpu: "1000m"

버퍼가 파일에 쌓이니 뒤가 밀려도 메모리는 안 늘어나고, limits 는 그래도 다른 경로로 새는 걸 막으려고 같이 걸었습니다.

배포하면서 막힌 곳들

먼저 StorageClass 이름이 안 맞아서 PVC 가 안 만들어졌습니다. 클러스터마다 이름이 다르니 매니페스트에 적기 전에 뭐가 있는지부터 확인해야 합니다.

kubectl get sc
# gp2 (default), ebs-sc, local-storage

그다음에 걸린 건 Elasticsearch 플러그인이고, Fluentd 는 Ruby 로 만들어져 있어서 플러그인이 gem — npm 이나 pip 의 패키지에 해당하는 Ruby 쪽 패키지 — 으로 배포되고, 설치 명령도 fluent-gem 입니다. 기본 Fluentd 이미지에는 fluent-plugin-elasticsearch 가 안 들어 있어서, 컨테이너가 뜰 때 이 명령으로 설치한 다음 원래 엔트리포인트를 이어받게 했습니다.

command:
  - /bin/sh
  - -c
  - |
    fluent-gem install fluent-plugin-s3
    fluent-gem install fluent-plugin-rewrite-tag-filter
    fluent-gem install fluent-plugin-elasticsearch
    exec /fluentd/entrypoint.sh

플러그인을 넣어 이미지를 새로 빌드하는 게 정석이지만, 우선 돌아가게 해야 해서 파드가 뜰 때마다 설치하는 쪽으로 뒀습니다.

resources 를 넣고 나서는 CPU 가 모자라 스케줄이 안 됐습니다. requests 를 적으면 노드에 그만큼 여유가 있어야 하니 값이 없을 때는 잘 뜨던 파드가 Pending 이 됩니다.

PVC 도 Pending 에서 안 넘어갔고, StatefulSet 의 volumeClaimTemplates 가 immutable 이라 kubectl apply 로는 안 바뀌고, 지우고 다시 만들어야 반영됩니다.

마지막에 걸린 건 Kustomize patch 였습니다. Kustomize 는 원본 YAML 을 복사해 고치는 대신 바꿀 부분만 따로 적어 두고 배포할 때 합쳐 주는데, patch 대상의 이름이나 종류가 한 글자라도 다르면 에러도 없이 조용히 무시됩니다. 적용됐겠거니 하지 말고 결과를 직접 열어 봐야 합니다.

버린 로그를 세지 않고 있음

drop_oldest_chunk 는 버퍼가 5GB 를 넘으면 로그를 버립니다. 파이프라인을 살리려고 고른 동작이지만 지금은 버린 양을 세지 않으니 유실이 나도 알 방법이 없습니다. Fluentd 가 노출하는 버퍼 지표를 Prometheus 로 가져오면 상한에 얼마나 근접했는지와 실제로 버려진 양이 같이 보입니다.

플러그인을 파드가 뜰 때마다 fluent-gem 으로 받아 오는 것도 임시로 둔 겁니다. 네트워크가 안 되면 파드가 아예 못 뜨고 뜰 때마다 설치 시간이 붙으니, 플러그인을 미리 넣어 둔 이미지를 만들어 쓰는 쪽으로 가야 합니다.

  1. 불러오는 중