Android MediaCodec, MediaMuxer 살펴보기

꽤나 최근부터 Android의 API중 MediaCodec나 MediaMuxer, MediaExtractor 를 이용하여 동영상 파일에 대한 후처리 작업을 진행하고 있었다.

처음에는 FFmpeg(https://ffmpeg.org/)의 도입을 강력하게 생각하고 있었는데, 이는 iOS나 타 플랫폼에도 동일하게 적용할 수 있고, 실제로 원하는 기능에 대해 구현에 성공했기 때문이었다.

하지만 FFmpeg 의 LGPL 라이센스 문제나, 일부 플랫폼의 static linking 불가 문제, 일부 플랫폼의 애플리케이션 용량 문제로 도입을 하지 않고 각자의 영상 관련 API를 사용하기로 결정을 내렸고, 이 것이 MediaCodec 나 MediaMuxer 에 대해 알아본 계기였다.

분명히 직전 회사에 재직하고 있을 2017년 11월에도 같은 고민을 한 적이 있던 것 같지만(https://blog.uzuki.live/?p=174), 이번에는 직접 API들을 다뤄보면서 Inter-Frame coding 된 영상을 받아서 모든 프레임을 KeyFrames 로 바꾸는 클래스를 제작해보기로 한다.

키 프레임? Inter Frame?

우리의 작업을 시행하기 전에, Inter-Frame coding (https://en.wikipedia.org/wiki/Data_compression#Inter-frame_coding)에 대해 좀 더 알아볼 필요가 있다.

하나의 영상은 여러 개의 프레임을 가지고 있다. 우리가 흔히 30fps다, 60fps다 부르는 것은, 1초당 프레임이 30/60개가 존재한다는 의미로도 사용된다. 그리고 이 프레임 하나를 ‘인트라 프레임’ 또는 ‘키 프레임’ 이라고 부른다.

특히, 영상의 재생 시간이 길 수록 이 프레임 갯수도 늘어나게 되는데, 문제는 리소스가 많이 든다는 점이다.

이런 점을 해결하기 위해 ‘인터 프레임’ 이라는 기법이 도입되었는데, 인트라 프레임이 각 프레임이 모든 정보를 갖고 있는 반면, 인터 프레임은 인트라 프레임 하나에 여러 인터 프레임으로, 인터 프레임은 인트라 프레임 대비 변동된 부분에 대한 데이터만 갖고 있는다.

Android%20MediaCodec%20MediaMuxer%20c57ffb3225c44f43a8c2f3cf85d75375/Untitled.png

출처: https://www.bhphotovideo.com/explora/video/tips-and-solutions/compression-a-basic-understanding

위 사진이 그런 점을 잘 설명하고 있는데, 인트라 프레임은 사람의 모든 모습을 가지고 있는 반면, 인터프레임은 사람의 팔이 움직이는 부분의 데이터만 갖고 있는다.

그렇기 때문에 비교적 적은 리소스로도 영상을 구성하는 것이 가능했고, 이런 기술들을 통해 영상이 많이 진화될 수 있었다.

하지만, 이러한 ‘인터 프레임’ 은 앞으로 우리가 할 것에 방해를 주는데, 인터프레임은 인트라 프레임 대비 변동된 데이터만 갖고 있는 특성을 가진 탓에, 프레임을 직접 조작하기에는 다소 많은 어려움이 존재한다.

그렇기 때문에, 이러한 인터 프레임을 인트라 프레임으로 변경하는 작업이 필요하고 그것이 우리가 이제부터 할 작업이다.

일단 사용해야 할 것을 정리해보자.

여러 API가 있지만, MediaExtractor, MediaCodec, MediaFormat, MediaMuxer 이 4개가 주인공으로 각 역할을 맡게 된다.

  • MediaFormat: MediaCodec 등이 사용할 포맷등을 정의하는 역할
  • MediaCodec: MediaExtractor 로 가져온 ByteBuffer를 읽거나 작성하는 역할
  • MediaExtractor: 비디오 파일로부터 인코드된 비디오 프레임을 얻는 역할
  • MediaMuxer: 인코드 된 버퍼를 작성하는 역할

이 중 MediaCodec가 가장 큰 역할을 맡게 되는데, 이는 다음부터 설명하기로 한다.

MediaCodec의 동작 방식

안드로이드 공식 문서에서도 나와있는 이 그림은 MediaCodec의 동작 방식을 단적으로 보여준다.

Android%20MediaCodec%20MediaMuxer%20c57ffb3225c44f43a8c2f3cf85d75375/02.png

Step 1. InputBuffer에 대한 해독

// 해독할 InputBuffer의 Index를 반환한다. 주어진 timeoutUs 만큼 버퍼의 가용성을 기다린다.
val inBufferId = decoder.dequeueInputBuffer(mediaCodedTimeoutUs)
if (inBufferId >= 0) {
  // InputBuffer 의 Index를 가지고 실제 버퍼를 가져온다.
  val buffer = decoder.getInputBuffer(inBufferId)
  ...
}

Step 2. 해독한 InputBuffer에 대한 큐잉

// 현재 인코딩된 sample를 가져오고, 주어진 오프셋부터 byte buffer에 저장
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize >= 0) {
  // queue 할 수 있는 InputBuffer가 존재함. 따라서 sampleSize, 현재 버퍼의 presentation timestamp, 샘플의 플래그를 이용하여 InputBuffer를 Decoder에 큐잉하게 됨.
  decoder.queueInputBuffer(inBufferId, 0, sampleSize, extractor.sampleTime, extractor.sampleFlags)
  extractor.advance()
} else {
  // 모든 InputBuffer를 읽었으므로, BUFFER_FLAG_END_OF_STREAM 플래그를 통해 모든 버퍼를 읽었다는 것을 알린다.
  decoder.queueInputBuffer(inBufferId, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
  allInputExtracted = true
}

Step 3. OutputBuffer에 대한 해독과 Muxer에 작성

// 해독할 OutputBuffer의 Index를 반환한다.
val encodeOutBufferId = encoder.dequeueOutputBuffer(bufferInfo, mediaCodedTimeoutUs)
if (encodeOutBufferId >= 0) {
  // OutputBuffer의 Index를 가지고 실제 버퍼를 가져온다.
  val encodedBuffer = encoder.getOutputBuffer(encodeOutBufferId)
  // 특정 트랙에 가져온 OutputBuffer를 작성한다.
  muxer.writeSampleData(trackIndex, encodedBuffer, bufferInfo)
}

Step 4. OutputBuffer의 릴리즈

// 작성한 OutputBuffer는 바로 릴리즈하여 다음에도 해독할 수 있도록 준비
encoder.releaseOutputBuffer(encodeOutBufferId, false)

일반적으로 코덱은 입력 데이터를 처리해서 출력 데이터를 생성하는 역할을 하는데, 여기에서는 빈 입력 버퍼를 요청하여 버퍼를 채우고, 데이터를 모두 사용하여 빈 출력 버퍼를 릴리즈하는 과정을 가지고 있다.

위 사항을 기반으로 하나씩 준비해보자.

필요한 요소 준비하기

MediaFormat는 영상 파일에 대한 정보 (가로, 세로, 비트레이트 등) 을 담고 있는 클래스로, 여기에서는 Input 파일의 정보를 가져와 Output 파일에 대한 정보를 기록한다.

extractor = MediaExtractor()
extractor.setDataSource(input)
val inFormat = selectVideoTrack(extractor, "video/")

fun selectVideoTrack(extractor: MediaExtractor, prefix: String = "video/"): MediaFormat {
   for (i in 0 until extractor.trackCount) {
       val format = extractor.getTrackFormat(i)
       if (format.getString(MediaFormat.KEY_MIME).startsWith(prefix)) {
           extractor.selectTrack(i)
           return format
      }
  }
   throw InvalidParameterException("File contains no video track")
}

기본적으로 MP4 파일은 여러 개의 트랙을 가지고 있을 수 있는데, 일반적으로 지원하는 형태는 1 영상 트랙, 1 음악 트랙인 경우가 많다.

여기서 우리는 ‘비디오 트랙’ 에 대한 정보가 필요하므로, MediaExtractor의 트랙 저옵를 꺼내서 video/ 로 시작하는 첫 번째 트랙을 선택한다.

이렇게 가져온 MediaFormat를 기반으로 Output 파일에 대한 MediaFormat를 생성하는데, 이는 다음과 같다.

private fun getOutputFormat(inputFormat: MediaFormat, frameInterval: Int = 0): MediaFormat {
  // inputFomrat 에 대한 width, height를 가지고 Size를 생성한다.
  val inputSize = Size(inputFormat.getInteger(MediaFormat.KEY_WIDTH), inputFormat.getInteger(MediaFormat.KEY_HEIGHT))
  // inputSize를 가지고 기기에서 해당 사이즈를 지원하는지 체크한다. mime 로 video/avc를 넘기는데, 이는 H.264 컨테이너에 대한 mime라고 볼 수 있다.
  val outputSize = getSupportedVideoSize(encoder, "video/avc", inputSize)
 
  return MediaFormat.createVideoFormat("video/avc", outputSize.width, outputSize.height).apply {
           // Surface 객체로 통해 인코딩이 가능하도록 한다.
           setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
           setInteger(MediaFormat.KEY_BIT_RATE, 20000000)
           setInteger(MediaFormat.KEY_FRAME_RATE, inputFormat.getInteger(MediaFormat.KEY_FRAME_RATE))
           // 전체 프레임을 키 프레임으로 변환하기 위하여, I_FRAME_INTERVAL를 0으로 준다.
           // (일부 기기에서는 -1를 사용하는 경우도 있어, try-catch 로 CodecException이 발생했을 때 frameInterval를 -1로 주고 다시 configure를 시도한다.
           setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, frameInterval)
           setString(MediaFormat.KEY_MIME, "video/avc")
  }
}

@RequiresApi(21)
fun getSupportedVideoSize(mediaCodec: MediaCodec, mime: String, preferredResolution: Size): Size {
   // 주어진 Size가 기기에서 지원하는지 먼저 체크한다.
   if (isSizeSupported(mediaCodec, mime, preferredResolution)) {
       return preferredResolution
  }
   
   // 기기에서 지원하지 않는 사이즈면, 비율에 맞는 최대한 가까운 크기를 선택한다.
   val resolutions = mutableListOf(
       Size(176, 144),
       Size(320, 240),
       Size(320, 180),
       Size(640, 360),
       Size(720, 480),
       Size(1280, 720),
       Size(1920, 1080)
  )

   val pix = preferredResolution.width * preferredResolution.height
   val preferredAspect = preferredResolution.width.toFloat() / preferredResolution.height.toFloat()

   val nearestToFurthest = resolutions.sortedWith(
       compareBy(
          { pix - it.width * it.height },
          {
               val aspect = if (it.width < it.height) it.width.toFloat() / it.height.toFloat() else it.height.toFloat() / it.width.toFloat()
              (preferredAspect - aspect).absoluteValue
          })
  )

   val result = nearestToFurthest.firstOrNull { isSizeSupported(mediaCodec, mime, it) }

   if (result != null) {
       return result
  }

   throw RuntimeException("Couldn't find supported resolution")
}

@RequiresApi(21)
private fun isSizeSupported(mediaCodec: MediaCodec, mime: String, size: Size): Boolean {
   return mediaCodec.codecInfo.getCapabilitiesForType(mime).videoCapabilities.isSizeSupported(size.width, size.height)
}

Output 파일에 대한 MediaFormat를 생성했다면, 이를 MediaCodec 객체에 configure 라는 메서드를 통하여 설정한다.

var outFormat: MediaFormat
try {
   // inFormat 값을 기반으로 Output 파일에 대한 Format를 설정
   outFormat = getOutputFormat(inFormat).also {
       width = it.getInteger(MediaFormat.KEY_WIDTH)
       height = it.getInteger(MediaFormat.KEY_HEIGHT)
  }

   // encoder 객체에 outFormat를 설정.
   // surface와 crypto의 역할은 다음과 같다.
   // surface: 해당 Decoder에 대해 결과물을 출력할 android.view.Surface 객체. 만일 해당 MediaCodec가 raw video output를 출력하지 않거나 (Decoder가 아니거나) ByteBuffer로 아웃풋을 내고 싶을 경우에는 null 로 선언한다.
   // crypto: DRM 등 MediaCrypto가 걸린 영상을 Decode/Encode하려고 할 때 선언하는 adnroid.media.MediaCrypto 객체.
   // 여기서는 Encoder 객체이므로 surface, crypto 둘 다 null를 주고, flags로는 CONFIGURE_FLAG_ENCODE 로 해당 MediaCodec 객체가 Encoder로서 활용할 것임을 선언한다.
   encoder.configure(outFormat, surface = null, crypto = null, MediaCodec.CONFIGURE_FLAG_ENCODE)
} catch (e: MediaCodec.CodecException) {
   // MediaCodec.configure 가 실패할 수 있는 경우
   // IllegalArgumentException -> surface 가 이미 릴리즈 되었거나, Format가 기기에서 지원하지 않는 경우. 또는 플래그가 잘못 설정된 경우
   // IllegalStateException -> 초기화되지 않은 상태가 아닐 경우
   // CryptoException -> DRM 관련 에러
   // CodecException -> Codec 관련 에러

   // 일부 기기의 경우, i-frame-interval 가 0 이 아닌 -1 이 '키 프레임' 을 나타내는 경우가 있다.
   // 따라서, -1로 OutputFormat 를 생성하고 다시 configure를 시도한다.
   outFormat = getOutputFormat(inFormat, -1).also {
       width = it.getInteger(MediaFormat.KEY_WIDTH)
       height = it.getInteger(MediaFormat.KEY_HEIGHT)
  }

   encoder.configure(outFormat, surface = null, crypto = null, MediaCodec.CONFIGURE_FLAG_ENCODE)
}

surface = encoder.createInputSurface()

// Decoder 객체의 경우, 결과물을 출력할 Surface를 삽입한다.
decoder = MediaCodec.createDecoderByType("video/avc").apply {
    configure(inFormat, surface, null, 0)
}

마지막으로 Muxer 객체를 선언하고, encoderdecoder 객체를 생성하고 준비 작업을 마친다.

muxer = MediaMuxer(outPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
encoder.start()
decoder.start()

디코딩, 인코딩, Muxing

위 과정을 통해 MediaCodec에 대한 초기화 작업을 마쳤다면, 이제 영상의 Buffer를 꺼내면서 디코딩 – 인코딩 – Muxing를 거칠 차례이고, 아래 순서로 진행하여 이를 영상의 끝 까지 반복시킨다.

  1. decoder.dequeInputBuffer ~ decoder.getInputBuffer ~ readSampleData ~ queueInputBuffer 로 Decoder에 input data 주입
  2. decoder.dequeueOutputBuffer ~ decoder.releaseOutputBuffer로 decoder에서 Output data를 꺼내 encoder에 주입
  3. encoder.dequeueOutputBuffer ~ encoder.getOutputBuffer로 Encoder에서 output data를 꺼냄
  4. muxer.writeSampleData 로 muxer에 버퍼 기록

이를 반영한 것이, 아래 코드가 된다.

private fun convert() {
    allInputExtracted = false
    allInputDecoded = false
    allOutputEncoded = false

    while (!allOutputEncoded) {
        // Decoder에 input data를 삽입
        if (!allInputExtracted) {
            val inBufferId = decoder.dequeueInputBuffer(mediaCodedTimeoutUs)
            if (inBufferId >= 0) {
                // input buffer의 인덱스에서 `cleared` 하고 쓰기 가능한 ByteBuffer 객체를 반환함.
                val buffer = decoder.getInputBuffer(inBufferId)
                // 반환된 ByteBuffer를 가지고 extractor에서 읽어온다. buffer에는 읽어온 ByteBuffer가 덮어지고, 반환 값에는 샘플 사이즈가 기록된다. 
                val sampleSize = extractor.readSampleData(buffer, 0)
       
                if (sampleSize >= 0) {
                    decoder.queueInputBuffer(inBufferId, 0, sampleSize, extractor.sampleTime, extractor.sampleFlags)
                    extractor.advance()
                } else {
                    decoder.queueInputBuffer(inBufferId, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
                    allInputExtracted = true
                }
            }
        }

        var encoderOutputAvailable = true
        var decoderOutputAvailable = !allInputDecoded

        while (encoderOutputAvailable || decoderOutputAvailable) {
            // Encoder에서 데이터를 꺼내고, 이를 muxer에 주입함.
            val encodeOutBufferId = encoder.dequeueOutputBuffer(bufferInfo, mediaCodedTimeoutUs)
            if (encodeOutBufferId >= 0) {
                // output buffer의 인덱스에서 `cleared` 하고 쓰기 가능한 ByteBuffer 객체를 반환함.
                val encodedBuffer = encoder.getOutputBuffer(encodeOutBufferId)
                // 인코딩된 샘플을 muxer에 작성한다.
                muxer.writeSampleData(trackIndex, encodedBuffer, bufferInfo)
                encoder.releaseOutputBuffer(encodeOutBufferId, false)

                // MPEG4 포맷의 경우, BUFFER_FLAG_END_OF_STREAM 플래그와 함께 빈 버퍼(bufferInfo.size = 0) 을 전달하여 트랙 내 마지막 샘플을 설정할 수 있다.
                // 즉, 이 과정이 모두 끝나면 작업이 모두 완료되었다고 가정함.
                if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
                    allOutputEncoded = true
                    break
                }
            } else if (encodeOutBufferId == MediaCodec.INFO_TRY_AGAIN_LATER) {
                encoderOutputAvailable = false
            } else if (encodeOutBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
                trackIndex = muxer.addTrack(encoder.outputFormat)
                muxer.start()
            }

            if (encodeOutBufferId != MediaCodec.INFO_TRY_AGAIN_LATER) {
                continue
            }

            // Decoder에서 output 를 읽고, 이를 encoder에 주입한다. 
            // (이 상태를 거치지 않으면, 위의 encodeOutBufferId 는 -1를 반환한다.)
            if (!allInputDecoded) {
                val decoderOutBufferId = decoder.dequeueOutputBuffer(bufferInfo, mediaCodedTimeoutUs)
                if (decoderOutBufferId >= 0) {
                    val render = bufferInfo.size > 0
                    decoder.releaseOutputBuffer(decoderOutBufferId, render)
                    // 이 단계에서 Decoder에서 꺼내온 Decoded frame가 SurfaceTexture에 주입되고, onFrameAvailable() 가 불린다.
                    // 그렇기에, render 가 true이고, onFrameAvailable 가 호출되었다면 Surface에 작업을 가할 수 있다. (OpenGL 등)

                    // Decoder에서 모든 정보를 다 꺼냈다면, encoder에 모든 정보가 다 입력됨을 알려준다.
                    if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
                        allInputDecoded = true
                        encoder.signalEndOfInputStream()
                    }
                } else if (decoderOutBufferId == MediaCodec.INFO_TRY_AGAIN_LATER) {
                    decoderOutputAvailable = false
                }
            }
        }
    }
}

마지막으로 모든 작업을 마친 후에는 리소스 해제 작업을 진행한다.

private fun release() {
    extractor.release()

    decoder.stop()
    decoder.release()

    encoder.stop()
    encoder.release()

    muxer.stop()
    muxer.release()

    surface.release()

    width = -1
    height = -1
    trackIndex = -1
}

레퍼런스

Alert with Grafana

지난 글인 Monitoring with cAdvisor + Prometheus + Grafana(https://blog.uzuki.live/?p=1764) 에 이어서, 지난 글에서 다루지 못했던 Grafana Alert 기능에 대해 간단히 소개하고자 한다.

준비물

  • Prometheus, Grafana, Caddy 등 : 지난 글 참조
  • node-exporter : 하드웨어나 OS 측정 항목에 대한 Prometheus exporter (옵션)
  • Telegram Bot Token: BotFather (https://telegram.me/BotFather) 로 생성 가능한 봇의 토큰
  • Telegram User ID (or Chat ID): 알림을 발송할 대상 그룹 (유저 ID 또는 그룹 ID)

User ID 또는 ChatID 얻기

  1. 생성한 봇과 대화 시작 (또는 그룹에 초대)
  2. https://api.telegram.org/bot{BOT_TOKEN}/getUpdates 으로 접속. {BOT_TOKEN} 에는 봇의 토큰을 입력
  3. 메세지 입력
  4. 2번에서 접속한 페이지를 새로고침 하면, 메세지 데이터가 조회됨. result > 0 > message > from > id 가 사용자의 ID, result > 0 > message > chat > id 가 그룹 ID
{"ok":true,"result":[{"update_id":917237895,
"message":{"message_id":703,"from":{"id":47220554,"is_bot":false,"first_name":"Pyxis","username":"WindSekirun","language_code":"ko"},"chat":{"id":-1001363414751,"title":"","type":"supergroup"},"date":1588476517,"text":"/start","entities":[{"offset":0,"length":,"type":"bot_command"}]}}]}

Node-Exporter (옵션)

하드웨어나 OS 측정 항목에 대한 데이터를 제공하는 exporter로, 이 글에서는 ‘디스크 사용량’에 대해 Alert를 보낼 것이기 때문에 아래 인스턴스를 추가로 올린다.

  nodeexporter:
  image: prom/node-exporter:v0.18.1
  container_name: nodeexporter
  volumes:
    - /proc:/host/proc:ro
    - /sys:/host/sys:ro
    - /:/rootfs:ro
  command:
    - '--path.procfs=/host/proc'
    - '--path.rootfs=/rootfs'
    - '--path.sysfs=/host/sys'
    - '--collector.filesystem.ignored-mount-points=^/(sys|proc|dev|host|etc)($
  restart: unless-stopped

prometheus.yml에는 아래와 같이 등록한다.

  - job_name: 'nodeExporter'
  scrape_interval: 5s
  static_configs:
  - targets: ['nodeexporter:9100']

제공하는 측정 항목에 대해서는 https://github.com/prometheus/node_exporter#enabled-by-default 에서 볼 수 있다.

Notification Channels 등록하기

Grafana Alerts 기능은 6.7.3 버전 기준으로 Email, Slack, Discord, LINE, Telegram, Webhooks 등 여러 타입을 제공한다. 이 글에서는 상기했었던 Telegram를 사용한다.

Grafana 의 왼쪽 알림 아이콘을 눌러 Notification Channel를 생성한다.

Name에는 알림 채널의 이름을 Bot API Token는 봇의 토큰, Chat ID에는 메세지를 보낼 대상을 선택한다.

타입 밑의 옵션은 다음과 같다.

  • Default: 모든 알림의 기본 대상 설정
  • Include image: 알림을 보낼 때 그래프가 담긴 이미지를 포함할지 설정
  • Disable Resolve Message: 그래프의 상태가 해결되었을 때, ‘해결됨’ 메세지를 보낼지 설정
  • Send reminders: 그래프의 상태가 지속되었을 때 메세지를 보낼지 설정

호스트 디스크 사용량 그래프 생성

기존 Panel를 사용해서 알림을 보낼 수 있지만, 이 글에서는 Node-exporter의 metrics를 사용하여 Panel를 추가하려고 한다.

  • 사용할 측정 항목: node_filesystem_avail_bytes, node_filesystem_size_bytes
  • PromQL: (node_filesystem_size_bytes{mountpoint=”/”} – node_filesystem_avail_bytes{mountpoint=”/”}) / 1024 / 1024
  • 시각화 방법: Graph, Axes > Left Y > Unit: mebibytes, Decimals: 2

node_filesystem_avail_bytes 는 사용 가능한 용량을, node_filesystem_size_bytes 는 전체 용량을 나타낸다. 따라서 디스크 사용량을 나타내기 위해 전체 용량에서 사용 가능한 용량을 빼고, 이를 MB 단위로 환산한다.

알림 항목 설정

Panel 설정의 ‘알림’ 아이콘을 눌러 새로운 알림을 생성하면, 아래와 같은 화면을 볼 수 있다.

각 옵션은 다음과 같다.

  • Evaluate every: 특정 시간마다 아래 Conditions를 만족하는지 확인
  • For: Pending 상태가 특정 시간동안 지속되었을 때 Alert 를 보내도록 설정 (짧은 시간 내에 알림을 방지하기 위한 수단으로 사용됨)
  • Condition: query의 계산값에 대해 조건을 만족할 때 Alert를 전송할 지 설정
    • WHEN: {avg, min, max, sum, count, last, median, diff, diff_abs, percent_diff, percent_diff_abs, count_non_null} 등 쿼리에 대한 계산식
    • OF: query(A, 5m, now) 현재부터 5분 전 까지의 A 레코드에 대한 케디엍
    • IS ABOVE: 특정 값에 대해 ‘초과’, ‘미만’, ‘범위 초과’, ‘범위 포함’ ‘값 없음’ 에 대한 조건 설정
  • If no data or all values are null: 데이터가 없거나 모든 데이터가 Null 일 때 상태 설정값. Alerting, No Data, Keep Last State, OK 로 설정할 수 있음.
  • If execution error or timeout: 쿼리 실행에 실패하거나 타임아웃이 발생한 경우: Alerting, Keep Last State로 설정할 수 있음

예시로 데이터 사용량이 80%를 임계했을 때 보내려고 하므로, 전체 디스크 용량의 80%를 MB로 환산한 값인 104857 를 입력하여 Condition을 작성한다. WHEN max() of query(A, 5m, now) IS ABOVW 104857

알림 테스트

생성한 알림을 확인해보기 위해, time sh -c 'fallocate -l 10G aaa; sync' 를 입력하여 임시 파일을 생성하고, 약간만 기다리면 알림이 발송되는 것을 확인할 수 있다.

Monitoring with cAdvisor + Prometheus + Grafana

UzukiLive 서버가 제공하는 기능들이 점점 많아지고, 접속이 활발해지면서 특정 인스턴스가 많은 양의 자원을 사용할 때 알려주는 기능이 필요했었다.

그래서 2019년 4월에 High Compute 머신으로 옮기면서 모니터링 스택 + 관리 봇을 만들었는데, 오늘은 그 중 ‘모니터링 스택’ 에 대해 사용한 툴과 사용법을 간단히 소개하고자 한다.

사용한 도구

  • cAdvisor
  • Prometheus
  • Grafana
  • Caddy: 물론, Nginx 나 Apache로도 가능하지만 최근에는 간단한 사용법을 가진 Caddy를 선호하긴 한다.

준비 과정 – cAdvisor

cAdvisor(https://github.com/google/cadvisor)는 실행중인 컨테이너에 대한 자원 사용량이나 성능에 대한 분석 기능을 제공하는 도구로, 아래와 같이 올릴 수 있다.

  cadvisor:
  image: google/cadvisor:latest
  container_name: cadvisor
  volumes:
    - /:/rootfs:ro
    - /var/run:/var/run:ro
    - /sys:/sys:ro
    - /var/lib/docker/:/var/lib/docker:ro
    - /dev/disk/:/dev/disk:ro
  restart: always

자체적인 웹 UI도 제공하긴 하지만, Prometheus가 데이터를 가져올 수 있게 하는 용도면 충분하므로 웹 UI는 사용하지 않도록 한다.

준비 과정 – Prometheus

Prometheus(https://github.com/prometheus/prometheus) 는 cAdvisor, node-exporter 등 여러 정보를 수집해주는 도구의 데이터를 가져오고, 이에 대해 분석할 수 있는 쿼리 기능을 제공하는 도구로, 아래와 같이 올릴 수 있다.

prometheus:
  image: prom/prometheus
  container_name: prometheus
  restart: always
  volumes:
    - ./prometheus.yml:/etc/prometheus/prometheus.yml
  command:
    - '--config.file=/etc/prometheus/prometheus.yml'

Prometheus 구동에는 설정 파일이 필요한데, docker-compose가 위치하는 폴더에 prometheus.yml 이라는 파일을 만들고, 다음과 같이 작성한다.

global:
scrape_interval: 15s

external_labels:
  monitor: 'uzukilive-monitor'

scrape_configs:
- job_name: 'cAdvisor'
  scrape_interval: 5s
  static_configs:
  - targets: ['cadvisor:8080']

각각 항목은 다음과 같다.

  • global.scrape_interval: 기본적인 수집 간격
  • global.external_labels.monitor: 모니터에 대한 별칭 설정
  • scrape_config: 스크래핑 할 수집 도구의 정보를 정의한다.
  • scrape_config.scrape_interval: 위 global.scrape_interval 와 다르게 해당 수집 도구의 수집 간격을 설정한다.
  • scape_config.static_configs.targets: 스크래핑할 수집 도구의 주소를 입력한다. 여기에서는 cAdvisor를 설정할 것이므로, 컨테이너 이름:포트 형식으로 입력한다.

마찬가지로, Prometheus는 자체적인 웹 UI를 제공하지만 Grafana로 볼 수 있도록 할 것이므로 웹 UI는 사용하지 않도록 한다. (하지만, 초반에 쿼리 습득을 위해 웹 UI를 잠시 열어놓고 사용하는 것도 나쁘지는 않다고 본다.)

준비 과정 – Grafana

Grafana(https://github.com/grafana/grafana) 는 각종 데이터 소스(Prometheus 등)에 대해 데이터를 대시보드 형태로 시각화하는 도구로, 최근에는 자체적인 Alert 기능을 제공하기 시작했다. Grafana는 아래와 같이 올릴 수 있다.

  grafana:
  image: grafana/grafana
  container_name: grafana
  environment:
    - GF_SECURITY_ADMIN_PASSWORD=<GF_SECURITY_ADMIN_PASSWORD>
  volumes:
    - grafana-storage:/var/lib/grafana
  depends_on:
    - prometheus

준비 과정 – Caddy

Caddy(https://github.com/caddyserver/caddy/) 는 Web server로 Let’s encrypt 자동 연동 기능을 제공하거나, 손쉽게 사용할 수 있는 장점을 가지고 있다.

  caddy:
  image: abiosoft/caddy
  container_name: caddy
  ports:
    - '80:80'
    - '443:443'
  volumes:
    - .Caddyfile:/etc/Caddyfile
    - .caddy:/root/.caddy
  restart: always

Caddy 구동에는 설정 파일이 필요한데, docker-compose가 위치하는 같은 폴더 내에 .Caddyfile 이라는 파일을 만들고 다음과 같이 작성한다.

사용할 도메인 {
  proxy / grafana:3000
}

proxy 명령어는 match 하는 주소에 특정 주소를 연결하는 명령어로, proxy {matrcher token} {url} 로 이루어진다. 여기에서는 / 에 대해 grafana 컨테이너에 접속한다.

Grafana 설정 – 데이터 소스 추가

위 4개의 컨테이너를 모두 설정하고 Caddy에 설정한 도메인으로 접속한 다음, grafana에 설정한 비밀번호와 함께 admin 으로 로그인하면 Grafana의 메인 페이지가 보이게 된다.

‘Add data source’ 를 클릭하여, Prometheus 를 클릭하고 URL에 http://prometheus:9090 을 입력한다.

그 다음, 밑의 ‘Save & Test’ 를 클릭하여 저장한다.

Grafana 설정 – 대시보드 구현

데이터 소스를 추가했다면, 왼쪽의 +를 눌러 대시보드를 추가한다. 대시보드에는 다수의 Panel를 보여지게 할 수 있는데, 이 Panel는 각각 Prometheus의 쿼리를 통해 가져온 데이터를 표시한다.

Panel는 우측 상단 그래프 모양 +를 눌러 추가할 수 있고, 패널에 표시할 데이터는 Add Query를 클릭하여 추가할 수 있다.

Add Query를 클릭하면 상단 그래프와 함께 쿼리를 입력하는 곳이 나오는데, 여기에 후술할 Prometheus 쿼리 (PromQL) 를 입력한다.

쿼리를 입력하여 데이터가 나오는 것을 확인했다면, 좌측의 그래프 모양을 눌러 표시할 UI를 선택할 수 있다.

그래프, 스탯, 게이지 등 다양한 UI를 가지고 있고, 각각에 대해 설정도 가능하다.

마지막으로 설정 아이콘을 눌러 Panel에 대한 기본 설정 (이름, 설명) 등에 대해 추가가 가능하다.

cAdvisor 가 제공하는 컨테이너 측정 항목은 https://github.com/google/cadvisor/blob/master/docs/storage/prometheus.md 여기에서 볼 수 있는데, 이 글에서는 아래의 측정 항목을 보여주려고 한다.

  • 실행중인 컨테이너 갯수
  • 총 메모리 사용량
  • 총 CPU 사용량
  • 컨테이너별 CPU 사용량
  • 컨테이너별 메모리 사용량
  • 컨테이너별 네트워크 Rx (수신량)
  • 컨테이너별 네트워크 Tx (발송량)

PromQL 설명

Prometheus 가 제공하는 쿼리를 PromQL(Prometheus Query Language) 라고 하는데, 아래 4가지 타입을 제공한다.

  • 즉석 벡터(Instant vector) – 각 시계열에 대해 단일 표본을 포함하는 집합으로, 모두 동일한 timestamp를 공유한다.

즉석 벡터는 주어진 타임스탬프에서 각 시계열에 대해 단일 표본을 선택할 수 있다. 간단하게는 측정 항목의 이름으로만 지정되는데, 가령 후술할 container_last_seen 에 대한 모든 시계열을 보고 싶다면 container_last_seen 를 쿼리로 입력하면 된다.

위 사진에서도 알 수 있듯이 container_last_seen 에 대한 시계열은 여러 데이터를 가지고 있는데, 이에 대한 필터링을 {} 에 추가하여 설정할 수 있다. 가령, 도커 이미지가 있는 실제 데이터를 필터링하고 싶다면 container_last_seen{image != ""} 를 쿼리로 입력하면 된다.

조건대로, image가 비어있지 않은 항목만 나온 것을 알 수 있다.

  • 범위 벡터(Range vector) – 각 시계열에 대해 시간 경과에 따른 데이터의 범위를 포함하는 시계열 집합

범위 벡터는 주어진 타임스탬프에서 지정한 과거 시간까지에 대해 각 시계열에서 데이터를 추출할 수 있다. 기본적으로 ‘즉석 벡터’와 같으나, [5m] 등의 추가 쿼리가 붙는다.

가령, 후술할 container_network_receive_bytes_total 에 대해 5초동안의 데이터를 보고 싶다면, container_network_receive_bytes_total{image != ""}[5s] 를 쿼리로 입력하면 된다.

  • 스칼라(Scalar) – 단순한 부동 소수점(floating point)
  • 문자열(String) – 단순한 문자열 값, 현재는 사용되지 않음

위 네 가지 타입을 제공하면서 같이 자체적인 쿼리 펑션을 제공한다. sum 이나 count 같은 기본적인 구성부터, 범위 벡터에 대한 초당 시계열을 제공하는 irate까지 다양하나 다 설명하기는 어렵고, 실제로 쿼리를 사용해 시각화해보면서 설명하기로 한다.

문서는 https://prometheus.io/docs/prometheus/latest/querying/functions/ 에서 볼 수 있다.

실행중인 컨테이너 갯수 표시하기

  • 사용할 측정 항목: container_last_seen
  • PromQL: count(container_last_seen{image!=””})

container_last_seen 이 컨테이너가 마지막으로 보여진 timestamp 를 반환하는 것을 이용하여 현재 실행중인 컨테이너에 대해 시계열을 가져오고, 이를 count 펑션을 통해 갯수를 가져온다.

단순 컨테이너 텍스트이므로, 시각화 방법은 ‘Singlestat’를 사용한다.

CPU 전체 사용량

  • 사용할 측정 항목: container_cpu_user_seconds_total
  • PromQL: sum(rate(container_cpu_user_seconds_total{image != “”}[5m]) * 100)

container_cpu_user_seconds_total 는 각 컨테이너에 대한 CPU 사용량을 보여주는데, 해당 쿼리는 현재부터 5분 전까지의 시계열 데이터를 가져와 퍼센트를 구하고, (0.12 * 100 = 12%) 이를 합산(sum) 하여 보여주는 역할이다.

퍼센트 데이터의 합계이므로 시각화 방법은 ‘Gauge’ 로 설정하고 필드는 percent (0-100) 를 선택한다.

메모리 전체 사용량

  • 사용할 측정 항목: container_memory_usage_bytes
  • PromQL: sum(container_memory_usage_bytes{image != “”}) / 1024 / 1024

container_memory_usage_bytes 는 각 컨테이너의 메모리 사용량을 보여주는데, 해당 쿼리는 메모리 사용량을 더하고 MB 단위로 표시한다.

단순한 사용량의 합계이므로 시각화 방법은 ‘Singlestat’를 사용하고, Value의 Unit는 megabytes를 선택한다.

참고로, cAdvisor는 기본적인 하드웨어 사용량을 제공하는데, 이를 활용하여 메모리 사용량 퍼센트도 구할 수 있다.

  • 사용할 측정 항목: machine_memory_bytes, container_memory_usage_bytes
  • PromQL: sum(container_memory_usage_bytes{image!=””}) / sum(machine_memory_bytes) * 100

이미지가 존재하는 container_memory_usage_bytes 시계열 데이터에 대해 합계를 구하고, 이를 하드웨어 전체 메모리로 나눈다.

CPU 전체 사용량과 같이 퍼센트 데이터이므로 시각화 방법은 Guage로 설정한다.

컨테이너별 CPU 사용량

  • 사용할 측정 항목: container_cpu_user_seconds_total
  • PromQL: rate(container_cpu_user_seconds_total{image != “”}[5m]) * 100

container_cpu_user_seconds_total 는 각 컨테이너의 초당 CPU 사용량을 보여주는데, 해당 쿼리는 5분동안의 CPU 사용량에 대한 퍼센트를 표시한다. 자세히 보면 전체 사용량의 쿼리에서 sum이 빠진 것을 제외하고는 같다.

그래프 데이터이므로 시각화 방법은 ‘Graph’를 표시한다. 이 때 Legend가 해당 시계열 데이터의 모든 정보를 표시하는데, 이를 ‘이름’ 만 보여주고 싶다면 쿼리 입력하는 곳의 Legend에 {{name}} 를 입력하면 이름만 보이게 된다.

컨테이너별 메모리 사용량

  • 사용할 측정 항목: container_memory_usage_bytes
  • PromQL: container_memory_usage_bytes{image != “”}

container_memory_usage_bytes 는 각 컨테이너의 메모리 사용량을 보여주는데, 해당 쿼리는 메모리 사용량을 나타낸다.

그래프 데이터이므로 시각화 방법은 ‘Graph’ 를 표시한다.

컨테이너별 네트워크 Rx (수신량)

  • 사용할 측정 항목: container_network_receive_bytes_total
  • PromQL: irate(container_network_receive_bytes_total{image != “”}[5m])

container_network_receive_bytes_total 는 각 컨테이너에 대해 수신된 용량을 보여주는데, 해당 쿼리는 각 컨테이너의 수신된 용량을 5분동안 추출하여 초당 시계열을 제공하는 irate 를 사용하여 그 순간에 대해 bytes/sec를 보여주게 한다.

그래프 데이터이므로 시각화 방법은 ‘Graph’를 표시하고, Unit는 byte/sec를 사용한다.

컨테이너별 네트워크 Tx (발송량)

  • 사용할 측정 항목: container_network_transmit_bytes_total
  • PromQL: irate(container_network_transmit_bytes_total{image != “”}[5m])

container_network_transmit_bytes_total 는 각 컨테이너에 대해 발송된 용량을 보여주는데, 해당 쿼리는 각 컨테이너의 발송한 용량을 5분동안 추출하여 초당 시계열을 제공하는 irate를 사용하여 그 순간에 대해 bytes/sec를 보여주게 한다.

그래프 데이터이므로 시각화 방법은 ‘Graph’를 표시하고, Unit는 byte/sec를 사용한다.

위 쿼리들을 사용하여 적절히 구성한 대시보드는 다음과 같다.

컨테이너 json

마지막으로, Grafana는 대시보드에 대해 JSON Model로 Import/Export할 수 있는 기능을 가지고 있다.

이 글에서 최종적으로 만든 Json는https://gist.github.com/WindSekirun/e557879487aa87cfb14745ecdfbf8682 에서 볼 수 있다.