Spring Boot + Actuator + Micrometer로 Prometheus 연동하기

이제까지 블로그에서 Prometheus, Grafana 에 대해 여러 번 다룬 적이 있었다.

두번째 글 까지 해서 기본적인 Host에 대한 정보를 수집하고 알려주는 것을 했다면, 이제는 직접 서비스에 대한 정보를 수집하고 알려주는 것을 작성해보려고 한다.

마침 작년에 개발했던 개인 프로젝트의 API 서버를 다시 구성하는 일이 있어 마일스톤 ‘Prometheus 연동’ 을 포함하여 진행하였고, 그에 대한 결과를 정리해보려고 한다.

글을 작성하는 시점(2020. 08. 23) 은 새 API 서버가 구성된지 약 일주일 정도밖에 지나지 않았으므로, 많은 데이터를 보여주고 있지는 않다. (기능도 별로 적기도 하다.)

의존성 준비

dependencies {
  ...
   implementation("org.springframework.boot:spring-boot-starter-actuator")
   implementation("io.micrometer:micrometer-registry-prometheus")
  ...
}

Actuator는 Spring Boot 애플리케이션의 정보를 다룰 수 있게 하며, micrometer-registry-prometheus 는 Prometheus가 읽을 수 있는 metrics를 제공하는 역할을 한다.

Actuator 설정

Actuator는 applications.yml 내 management.endpoints.web.exposure.include 라는 옵션을 통해 /actuator 페이지를 통해 노출할 엔드포인트를 설정할 수 있다.

management:
endpoints:
  prometheus:
    enabled: true
  web:
    exposure:
      include: prometheus

이 글에서는 ‘Prometheus’ 에 대한 연동을 기본으로 하므로, management.endpoints.web.exposure.include 옵션에 promethues 만 정의한다.

이를 설정하고, Spring Boot Application을 열면 아래와 같은 로그를 확인할 수 있다.

INFO 12928 --- [           main] o.s.b.a.e.web.EndpointLinksResolver      : Exposing 1 endpoint(s) beneath base path '/actuator'

해당 주소로 들어가면, 아래와 같은 json response가 반환되는 것을 확인할 수 있다.

{
"_links": {
  "self": {
    "href": "http://[ip]:[port]/actuator",
    "templated": false
   },
  "prometheus": {
    "href": "http://[ip]:[port]/actuator/prometheus",
    "templated": false
   }
 }
}

_linksmanagement.endpoints.web.exposure.include 에 정의한 항목이 보이게 되고, 각자의 endpoint는 /actuator/prometheus 와 같은 방식으로 접근할 수 있다.

이제 /actuator/prometheus 에 진입하게 되면 아래와 같은 형식의 텍스트가 나열된 것을 볼 수 있다.

# HELP jvm_threads_daemon_threads The current number of live daemon threads
# TYPE jvm_threads_daemon_threads gauge
jvm_threads_daemon_threads 19.0
# HELP system_cpu_count The number of processors available to the Java virtual machine
# TYPE system_cpu_count gauge
system_cpu_count 8.0
# HELP tomcat_sessions_expired_sessions_total  
# TYPE tomcat_sessions_expired_sessions_total counter
tomcat_sessions_expired_sessions_total 0.0

여기서 system_cpu_count, jvm_threads_daemon_threads, tomcat_sessions_expired_sessions_total 각각이 Promethues가 인식할 수 있는 Metrics 가 되고, 각 메트릭 위에 HELP (도움말) 과 TYPE를 확인할 수 있다.

이제, 기본적인 메트릭 외에 다른 메트릭을 추가하는 것을 정리해보려고 한다.

타이머 추가하기

모니터링을 하는 일반적인 케이스는 특정 부분에 대해 실행 시간을 기록하고, 실행 시간이 어느 정도를 넘어가면 Alert를 연동하는 케이스가 있을 것이다.

이러한 타이머를 추가하기 위해, micrometer 는 여러 기능을 제공하는데, 먼저 Timer 객체에 대해 알아볼 필요가 있다.

Timer 객체는 아래와 같은 인터페이스를 제공한다.

public interface Timer extends Meter, HistogramSupport {
void record(long amount, TimeUnit unit);
   <T> T recordCallable(Callable<T> f)
   static Sample start()
}

record는 주어진 amount에 대한 기록, recordCallable 는 주어진 block에 대한 기록, start() 는 나중에 기록할 수 있는 Timer.Sample 객체를 제공한다.

가령, record는 아래와 같이 사용할 수 있다.

// registry 는 Autowired로 주입받은 MeterRegistry, 또는 SimpleMeterRegistry 등을 사용해도 된다. 
val timer = Timber.builder("query_get_gallery_execution_time").register(registry) 
​
fun recordMillis(name: String, millis: Long) {
    if (timerMap.containsKey(name)) {
        timerMap[name]?.record(Duration.ofMillis(millis))
    }
}
​
...
​
protected suspend fun <R> execute(name: String, block: suspend () -> R): R {
    val start = System.currentTimeMillis()
    val result = block()
    val time = System.currentTimeMillis() - start
    metricsTimer.recordMillis(name, time)
    return result
}

위에서는 execute라는 메서드로 통해 직접 time를 계산하고 있지만, timer.recordCallable 를 사용할 수도 있다.

이러한 방식으로 정의했을 경우, actuator의 endpoints에서는 아래와 같이 나오게 된다.

query_get_gallery_execution_time_seconds_max 
query_get_gallery_execution_time_seconds_count
query_get_gallery_execution_time_seconds_sum 

Timer 객체의 기본 TimeUnit는 Second로, Second에 대한 횟수와 전체 합계가 나온 것을 볼 수 있다. (이를 Grafana에 반영할 때의 Query는 후술하도록 한다.)

Timed 어노테이션

위 Timer 객체로 측정할 수 있지만, 좀 더 간편하게 메서드에 어노테이션을 달아주는 것 만으로도, 해당 메서드의 실행 시간을 측정할 수 있다.

그 것이 Timed 어노테이션이고, 만일 API Endpoint에 대해 측정하고 싶다면, Controller 메서드에 아래와 같이 달아줄 수 있다.

@Timed(value = "get-gallery")
@PostMapping("gallery")
fun getGallery(@RequestBody request: ...) = runBlocking {
    ...
}

이러한 방식으로 정의했을 경우, actuator의 endpoints에서는 아래와 같이 나오게 된다.

# HELP get_gallery_seconds_max  
# TYPE get_gallery_seconds_max gauge
get_gallery_seconds_max{exception="None",method="POST",outcome="SUCCESS",status="200",uri="...",} 
# HELP get_gallery_seconds  
# TYPE get_gallery_seconds summary
get_gallery_seconds_count{exception="None",method="POST",outcome="SUCCESS",status="200",uri="...",} 
get_gallery_seconds_sum{exception="None",method="POST",outcome="SUCCESS",status="200",uri="...",} 

Timer 객체를 다룰 때와 마찬가지로 기본적인 TimeUnit는 Second이므로, _max, _count, _sum 세 개가 정의된 것을 확인할 수 있다.

차이점은 exception, method, outcome, status, uri 등의 정보를 추가로 제공한다는 점이다. 또한, Timed 어노테이션에 value를 붙이지 않았을 때에도 아래와 같이 uri에 대한 정보가 나오게 된다.

http_server_requests_seconds_count{exception="None",method="GET",outcome="SUCCESS",status="200",uri=,}
http_server_requests_seconds_sum{exception="None",method="GET",outcome="SUCCESS",status="200",uri=,} 
http_server_requests_seconds_count{exception="IllegalStateException",method="GET",outcome="CLIENT_ERROR",status="400",uri=,} 
http_server_requests_seconds_sum{exception="IllegalStateException",method="GET",outcome="CLIENT_ERROR",status="400",uri=,} 

Prometheus에 추가하기

위와 같은 작업을 통해 Metrics를 추가했다면, 이제 Prometheus가 actuator endpoint에 접근할 수 있도록 prometheus.yml 에 아래와 같이 추가한다.

global:
  scrape_interval: 15s

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

...

  - job_name: '...'
    scrape_interval: 15s
    metrics_path: '/actuator/prometheus'
    static_configs:
    - targets: ['[container_name or ip]:[port]']

이제 15초마다 prometheus가 actuator에 접근하여 정보를 가져올 수 있게 되었다.

Grafana 연동하기

마지막으로, 추가한 Metrics 중 Timed 어노테이션으로 추가된 Metric 를 Grafana에 표시하려 한다.

Timer로 추가된 Metric 중 _sum, _count는 Counter 이므로, rate 를 추가하여 표시할 수 있다.

이를 이용해서, 5m 간의 평균 수행 시간을 산출할 수 있다.

사용한 Query는 rate(get_gallery_seconds_sum{status="200"}[5m]) * 1000 / rate(get_gallery_seconds_count{status="200"}[5m]) 로 아래와 같은 작업을 거친다.

  1. get_gallery_seconds_sum, get_gallery_seconds_count metrics에 대해 정상 응답(200) 인 것만 선택하여 5분동안의 수치를 꺼낸다.
  2. Timer 객체에서 기록/표시하는 단위가 서로 다르므로 (기록은 Millis, 표시는 Seconds) get_gallery_seconds_sum 에 1000을 곱하여 seconds 로 변환한다.
  3. 두 개의 식을 나눠준다.
  4. 표시 기준은 Last (Not Null), 단위는 milliseconds (ms) 를 선택한다.

oss-licenses-plugin 사용해보기

최근 개인 프로젝트를 진행하면서, 사용한 오픈소스 라이센스 라이브러리들에 대해 표시할 메뉴를 만들 일이 있었다.

기존에는 하나하나 조사했던 반면, 최근에 Google이 ‘oss-service-plugin’ 라는 플러그인을 만든 것을 알아내서 사용해보기로 했다.

사용

최상단의 build.gradle에 아래 의존성을 추가한다.

dependencies {
   ...
   classpath 'com.google.android.gms:oss-licenses-plugin:0.10.2'
   ...
}

