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

Spring Boot + JDBC Template 이용하기

대부분의 경우에는 ORM (서버라면 JPA, 안드로이드는 ObjectBox) 를 사용했었지만, 이번 개인 프로젝트를 진행하면서 ORM을 사용하지 않게 되었다.

그 이유는, 안드로이드의 경우에는 한번 쯤은 Room을 제대로 써보고 싶다는 생각이 들었기도 했고, 서버에서는 비교적 쿼리가 들어가기 때문이었다.

물론, 쿼리의 복잡성 때문에 ORM을 선택하지 않았다는 이유는 아니고, 다른 오픈소스 프로젝트를 Spring Boot로 포팅하면서 기능을 덧붙이는 방식으로 진행했었기 때문에, DB 구조 등을 그대로 가져왔기 때문이다.

DB 구조는 표현하면 다음과 같다.

작품 테이블 - 작품 ID, 작품의 정보, 작가 등
태그 테이블 - 태그 ID, 태그 이름
작품ID-태그ID 테이블 - 작품 ID, 태그 ID (1:N)

그리고 클라이언트가 원하는 자료에는 태그 정보가 당연하게도 필요했었고, 검색하는 필터에도 태그 정보가 당연하게 들어가게 된다.

이를 위해 가능하기 위해 태그 정보까지 검색하고, 태그 정보까지 불러올 수 있는 기능을 최소한의 LEFT JOIN과 INNER JOIN을 사용해서 하려고 했었고, 결국에는 Spring Boot에서 query string를 구성해서 실행하는 방식으로 하기로 결정했다.

그렇게 되면, Spring Boot에서 ORM을 사용하지 않고 바로 데이터 소스에 연결해서 사용해야 하는데, 그 때 JDBC Template를 사용하면 비교적 쉽게 할 수 있다.

따라서 본 글에서는 간단하게나마 Spring Boot 2.1 + JDBC Template 기반으로 쿼리를 실행하고 데이터를 받을 수 있도록 다뤄보려 한다. 참고로 본 예제에서 사용한 DB는 MariaDB이다.

임포트

implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
implementation("org.springframework.boot:spring-boot-starter-jdbc")
runtimeOnly("mysql:mysql-connector-java")

MariaDB를 connector로 사용할 것이므로 mysql-connector-java도 같이 둔다.

모델 정의

여기서 모델이란 DB 결과를 파싱해서 보여줄 수 있는 클래스 객체이며, ORM의 Entity와는 다르게 어떠한 부가정보도 필요로 하지 않는다.

단, 이 클래스의 구현은 RowMapper와 관련이 있는데, 만일 쿼리의 SELECT 로 가져오는 Column들에 대해서 같은 이름으로 클래스의 프로퍼티를 만들어주는 것이 가능하다면, BeanPropertyRowMapper 라고 하는 Mapper로 쓸 수 있기 때문이다.

즉, 쿼리에서 나올 항목이 id, name 이라면 해당 클래스는 똑같이 id, name를 담고 있으면 된다.

applications.properties

## 데이터 소스 접속 정보 설정
spring.datasource.url=jdbc:mysql://{ADDRESS}/{DB_NAME}?characterEncoding=UTF-8&serverTimezone=UTC
spring.datasource.username={USERNAME}
spring.datasource.password={PASSWORD}

## 로깅 설정
logging.path=logs
logging.level.com.tutorial.springboot=DEBUG

## HikariCP에서 사용되는 타임아웃 시간 정의
spring.datasource.hikari.idleTimeout=40000
spring.datasource.hikari.connection-timeout=5000
spring.datasource.hikari.validation-timeout=10000
spring.datasource.hikari.maxLifetime=580000

Spring Boot 2.0 부터 HikariCP가 기본 Connection Pool 관리 도구로 선정되었으므로, HikariCP의 설정을 포함하면서도 기본 데이터 소스에 대한 접속 정보를 설정한다.

상기된 것 처럼 여기에서는 MariaDB를 사용할 것이므로 jdbc:mysql:// 를 사용했다.

쿼리 실행하기

쿼리는 Repository에서 접근하는 구조로 간다고 가정해본다.

Repository에서는 JdbcTemplate 라는 클래스를 Autowired로 주입받게 되면, SpringBootApplication이 실행될 때 applications.properties 에 기재된 접속 정보를 가지고 DataSource 객체를 생성해서 JdbcTemplate 객체로 관리할 수 있게 된다.

@Autowired
private lateinit var jdbcTemplate: JdbcTemplate

또는, 전통적인 ? 를 대신해 파라미터 이름을 사용할 수 있게 하는 NamedParameterJdbcTemplate 도 사용이 가능하다.

@Autowired
private lateinit var jdbcTemplate: NamedParameterJdbcTemplate

