본문 바로가기
오픈소스/오픈서치

[오픈소스 기여] OpenSearch GetStats 클래스의 필드 수정

by 오오오오니 2025. 3. 18.

이슈 및 pr 링크

https://github.com/opensearch-project/OpenSearch/issues/16894

https://github.com/opensearch-project/OpenSearch/pull/17009

♦️이슈 설명

  • GetStats 클래스의 필드 중 하나가 time으로 명명되어야 하는데, getTime이라는 잘못된 이름을 가지고 있다.
  • 2년 전에 코드 리팩토링 중 잘못된 find & replace 작업에서 부터 시작되었다.
  • time_in_millis와 쌍을 이루는 "human-readable" 필드가 getTime 이다.
  • 경우에서는 time이라는 일관된 이름을 사용해고 있다.

♦️문제의 근본 원인

  • GetStats 클래스에서 "time" 필드의 직렬화(serialization) 및 직렬화 이름이 getTime으로 잘못 지정되어 있음
  • 이 문제는 리팩토링 시 발생한 실수로 인해 코드의 명명 규칙과 API 응답의 일관성이 깨진 것

♦️재현과정

  • 폴더에 docker-compose.yml만들기
  • docker compose up -d
version: '3'
services:
  opensearch:
    image: opensearchproject/opensearch:2.9.0
    container_name: opensearch
    environment:
      - discovery.type=single-node
      - bootstrap.memory_lock=true
      - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m"
      - plugins.security.disabled=true # 에러나서 고친 부분  - 보안을 비활성화
    ulimits:
      memlock:
        soft: -1
        hard: -1
    ports:
      - "9200:9200"
      - "9600:9600"
  • 에러

      [opensearch@b17d68ada4eb ~]$ curl -X GET "http://localhost:9200/"
      curl: (52) Empty reply from server
      Caused by: io.netty.handler.ssl.NotSslRecordException: not an SSL/TLS record: 474554202f20485454502f312e310d0a486f73743a206c6f63616c686f73743a393230300d0a557365722d4167656e743a206375726c2f382e372e310d0a4163636570743a202a2f2a0d0a0d0a
          at io.nett

    io.netty.handler.ssl.NotSslRecordException 에러는 OpenSearch 서버가 SSL/TLS 통신을 기대하고 있지만, 클라이언트(curl)가 HTTP를 사용하여 요청을 보낼 때 발생

    → SSL 설정과 HTTP 요청 간의 불일치

    원인 분석

    1. OpenSearch의 SSL/TLS 활성화

      • OpenSearch는 보안을 위해 기본적으로 SSL/TLS를 활성화
      • SSL이 활성화된 상태에서 HTTP 요청을 보내면 OpenSearch는 해당 요청을 이해하지 못하고 NotSslRecordException을 반환
    2. 클라이언트가 HTTP 요청을 사용

      • curl -X GET "http://localhost:9200/" 요청은 HTTPS가 아니라 HTTP를 사용
      • OpenSearch가 HTTPS만 허용하도록 설정되어 있다면, 이 요청은 실패

      💡 해결 방법

      1. OpenSearch에서 SSL 비활성화

      SSL/TLS를 비활성화하여 HTTP 요청을 처리할 수 있도록 만듬.

    3. docker-compose.yml 수정plugins.security.disabled=true를 설정하여 보안을 비활성화합니다:

hye-oni@hye-oniui-MacBookPro opensearch_test % curl -X GET "http://localhost:9200/"
{
  "name" : "806a0aaed5e0",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "baCr8KTOShaPZKz8gievsg",
  "version" : {
    "distribution" : "opensearch",
    "number" : "2.9.0",
    "build_type" : "tar",
    "build_hash" : "1164221ee2b8ba3560f0ff492309867beea28433",
    "build_date" : "2023-07-18T21:22:48.164885046Z",
    "build_snapshot" : false,
    "lucene_version" : "9.7.0",
    "minimum_wire_compatibility_version" : "7.10.0",
    "minimum_index_compatibility_version" : "7.0.0"
  },
  "tagline" : "The OpenSearch Project: https://opensearch.org/"
}
hye-oni@hye-oniui-MacBookPro opensearch_test % 

❣️ 인덱스 생성

인덱스를 생성합니다.

curl -X GET "http://localhost:9200/"

인덱스 생성 완료 응답

{
  "acknowledged": true,
  "shards_acknowledged": true,
  "index": "movies"
}
  • OpenSearch에서 데이터를 저장하거나 관리하려면 인덱스(index)라는 구조를 먼저 생성해야 합니다.
  • 이 명령은 movies라는 이름의 빈 인덱스를 생성합니다.
  • 인덱스는 데이터베이스의 테이블과 유사한 개념으로, 특정 데이터셋을 저장할 공간을 만듭니다.

왜 필요한가?

  • _stats API는 특정 인덱스의 통계를 반환합니다.
    따라서 _stats를 호출하려면 해당 인덱스(movies)가 존재해야 합니다.
  • 빈 인덱스를 생성한 뒤 _stats를 호출하면, 해당 인덱스의 통계 데이터가 기본값으로 반환됩니다.

❣️ 2 _statts API 호출

curl -X GET "http://localhost:9200/movies/_stats?human&filter_path=_all.primaries.get"