(버전 참조: https://maven.google.com/web/index.html?q=oss-licenses-plugin#com.google.android.gms:oss-licenses-plugin:0.10.2)

그 다음, 앱 모듈의 build.gradle에 아래 의존성을 추가한다.

...
apply plugin: 'com.google.android.gms.oss-licenses-plugin'

...

dependencies {
   implementation 'com.google.android.gms:play-services-oss-licenses:17.0.0'
}

(버전 참조: https://maven.google.com/web/index.html?q=play-services-oss-licenses#com.google.android.gms:play-services-oss-licenses:17.0.0)

마지막으로, Setting 등의 공간에 메뉴를 추가하고 클릭했을 때 아래의 코드를 실행한다.

startActivity(Intent(requireContext(), OssLicensesMenuActivity::class.java))

적용 사진

OssLicensesMenuActivity 를 실행하면 위와 같이 사용한 라이브러리의 목록이 나오고, 각 목록을 클릭하면 해당 라이브러리의 라이센스 문서가 나온다.

위 처럼 의존성을 적어주고 startActivity를 해주는 것 만으로도 쉽게 구현할 수 있었다.

내부 살펴보기

기재한 플러그인인 ‘com.google.android.gms.oss-licenses-plugin’ 은 아래와 같은 작업을 가지고 있다.

getDependencies

코드: https://github.com/google/play-services-plugins/blob/master/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/DependencyTask.groovy

configuration 에서 의존성의 목록(ResolvedDependency, https://gradle.github.io/gradle-script-kotlin-docs/api/org.gradle.api.artifacts/-resolved-dependency/) 과 그의 child dependency 를 재귀로 통해 가져와, build/generated/dependencies.json 를 구성한다.

...
{
       "group": "com.google.dagger",
       "version": "2.28",
       "fileLocation": "C:\\Users\\winds\\.gradle\\caches\\modules-2\\files-2.1\\com.google.dagger\\dagger\\2.28\\3c1b86e40d4957297d6fde6bdce74b3f48aac49d\\dagger-2.28.jar",
       "name": "dagger"
},
...

generateLicenses

코드: https://github.com/google/play-services-plugins/blob/master/oss-licenses-plugin/src/main/groovy/com/google/android/gms/oss/licenses/plugin/LicensesTask.groovy

getDependencies에서 생성한 dependencies.json를 가지고 한 개씩 순회하면서 아래 작업을 진행한다.

  • 만일, 라이브러리의 그룹이 구글 라이브러리면, (com.google.android.gms 또는 com.google.firebase) addLicensesFromPom() 를 실행하고 그의 transitive licenses(전이된 라이센스) 를 추가한다.
    • 구글 라이브러리 이면, 각자의 라이브러리 파일에 “third_party_licenses.json”, “third_party_licenses.txt” 가 있으므로 Zip 파일에서 해당 파일을 얻어 appendLicense() 를 실행한다.
  • 아니라면, addLicensesFromPom() 를 실행한다.

addLicensesFromPom() 는 아래와 같은 작업을 진행한다.

  1. 아키텍트 파일에서 .pom 파일을 가져온다.
  2. .pom 파일에서 라이센스 정보를 가져와 third_party_licenses 파일에 작성한다.
  3. “${start}:${content.length} ${group}:${name}” 처럼 각 라이브러리에 대해 정보를 third_party_license_metadata 에 추가한다.

즉, third_party_licenses 파일은 표시할 모든 라이브러리 라이센스에 대한 내용을 가지고 있고, third_party_license_metadata 는 특정 라이브러리의 라이센스를 표시할 메타데이터를 가지고 있다.

third_party_license_metadata 에는 아래의 데이터들을 볼 수 있다.

0:46 androidx.databinding:databinding-runtime
48:46 androidx.lifecycle:lifecycle-common
96:46 androidx.annotation:annotation
144:46 androidx.arch.core:core-common
192:46 androidx.databinding:databinding-common
240:46 androidx.collection:collection
288:46 androidx.lifecycle:lifecycle-runtime
336:46 androidx.databinding:viewbinding
384:46 androidx.databinding:databinding-adapters
432:46 com.google.firebase:firebase-installations
480:46 androidx.localbroadcastmanager:localbroadcastmanager
528:47 com.google.android.gms:play-services-measurement-impl

그리고, third_pary_licenses에는 아래의 데이터들을 볼 수 있다.

http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
http://www.apache.org/licenses/LICENSE-2.0.txt
https://developer.android.com/studio/terms.html

  Copyright (c) 2005-2011, The Android Open Source Project

  Licensed under the Apache License, Version 2.0 (the "License");
  you may not use this file except in compliance with the License.

  Unless required by applicable law or agreed to in writing, software

이 데이터들을 가지고, OssLicensesMenuActivity 를 실행시키면 내부 코드에서 third_pary_licenses와 third_party_license_metadata 를 가지고 목록을 표시하거나, 내용을 표시하는 것을 알 수 있다.

for(int var6 = 0; var6 < var5; ++var6) {
  String var7;
  int var8 = (var7 = var4[var6]).indexOf(32);
  String[] var9;
  boolean var14 = (var9 = var7.substring(0, var8).split(":")).length == 2 && var8 > 0;
  String var10002 = String.valueOf(var7);
  String var10001;
  if (var10002.length() != 0) {
      var10001 = "Invalid license meta-data line:\n".concat(var10002);
  } else {
      String var10003 = new String;
      var10001 = var10003;
      var10003.<init>("Invalid license meta-data line:\n");
  }

  String var13 = var10001;
  if (!var14) {
      throw new IllegalStateException(String.valueOf(var13));
  }

  long var10 = Long.parseLong(var9[0]);
  int var12 = Integer.parseInt(var9[1]);
  var3.add(zzc.zza(var7.substring(var8 + 1), var10, var12, var1));
}

Use MediaCodec in NDK

이 글은 아래 글의 후속이다.

Android MediaCodec, MediaMuxer 살펴보기

작업을 하다 보니까, 하위 버전에서 오래 걸리는 문제가 있어 고민하던 차에 ‘ndk를 이용해 cpp로 작업하면 좀 더 빠르지 않을까?’ 하는 생각이 있었다.

일단, 결론은 다음과 같다.

  • 데모용 코드로 작성했을 때 성능은 12% 정도 이득을 보임
  • API 26 이상부터 지원 가능

이러한 이유 때문에 실제로 도입은 못했지만, 그래도 어느정도 매칭은 가능해서 사용이 가능했었고, 몇 가지 겪었던 문제를 소개하고자 한다.

MediaExtractor – setDataSource 에서 FileDescriptor

MediaExtractor에 설정할 때, FileDescriptor 로 통해 설정하는 경우가 있다.

먼저, FileDescriptor를 파라미터에 선언한다.

external fun convert(input: FileDescriptor, inLength: Long, output: FileDescriptor, outLength: Long)

이에 대한 JNI Header는 다음과 같다.

extern "C"
JNIEXPORT jint JNICALL
Java_com_github_windsekirun_***_***_convert(JNIEnv *env, jobject thiz,
                                           jobject input,
                                           jlong inLength,
                                           jobject output,
                                           jlong outLength)

FileDescriptor 는 jobject 로 매칭되는데, 이를 FileDescriptor (정확히는 FileDescriptor.descriptor) 로 변환하려면 아래와 같은 코드를 사용한다.

static int jniGetFDFromFileDescriptor(JNIEnv * env, jobject fileDescriptor) {
   jint fd = -1;
   jclass fdClass = env->FindClass("java/io/FileDescriptor");

   if (fdClass != NULL) {
       jfieldID fdClassDescriptorFieldID = env->GetFieldID(fdClass, "descriptor", "I");
       if (fdClassDescriptorFieldID != NULL && fileDescriptor != NULL) {
           fd = env->GetIntField(fileDescriptor, fdClassDescriptorFieldID);
      }
  }

   return fd;
}

마지막으로, 얻은 FileDescriptor는 아래와 같이 설정한다.

AMediaExtractor *extractor = AMediaExtractor_new();
media_status_t amresult = AMediaExtractor_setDataSourceFd(extractor, inputFd, 0, inLength);
if (amresult != AMEDIA_OK) {
   LOGE("Error setting extractor data source, err %d", amresult);
   return -1;
}

여기서 media_status_t 는 NdkMediaError.h 에 선언되어 있다. (https://cs.android.com/android/platform/superproject/+/master:frameworks/av/media/ndk/include/media/NdkMediaError.h;l=43?q=NdkMediaError.h)

MediaFormat 에서 KEY_COLOR_FORMAT 설정

지난 글에서는 setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) 로 설정했었는데, NDK에서는 아래와 같이 사용한다.

AMediaFormat *outputFormat = AMediaFormat_new();
AMediaFormat_setInt32(outputFormat, AMEDIAFORMAT_KEY_COLOR_FORMAT, 2130708361);

여기서 2130708361MediaCodecInfo.CodecCapabilities 에 선언되어 있는 COLOR_FormatSurface 의 값이다. (https://cs.android.com/android/platform/superproject/+/master:frameworks/base/media/java/android/media/MediaCodecInfo.java;l=218?q=CodecCapabilities)

Decoder용 MediaCodec에서 Surface 객체 관련

이 것이 도입을 하지 못하게 했었던 ‘API 26’ 문제 중 하나인데, surface = encoder.createInputSurface() 에 대응되는 아래 코드가 API 26부터 사용이 가능했다.

ANativeWindow *surface;
AMediaCodec_createInputSurface(encoder, &surface);
media_status_t decoderConfigure = AMediaCodec_configure(decoder, inFormat, surface, nullptr, 0);

Backport 되어 있는 것이 있는지 찾지는 못했지만, 위와 같이 사용할 수 있다.

Encoder에 SignalEndOfInputStream 보내기

encoder.signalEndOfInputStream() 에 대응되는 코드인 AMediaCodec_signalEndOfInputStream(encoder); 가 API 26부터 사용이 가능했다.

설명상으로는 Equivalent to submitting an empty buffer with AMEDIACODEC_BUFFER_FLAG_END_OF_STREAM set 로 되어있어, encoder의 inputBuffer에 AMEDIACODEC_BUFFER_FLAG_END_OF_STREAM 를 설정하면 될 것 같아서 시도해 보았지만, sf MediaCodecError -38 이라는 오류가 나오는 것 같았다.

이 것도 위와 마찬가지로 적절한 해결 방법을 찾지 못했다.

레퍼런스