쿼리는 jdbcTemplate.query(String, RowMapper) 로 실행이 가능하고, NamedParameterJdbcTemplate 한정으로 Param을 넣을 수 있는 jdbcTemplate.query(String, Map, RowMapper) 사용이 가능하다.

가령, 유저의 id와 이름을 가져오는 query를 실행한다면, 아래와 구현할 수 있다.

@Repository
class JdbcUserRepository : UserRepository {

  @Autowired
  private lateinit var jdbcTemplate: NamedParameterJdbcTemplate

  override fun findAll(): List<User> {
      val query = "SELECT id, name FROM users"
      return jdbcTemplate.query(query, BeanPropertyRowMapper(User::class.java))
  }
}

전 챕터에서 잠깐 언급되었던 BeanPropertyRowMapper 는 주어진 클래스 객체를 가지고 결과를 파싱할 수 있게 도와주는 클래스로, 편의성을 위해 사용된다. (성능을 고려한다면, 직접 RowMapper 클래스를 구현하여 사용하도록 권장되고 있다.)

만일 RowMapper를 직접 구현한다면 아래와 같다.

@Repository
class JdbcUserRepository : UserRepository {

  @Autowired
  private lateinit var jdbcTemplate: NamedParameterJdbcTemplate

  override fun findAll(): List<User> {
      val query = "SELECT id, name FROM users"
      return jdbcTemplate.query(query) { rs, rowNum ->
          User().apply {
              name = rs.getString("name")
              id = rs.getString("id")
          }
      }
  }
}

그 외에도 가변 인자를 사용할 수 있는 SimpleJdbcTemplate 등도 있으나 더 언급하지는 않을 예정이다.

JdbcTemplate 의 이점

먼저, JdbcTemplate는 JDBC 사용을 단순화하고 일반적인 오류를 피하는 데에 큰 도움을 줄 수 있다는 것이다. 위의 코드를 보면 단순히 개발자가 한 것은 접속 정보를 설정하고 Repository에서 @Autowired 어노테이션으로 통해 인스턴스를 주입받은 것 밖에는 존재하지 않는다.

이는 나머지 처리에 대해 JdbcTemplate가 적절하게 처리해준다는 것을 보여주기도 하며, org.springframework.dao 패키지에 정의된 것 보다 유익한 계층 구조로 반환해줄 수 있다는 의미이다.

또한, JdbcTemplate의 인스턴스는 한번 메모리에 로드되면 Thrad-Safe 하게 된다.

즉, 한번 생성된 인스턴스는 SpringBootApplications 내부에 있는 Repository들에게 공유된 참조를 보내줄 수 있는데, 이는 JdbcTemplate가 DataSource에 대한 상태는 가지고 있지만 이 상태는 대화 상태(Conversational state) 가 아니다는 특성을 가지고 있기 때문이다.

물론 이러한 특성을 가진 탓에 실행하는 쿼리에 maxResult를 설정하는 것이 중요한데, 이는 하나의 Repository에서 하는 작업이 다른 Repository에 영향을 줄 수 있기 때문이다.

Build and Deploy Dockerfile in Jenkins

[Start RESTful Service With Spring Boot 2.x + Kotlin] 글에서 유일하게 다루지 못했던 주제가 있었다. 바로 Jenkins를 이용한 CI/CD 연동이다.

글을 올린지 약 1달 반 정도 지났지만, 성공적으로 빌드할 수 있게 되었고 이를 작성해보려 한다.

환경설정

Jenkins 내부에서 Docker를 빌드하려면 Docker 바이너리가 Jenkins에 포함되어 있어야 한다. Jenkins 역시 도커 이미지로 배포되기 때문에, 먼저 커스텀 Jenkins 이미지를 수정할 필요성이 있다.

Docker + Jenkins 연동은 [Run Docker + Jenkins for Android Build] 글에서 다루니 그쪽부터 보면 좋을 것 같다.

Docker를 설치하기 위해서 추가적으로 필요한 의존성은 다음과 같다.

  • ca-certificates
  • curl
  • gnupg2
  • file
  • lxc
  • apt-transport-https

이 6개를 apt install로 통해 설치하고 순서대로 명령어를 입력하면 된다.

