누가 Kubernetes 클러스터에 있는 나의 사랑스러운 Prometheus 컨테이너를 죽였나!

안녕하세요. 이번 글에서는 CrashLoopBackoff 상태에 있는 Prometheus 컨테이너 이슈의 원인을 조사하고 해결하는 과정에서 겪은 흥미로운 경험을 공유하려고 합니다. 사실 이런 현상이 발생하는 원인과 해결책은 너무 뻔하고 간단해서 굳이 이런 주제를 다루는 데 시간을 투자할 가치가 있나는 질문을 던질 수도 있습니다. 하지만 저와 마찬가지로, 이 문제를 조사하는 과정의 각 단계를 자세히 이해하고자 하는 독자가 있을 거라고 생각하며 시작하겠습니다. 이 글을 읽으면서 얻을 수 있는 것들을 아래와 같이 정리해 봤습니다.

  1. Prometheus와 Kubernetes, 컨테이너 런타임, OS 커널 등에 대한 새로운 지식을 습득합니다.
  2. LINE Verda 팀의 인프라 엔지니어처럼 문제를 해결하는 방법을 터득합니다.
  3. 지금 맡고 있는 업무를 도와줄 수 있는 방법론을 얻을 수 있습니다(기술은 뒤처질 수 있지만 방법론은 그렇지 않습니다).

 

범죄 현장 – Prometheus의 현재 상태는?

어느 월요일, 출근해서 Kubernetes 클러스터에서 발생한 경고는 없었는지 확인하기 위해 Prometheus 대시보드를 열었습니다. 하지만 서버가 다운됐는지 Prometheus 대시보드에 접근할 수 없었습니다. 그래서 Prometheus에서 컨테이너 상태부터 시작해 총 세 부분을 살펴보았습니다.

먼저 Prometheus 서버 컨테이너의 상태를 확인하니 CrashLoopBackOff 상태인 것으로 보였습니다.

> kubectl -n Prometheus get pods -o wide | grep Prometheus
cluster-monitoring-Prometheus-d56bb6d46-tzmcj     1/2     CrashLoopBackOff   950      3d20h   <IP>  worker3


> kubectl -n Prometheus describe pods cluster-monitoring-Prometheus-d56bb6d46-tzmcj
...
Containers:
  Prometheus-configmap-reload:
    Image:         jimmidyson/configmap-reload:v0.1
    Args:
      --volume-dir=/etc/config
      --webhook-url=http://localhost:9090/-/reload
    State:          Running
      Started:      Wed, 01 Apr 2020 08:15:52 +0000
    Ready:          True
    Restart Count:  0
    Limits:
      cpu:     200m
      memory:  100Mi
    Requests:
      cpu:        100m
      memory:     50Mi
  Prometheus:
    Image:         prom/Prometheus:v2.14.0
    Args:
      --config.file=/etc/config/Prometheus.yml
      --storage.tsdb.path=/data
      --web.console.libraries=/etc/Prometheus/console_libraries
      --web.console.templates=/etc/Prometheus/consoles
      --web.enable-lifecycle
      --storage.tsdb.retention.size=25GB
    State:          Waiting
      Reason:       CrashLoopBackOff
    Last State:     Terminated
      Reason:       Error
      Exit Code:    137
    Ready:          False
    Restart Count:  20

CrashLoopBackOff 상태란 파드(pod)가 시작과 비정상 종료를 연속해서 반복하는 상태를 말합니다. 종료 코드(exit code)는 137이었습니다. 컨테이너 상태를 확인한 뒤 서버 공간이 충분한 지 확인했습니다. 과거 경험에 비추어 볼 때, Prometheus가 대상에서 읽어오는 수집 지표(metrics)들의 크기가 가용 디스크 공간을 초과해서 비정상 종료되는 경우가 종종 있었습니다. Prometheus 파드의 PVC를 기준으로 저장 공간의 사용량을 확인했습니다.

>  kubectl -n vks-system get pvc -o wide | grep Prometheus
cluster-monitoring-Prometheus-storage     Bound    pvc-384dacb8-3e75-11ea-a71c-fa163f8730b0   30Gi       RWO            cinder-ssd     71d


# log into the VM host where the Prometheus container was deployed
> df  -H | grep pvc
/dev/vdd         32G   11G   21G  35% /var/lib/kubelet/plugins/kubernetes.io/csi/pv/pvc-384dacb8-3e75-11ea-a71c-fa163f8730b0/globalmount

확인 결과 Prometheus 서버 컨테이너의 디스크 공간은 충분했습니다. 그래서 마지막으로 서비스의 문제를 직접 확인할 수 있는 로그를 찾아보았습니다.

> kubectl -n vks-system logs cluster-monitoring-Prometheus-d56bb6d46-tzmcj -f -c Prometheus
level=info ts=2020-04-05T04:53:16.664Z caller=web.go:496 component=web msg="Start listening for connections" address=0.0.0.0:9090
level=info ts=2020-04-05T04:53:16.664Z caller=main.go:657 msg="Starting TSDB ..."
level=info ts=2020-04-05T04:53:16.793Z caller=head.go:535 component=tsdb msg="replaying WAL, this may take awhile"
level=info ts=2020-04-05T04:53:22.395Z caller=head.go:583 component=tsdb msg="WAL segment loaded" segment=0 maxSegment=12

특별히 눈에 띄는 에러 메시지는 없었습니다. Prometheus 서버는 비정상 종료되기 전까지 아무런 문제 없이 작동했고, 비정상 종료 전 WAL 파일 데이터를 메모리 공간으로 리플레이하고 있었습니다.

 

사건 조사

더욱 깊이 조사하기 전에 해당 클러스터와 Prometheus의 기초 정보를 확인할 필요가 있습니다.

  • 클러스터 정보: 클러스터 버전은 1.13.5입니다. 235개의 노드와 7개의 컨트롤 플레인(control plane) 노드, 5개의 etcd 노드 및 워커 노드로 구성되었습니다. 모든 노드는 VM 노드입니다.
  • 호스트 정보: Prometheus 서버가 배포된 호스트는 vCPU 4개와 8GB의 메모리, 100GB SSD로 구성되었습니다. 모든 호스트는 스와프(swap) 기능을 중단한 상태입니다.
  • Prometheus 정보: Prometheus 이미지 버전은 2.14.0이고 storage.tsdb.retention.size 파라미터를 25GB로 설정했습니다.

 

증거 1: CrashLoopBackOff

Prometheus 컨테이너가 CrashLoopBackOff 상태로 발견되었으니 CrashLoopBackOff에 대해서 먼저 알아보도록 합시다. 앞에서 간략히 설명한 것처럼 CrashLoopBackOff는 파드가 비정상 종료와 재시작을 반복하는 상태이고, Prometheus 파드의 restartPolicy 필드와 연관이 있습니다. 일반적으로 restartPolicy 필드에는 다음 세 가지 값이 들어갈 수 있습니다.

  • Always
  • OnFailure
  • Never

restartPolicyAlways로 설정되어 있었다면 종료 코드가 0이든 혹은 다른 코드이든 상관없이 컨테이너가 종료되면 kubelet은 항상 컨테이너를 재시작합니다. pod.Spec의 restartPolicy는 파드의 모든 컨테이너에 적용됩니다.

 

Prometheus 파드가 비정상 종료된 원인