curl -X GET "http://localhost:9200/movies/_stats?human&filter_path=_all.primaries.get"
hye-oni@hye-oniui-MacBookPro opensearch_test % curl -X PUT "http://localhost:9200/movies"
{"acknowledged":true,"shards_acknowledged":true,"index":"movies"}%                                                                                                                 hye-oni@hye-oniui-MacBookPro opensearch_test % curl -X GET "http://localhost:9200/movies/_stats?human&filter_path=_all.primaries.get"

{"_all":{"primaries":{"get":{"total":0,"getTime":"0s","time_in_millis":0,"exists_total":0,"exists_time":"0s","exists_time_in_millis":0,"missing_total":0,"missing_time":"0s","missing_time_in_millis":0,"current":0}}}}%  

의미

  • _stats API는 특정 인덱스(여기서는 movies)의 통계를 반환합니다.
  • ?human 옵션은 숫자 데이터를 사람이 읽기 쉬운 형식(예: 5s, 10ms)으로 변환합니다.
  • filter_path=_all.primaries.get는 응답에서 get 통계만 반환하도록 필터링합니다.

왜 필요한가?

  • 이슈에서 언급된 대로 응답에 time 대신 getTime이라는 잘못된 필드가 포함되어 있는지 확인하기!

♦️ 이슈 해결 과정

1. getTIme → time 필드로 수정

  • 문제: api응답을 바로 바꾸면 호환성의 문제가 있음

⇒ 해결: time 필드를 추가하고 getTime을 deprecated 처리

2. time을 추가 한 후 둘다(GET_TIME, TIME) humanReadable한 필드로 바꿔주기

→ 통합 테스트 작성 후 에러를 발견하였습니다.

수정한 코드

builder.humanReadableField(Fields.TIME_IN_MILLIS, Fields.GET_TIME, getTime());
// 추가한 코드
builder.humanReadableField(Fields.TIME_IN_MILLIS, Fields.TIME, getTime());

문제

humanReadableField의 역할을 처음에 TIME_IN_MILLIS의 값을 읽기 쉬운 단위 로 바꿔서 api에 추가해주는 역할이라고 생각 했습니다. 즉 2번째 인자만 api응답에 들어가는 것으로 생각했습니다.

하지만 humanReadableField 의 역할은 TIME_IN_MILLIS 도 api에 추가하고 TIME_IN_MILLIS 을 휴먼 리더블한 필드로 바꿔서 GET_TIME 도 추가하는 것이였습니다.

→ 즉 첫번째 인자, 2번째 인자 총 2개가 api 응답에 추가 됨 (아래 코드 참고)

humanReadableField 구현 코드

    public XContentBuilder humanReadableField(String rawFieldName, String readableFieldName, Object value) throws IOException {
        assert rawFieldName.equals(readableFieldName) == false : "expected raw and readable field names to differ, but they were both: "
            + rawFieldName;
        if (humanReadable) {
        // ❣️ 필드 추가 1
            field(readableFieldName, Objects.toString(value));
        }
        HumanReadableTransformer transformer = HUMAN_READABLE_TRANSFORMERS.get(value.getClass());
        if (transformer != null) {
            Object rawValue = transformer.rawValue(value);
            // ❣️ 필드 추가 2
            field(rawFieldName, rawValue);
        } else {
            throw new IllegalArgumentException("no raw transformer found for class " + value.getClass());
        }
        return this;
    }

위 코드에서 에러 발생!

⇒ 이유: TIME_IN_MILLIS가 2번 추가 된다.

테스트 작성 후 에러 발견

중복 발생

builder.humanReadableField(Fields.*TIME_IN_MILLIS*, Fields.*GET_TIME*, getTime());builder.humanReadableField(Fields.*TIME_IN_MILLIS*, Fields.*TIME*, getTime());

  • java.lang.RuntimeException: Failure at [indices.stats/60_time_fields.yml:11]: Duplicate field 'time_in_millis'
    at [Source: REDACTED (StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION disabled); line: 1, column: 150]

    java.lang.RuntimeException: Failure at [indices.stats/60_time_fields.yml:11]: Duplicate field 'time_in_millis'
    at [Source: REDACTED (StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION disabled); line: 1, column: 150]
    at __randomizedtesting.SeedInfo.seed([6B31651E5379D26D:E3655AC4FD85BF95]:0)

해결법

time 필드는 humanReadableField 를 사용하고, getTime은 field 를 사용해 api응답에 추가!

❓왜 toString 을 썼을까?

간단하게 설명하자면 ⭐ 다형성 ⭐ 덕분에 toString 을 TimeValue에 쓰면 휴먼 리더블한 값으로 바꿔준다.

그래서 time_in_millis의 값에 아래와 같이 ms, s와 같은 단위를 붙여준다.
(이부분을 자세히 설명하면 길어져서 나중에 자세히 블로그를 써보도록 하겠습니다!)

이와 관해 주고 받은 코드 리뷰!

3. 테스트 작성 및 통과

---
setup:
  - do:
      indices.create:
        index: test1
        body:
          settings:
            number_of_shards: 1
            number_of_replicas: 0
        wait_for_active_shards: all

  - do:
      index:
        index: test1
        id: 1
        body: { "foo": "bar" }

  - do:
      indices.refresh:
        index: test1

---
"Test _stats API includes both time and getTime metrics with human filter":
  - skip:
      version: " - 2.19.99"
      reason: "this change is added in 3.0.0"

  - do:
      indices.stats:
        metric: [ get ]
        human: true

  - is_true: _all.primaries.get.time
  - is_true: _all.primaries.get.getTime
  - match: { _all.primaries.get.time: "0s" }
  - match: { _all.primaries.get.getTime: "0s" }