## Install requirements
RUN dpkg --add-architecture i386
RUN rm -rf /var/lib/apt/list/* && apt-get update && apt-get install ca-certificates curl gnupg2 software-properties-common git unzip file apt-utils lxc apt-transport-https -y

그 다음, docker 서버로부터 인증서와 레포지토리 주소를 받아서 설치하면 된다.

## Install Docker-ce into Image
RUN curl -fsSL https://download.docker.com/linux/$(. /etc/os-release; echo "$ID")/gpg > /tmp/dkey;
RUN apt-key add /tmp/dkey
RUN add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/$(. /etc/os-release; echo "$ID") $(lsb_release -cs) stable"
RUN apt-get update && apt-get install docker-ce -y --no-install-recommends
RUN usermod -a -G docker jenkins

여기까지 마치면 Jenkins 이미지에 Docker-CE 바이너리가 설치된다.

아니면, 업데이트 된 [WindSekirun/Jenkins-Android-Docker] 이미지를 활용해도 문제는 없다.

JenkinsFile 작성

Spring-Boot 프로젝트를 빌드하고, 이를 Docker로 배포하는 과정에 있어서 Jenkins에 필요한 작업을 정의하는 작업이 필요하다.

Jenkins는 각 파이프라인 상에서 수행해야 될 작업을 Jenkinsfile 이라는 파일에 정의하고, 이를 빌드할 때 마다 사용할 수 있다.

필요한 작업을 정리하면 다음과 같을 것이다.

  • 환경설정
  • Gradle 빌드
  • DockerImage 빌드
  • DockerHub로 이미지 푸시
  • 빌드된 이미지 제거

이 중, gradle 까지의 작업을 반영하면 다음과 같다

pipeline {
    agent any
    stages {
        stage('Environment') {
            parallel {
                stage('chmod') {
                    steps {
                        sh 'chmod 755 ./gradlew'
                    }
                }
                stage('display') {
                    steps {
                        sh 'ls -la'
                    }
                }
            }
        }
        stage('Build Jar') {
            steps {
                sh './gradlew build'
            }
        }
     }
}

Docker 이미지 빌드

환경변수에 registry 이라는 변수를 정의한다.

environment {
        registry = "windsekirun/uploadfileboot"
}

registry 는 이미지에 대한 태그이며, https://hub.docker.com/r/windsekirun/uploadfileboot 이런 식으로 접근이 가능해진다.

그 다음 docker 빌드 명령어를 작성한다. 기본적인 빌드 명령어는 docker build -t windsekirun/uploadfileboot:latest .이므로 Jenkinsfile의 형식에 맞게 작성하면 된다.

stage('Build docker image') {
            steps {
                sh 'docker build -t $registry:latest .'
            }
}

latest 대신 $BUILD_NUMBER 나 다른 환경변수를 이용해서 사용도 가능하다.

빌드된 이미지 푸시

Jenkins 설정 > 인증정보에 정보를 추가한다

그리고 환경변수에 추가한 정보의 id를 정의하면, 준비는 완료된다.

environment {
        registry = "windsekirun/uploadfileboot"
        registryCredential = 'dockerhub'
}

마지막으로, registry에 푸시하는 명령어는 docker push windsekirun/uploadfileboot:latest 이므로, Jenkinsfile 형식에 맞춰 작성한다. 다만 crediental 정보가 필요하므로 withDockerRegistry 명령어를 통해 crediental 정보를 입력한다.

stage('Deploy docker image') {
            steps {
                withDockerRegistry([ credentialsId: registryCredential, url: "" ]) {
                    sh 'docker push $registry:latest'
                }
            }
}

빌드한 이미지 삭제

빌드된 이미지를 계속 남겨둘 필요는 없으므로, 지금까지 빌드한 이미지를 삭제한다.

stage('Clean docker image') {
            steps{
                sh "docker rmi $registry"
            }
}

전체 코드

pipeline {
    environment {
        registry = "windsekirun/uploadfileboot"
        registryCredential = 'dockerhub'
    }
    agent any
    stages {
        stage('Environment') {
            parallel {
                stage('chmod') {
                    steps {
                        sh 'chmod 755 ./gradlew'
                    }
                }
                stage('display') {
                    steps {
                        sh 'ls -la'
                    }
                }
            }
        }
        stage('Build Jar') {
            steps {
                sh './gradlew build'
            }
        }
        stage('Build docker image') {
            steps {
                sh 'docker build -t $registry:latest .'
            }
        }
        stage('Deploy docker image') {
            steps {
                withDockerRegistry([ credentialsId: registryCredential, url: "" ]) {
                    sh 'docker push $registry:latest'
                }
            }
        }
        stage('Clean docker image') {
            steps{
                sh "docker rmi $registry"
            }
        }
     }
}

이 Jenkinsfile 를 가지고 Jenkins를 설정하면 다음과 같이 나오게 된다.

마지막으로 push된 이미지는 Dockerhub 홈페이지에서 확인할 수 있다.

마무리

Jenkins 를 이용해 도커 이미지를 빌드하고 배포하는 과정까지 살펴보았다.

물론 master에 빌드한 것을 그대로 push하는 것은 매우 위험하기 때문에 개발하는 사이클마다 별도로 적용해야 되지만, 개론적인 것은 살펴본 것과 크게 다르지는 않을 것이다.

위에 살펴본 DockerHub에 올리는 것 말고도 private registry에 올리는 것 또한 가능하기에 선택하기 나름일 것 같다.