kubectl -n prometheus describe pod cluster-monitoring-Prometheus-d56bb6d46-tzmcj 커맨드로 Prometheus 서버 컨테이너가 에러 코드 137을 남기고 비정상 종료된 것을 알 수 있습니다. 종료 코드 137은 컨테이너 프로세스가 SIGKILL 신호를 수신했을 때 발생하며, 이는 OS 커널이 컨테이너를 강제 종료시켰다는 것을 의미합니다. 다음 세 가지 경우에 SIGKILL 신호가 발생할 수 있습니다.

  • 수동으로 신호 발생: 컨테이너 프로세스를 위해 호스트에서 kill 커맨드를 실행하는 경우(컨테이너는 OS 프로세스의 일종이기 때문에 OS 프로세스의 모든 커맨드를 컨테이너에 적용 가능)
  • 컨테이너 런타임에서 신호 발생: 특정 컨테이너에 docker kill 커맨드를 실행하는 경우
  • OS 커널에서 특정 프로세스가 다른 프로세스에 영향을 줄 수 있다고 판단하여 다른 프로세스를 보호하기 위해 해당 프로세스를 종료시킨 경우

Prometheus 컨테이너를 종료시킬 수 있는 호스트에 루트 권한으로 로그인할 수 있는 사람이 저 밖에 없는 상황에서 누군가 수동으로 Prometheus 컨테이너를 종료하는 가정인 첫 번째 방법은 불가능하기 때문에 바로 탈락시켰습니다. 아무도 Prometheus 컨테이너에 kill 커맨드를 실행하지 않았기 때문에 컨테이너 런타임 때문에 종료되었을 가능성이 있습니다. Kubernetes 클러스터에서는 kubelet이 컨테이너 런타임 인터페이스를 호출하면서 호스트의 컨테이너를 관리합니다. 그러니 Prometheus 서버 컨테이너가 배포된 호스트의 kubelet 로그를 살펴보겠습니다.

...
[prober.go:111]Readiness probe for "cluster-monitoring-Prometheus-d56bb6d46-tzmcj:Prometheus" failed (failure): HTTP probe failed with statuscode: 503

[oom_watcher.go:68] Got sys oom event from cadvisor

[prober.go:111] Liveness probe for "cluster-monitoring-Prometheus-d56bb6d46-tzmcj:Prometheus" failed (failure): Get http://IP:9090/-/healthy: read: connection reset by peer

[kubelet.go:1953] SyncLoop (PLEG): "cluster-monitoring-Prometheus-d56bb6d46-tzmcj", event: \u0026pleg.PodLifecycleEvent{ID:"b051e8a5-4dff-11ea-a71c-fa163f8730b0", Type:"ContainerDied", Data:"4da915ce720e18b45d9828116e61988c997a72b2a168b66dbc381b2f0474c86d"}
...

Prometheus 파드가 생성된 후 Readiness probe를 통과하지 못해 Prometheus의 엔드 포인트가 제거되었고, kubelet은 cadvisor에서 보낸 ‘An OOM event was triggered’라는 메시지를 받았습니다. 이후 Prometheus 서버 컨테이너가 다운된 것으로 판단한 kubelet은 Always로 설정된 restartPolicy에 따라 Prometheus 컨테이너를 재시작합니다. 이 로그를 통해 kubelet이 자발적으로 Prometheus 컨테이너를 종료시키진 않았다고 가정할 수 있습니다. Prometheus가 OOM 이벤트를 발생시킨 것으로 보이고, 그렇다면 OS 커널이 직접 Prometheus 컨테이너를 종료시켰을 거라고 추측할 수 있습니다.

이 가설을 검증하기 전에 cAdvisor가 무엇이고 OOM 이벤트는 어떤 식으로 모니터링하는지를 먼저 이해할 필요가 있습니다. cAdvisor는 kubelet 바이너리에 통합된 오픈 소스 에이전트로 자원 사용량을 모니터링하고 컨테이너의 성능을 분석합니다. 특정 노드(파드 레벨에서는 실행되지 않음)에서 실행되고 있는 모든 컨테이너의 CPU와 메모리, 파일, 네트워크 사용량 등의 지표를 수집하고 그 외에 시스템 이벤트도 모니터링합니다. kubelet 코드의 몇 부분을 읽어보니 cAdvisor는 kubelet.Dependencies의 CAdvisorInterface라는 이름의 필드로 만들어져 있었습니다. kubelet.Dependencies는 kubelet을 지원하는 다양한 툴의 집합이라고 생각하면 됩니다.

// Detailed Implementation: https://sourcegraph.com/github.com/kubernetes/kubernetes@v1.13.5/-/blob/cmd/kubelet/app/server.go#L648
if kubeDeps.CAdvisorInterface == nil {
    imageFsInfoProvider := cadvisor.NewImageFsInfoProvider(s.ContainerRuntime, s.RemoteRuntimeEndpoint)
    kubeDeps.CAdvisorInterface, err = cadvisor.New(imageFsInfoProvider, s.RootDirectory, cgroupRoots, cadvisor.UsingLegacyCadvisorStats(s.ContainerRuntime, s.RemoteRuntimeEndpoint))
    if err != nil {
      return err
    }
}

kubelet이 시작되고 나서 cAdvisor의 Start() 메서드가 실행됐습니다. cAdvisor.Start()의 코드를 보면 Manager 타입인 manager 객체의 Start() 메서드를 실행하고 있습니다. 이 객체는 cAdvisor가 초기화될 때 생성되었습니다.

func (kl *kubelet) initializeRuntimeDependentModules() {
  if err := kl.cadvisor.Start(); err != nil {
    // Fail kubelet and rely on the babysitter to retry starting kubelet.
    // TODO(random-liu): Add backoff logic in the babysitter
    klog.Fatalf("Failed to start cAdvisor %v", err)
  }
  ...
}

func (cc *cadvisorClient) Start() error {
  return cc.Manager.Start()
}

// New creates a cAdvisor and exports its API on the specified port if port > 0.
func New(imageFsInfoProvider ImageFsInfoProvider, rootPath string, usingLegacyStats bool) (Interface, error) {
...
  // Create and start the cAdvisor container manager.
  m, err := manager.New(memory.New(statsCacheDuration, nil), sysFs, maxHousekeepingInterval, allowDynamicHousekeeping, includedMetrics, http.DefaultClient, rawContainerCgroupPathPrefixWhiteList)
...
}

type manager struct {
  ...
  cadvisorContainer        string
  eventHandler             events.EventManager
  containerWatchers        []watcher.ContainerWatcher
  eventsChannel            chan watcher.ContainerEvent
  ...
}

// Start the container manager.
func (self *manager) Start() error {
  ...
  // Watch for OOMs.
  err = self.watchForNewOoms()
  if err != nil {
    klog.Warningf("Could not configure a source for OOM detection, disabling OOM events: %v", err)
  }
  ...
}

func (self *manager) watchForNewOoms() error {
  klog.V(2).Infof("Started watching for new ooms in manager")
  outStream := make(chan *oomparser.OomInstance, 10)
  oomLog, err := oomparser.New()
  if err != nil {
    return err
  }
  go oomLog.StreamOoms(outStream)

  go func() {
    for oomInstance := range outStream {
      // Surface OOM and OOM kill events.
      newEvent := &info.Event{
        ContainerName: oomInstance.ContainerName,
        Timestamp:     oomInstance.TimeOfDeath,
        EventType:     info.EventOom,
      }
      err := self.eventHandler.AddEvent(newEvent)
      if err != nil {
        klog.Errorf("failed to add OOM event for %q: %v", oomInstance.ContainerName, err)
      }
      klog.V(3).Infof("Created an OOM event in container %q at %v", oomInstance.ContainerName, oomInstance.TimeOfDeath)

      newEvent = &info.Event{
        ContainerName: oomInstance.VictimContainerName,
        Timestamp:     oomInstance.TimeOfDeath,
        EventType:     info.EventOomKill,
        EventData: info.EventData{
          OomKill: &info.OomKillEventData{
            Pid:         oomInstance.Pid,
            ProcessName: oomInstance.ProcessName,
          },
        },
      }
      err = self.eventHandler.AddEvent(newEvent)
      if err != nil {
        klog.Errorf("failed to add OOM kill event for %q: %v", oomInstance.ContainerName, err)
      }
    }
  }()
  return nil
}

manager.Start()에서 watchForNewOoms 메서드를 호출하는 건 다음 코드인 err := oomparser.New()에서 oomparser라는 객체를 생성했기 때문입니다.oomparserkmsgparser라고 부르는 아주 중요한 객체를 감싸고 있습니다. kmsg는 무엇일까요? kmsq는 리눅스 OS의 로깅 메커니즘과 연관이 있습니다. 일반적으로 리눅스 커널은 메모리 버퍼로 로그를 출력합니다. 사용자 애플리케이션에서는 이 로그에 다음 두 가지 방법으로 접근할 수 있습니다.

  • /proc/kmsg: 작동 원리가 FIFO와 유사해서 한 번 로그를 읽으면 사라져 버리기 때문에 여러 클라언트가 읽을 수 없습니다.
  • /dev/kmsg: 여러 프로세스에서 서로에게 영향을 주지 않고 동일한 데이터 스트림을 읽을 수 있으며, 쓰기 접근을 하면 커널이 생성한 것처럼 커널의 로그 스트림에 메시지를 삽입할 수 있습니다.

아래는 두 번째 방법으로 로그에 접근한 코드입니다.

func NewParser() (Parser, error) {
    f, err := os.Open("/dev/kmsg")
    if err != nil {
        return nil, err
    }
...
}

func (p *parser) Parse() <-chan Message {
 
    output := make(chan Message, 1)
 
    go func() {
        defer close(output)
        msg := make([]byte, 8192)
        for {
            // Each read call gives us one full message.
            // https://www.kernel.org/doc/Documentation/ABI/testing/dev-kmsg
            n, err := p.kmsgReader.Read(msg)
            if err != nil {
                if err == syscall.EPIPE {
                    p.log.Warningf("short read from kmsg; skipping")
                    continue
                }
 
                if err == io.EOF {
                    p.log.Infof("kmsg reader closed, shutting down")
                    return
                }
 
                p.log.Errorf("error reading /dev/kmsg: %v", err)
                return
            }
 
            msgStr := string(msg[:n])
 
            message, err := p.parseMessage(msgStr)
            if err != nil {
                p.log.Warningf("unable to parse kmsg message %q: %v", msgStr, err)
                continue
            }
 
            output <- message
        }
    }()
 
    return output
}

kmsgparser 객체가 생성된 후 cAdvisor.manager는 oomLog.StreamOoms(outStream) 메서드를 실행합니다. outStream은 채널 객체로, kmsgparser는 시스템 로그를 받으면 outStream으로 보냅니다.

// StreamOoms writes to a provided a stream of OomInstance objects representing
// OOM events that are found in the logs.
// It will block and should be called from a goroutine.
func (self *OomParser) StreamOoms(outStream chan<- *OomInstance) {
  kmsgEntries := self.parser.Parse()
  defer self.parser.Close()

  for msg := range kmsgEntries {
    in_oom_kernel_log := checkIfStartOfOomMessages(msg.Message)
    if in_oom_kernel_log {
      oomCurrentInstance := &OomInstance{
        ContainerName: "/",
        TimeOfDeath:   msg.Timestamp,
      }
      for msg := range kmsgEntries {
        err := getContainerName(msg.Message, oomCurrentInstance)
        if err != nil {
          klog.Errorf("%v", err)
        }
        finished, err := getProcessNamePid(msg.Message, oomCurrentInstance)
        if err != nil {
          klog.Errorf("%v", err)
        }
        if finished {
          oomCurrentInstance.TimeOfDeath = msg.Timestamp
          break
        }
      }
      outStream <- oomCurrentInstance
    }
  }
  // Should not happen
  klog.Errorf("exiting analyzeLines. OOM events will not be reported.")
}

outStream에서 OOM 로그를 읽는 동안 watchForNewOoms은 다음 두 가지 이벤트를 생성합니다.

  • OOMEvent
  • OOMKillEvent 

두 가지 이벤트 모두 eventHandlerAddEvent 메서드를 호출하면 추가됩니다. eventHandlerNewEventManager라는 함수가 생성합니다.

// returns a pointer to an initialized Events object.
func NewEventManager(storagePolicy StoragePolicy) *events {
  return &events{
    eventStore:    make(map[info.EventType]*utils.TimedStore, 0),
    watchers:      make(map[int]*watch),
    storagePolicy: storagePolicy,
  }
}


// method of Events object that adds the argument Event object to the
// eventStore. It also feeds the event to a set of watch channels
// held by the manager if it satisfies the request keys of the channels
func (self *events) AddEvent(e *info.Event) error {
    self.updateEventStore(e)
    self.watcherLock.RLock()
    defer self.watcherLock.RUnlock()
    watchesToSend := self.findValidWatchers(e)
    for _, watchObject := range watchesToSend {
        watchObject.eventChannel.GetChannel() <- e
    }
    klog.V(4).Infof("Added event %v", e)
    return nil
}
 
 
func (self *events) findValidWatchers(e *info.Event) []*watch {
    watchesToSend := make([]*watch, 0)
    for _, watcher := range self.watchers {
        watchRequest := watcher.request
        if checkIfEventSatisfiesRequest(watchRequest, e) {
            watchesToSend = append(watchesToSend, watcher)
        }
    }
    return watchesToSend
}

AddEvent 함수는 다음 두 가지 동작을 수행합니다.

  • Map 유형의 eventStore에 이벤트를 저장합니다.
  • 해당 종류의 이벤트를 관찰하는 watcher 중 가용 가능한 watcher를 찾아서 해당 이벤트 채널로 이벤트를 전송합니다.

findValidWatchers 함수는 현재 이벤트와 동일한 종류의 이벤트를 보고 있는 watcher를 찾는 함수입니다. 해당 watcher를 찾으면 이벤트를 보냅니다. 아래 watcher의 정의에 따르면 모든 watcher는 WatchEvent 함수를 호출해서 초기화해야 합니다. watcher는 eventHandler의 watchers라는 맵에 등록됩니다.

// initialized by a call to WatchEvents(), a watch struct will then be added
// to the events slice of *watch objects. When AddEvent() finds an event that
// satisfies the request parameter of a watch object in events.watchers,
// it will send that event out over the watch object's channel. The caller that
// called WatchEvents will receive the event over the channel provided to
// WatchEvents
type watch struct {
    // request parameters passed in by the caller of WatchEvents()
    request *Request
    // a channel used to send event back to the caller.
    eventChannel *EventChannel
}

여기까지 시스템 로그에서부터 모든 이벤트를 저장하는 이벤트 버퍼까지 OOM 이벤트를 모니터링하는 프로세스의 절반을 살펴봤습니다. 이제 cAdvisor가 watcher 객체를 어떻게 watcher 맵에 등록하는지 알아봅시다. kubelet 로그가 다시 한 번 힌트를 제공합니다.

[oom_watcher.go:68] Got sys oom event from cadvisor

cAdvisoroom_watcher.go 파일에 구현되어 있는 oom_watcher라는 watcher 인스턴스를 생성한 것으로 보입니다.

// NewOOMWatcher creates and initializes a OOMWatcher based on parameters.
func NewOOMWatcher(cadvisor cadvisor.Interface, recorder record.EventRecorder) OOMWatcher {
  return &realOOMWatcher{
    cadvisor: cadvisor,
    recorder: recorder,
  }
}

const systemOOMEvent = "SystemOOM"

// Watches cadvisor for system oom's and records an event for every system oom encountered.
func (ow *realOOMWatcher) Start(ref *v1.ObjectReference) error {
  request := events.Request{
    EventType: map[cadvisorapi.EventType]bool{
      cadvisorapi.EventOom: true,
    },
    ContainerName:        "/",
    IncludeSubcontainers: false,
  }
  eventChannel, err := ow.cadvisor.WatchEvents(&request)
  if err != nil {
    return err
  }

  go func() {
    defer runtime.HandleCrash()

    for event := range eventChannel.GetChannel() {
      klog.V(2).Infof("Got sys oom event from cadvisor: %v", event)
      ow.recorder.PastEventf(ref, metav1.Time{Time: event.Timestamp}, v1.EventTypeWarning, systemOOMEvent, "System OOM encountered")
    }
    klog.Errorf("Unexpectedly stopped receiving OOM notifications from cAdvisor")
  }()
  return nil
}

oomWatcher가 스스로 eventHandler에 등록하고 이벤트 채널에서 OOM 이벤트를 읽은 것을 알 수 있습니다. 사실상 oomWatcher는 kubelet이 시작할 때 초기화되었고(참고), oomWatcher.Start() 메서드가 이와 동시에 호출되었습니다(참고). OOM 이벤트를 생성하고 관찰하는 전체 프로세스는 아래 그래프와 같습니다.

This image has an empty alt attribute; its file name is Untitled-4-1024x816.png

여기까지 OOM 이벤트가 어떻게 발생해서 사용자 공간(user space)에서 관찰됐는지 알아냈습니다. 조사를 더 진행하기 전에 다음 세 가지를 반드시 기억해야 합니다. 

  • OOM 이벤트는 OS가 생성합니다. 다시 말해 OS 커널이 Prometheus 프로세스(컨테이너)가 사용하는 메모리 자원이 한도를 초과한 것을 인지했습니다.
  • cadvisor은 /dev/kmsg에서 OOM 이벤트를 관찰하고 oom_watcher 채널로 전송합니다.
  • oomWatcher는 이벤트 채널에서 OOM 이벤트를 관찰하고 기록합니다.

이쯤 되면 Prometheus 컨테이너를 죽인 범인은 kubelet이 아니라는 것을 알아챘을 겁니다. OOM 이벤트 때문에 OS 커널이 직접 Prometheus 컨테이너를 죽였습니다. 시스템 로그를 보면 상황을 확인할 수 있습니다.

This image has an empty alt attribute; its file name is oom_system_log-1024x120.png
여기를 클릭하면 확대할 수 있습니다.

 

Prometheus 컨테이너는 왜 많은 양의 메모리를 사용하는가

이제 OS 커널이 OOM 이벤트 때문에 Prometheus 컨테이너를 종료시켰다는 사실까지 알아냈습니다. 하지만 Prometheus 컨테이너가 실제로 얼마나 많은 메모리를 사용했는지는 아직 파악하지 못했습니다. 과연 어느 정도가 지나치게 많은 양일까요? 몇 가지 떠오르는 질문에 대한 답을 찾기 위해 Prometheus 컨테이너의 메모리 자원 사용량을 살펴보았습니다.

Prometheus와 연동한 그라파나(Grafana) 대시보드를 보니 Prometheus가 시작된 지 불과 5분 만에 시계열 수가 ‘2,162,256’까지 급격하게 증가했습니다. 당연히 Prometheus 컨테이너는 거의 즉시 내려졌습니다. 그리고, 아래와 같이 시계열 수를 기준으로 Prometheus 컨테이너에 할당된 메모리를 추정해볼 수 있는 계산기를 찾았습니다(참고).

This image has an empty alt attribute; its file name is metrics_calculator-1024x680.png

추정한 메모리 사용량은 5.7GB였습니다. 추정치이긴 하지만, 총 메모리가 8GB인 호스트에선 감당할 여유가 없는 수치입니다. 무엇보다 이는 Prometheus 컨테이너를 단지 5분 동안 실행하는 데 필요한 메모리였습니다. 제 가설을 검증하기 위해서 Prometheus의 configMap에서 불필요한 설정 대부분을 제거했더니 Prometheus 컨테이너가 더는 비정상적으로 종료되지 않았습니다. 

지금까지 조사한 바에 따르면 Prometheus 컨테이너에서 지나치게 많은 메모리를 사용해 OOM 이벤트가 발생했고, 이에 OS 커널이 Prometheus 컨테이너를 종료시킨 것으로 보입니다. 이제 OOM 이벤트 자체에 대한 몇 가지 의문이 남았습니다.

  • 커널 스페이스에서 OOM 이벤트가 어떻게 발생할까? 메모리 사용량이 이 현상에 영향을 주는 유일한 요인일까?
  • OS 커널의 어떤 기능이 OOM 이벤트 처리를 담당할까?
  • OS 커널이 Prometheus 컨테이너의 비정상적인 메모리 사용량을 인지해서 OOM 이벤트가 발생하기까지 거치게 되는 전체 프로세스는 어떻게 될까?

이런 의문을 가슴에 품고, Prometheus 컨테이너가 호스트에서 엄청난 양의 메모리를 소비한 후 무슨 일이 일어났는지 알아내기 위해 조사를 이어갔습니다.

 

증거 2:  OOM 이벤트

문제의 원인을 찾아내는 가장 좋은 방법은 현상을 재현해서 무슨 일이 일어났는지 직접 살펴보는 것입니다. 그래서 Prometheus 컨테이너를 재시작하는 docker restart <Prometheus_container_name> 커맨드를 사용했습니다(단, 현재 메모리 자원 사용량을 잊지 마시길!).

This image has an empty alt attribute; its file name is image2020-4-13_16-39-24-1-1024x125.png
여기를 클릭하면 확대할 수 있습니다.

Prometheus 컨테이너가 재시작된 뒤 호스트의 메모리 자원을 거의 모두 사용한 것을 확인했습니다. 이 때문에 OOM 이벤트가 발생했고 OS 커널은 Prometheus 컨테이너를 종료시켰습니다.

This image has an empty alt attribute; its file name is image2020-4-13_16-40-40-1-1024x182.png
여기를 클릭하면 확대할 수 있습니다.

OOM 이벤트는 프로세스가 메모리를 너무 많이 사용해서 시스템 메모리가 부족해지면 발생합니다. 이 이벤트가 발생하면 OS는 해당 프로세스를 강제로 종료시킵니다. 이번 경우도 OOM 이벤트로 분류할 수 있습니다. 그런데 때때로 OS 커널에서 메모리를 과대 할당하기도 합니다. 예를 들어, 물리적 메모리 용량이 5GB인데 OS 커널에서 5.5GB의 메모리를 프로세스들에게 할당하기도 합니다. 그 이유는 프로세스가 요청한 메모리만큼 항상 실제로 사용하지는 않기 때문입니다. 다시 말해 1GB의 메모리를 요청한 A라는 프로세스가 실제로는 500MB만 사용하기도 합니다. 프로세스에선 최초에 요청할 때 즉시 사용하지 않거나 혹은 전혀 사용하지 않을 수도 있는 메모리 용량까지 포함해서 요청하고, OS 커널은 메모리 자원을 관리하는 차원에서 종종 이런 오버헤드를 그대로 남겨둡니다. 정상적인 상황에서는 이렇게 해도 별문제 없이 모든 게 잘 돌아갑니다. 하지만 특정 프로세스가 너무 많은 메모리를 사용하면 지원할 수 있는 메모리가 부족해질 수 있는데요. 그런 상황이 오면 OS는 다른 프로세스가 영향을 받기 전에 즉시 문제를 해결합니다. 이때 OS 커널이 해결사로 사용하는 함수가 ‘OOM Killer (Out of Memory Killer)’라고 부르는 함수입니다. 

 

OOM Killer가 일하는 방식

OOM Killer의 주요 업무는 다음 두 가지입니다.

  • 실행 중인 모든 프로세스를 살펴보며 각 프로세스의 메모리 사용량에 따라 OOM 점수를 산출합니다.
  • OS에서 메모리가 더 필요하면 점수가 가장 높은 프로세스를 종료시킵니다.

각 프로세스의 oom_score 관련 정보는 /proc/<pid> 디렉토리 하위에서 찾을 수 있습니다.

  • oom_adj (oom_adjust) 
  • oom_score_adj 
  • oom_score 

oom_killer는 점수를 나타내는 oom_score만 있으면 임무를 완수할 수 있습니다. 그렇다면 oom_adj와 oom_score_adj의 역할은 무엇일까요? man proc을 이용해 확인해 보겠습니다.

  • /proc/[pid]/oom_adj (Linux 2.6.11 이후): 점수를 조정하는 데 사용합니다. 일반적으로 -16에서 +15 사이의 값을 갖고, 특수 값 -17이 적용된 프로세스는 OOM killer 대상에서 제외됩니다. Linux 2.6.36 이후엔 /proc/[pid]/oom_score_adj로 대체됐고 더 이상 이 파일을 사용하지 않습니다.
  • /proc/[pid]/oom_score (Linux 2.6.11 이후): 현재 프로세스의 OOM 점수를 나타냅니다. 점수가 높을수록 OOM Killer의 대상이 될 확률이 높아집니다.
  • /proc/[pid]/oom_score_adj (Linux 2.6.36 이후): OOM 상황에서 어떤 프로세스를 종료할지 선택하는 기준이 되는 ‘badness 값’을 조정하는 데 사용합니다. -1000(oom_score_adj_MIN)에서 +1000(oom_score_adj_MAX) 사이의 값을 갖습니다. 이전 커널과의 하위 호환성을 위해 /proc/[pid]/oom_adj으로도 점수를 조정할 수 있으며 이때는 oom_score_adj에 비례해 값이 정해집니다. /proc/[pid]/oom_score_adj나 /proc/[pid]/oom_adj 중 하나의 값을 변경하면 다른 하나의 값도 비례하여 변경됩니다.

위의 설명에 따르면 OOM Killer가 유일하게 의존하는 변수는 oom_score이고, oom_adj 또는 oom_score_adj을 이용해 그 값을 조정할 수 있습니다. 현재 사용하고 있는 커널 버전은 kernel-3.10.0-957.el7입니다. 리눅스 저장소에서 버전에 맞는 커널 소스 코드를 찾았습니다.

  1. https://github.com/torvalds/linux/blob/v3.10/fs/proc/base.c#L439
  2. https://github.com/torvalds/linux/blob/v3.10/mm/oom_kill.c#L141
static int proc_oom_score(struct task_struct *task, char *buffer)
{
  unsigned long totalpages = totalram_pages + total_swap_pages;
  unsigned long points = 0;

  read_lock(&tasklist_lock);
  if (pid_alive(task))
    points = oom_badness(task, NULL, NULL, totalpages) *
            1000 / totalpages;
  read_unlock(&tasklist_lock);
  return sprintf(buffer, "%lu\n", points);
}

struct limit_names {
  char *name;
  char *unit;
};

unsigned long oom_badness(struct task_struct *p, struct mem_cgroup *memcg,
        const nodemask_t *nodemask, unsigned long totalpages)
{
  long points;
  long adj;

  if (oom_unkillable_task(p, memcg, nodemask))
    return 0;

  p = find_lock_task_mm(p);
  if (!p)
    return 0;

  adj = (long)p->signal->oom_score_adj;
  if (adj == oom_score_adj_MIN) {
    task_unlock(p);
    return 0;
  }

  /*
   * The baseline for the badness score is the proportion of RAM that each
   * task's rss, pagetable and swap space use.
   */
  points = get_mm_rss(p->mm) + p->mm->nr_ptes +
     get_mm_counter(p->mm, MM_SWAPENTS);
  task_unlock(p);

  /*
   * Root processes get 3% bonus, just like the __vm_enough_memory()
   * implementation used by LSMs.
   */
  if (has_capability_noaudit(p, CAP_SYS_ADMIN))
    adj -= 30;

  /* Normalize to oom_score_adj units */
  adj *= totalpages / 1000;
  points += adj;

  /*
   * Never return 0 for an eligible task regardless of the root bonus and
   * oom_score_adj (oom_score_adj can't be oom_score_adj_MIN here).
   */
  return points > 0 ? points : 1;
}

아래 그래프는 점수 계산 로직을 나타냅니다.

This image has an empty alt attribute; its file name is Untitled-1-1024x658.png

위 계산 프로세스를 보면 OS 커널은 메모리 사용량만 고려할 뿐입니다. 현재 버전의 커널에서는 oom_score_adj나 oom_adj를 사용하지 않았습니다. 하지만 여전히 이들이 값을 가지고 있었는데요. Prometheus 컨테이너의 oom_score_adj 값을 확인해 보니 999였습니다. 이제 다음 질문은 ‘누가 oom_score_adj를 수정할 수 있는가’입니다.

 

누가 oom_score_adj를 수정할 수 있는가

oom_score 값은 메모리 사용량과 oom_score_adj에 따라 결정된다는 점을 알게 되었습니다. 실제 메모리 사용량은 특정 프로세스와 연관되어 있습니다. Prometheus 컨테이너가 지나치게 많은 메모리를 사용한다는 것을 알고 있지만, OS 커널을 제외한 다른 서비스가 oom_score_adj 값을 조정해서 Prometheus에서 OOM 이벤트를 발생시키는 것은 아닌지 확인하고 싶었습니다. Prometheus 컨테이너의 oom_score_adj를 설정하는 방법으론 아래 두 가지가 있습니다.

  • 준비 단계에서 설정: kubelet이 Prometheus 컨테이너 프로세스에 필요한 설정과 파라미터를 준비합니다. 이때 kubelet이 oom_score_adj의 초기 값을 설정하거나 커널이 양수 중 하나를 기본 값으로 설정합니다.
  • 실행 단계에서 설정: Prometheus 컨테이너 프로세스가 실행되기 시작하면 일부 서비스 또는 사용자가 /proc/<proemtheus_pid>/oom_score_adj에 값을 입력할 수 있습니다.

Prometheus 컨테이너를 실행하는 중에 어떤 서비스가 oom_score_adj를 수정하는지 확인하려면 /proc/<proemtheus_pid>/oom_score_adj 파일의 상태 변경을 관찰해야 합니다. 이때 ‘SystemTap’을 활용할 수 있습니다. SystemTap은 OS 커널의 행동을 파악할 수 있도록 도와주는 툴이라고 생각하면 됩니다. SystemTap 스크립트를 작성해 /proc/<pid>/oom_score_adj 파일을 관찰할 수 있는데요. 이것을 살짝 변형해서 접근해 보았습니다. 어떤 파일이든 내용을 수정하려면 수정을 위한 시스템 콜을 사용할 수밖에 없기 때문에 /proc/<proemtheus_pid>/oom_score_adj 파일에 ‘쓰기’ 시스템 콜을 관찰하는 SystemTap 스크립트를 구현했습니다.

probe syscall.write {
        if (isinstr(filename, "oom_score_adj")) {
                printf("%s(%d) %s modify  %s\n", execname(), pid(), pexecname(),filename)
        }
}

probe syscall.open {
        if (isinstr(filename, "oom_score_adj")) {
                printf("%s(%d) %s open  %s\n", execname(), pid(), pexecname(),filename)
        }
}

이 스크립트와 Prometheus 컨테이너를 동시에 실행했지만 Prometheus 컨테이너가 OOM으로 종료되기 전까지 oom_score_adj를 변경하는 프로세스나 서비스는 없었습니다. 

다음으로 OS 커널이 프로세스에 기본적으로 높은 oom_score_adj 값을 주는지 테스트해 보았습니다. time.Sleep(3600 * time.Seconds)을 호출하는 Golang 데모를 구현해서 실행시킨 뒤 oom_score_adj 값을 확인해 보았는데 값이 0이었습니다. 다시 말해 특정 프로세스의 oom_score_adj 값을 명시적으로 지정하지 않으면 OS 커널이 그 값을 변경하지는 않습니다.

마지막으로 kubelet이 설정하는 경우를 살펴보았습니다. kubelet의 경우 컨테이너 프로세스를 생성하는 과정에서 설정이 이루어진다고 생각했습니다. 먼저 프로세스를 다시 상기해 보겠습니다.

This image has an empty alt attribute; its file name is Untitled-2-1024x563.png

kube-apiserver로 요청을 보내 워크 로드 리소스 오브젝트를 생성하면 워크 로드는 대신 파드를 생성합니다. kube-scheduler는 생성된 파드를 사용 가능한 노드에 배정합니다. kubelet은 자신이 위치한 노드와 동일한 nodeName 필드 값을 가진 파드를 보면 파드 스펙에 따라 컨테이너를 생성합니다. 호스트에서 컨테이너를 생성하거나 시작하는 것은 컨테이너 런타임과 관련 있습니다. 저희는 Docker를 컨테이너 엔진으로 사용하고 있습니다. Docker가 사용하는 컨테이너 런타임은 containerd이고, containerd는 runc project에 의존합니다.

This image has an empty alt attribute; its file name is Untitled-3-e1590991361726-1024x659.png

CRI(Container Runtime Interface)는 단순히 Kubernetes와 컨테이너 런타임을 분리하는 추상적 레이어의 일종이고, runc는 컨테이너를 격리시키는 역할을 할 뿐이니 잠시 잊어도 괜찮습니다. 여기선 아래 두 가지 요소만이 oom_score_adj를 설정할 수 있습니다.

  • kubelet
  • containerd

앞서 언급했듯이 containerd는 컨테이너를 생성하고 runc 인터페이스를 호출해 실행합니다. 그래서 명시적으로 값을 지정하지 않았을 때 모든 컨테이너에 기본적으로 주어지는 oom_score_adj 값이 있다고 추측했습니다. 이 가정을 바탕으로 NewContainer 함수의 소스 코드를 살펴보겠습니다.

// NewContainer will create a new container in container with the provided id
// the id must be unique within the namespace
func (c *Client) NewContainer(ctx context.Context, id string, opts ...NewContainerOpts) (Container, error) {
    ctx, done, err := c.WithLease(ctx)
    if err != nil {
        return nil, err
    }
    defer done(ctx)
 
    container := containers.Container{
        ID: id,
        Runtime: containers.RuntimeInfo{
            Name: c.runtime,
        },
    }
    for _, o := range opts {
        if err := o(ctx, c, &container); err != nil {
            return nil, err
        }
    }
    r, err := c.ContainerService().Create(ctx, container)
    if err != nil {
        return nil, err
    }
    return containerFromRecord(c, r), nil
}

이 함수는 컨테이너의 모든 설정을 준비하고 저장합니다. 다음 단계가 컨테이너를 실행하는 단계이기 때문에 oom_score_adj 파라미터 역시 여기에서 설정되어야 합니다. opts 변수에서 특히 많은 함수에 대한 힌트를 발견할 수 있었습니다. NewContainer 함수는 opts 내 모든 메서드를 실행한 다음 파라미터로 전달됩니다.

그럼 누가 컨테이너를 생성하기 위해 이 함수를 호출할까요? 컨테이너를 시작하는 프로세스 그래프를 보았다면 CRI라는 것을 알 수 있을 것입니다. 하지만 CRI는 그저 하나의 표준 혹은 정의입니다. 모든 컨테이너 런타임은 kubelet에 적용할 수 있도록 CRI의 모든 인터페이스를 구현하는 특별 서비스를 제공하는데요. containerd에서는 containerd-shim이 이런 역할을 합니다. containerd-shim은 CRI의 모든 인터페이스를 구현한 일종의 gRPC 서버입니다. kubelet이 컨테이너를 운영하려면 먼저 gRPC 요청의 형태로 CRI 인터페이스를 호출하고, 이를 containerd-shim이 처리합니다. 그럼 NewContainer를 호출한 쪽을 살펴보겠습니다. containerd의 CreatContainer 함수를 보면, containerd는 WithSpec() 함수를 이용해 컨테이너 프로세스의 필수 설정이 포함된 containerSpec 객체를 생성하는 익명(anonymous) 함수를 만듭니다. 이 익명 함수는 opts에 할당됩니다.

// CreateContainer creates a new container in the given PodSandbox.
func (c *criService) CreateContainer(ctx context.Context, r *runtime.CreateContainerRequest) (_ *runtime.CreateContainerResponse, retErr error) {
  config := r.GetConfig()
  log.G(ctx).Debugf("Container config %+v", config)
  ...
  spec, err := c.containerSpec(id, sandboxID, sandboxPid, sandbox.NetNSPath, config, sandboxConfig,
    &image.ImageSpec.Config, append(mounts, volumeMounts...), ociRuntime)
  if err != nil {
    return nil, errors.Wrapf(err, "failed to generate container %q spec", id)
  }
  ...
  opts = append(opts,
    containerd.WithSpec(spec, specOpts...),
    containerd.WithRuntime(sandboxInfo.Runtime.Name, runtimeOptions),
    containerd.WithContainerLabels(containerLabels),
    containerd.WithContainerExtension(containerMetadataExtension, &meta))
  var cntr containerd.Container
  if cntr, err = c.client.NewContainer(ctx, id, opts...); err != nil {
    return nil, errors.Wrap(err, "failed to create containerd container")
  }
  ...
}

// WithSpec sets the provided spec on the container
func WithSpec(s *oci.Spec, opts ...oci.SpecOpts) NewContainerOpts {
  return func(ctx context.Context, client *Client, c *containers.Container) error {
    if err := oci.ApplyOpts(ctx, client, c, s, opts...); err != nil {
      return err
    }

    var err error
    c.Spec, err = typeurl.MarshalAny(s)
    return err
  }
}

그렇다면 spec 객체의 내용은 무엇일까요? containerSpec() 함수의 로직을 보겠습니다.

func (c *criService) containerSpec(id string, sandboxID string, sandboxPid uint32, netNSPath string,
  config *runtime.ContainerConfig, sandboxConfig *runtime.PodSandboxConfig, imageConfig *imagespec.ImageConfig,
  extraMounts []*runtime.Mount, ociRuntime config.Runtime) (*runtimespec.Spec, error) {
...
  specOpts = append(specOpts,
    customopts.WithOOMScoreAdj(config, c.config.RestrictOOMScoreAdj),
    customopts.WithPodNamespaces(securityContext, sandboxPid),
    customopts.WithSupplementalGroups(supplementalGroups),
    customopts.WithAnnotation(annotations.ContainerType, annotations.ContainerTypeContainer),
  )

살펴보니 WithOOMScoreAdj라는 함수가 있었습니다. 이 함수에 컨테이너의 oom_source_adj 값을 할당하는 로직이 구현되어 있습니다.

// WithOOMScoreAdj sets the oom score
func WithOOMScoreAdj(config *runtime.ContainerConfig, restrict bool) oci.SpecOpts {
    return func(ctx context.Context, client oci.Client, c *containers.Container, s *runtimespec.Spec) error {
        if s.Process == nil {
            s.Process = &runtimespec.Process{}
        }
 
        resources := config.GetLinux().GetResources()
        if resources == nil {
            return nil
        }
        adj := int(resources.GetOomScoreAdj())
        if restrict {
            var err error
            adj, err = restrictOOMScoreAdj(adj)
            if err != nil {
                return err
            }
        }
        s.Process.OOMScoreAdj = &adj
        return nil
    }
}

이제 컨테이너 프로세스의 oom_source_adj 값을 어디에서 할당하는지 찾았습니다. 하지만, oom_source_adj 값이 어디에서 오는지는 아직 모릅니다. WithOOMScoreAdj 함수를 보면 oom_source_adj의 값은 config 파라미터에서 옵니다. CreateContainer 함수로 돌아가서 이 파라미터에 누가 값을 전달했는지 찾아보겠습니다.

// CreateContainer creates a new container in the given PodSandbox.
func (c *criService) CreateContainer(ctx context.Context, r *runtime.CreateContainerRequest) (_ *runtime.CreateContainerResponse, retErr error) {
  config := r.GetConfig()
  log.G(ctx).Debugf("Container config %+v", config)
  ...
  spec, err := c.containerSpec(id, sandboxID, sandboxPid, sandbox.NetNSPath, config, sandboxConfig,
    &image.ImageSpec.Config, append(mounts, volumeMounts...), ociRuntime)
  if err != nil {
    return nil, errors.Wrapf(err, "failed to generate container %q spec", id)
  }
 ...

코드를 보면 oom_source_adj의 값은 CreateContainerRequest.ContainerConfig에서 가져와야 합니다. 그렇다면 누가 ContainerCreateRequest를 containerd-shim에 발행할 수 있는지 상기해 봅시다. 바로 kubelet입니다. 실제로 CreateContainerRequest의 ContainerConfig 필드를 보면 oom_score_adj뿐 아니라 CGroup과 연관된 파라미터도 설정할 수 있습니다.

type CreateContainerRequest struct { 
  ...
  // Config of the container.
  Config *ContainerConfig protobuf:"bytes,2,opt,name=config,proto3" json:"config,omitempty"
  ...
}

type ContainerConfig struct {
  ...
  Linux *LinuxContainerConfig protobuf:"bytes,15,opt,name=linux,proto3" json:"linux,omitempty"
  ...
}

type LinuxContainerConfig struct {
  // Resources specification for the container.
  Resources *LinuxContainerResources protobuf:"bytes,1,opt,name=resources,proto3" json:"resources,omitempty"
  ...
}

type LinuxContainerResources struct {
  // CPU CFS (Completely Fair Scheduler) period. Default: 0 (not specified).
  CpuPeriod int64 protobuf:"varint,1,opt,name=cpu_period,json=cpuPeriod,proto3" json:"cpu_period,omitempty"
  // CPU CFS (Completely Fair Scheduler) quota. Default: 0 (not specified).
  CpuQuota int64 protobuf:"varint,2,opt,name=cpu_quota,json=cpuQuota,proto3" json:"cpu_quota,omitempty"
  // CPU shares (relative weight vs. other containers). Default: 0 (not specified).
  CpuShares int64 protobuf:"varint,3,opt,name=cpu_shares,json=cpuShares,proto3" json:"cpu_shares,omitempty"
  // Memory limit in bytes. Default: 0 (not specified).
  MemoryLimitInBytes int64 protobuf:"varint,4,opt,name=memory_limit_in_bytes,json=memoryLimitInBytes,proto3" json:"memory_limit_in_bytes,omitempty"
  // OOMScoreAdj adjusts the oom-killer score. Default: 0 (not specified).
  OomScoreAdj int64 protobuf:"varint,5,opt,name=oom_score_adj,json=oomScoreAdj,proto3" json:"oom_score_adj,omitempty"
  // CpusetCpus constrains the allowed set of logical CPUs. Default: "" (not specified).
  CpusetCpus string protobuf:"bytes,6,opt,name=cpuset_cpus,json=cpusetCpus,proto3" json:"cpuset_cpus,omitempty"
  // CpusetMems constrains the allowed set of memory nodes. Default: "" (not specified).
  CpusetMems string protobuf:"bytes,7,opt,name=cpuset_mems,json=cpusetMems,proto3" json:"cpuset_mems,omitempty"
  // List of HugepageLimits to limit the HugeTLB usage of container per page size. Default: nil (not specified).
  HugepageLimits       []*HugepageLimit protobuf:"bytes,8,rep,name=hugepage_limits,json=hugepageLimits,proto3" json:"hugepage_limits,omitempty"
  XXX_NoUnkeyedLiteral struct{}         json:"-"
  XXX_sizecache        int32            json:"-"
}

 

왜 Prometheus 컨테이너의 oom_score_adj가 999로 설정되어 있었나

컨테이너의 oom_score_adj 값을 kubelet이 할당한다는 것을 확인하고 나니 이제 왜 kubelet이 Prometheus 컨테이너의 oom_score_adj에 높은 값을 할당했는지 궁금해졌습니다. kubelet의 코드를 보기 전에, Kubernetes에서 파드의 자원 관리를 알아보겠습니다. OS 관점에서 자원은 두 가지 종류로 분류할 수 있습니다.

  • 컴퓨팅 관련 자원: CPU, 메모리
  • 저장 관련 자원: 디스크

하지만 Kubernetes 측면에서는 다른 기준으로 분류합니다.

  • 압축 가능한(compressible) 자원: 파드에서 사용하는 자원이 한도를 초과하더라도 가용 자원이 있다면 추가로 사용할 수 있습니다. Kubernetes는 현재 압축 가능한 자원으로 CPU만 지원합니다.
  • 압축 불가능한(incompressible) 자원: 파드(컨테이너 아님)가 사용하는 자원이 한도를 초과하면 해당 파드 내에서 가장 많은 자원을 사용하는 컨테이너가 종료됩니다. 또한 파드에서 사용하는 자원이 요청한 크기는 초과하지만 한도보다는 적더라도 다른 중요한 파드에서 자원을 필요로 하면 해당 파드는 종료될 수 있습니다. Kubernetes는 현재 메모리를 압축 불가능한 자원으로 지원합니다.

Kubernetes는 위의 기준에 따라 파드의 서비스 품질(QoS)을 세 가지 유형으로 분류합니다. Kubernetes에서 파드가 생성될 때마다 QoS 클래스 중 하나가 파드에 할당됩니다.

  • Guaranteed: 여기에 속한 파드는 최우선 순위를 갖습니다. 한도를 초과하지 않는 한 종료되지 않도록 보장받습니다. 모든 컨테이너의 모든 자원에 대해 상한과 요청량(0이 아님)이 동일하게 설정되면 파드가 Guaranteed로 분류됩니다.
  • Burstable: 시스템 메모리가 압박을 받을 때, 여기에 속한 컨테이너는 요청을 초과하면 종료될 가능성이 큽니다. 파드 내에서 최소 하나 이상의 컨테이너가 메모리 또는 CPU 요청량을 가집니다.
  • Best-Effort: 가장 낮은 우선순위로 처리됩니다. 시스템에서 메모리가 부족해지면 가장 먼저 종료됩니다. 하지만 노드에 가용 메모리가 있다면 크기에 상관없이 사용할 수 있습니다. 파드의 컨테이너에 메모리 또는 CPU의 상한이나 요청량이 없어야 해당 파드가 Best-Effort로 분류됩니다. 

QoS는 우선순위로 생각하면 이해하기 쉽습니다. Guaranteed의 경우, 노드의 자원이 부족해지면 다른 파드를 종료시키고 해당 파드를 지속적으로 실행시킬 수 있는 최선의 옵션입니다. Best-Effort의 경우, 호스트에 남아있는 메모리를 크기에 상관없이 사용할 수 있기 때문에 때에 따라서 좋은 선택이 될 수 있습니다. 물론 자원이 부족해지면 가장 먼저 종료될 것입니다. 

파드의 QoS가 특히 메모리에 있어서 프로세스 종료 규칙에 영향을 준다는 것을 알게 되었습니다. CPU는 압축 가능한 자원이기 때문에 CPU 자원을 많이 사용하고 있다고 해서 OS가 컨테이너 프로세스를 종료하지는 않습니다. 하지만 사용자가 설정할 수 있는 파라미터는 QoS와 oom_score_adj뿐입니다. 그렇다면 QoS와 oom_score_adj은 서로 연관성이 있을까요? kubelet 소스 코드를 살펴보겠습니다. kubelet 코드에 GetContainerOOMScoreAdjust라는 이름의 함수가 있습니다. 이 함수는 파드의 QoS를 기준으로 컨테이너의 oom_score_adj 값을 산출합니다. Burstable 계산 방법이 좀 더 복잡하고, 다른 두 개에는 디폴트 값이 할당되어 있다는 것을 알 수 있습니다.

// GetContainerOOMScoreAdjust returns the amount by which the OOM score of all processes in the
// container should be adjusted.
// The OOM score of a process is the percentage of memory it consumes
// multiplied by 10 (barring exceptional cases) + a configurable quantity which is between -1000
// and 1000. Containers with higher OOM scores are killed if the system runs out of memory.
// See https://lwn.net/Articles/391222/ for more information.
func GetContainerOOMScoreAdjust(pod *v1.Pod, container *v1.Container, memoryCapacity int64) int {
    if types.IsCriticalPod(pod) {
        // Critical pods should be the last to get killed.
        return guaranteedOOMScoreAdj
    }
 
    switch v1qos.GetPodQOS(pod) {
    case v1.PodQOSGuaranteed:
        // Guaranteed containers should be the last to get killed.
        return guaranteedOOMScoreAdj
    case v1.PodQOSBestEffort:
        return besteffortOOMScoreAdj
    }
 
    // Burstable containers are a middle tier, between Guaranteed and Best-Effort. Ideally,
    // we want to protect Burstable containers that consume less memory than requested.
    // The formula below is a heuristic. A container requesting for 10% of a system's
    // memory will have an OOM score adjust of 900. If a process in container Y
    // uses over 10% of memory, its OOM score will be 1000. The idea is that containers
    // which use more than their request will have an OOM score of 1000 and will be prime
    // targets for OOM kills.
    // Note that this is a heuristic, it won't work if a container has many small processes.
    memoryRequest := container.Resources.Requests.Memory().Value()
    oomScoreAdjust := 1000 - (1000*memoryRequest)/memoryCapacity
    // A guaranteed pod using 100% of memory can have an OOM score of 10. Ensure
    // that burstable pods have a higher OOM score adjustment.
    if int(oomScoreAdjust) < (1000 + guaranteedOOMScoreAdj) {
        return (1000 + guaranteedOOMScoreAdj)
    }
    // Give burstable pods a higher chance of survival over besteffort pods.
    if int(oomScoreAdjust) == besteffortOOMScoreAdj {
        return int(oomScoreAdjust - 1)
    }
    return int(oomScoreAdjust)
}

이 소스 파일의 상단에서 기본 값을 찾았습니다.

const (
    // kubeletOOMScoreAdj is the OOM score adjustment for kubelet
    kubeletOOMScoreAdj int = -999
    // KubeProxyOOMScoreAdj is the OOM score adjustment for kube-proxy
    KubeProxyOOMScoreAdj  int = -999
    guaranteedOOMScoreAdj int = -998
    besteffortOOMScoreAdj int = 1000
)

현재 코드 구현을 보면 Guaranteed와 Best Effort 파드의 기본 값은 각각 -998과 1000으로 설정되어 있습니다. 하지만 Prometheus 컨테이너의 oom_score_adj는 999으로 설정되어 있었습니다. Prometheus 파드가 Burstable로 분류됐다는 가능성을 보여줍니다. 확인하기 위해 사용자의 클러스터에 저희 Prometheus를 배포해 보기로 했습니다. Prometheus의 오케스트레이션 파일을 먼저 살펴보겠습니다.

spec:
  containers:
    imagePullPolicy: IfNotPresent
    name: Prometheus-configmap-reload
    resources:
      limits:
        memory: "100Mi"
        cpu: "200m"
      requests:
        memory: "50Mi"
        cpu: "100m"
        ...
  - args:
  ...
    - --storage.tsdb.retention.size=25GB
    image: prom/Prometheus:v2.14.0
    imagePullPolicy: IfNotPresent
    livenessProbe:
      failureThreshold: 5
      httpGet:
        path: /-/healthy
        port: 9090
        scheme: HTTP
    name: Prometheus
    ...

여기에서 config-reload 컨테이너에만 요청량과 상한을 설정한 것으로 보입니다. 따라서 Prometheus 파드의 QoS는 Burstable로 분류되었고, kublet이 oom_score_adj을 산출했습니다. 최종 결과 값은 컨테이너 스펙에서 확인할 수 있듯이 999입니다.

...
"IpcMode": "container:eceeb982740fcabc2630dc6647568640202318d95eb81206ad696210e2732ce3",
"Cgroup": "",
"Links": null,
"OomScoreAdj": 999,
...

 

결론

이 글을 시작하며 언급했던 것처럼 문제 해결 프로세스의 끝은 뻔하고 간단합니다. 이 사건의 범인은 두 명입니다.

  • 잘못된 자원 설정: Prometheus 파드의 자원 요청량 및 상한 설정을 잘못해서 Prometheus 서버 컨테이너에 할당되는 oom_score_adj에 큰 값이 할당되었습니다.
  • 잘못된 메모리 자원 예측: Prometheus에서 필요한 메모리 자원을, 특히 대규모 클러스터에서 미리 예측하지 않았습니다.

가능한 해결책 중 몇 가지를 공유하겠습니다.

  • Prometheus 파드의 자원 요청량 및 상한 설정을 적절하게 수정하여 설정합니다. 현재 발생하는 문제를 완전하게 해결할 수는 없어도 비슷한 문제가 발생하는 것을 방지해 문제 해결 시간을 줄일 수 있습니다.
  • 불필요한 수집 지표를 줄이기 위해서 수집 대상이나 수집 간격을 조정합니다.
  • Prometheus에서 필요한 메모리 자원을 예측하고 신규 호스트를 마련해 deploy-topology: Prometheus와 같은 특정 노드 레이블을 지정합니다. Prometheus 오케스트레이션 파일에 nodeSelector를 추가하고 이런 호스트에 배정된 Prometheus 파드만 허용합니다.

지금 이 글의 마지막 문장을 읽고 있는 독자라면 분명히 기술에 대한 뜨거운 열정을 가지고 있는 엔지니어임에 틀림이 없습니다. Kubernetes에 대해 더욱 많이 알고 싶고 문제를 해결하는 것을 즐기며 대형 시스템 안정화에 관심이 있는 분이라면 우리 팀에서 항상 새로운 엔지니어를 찾고 있다는 점을 기억해 주세요. 언젠가 함께 일하는 날이 오길 바랍니다!