NoSQL

Prometheus TimeSeries DB 취급기

0. 시작하며

최근 NoSQL DB – Prometheus를 사용한 코드 구현적 이야기를 정리 하고자 한다. Prometheus를 Java Client로 다루면서 여러가지 개인적인 경험을 정리하고자 한다.

최근에는 개인적인 일이 몇가지 발생해서, 다소 기억적인 부분에서 혼선이 있고(포스팅하는 글들이 이상할수 있다는 빠져나가기 위한 떡밥이다.) 포스팅에도 소홀한 점이 있었지만, 그래도 생각을 정리하는 차원에서 포스팅을 시작해 본다.

1. NoSQL – Prometheus

NoSQL 주제에 대한 내용은 우리 Guild에서 이번에 2번째 포스팅이 된다. (지난 포스팅은 @Tom 이 작성한 NoSQL 입문이 있다. (참고) https://t3guild.com/archives/207) 당시에는 InfluxDB를 Time Series 대표로써 설명하고 마무리가 되었지만, 이번에 이야기 할 내용은 Prometheus 이다.

https://db-engines.com/en/ranking

DBMS 인기 순위에서 포스팅하는 현재 기준으로 InfluxDB는 30위, Prometheus는 72위로 되어 있다. 순위가 무슨 의미가 있는지 모르겠지만, 사용하고자 하는 용도에 따라 선택하는 것이 중요하다고 생각된다.

그럼, TimeSeries DB로써 Prometheus를 사용한 이유는 무엇때문이었을까?

수집방식(Scrape)

Prometheus는 폴링 방식으로 데이터를 수집한다. 특이하다. 지금까지 DataBase라 함은 누군가는 데이터를 넣어주고, 누군가는 읽어가며, 누군가는 변경하거나 삭제하는 형태 였는데, 여기서는 데이터 원천이 있는 곳에서는 정보를 표출만 할뿐, 일정 주기로 데이터를 가져온다. IT적인 관점으로 생각하면, 많은 생각이 머리속에 지나가지 않는가? 그렇다. 생각나는 몇가지 의문점들이 있다. 그러나 이런 의구심은 뒤로 하고, 폴링 하는 방식이 환경적으로 제약될 수 있는 단점을 극복하는 최선의 선택사항이 되었다.

Http 통신

지금까지 경험한 여타의 DB들은 Java언어 기준으로 각기 제공하는 JDBC 드라이버가 존재했고, 없더라도 그에 유사한 API라도 제공하는 방식이었는데, Prometheus는 아예 없다. 처음에는 정말 열심히 검색 했는데, 결과가 없길래 검색능력이 없거나 떨어진줄 알았다. (사실 Prometheus 홈페이지에도 이와 같은 부분에 대해 설명되어 있고, 국내 번역서들에도 잘 나와 있지만… 고정관념이 이렇게 무섭다)

결론적으로, Prometheus의 모든 질의는 Http 통신으로 제공한다. Data 제어도 일부 Admin Web UI 옵션(–web.enable-admin-api)을 넣으면 가능하고, Client에서의 질의는 Http 통신으로 구현해야 한다. 이부분에서 자바의 고충이 들어나는데, 아래부분에서 좀더 자세히 정리하겠다.

Web UI와 그래프

Http 통신으로 질의가 제공되기 때문에 사용자는 Prometheus에 대한 Web UI 기본 화면을 제공받고, 질의를 수행하면 즉시 데이터를 그래프화 되어 확인 할 수 있다.

2. Prometheus 데이터 생성

데이터 생성되는 측면을 보면, 잠시 이야기 한 바와 같이 Prometheus는 폴링 방식으로 동작한다. 따라서, 데이터를 발생하는 주최는 Prometheus가 가져갈 수 있는 방법으로 데이터를 제공해야 하며, 이를 Exporter라고 부른다.

이미 수많은 Exporter들이 제공되고 있으나, 때로는 적합한 Export 검색에 실패하여 개발하는 사례도 종종 있다. 이렇듯 Open Source 생태계에는 언제나 내게 딱 맞는 경우는 쉽게 존재하지 않는 듯 하고, 조금은 손대야 할 것 같은 불안감을 가끔은 마주 하기도 한다.

https://prometheus.io/docs/instrumenting/exporters/

Export 종류에는 RDBMS 또는 Redis에 대한 DataBases 카테고리를 제공하고 있고, Nvidia GPU 같은 H/W 뿐만 아니라, Issue Tracker 솔루션들인 Jira, Bamboo 들도 제공하고 있으며, Jenkins 도 제공하고 있다. Open Source 생태계에 아주 잘 되어 있는 편이지만, 결국 내가 사용하고자 하는 목적에 맞게 제공하는가를 필히 확인해 봐야 한다.

이렇게 이야기를 시작하는 이유는 포스팅에서 제공된 Exporter를 사용한 데이터 폴링 방식이 아닌, Custom 하게 Exporter를 생성해서 취급하는 과정을 언급하고자 함이다.

Prometheus Exporter – Java Spring Boot

SpringBoot에서는 몇가지 설정으로 Prometheus Export를 제공할 수 있다. application.yml 또는 properties 내부에 아래와 같이 exposure를 설정하여 actuator를 활용해서 사용하는 방식이며, 관련된 Library를 추가한다. Library는 io.prometheus:simpleclient를 사용하여, 직접적인 Custom Export를 구현하기 위한 부분도 포함하여 추가한다.

-- application.yml
management:
    web:
      exposure:
        include: health,loggers,metrics,prometheus
-- build.gradle
...
implementation('org.springframework.boot:spring-boot-starter-actuator')
implementation('io.micrometer:micrometer-registry-prometheus')
compile('io.prometheus:simpleclient:0.9.0')

설정이 되고 SpringBoot를 동작하여, 기본 URI에 prometheus export가 생성되고 아래와 같이 기본적인 항목들이 제공된다.

❯ curl localhost:8080/actuator/prometheus

# HELP jvm_gc_memory_allocated_bytes_total Incremented for an increase in the size of the young generation memory pool after one GC to before the next
# TYPE jvm_gc_memory_allocated_bytes_total counter
jvm_gc_memory_allocated_bytes_total 3.48127232E8
# HELP process_files_open_files The open file descriptor count
# TYPE process_files_open_files gauge
process_files_open_files 164.0
# HELP tomcat_threads_config_max_threads
# TYPE tomcat_threads_config_max_threads gauge
tomcat_threads_config_max_threads{name="http-nio-8080",} 200.0
# HELP tomcat_sessions_active_max_sessions
# TYPE tomcat_sessions_active_max_sessions gauge
tomcat_sessions_active_max_sessions0.0
# HELP jvm_threads_daemon_threads The current number of live daemon threads
# TYPE jvm_threads_daemon_threads gauge
jvm_threads_daemon_threads 30.0

SpringBoot로 Exporter를 사용하는 경우 기본사항으로 JVM과 Embeded Tomcat에 대한 부분들이 제공되고 있다. 내용 구성은 Key, Value 방식이고, 항목의 상위 라인에 #으로 설명을 제공한다. Prometheus는 이 내용을 설정에 따라 일정 주기 별로 수집하여, 보관하며, 사용자가 원하는 방식의 질의에 제공하는 형태이다. (Prometheus에 어떻게 설정하는지는 Prometheus 관점에 대한 부분으로 생략하고, 이번 포스팅은 Exporter에초첨을 둔다)

Custom Exporter – Java Spring Boot

원하는 Exporter를 개발하기 위해서는 위에서 추가한 “io.prometheus:simpleclient” Library를 사용하여 Java에서 가능하다. Prometheus에서 제공하는 자료 타입에는 Counter, Gauge, Summary, Histogram 등이 있으나, 설명에는 Gauge에 대해서 예로 사용하였다.

import io.prometheus.client.CollectorRegistry;
import io.prometheus.client.Gauge;



@Slf4j
@Service
public class CustomExporterService {
    private Gauge sampleGauge;

    public CustomExporterService(CollectorRegistry registry) {

        sampleGauge = Gauge.build()
            .name("sample_counts") // Gauge 이름 지정
            .help("Sample Counts") // Gauge 주석 지정
            .labelNames("labelA", "labelB")  // Data Label을 2개 설정함.
            .register(registry); // Exporter Registry 등록
    }

    public void setMetric(List<DataSet> dataSets) {

        // 발생된 DataSets 을 건별로 Exporter를 생성함.
        dataSets.parallelStream().forEach(data -> {

        // sample test gauge
        sampleGauge
                .labels(
                        data.getLabelA(), // Data에 설정된 Label A를 획득
                        data.getLabelB()  // Data에 설정된 Label B를 획득
                )
                .set(data.getData()); // 데이터 설정
        });
    }
}

CustomerExporterService는 SpringBoot 에서 개발하는 Service이다. 멤버 변수에 sampleGauge를 설정하면서, 명칭과 설명을 등록한다. 이 과정에서 Label을 추가하는데, Prometheus에서 Label을 효과적으로 사용하는 방법에 대해서는 간략하게 위와 같이 해당값을 발현하는 2개의 식별 이름이 있는 것으로 정의하자. Label A1, B1 인 데이터는 10이라고 발생되었고, Label A2, B2 인 데이터는 20 이라고 발생 되는 형태라고 인식하자. 그리고 Registry에 등록하여 이제 이 Metric 항목은 Prometheus로 부터 수집 가능해 진다.

이제 기동하고, 데이터가 발현되었을 때, Metric을 확인해 보자

# HELP Sample Counts
# TYPE sample_counts gauge
sample_counts{labelA="A1", labelB="B1"} 10.0
sample_counts{labelA="A2", labelB="B2"} 20.0

Prometheus로 부터 이제 일정 주기마다 위와 같이 발현된 값은 폴링이 된다. 폴링의 방식이기 때문에 폴링 간격보다 이 항목이 빠르게 바뀐다면, 중간 변화 데이터는 보여지지 않지만, Prometheus는 시간에 따른 데이터는 최종 수집값이 의미가 있다면, 추세적인 중간 데이터는 가늠하여야 한다. 데이터의 발현 시점이 민감하다면, 그만큼 수집주기를 빠르게 하면 되고, 최종 값으로 해당 상황에 변화가 감안된다면 주기는 느슨하여도 된다.

3. Prometheus 데이터 취급

Export에 의해 생성된 데이터는 Prometheus에 의해 폴링되어 수집되었기에 데이터의 크기나 보관정책등 다양한 측면의 문제는 Prometheus의 숙제가 되었고, 데이터를 발현하는 Exporter는 이에 대한 부담이 끝났다. 앞으로는 Prometheus 안에서 데이터를 어떻게 취급하여 사용할 것인가에 집중할 수 있게 되었다.

기본포트 9090으로 동작 중인 Prometheus
global:
  scrape_interval: 15s
  scrape_timeout: 10s
  evaluation_interval: 15s
alerting:
  alertmanagers:
  - static_configs:
    - targets: []
    scheme: http
    timeout: 10s
    api_version: v1
scrape_configs:
- job_name: prometheus
  honor_timestamps: true
  scrape_interval: 15s
  scrape_timeout: 10s
  metrics_path: /metrics
  scheme: http
  static_configs:
  - targets:
    - localhost:9090
- job_name: node
  honor_timestamps: true
  scrape_interval: 15s
  scrape_timeout: 10s
  metrics_path: /metrics
  scheme: http
  static_configs:
  - targets:
    - 192.168.10.244:9100
- job_name: custom_exporter
  honor_timestamps: true
  scrape_interval: 1m
  scrape_timeout: 10s
  metrics_path: /actuator/prometheus
  scheme: http
  static_configs:
  - targets:
    - 172.30.18.8:8080

Prometheus Config 설정으로 수집 대상을 지정가능하다. 위에 Config 구성에는 ‘custom_exporter’ 라는 Job 항목에 1분 주기로 Spring Boot를 확인하고 있으며, 그 외에도 Node Exporter도 대상이다. 마지막으로 자기 자신도 대상에 포함된다. 따라서 총 3개의 Scape Job 구성이 되어 있는 상태이다.

다른 Metric 도 충분히 질의 가능하지만, Custom으로 만든 Matric 질의를 시도하자.

질의가 완료 되었다. 이제 다양한 질의 방법으로 원하는 방식으로 데이터를 표현해야 한다. 위와 같이 단순히 Metric만 명시하는 것은 최종 Scape 된 시점의 데이터 Value가 표현되고 있다. (Prometheus의 다양한 질의 방법과 Function등에 대해서는 Prometheus 주제가 아니라 접근하는 Client에 대한 방법에 집중하기로 한다.)

이제 포스팅의 핵심인 Prometheus 질의를 Java Client로 호출 하는 기능을 이야기 해 보자. 서론에 이야기 했듯이 Prometheus는 별도의 JDBC나 API제공없이 Http 통신으로 Client로 부터 구현해야 한다. 물론 이런 Client 구현을 왜? 해야 하는가는 데이터를 원하는 표현 방식에 직접적으로 가공하며, 또다른 그래픽한 표현 방식으로 제공할 수 있기 때문이다. 참고로 Go언어의 경우에는 API를 통해 수집된 데이터를 변경도 가능하지만, 일반적으로는 질의만 가능하다.

Prometheus와 Grafana의 밀접한 관계로 인해 대부분의 그래프화는 Grafana에 의해 질의하여 처리 되는 것이 일반적이지만, 데이터를 직접적으로 취급한다고 하여 별도의 데이터 포멧이 필요하다고 하였을 때 Java로 어떻게 해야 하는 가에 진행해 보자.

Http Client – RestTemplate

Prometheus와 통신하기 위한 Http Client로 Spring Boot 의 RestTemplate를 사용한다.GET, POST 둘다 가능하지만, POST 방식으로 아래와 같이 구현 하였다.

private <T> List<T> queryPrometheusServer(String query) {

        // Return Type
        List<T> list = new ArrayList<>();

        // Post Parameters
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("query", query);

        final HttpEntity<?> requestEntity = new HttpEntity<>(body, createHeader());

        try {
            ResponseEntity<?> responseEntity = restTemplate.exchange(
                    new URI("http://localhost:9090/api/v1/query"),
                    HttpMethod.POST,
                    requestEntity,
                    Map.class
            );

            // Success 인 경우만 처리
            Map<String, Object> responseBody = (Map<String, Object>) responseEntity.getBody();
            if ( responseBody.get("status").equals("success") ) {

                LinkedHashMap<String, Object> dataBody = (LinkedHashMap<String, Object>) responseBody.get("data");

                // Instant Vector 데이터 조회
                if (dataBody.get("resultType").equals("vector")) {

                    ((List<Map<String, Object>>) dataBody.get("result")).stream().forEach(data -> {

                        list.add(
                                (T) InstantVectorDataType.builder()
                                        .metric((Map<String, String>) data.get("metric"))
                                        .value( getInstantDataValue((List<Object>) data.get("value")))
                                        .build()
                        );

                    });
                }
                // Range Matrix 데이터 조회
                else if (dataBody.get("resultType").equals("matrix")) {

                        ((List<Map<String, Object>>) dataBody.get("result")).stream().forEach(data -> {

                            list.add(
                                    (T) RangeMatrixDataType.builder()
                                            .metric((Map<String, String>) data.get("metric"))
                                            .values( getMatrixDataValue((List<List<Object>>) data.get("values")))
                                            .build()
                            );
                        });
                    }
                else {
                    throw new NoSuchElementException("알수 없는 Prometheus 데이터 타입이 발견되었습니다.");
                }
            }
        }
        catch ( Exception e ) {
            throw new Exception("Error", e);
        }

        return list;
    }

Prometheus URI는 ‘/api/v1/query’로 Post 방식으로 Query를 던지면 수행되고, 결과는 ‘status’ 에 결과가 성공인지 알 수 있다. 이후 실제 데이터는 ‘data’에 발생되는데, 질의 유형에 따라 InstantVector와 RangeMetric으로 생성한다. Prometheus에 질의 내용에 현재 시점의 최근 데이터만 원하는가? 일정 기간동안 검색된 결과를 원하는가로 생각하면 된다. Java에서는 이에 따라 데이터 적재를 달리 해야 하기에 각각의 데이터 타입에 맞는 Class를 구현하였다.

    /**
     * Prometheus - InstantVector 
     */
    @Data
    @Builder
    static class InstantVectorDataType {
        Map<String, String> metric;
        DateTimeValue value;
    }


    /**
     * Prometheus - RangeMatrix
     */
    @Data
    @Builder
    static class RangeMatrixDataType {
        Map<String, String> metric;
        List<DateTimeValue> values;
    }

   /**
     * Prometheus DateTimeValue
     */
    @Data
    @Builder
    static class DateTimeValue {
        LocalDateTime dateTime;
        Object dateTimeValue;
    }

위와 같은 형태다. metric은 Label을 어떻게 표현하느냐에 따라 다양하게 제공될 수 있기 때문에, prometheus by, without으로 명시하는 항목에 따라 해당 Label 단위로 Map에 Key가 생성된다. 실제 값에 대한 부분은 다시 DateTimeValue 라는 객체로 한번 더 정의하여 시간과 값을 표시가능하도록 해두었다. Instant Vector는 단건, Range Matrix는 복수 구성의 차이가 있다. DateTimeValue는 값이 시간일 수도 있고, 사용자가 생성한 값일 수도 있기에, 이부분은 Object로 대체하였고, 실제 시간적인 요소만 LocalDateTime으로 처리 하였다.

4. 마무리

Java Client Code에서 추가적으로 더 작업이 되어야 겠지만, 대략적인 코드 컨셉으로 이해 되었으면 한다. 실질적으로는 데이터를 조회한 결과에서 시간값도 변환해야 하고, 중간중간 객체 Type에 대한 혼선도 발생되어 손이 가는 부분들이 더 있다. 이런 부분에서 Java가 불편하다고 느끼는 항목인데, 명확한 Type으로 unmarshal 하지 않으면 Java 코드 내부에서 취급에는 어려움이 많다. 따라서 그에 소모되는 부분도 반복적으로 발생되고 오류 조치 역시 소모성으로 발생된다. 그나마 Stream과 Lambda가 적용되는 8버전 이후부터는 코드량이 감소하여 그나마 이정도 가능 한게 아닐까 싶다. 기회가 된다면, 현재 Go 언어를 학습하고 있는데, Go 언어로 시도해 보고 차이점을 한번 더 포스팅 하게 되었으면 한다.

2 comments

  1. 열심히 삽질한 덕에 skill inventory 라인은 하나 더 늘게 되었군요. 축하드립니다.~ ㅋ
    근데 오타 있슴돠~ ㅋㅋ

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.

%d 블로거가 이것을 좋아합니다: