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) 를 선택한다.

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 에서 볼 수 있다.