고급 검색 개발 지침

이 페이지는 Elasticsearch 개발 및 작업에 대한 정보를 포함합니다.

Elasticsearch를 활성화하고 초기 인덱싱을 수행하는 방법에 대한 정보는 Elasticsearch 통합 문서에 있습니다.

심층 탐구

2019년 6월, Mario de la Ossa는 GitLab Elasticsearch 통합에 대한 심층 탐구를 주최했습니다 (GitLab 팀원 전용: https://gitlab.com/gitlab-org/create-stage/-/issues/1). 이는 이 코드베이스의 해당 부분에서 작업할 수 있는 분들과 도메인 특정 지식을 공유하기 위함이었습니다. 녹화는 YouTube에서 확인할 수 있으며, 슬라이드는 Google SlidesPDF로 제공됩니다. 이번 심층 탐구에서 다룬 모든 내용은 GitLab 12.0 기준으로 정확했으며, 특정 세부정보는 변경되었을 수 있지만 여전히 좋은 소개가 될 것입니다.

2020년 8월, 두 번째 심층 탐구가 개최되어 다중 인덱스 지원을 위한 GitLab 전용 아키텍처에 중점을 두었습니다. 녹화는 YouTube에서 확인할 수 있으며, 슬라이드는 제공됩니다. 이번 심층 탐구에서 다룬 모든 내용은 GitLab 13.3 기준으로 정확했습니다.

2024년 7월, Terri Chu는 고급 검색 기초, 통합, 인덱싱 및 검색에 대한 Lunch and Learn을 주최했습니다. Google Slides (GitLab 팀원 전용)과 녹화는 YouTube에서 (GitLab 팀원 전용) 확인할 수 있습니다. 이번 심층 탐구에서 다룬 모든 내용은 GitLab 17.0 기준으로 정확했습니다.

지원되는 버전

버전 요구 사항을 참조하세요.

Elasticsearch 쿼리에 중대한 변경을 하는 개발자는 모든 지원되는 버전에서 기능을 테스트해야 합니다.

개발 환경 설정

Elasticsearch GDK 설정 지침을 참조하세요.

유용한 Rake 작업

  • gitlab:elastic:test:index_size: 현재 인덱스가 사용하는 공간과 인덱스에 있는 문서 수를 알려줍니다.

  • gitlab:elastic:test:index_size_change: 인덱스 크기를 출력하고, 재인덱싱을 수행한 후 다시 인덱스 크기를 출력합니다. 인덱싱 크기 개선을 테스트할 때 유용합니다.

또한, 테스트를 위해 대규모 리포지토리나 여러 포크가 필요한 경우, 이 지침을 따라 하시기 바랍니다.

어떻게 작동하나요?

Elasticsearch 통합은 외부 인덱서에 의존합니다. 우리는 Go로 작성된 인덱서를 제공합니다.

사용자는 Rake 작업을 통해 초기 인덱싱을 트리거해야 하지만, 이 작업이 완료되면 GitLab 자체가 생성, 업데이트 및 삭제 시 after_ 콜백을 통해 재인덱싱을 트리거합니다. 에서 상속됩니다 /ee/app/models/concerns/elastic/application_versioned_search.rb.

초기 인덱싱이 완료된 후, 모든 모델의 생성, 업데이트 및 삭제 작업은 프로젝트를 제외하고(참조 #207494) Redis의 ZSET에 기록됩니다.

정기적인 sidekiq-cron ElasticIndexBulkCronWorker가 이 큐를 처리하며, Bulk Request API를 사용하여 한 번에 여러 Elasticsearch 문서를 업데이트합니다.

검색 쿼리는 ee/app/models/concerns/elastic에서 발견된 concerns에 의해 생성됩니다. 이 concerns는 접근 제어도 책임지며, 역사적으로 보안 버그의 원인이 되었으므로 주의 깊게 다뤄야 합니다!

아키텍처

참고: 우리는 이 에픽에서 이 아키텍처 패턴을 벗어나고 있습니다.

전통적인 설정은 elasticsearch-rails에 의해 제공되며, 내부 프록시 클래스를 통해 통신합니다.

개발자는 모델이 포함할 모듈에 모델별 로직을 작성합니다(예: SnippetsSearch).

__elasticsearch__ 메소드는 프록시 객체를 반환합니다. 예를 들면:

  • Issue.__elasticsearch__Elasticsearch::Model::Proxy::ClassMethodsProxy의 인스턴스를 반환합니다.
  • Issue.first.__elasticsearch__Elasticsearch::Model::Proxy::InstanceMethodsProxy의 인스턴스를 반환합니다.

이러한 프록시 객체는 Elasticsearch 서버와 직접 통신합니다(다이어그램의 상단 반).

Elasticsearch 아키텍처

계획된 새로운 디자인에서는 각 모델이 해당 서브 클래스화된 프록시 객체 쌍을 가지며, 이 객체 내에 모델별 로직이 위치합니다.

예를 들어, SnippetElasticsearch::Model::Proxy::ClassMethodsProxy의 서브클래스인 SnippetClassProxy를 가지며,

SnippetElasticsearch::Model::Proxy::InstanceMethodsProxy의 서브클래스인 SnippetInstanceProxy를 가집니다.

__elasticsearch__는 여러 실제 프록시 객체를 추적하는 또 다른 계층의 프록시 객체를 나타냅니다.

적절한 인덱스로 메소드 호출을 전달합니다. 예를 들면:

  • model.__elasticsearch__.search는 읽기 작업이므로 하나의 안정적인 인덱스로 전달됩니다.
  • model.__elasticsearch__.update_document는 모든 인덱스로 전달되어 모든 인덱스를 최신 상태로 유지합니다.

버전별 전역 구성은 이제 Elastic::(Version)::Config 클래스에 있습니다.

거기에서 매핑을 변경할 수 있습니다.

사용자 정의 라우팅

사용자 정의 라우팅은 프로젝트에 연관된 문서 유형에 사용됩니다.

라우팅 형식은 project_<project_id>입니다. 라우팅은 인덱싱 및 검색 작업 중에 설정됩니다. 사용자 정의 라우팅을 사용함으로써 얻을 수 있는 이점과 단점은 다음과 같습니다:

  • 프로젝트 범위 검색이 훨씬 빠릅니다.
  • 글로벌 및 그룹 범위 검색에 과도한 샤드가 발생할 경우 라우팅이 사용되지 않습니다.
  • 샤드 크기 불균형이 발생할 수 있습니다.

기존 분석기 및 토크나이저

다음 분석기 및 토크나이저는
ee/lib/elastic/latest/config.rb에서 정의됩니다.

분석기

path_analyzer

블롭의 경로를 인덱싱할 때 사용됩니다. path_tokenizerlowercase, asciifolding 필터를 사용합니다.

아래에서 path_tokenizer의 설명을 참조하여 예를 확인하세요.

sha_analyzer

블롭 및 커밋에서 사용됩니다. sha_tokenizerlowercase, asciifolding 필터를 사용합니다.

아래에서 sha_tokenizer의 설명을 참조하여 예를 확인하세요.

code_analyzer

블롭의 파일 이름 및 내용을 인덱싱할 때 사용됩니다. whitespace 토크나이저와
word_delimiter_graph, lowercase, 및 asciifolding 필터를 사용합니다.

whitespace 토크나이저는 토큰 분할 방법에 대한 더 많은 제어를 위해 선택되었습니다. 예를 들어 문자열 Foo::bar(4)Foobar(4)와 같은 토큰을 생성해야 올바르게 검색할 수 있습니다.

토큰이 분할되는 방법에 대한 설명은 code 필터를 참조하세요.

주의:
Elasticsearch code_analyzer는 모든 코드 사례를 고려하지 않습니다.

토크나이저

sha_tokenizer

이것은
edgeNGram 토크나이저
를 사용하여 SHAs를 하위 집합으로 검색할 수 있도록 하는 사용자 지정 토크나이저입니다(최소 5자).

예시:

240c29dc7e 는 다음과 같이 변환됩니다:

  • 240c2
  • 240c29
  • 240c29d
  • 240c29dc
  • 240c29dc7
  • 240c29dc7e

path_tokenizer

이것은
path_hierarchy 토크나이저
를 사용하여 입력된 경로가 얼마만큼 주어지든 관계없이 경로를 찾을 수 있도록 하기 위해 reverse: true를 사용하는 사용자 지정 토크나이저입니다.

예시:

'/some/path/application.js' 는 다음과 같이 변환됩니다:

  • '/some/path/application.js'
  • 'some/path/application.js'
  • 'path/application.js'
  • 'application.js'

주의 사항

  • 검색에는 자체 분석기가 있을 수 있습니다. 분석기를 편집할 때 확인하는 것을 잊지 마세요.
  • Character 필터(토큰 필터와는 달리)는 항상 원래 문자를 대체합니다. 이러한 필터는 정확한 검색에 방해가 될 수 있습니다.

Elasticsearch에 새 문서 유형 추가하기

데이터를 Elasticsearch의 기존 인덱스 중 하나에 추가할 수 없는 경우, 새 인덱스를 설정하고 채우기 위한 다음 지침을 따르세요.

권장 사항

  • Kibana 실행하여
    로컬 Elasticsearch 클러스터와 상호 작용합니다. 또는 Cerebro 같은 도구를 사용할 수 있습니다.
  • Elasticsearch의 로그를 확인하려면 다음 명령을 실행하세요:

    tail -f log/elasticsearch.log`
    

새 문서 유형 추가에 대한 권장 프로세스 를 참조하여 롤아웃 구조를 확인하세요.

인덱스 생성

NOTE
모든 새로운 인덱스는 다음을 가져야 합니다:

  • project_idnamespace_id 필드(사용 가능한 경우). 라우팅을 위해 필드 중 하나를 사용해야 합니다.
  • 효율적인 전역 및 그룹 검색을 위한 traversal_ids 필드. object.namespace.elastic_namespace_ancestry로 필드를 채우세요.
  1. ee/lib/search/elastic/types/Search::Elastic::Types:: 클래스를 생성합니다.

  2. 다음 클래스 메서드를 정의합니다:
    • index_name: 포맷 gitlab-<env>-<type> (예: gitlab-production-work_items).
    • mappings: 필드, 데이터 유형 및 분석기를 포함하는 인덱스 스키마의 해시.
    • settings: 복제본과 토크나이저와 같은 인덱스 설정을 포함하는 해시. 기본값은 대부분의 경우에 충분합니다.
  3. 인덱스를 생성하기 위해 새로운 고급 검색 마이그레이션을 추가합니다
    scripts/elastic-migration를 실행하고 지침을 따르세요.
    마이그레이션 이름은 형식 Create<Name>Index이어야 합니다.

  4. 사양 파일에 대해 Elastic::MigrationCreateIndex
    도우미와 'migration creates a new index' 공유 예제를 사용합니다.

  5. Gitlab::Elastic::Helper::ES_SEPARATE_CLASSES에 대상 클래스를 추가합니다.

  6. 인덱스 생성 테스트를 위해, 콘솔에서 Elastic::MigrationWorker.new.perform을 실행하고 인덱스가
    올바른 매핑 및 설정으로 생성되었는지 확인합니다:

    curl "http://localhost:9200/gitlab-development-<type>/_mappings" | jq .
    
    curl "http://localhost:9200/gitlab-development-<type>/_settings" | jq .
    

새로운 Elastic 참조 생성

ee/lib/search/elastic/references/Search::Elastic::References:: 클래스를 생성합니다.

참조는 Elasticsearch에서 집계 작업을 수행하는 데 사용됩니다.
이 파일은 Search::Elastic::Reference에서 상속받고 다음 메서드를 정의해야 합니다:

include Search::Elastic::Concerns::DatabaseReference # 각 문서에 대한 해당 데이터베이스 레코드가 있는 경우

override :serialize
def self.serialize(record)
   # 참조의 문자열 표현
end

override :instantiate
def self.instantiate(string)
   # 문자열을 역직렬화하고 initialize 호출
end

override :preload_indexing_data
def self.preload_indexing_data(refs)
   # `Search::Elastic::Concerns::DatabaseReference`가 포함된 경우 이 메서드를 제거합니다
   # 그렇지 않으면 refs를 반환합니다
end

def initialize
   # 인스턴스 변수를 사용하여 초기화
end

override :identifier
def identifier
   # 참조를 식별하는 방법
end

override :routing
def routing
   # 선택 사항: Elasticsearch에서 문서를 라우팅할 식별자
end

override :operation
def operation
   # `:index`, `:upsert` 또는 `:delete` 중 하나
end

override :serialize
def serialize
   # 참조의 문자열 표현
end

override :as_indexed_json
def as_indexed_json
   # 이 참조의 문서 표현을 포함하는 해시
end

override :index_name
def index_name
   # 인덱스 이름
end

def model_klass
   # `Search::Elastic::Concerns::DatabaseReference`가 포함된 경우 모델 클래스를 설정
end

인덱스에 데이터를 추가하기 위해, 새로운 참조 클래스의 인스턴스가 Elastic::ProcessBookkeepingService.track!()에서 호출되어
인덱싱을 위한 참조의 큐에 데이터를 추가합니다.
크론 작업자는 큐에 있는 참조를 가져와 Elasticsearch에 항목을 일괄 인덱싱합니다.

인덱싱 작업이 작동하는지 테스트하기 위해, 참조 클래스의 인스턴스를 사용하여
Elastic::ProcessBookkeepingService.track!()를 호출하고 Elastic::ProcessBookkeepingService.new.execute를 실행합니다.
로그에 업데이트가 표시됩니다. 인덱스에서 문서를 확인하려면 이 명령을 실행하세요:

curl "http://localhost:9200/gitlab-development-<type>/_search"

데이터 일관성

이제 인덱스를 가지고 있고 새로운 문서 유형을 Elasticsearch에 대량으로 인덱싱하는 방법이 마련되었으므로 인덱스에 데이터를 추가해야 합니다. 이는 데이터를 다시 채우고 인덱스 데이터가 최신 상태로 유지되도록 지속적인 업데이트를 수행하는 것으로 구성됩니다.

데이터 다시 채우기는 인덱싱되어야 하는 각 문서에 대해 Search::Elastic::Reference 인스턴스를 사용하여 Elastic::ProcessInitialBookkeepingService.track!()를 호출하여 수행합니다.

지속적인 업데이트는 인덱싱, 생성 또는 삭제가 필요한 모든 문서에 대해 Search::Elastic::Reference 인스턴스를 사용하여 Elastic::ProcessBookkeepingService.track!()를 호출하여 수행합니다.

데이터 다시 채우기

새로운 고급 검색 마이그레이션을 추가하여 scripts/elastic-migration를 실행하고 지침에 따라 데이터를 다시 채웁니다.

다시 채우기는 인덱싱되어야 하는 각 문서에 대해 이전에 생성된 Search::Elastic::Reference 인스턴스를 사용하여 Elastic::ProcessInitialBookkeepingService.track!()를 실행해야 합니다. BackfillEpics 마이그레이션을 예제로 사용할 수 있습니다.

다시 채우기를 테스트하려면 콘솔에서 Elastic::MigrationWorker.new.perform을 여러 번 실행하여 인덱스가 채워졌는지 확인하세요.

마이그레이션 진행 상황을 보려면 로그를 확인하세요:

tail -f log/elasticsearch.log

지속적인 업데이트

ActiveRecord 객체의 경우, 콜백을 기반으로 데이터를 인덱싱하기 위해 모델에 ApplicationVersionedSearch 컨cern을 포함할 수 있습니다. 그렇지 않은 경우 문서가 인덱싱되어야 할 때마다 Search::Elastic::Reference의 인스턴스를 사용하여 Elastic::ProcessBookkeepingService.track!()를 호출하세요.

Gitlab::CurrentSettings.elasticsearch_indexing?use_elasticsearch?를 항상 확인하세요. 일부 셀프 관리 인스턴스에서는 Elasticsearch가 활성화되어 있지 않으며 네임스페이스 제한을 활성화할 수 있습니다.

또한 인덱스 요청을 처리할 수 있는지 확인하세요. 예를 들어, 현재 주요 릴리스에서 추가된 경우 인덱스가 존재하는지 확인하고 인덱스를 추가하는 마이그레이션이 완료되었는지 확인하세요: Elastic::DataMigrationService.migration_has_finished?.

이전 및 삭제

프로젝트 및 그룹 이전 및 삭제는 고아 데이터가 발생하지 않도록 인덱스를 업데이트해야 합니다.

project_id 필드를 포함하는 인덱스는 Search::Elastic::DeleteWorker를 사용해야 합니다. namespace_id 필드가 있지만 project_id 필드가 없는 인덱스는 Search::ElasticGroupAssociationDeleteWorker를 사용해야 합니다.

  1. ElasticDeleteProjectWorker에서 excluded_classes에 인덱스 클래스 추가
  2. 인덱스에서 문서를 제거하도록 워커 업데이트

새 문서 유형 추가를 위한 권장 프로세스

다음 MRs를 생성하고 Global Search 팀의 구성원에게 검토를 요청하세요:

  1. 인덱스 생성.
  2. 새 Elasticsearch 참조 생성.
  3. 기능 플래그 뒤에서 지속적인 업데이트 수행. 다시 채우기 전에 플래그를 완전히 활성화하세요.
  4. 데이터 다시 채우기.

인덱싱이 완료된 후, 인덱스가 검색을 위해 준비됩니다.

검색 서비스에 새로운 범위 추가하기

검색 데이터는 SearchController
Search API에서 사용할 수 있으며, 두 가지 모두 SearchService를 사용하여 결과를 반환합니다.
SearchServiceSearchControllerSearch API 외부에서도 결과를 반환하는 데 사용할 수 있습니다.

검색 범위

SearchService전역,
그룹, 및 프로젝트 수준에서의 검색을 제공합니다.

새로운 범위는 다음 상수에 추가해야 합니다:

  • 각 EE SearchService 파일의 ALLOWED_SCOPES (또는 allowed_scopes 메서드 오버라이드)
  • Gitlab::Search::AbuseDetectionALLOWED_SCOPES
  • Search::Navigationsearch_tab_ability_map 메서드. 필요시 EE 버전에서 오버라이드

참고: 전역 검색은 범위에 대해 비활성화할 수 있습니다. 기본값이 trueglobal_search_SCOPE_tab라는 ops 기능 플래그를 생성하고, 이를 SearchService에서 global_search_enabled_for_scope? 메서드에 추가하세요.

결과 클래스

사용 가능한 검색 결과 클래스는 다음과 같습니다:

검색 유형 검색 수준 클래스
기본 검색 전역 Gitlab::SearchResults
기본 검색 그룹 Gitlab::GroupSearchResults
기본 검색 프로젝트 Gitlab::ProjectSearchResults
고급 검색 전역 Gitlab::Elastic::SearchResults
고급 검색 그룹 Gitlab::Elastic::GroupSearchResults
고급 검색 프로젝트 Gitlab::Elastic::ProjectSearchResults
정확한 코드 검색 전역 Search::Zoekt::SearchResults
정확한 코드 검색 그룹 Search::Zoekt::SearchResults
정확한 코드 검색 프로젝트 Search::Zoekt::SearchResults
모든 검색 유형 모든 수준 Search::EmptySearchResults

결과 클래스는 다음 데이터를 반환합니다:

  1. objects - Elasticsearch에서 데이터베이스 레코드 또는 PORO로 변환된 페이지네이션된 결과
  2. formatted_count - Elasticsearch에서 반환된 문서 수
  3. highlight_map - Elasticsearch에서 강조된 필드의 맵
  4. failed? - 실패가 발생했는지 여부
  5. error - Elasticsearch에서 반환된 오류 메시지
  6. aggregations - (선택적) Elasticsearch에서의 집계

새로운 범위는 Gitlab::Elastic::SearchResults 클래스 내에서 다음 메서드에 대한 지원을 추가해야 합니다:

  • objects
  • formatted_count
  • highlight_map
  • failed?
  • error

쿼리 작성

쿼리 빌더 프레임워크는 Elasticsearch 쿼리를 작성하는 데 사용됩니다.

쿼리는 다음을 사용하여 작성됩니다:

  • Search::Elastic::Queries의 쿼리
  • ::Search::Elastic::Filters의 하나 이상의 필터
  • (선택적) ::Search::Elastic::Aggregations의 집계
  • ::Search::Elastic::Formats의 하나 이상의 형식

새로운 범위는 Search::Elastic::QueryBuilder를 상속하는 새로운 쿼리 빌더 클래스를 생성해야 합니다.

필터

아래의 필터는 Elasticsearch 쿼리를 구축하는 데 사용할 수 있습니다. 필터를 사용하려면 인덱스에 매핑된 필드가 필요합니다. 필터는 options 해시를 사용하여 JSON을 작성하고, 이는 query_hash에 추가됩니다.

by_type

type 필드가 필요합니다. 옵션에서 doc_type으로 쿼리합니다.

{
  "term": {
    "type": {
      "_name": "filters:doc:is_a:milestone",
      "value": "milestone"
    }
  }
}
by_group_level_confidentiality

current_usergroup_ids 필드가 필요합니다. 기밀 그룹 엔티티를 읽기 위한 사용자 권한에 따라 쿼리합니다.

{
  "bool": {
    "must": [
      {
        "term": {
          "confidential": {
            "value": true,
            "_name": "confidential:true"
          }
        }
      },
      {
        "terms": {
          "namespace_id": [
            1
          ],
          "_name": "groups:can:read_confidential_work_items"
        }
      }
    ]
  },
  "should": {
    "term": {
      "confidential": {
        "value": false,
        "_name": "confidential:false"
      }
    }
  }
}
by_project_confidentiality

confidential, author_id, assignee_id, project_id 필드가 필요합니다. 옵션에서 confidential으로 쿼리합니다.

{
  "bool": {
    "should": [
      {
        "term": {
          "confidential": {
            "_name": "filters:non_confidential",
            "value": false
          }
        }
      },
      {
        "bool": {
          "must": [
            {
              "term": {
                "confidential": {
                  "_name": "filters:confidential",
                  "value": true
                }
              }
            },
            {
              "bool": {
                "should": [
                  {
                    "term": {
                      "author_id": {
                        "_name": "filters:confidential:as_author",
                        "value": 1
                      }
                    }
                  },
                  {
                    "term": {
                      "assignee_id": {
                        "_name": "filters:confidential:as_assignee",
                        "value": 1
                      }
                    }
                  },
                  {
                    "terms": {
                      "_name": "filters:confidential:project:membership:id",
                      "project_id": [
                        12345
                      ]
                    }
                  }
                ]
              }
            }
          ]
        }
      }
    ]
  }
}
by_label_ids

label_ids 필드가 필요합니다. 옵션에서 label_names으로 쿼리합니다.

{
  "bool": {
    "must": [
      {
        "terms": {
          "_name": "filters:label_ids",
          "label_ids": [
            1
          ]
        }
      }
    ]
  }
}
by_archived

archived 필드가 필요합니다. 옵션에서 search_levelinclude_archived로 쿼리합니다.

{
  "bool": {
    "_name": "filters:non_archived",
    "should": [
      {
        "bool": {
          "filter": {
            "term": {
              "archived": {
                "value": false
              }
            }
          }
        }
      },
      {
        "bool": {
          "must_not": {
            "exists": {
              "field": "archived"
            }
          }
        }
      }
    ]
  }
}
by_state

state 필드가 필요합니다. 지원되는 값: all, opened, closed, merged. 옵션에서 state로 쿼리합니다.

{
  "match": {
    "state": {
      "_name": "filters:state",
      "query": "opened"
    }
  }
}
by_not_hidden

hidden 필드가 필요합니다. 관리자는 적용되지 않습니다.

{
  "term": {
    "hidden": {
      "_name": "filters:not_hidden",
      "value": false
    }
  }
}
by_work_item_type_ids

work_item_type_id 필드가 필요합니다. 옵션에서 work_item_type_ids 또는 not_work_item_type_ids로 쿼리합니다.

{
  "bool": {
    "must_not": {
      "terms": {
        "_name": "filters:not_work_item_type_ids",
        "work_item_type_id": [
          8
        ]
      }
    }
  }
}
by_author

author_id 필드가 필요합니다. 옵션에서 author_username 또는 not_author_username으로 쿼리합니다.

{
  "bool": {
    "should": [
      {
        "term": {
          "author_id": {
            "_name": "filters:author",
            "value": 1
          }
        }
      }
    ],
    "minimum_should_match": 1
  }
}
by_target_branch

target_branch 필드가 필요합니다. 옵션에서 target_branch 또는 not_target_branch로 쿼리합니다.

{
  "bool": {
    "should": [
      {
        "term": {
          "target_branch": {
            "_name": "filters:target_branch",
            "value": "master"
          }
        }
      }
    ],
    "minimum_should_match": 1
  }
}
by_source_branch

source_branch 필드가 필요합니다. 옵션에서 source_branch 또는 not_source_branch로 쿼리합니다.

{
  "bool": {
    "should": [
      {
        "term": {
          "source_branch": {
            "_name": "filters:source_branch",
            "value": "master"
          }
        }
      }
    ],
    "minimum_should_match": 1
  }
}
by_group_level_authorization

current_user, group_ids, traversal_id, search_level 필드가 필요합니다. search_level로 쿼리하고 사용자가 각 그룹에 대해 가진 권한에 따라 namespace_visibility_level을 필터링합니다.

note
로그인한 사용자에 대한 예시입니다. JSON은 권한이 있는 사용자, 관리자, 외부 사용자 또는 익명 사용자에 대해 다를 수 있습니다.
global
{
  "bool": {
    "should": [
      {
        "bool": {
          "filter": [
            {
              "term": {
                "namespace_visibility_level": {
                  "value": 20,
                  "_name": "filters:namespace_visibility_level:public"
                }
              }
            }
          ]
        }
      },
      {
        "bool": {
          "filter": [
            {
              "term": {
                "namespace_visibility_level": {
                  "value": 10,
                  "_name": "filters:namespace_visibility_level:internal"
                }
              }
            }
          ]
        }
      },
      {
        "bool": {
          "filter": [
            {
              "term": {
                "namespace_visibility_level": {
                  "value": 0,
                  "_name": "filters:namespace_visibility_level:private"
                }
              }
            },
            {
              "terms": {
                "namespace_id": [
                  33,
                  22
                ]
              }
            }
          ]
        }
      }
    ],
    "minimum_should_match": 1
  }
}
그룹
[
  {
    "bool": {
      "_name": "filters:level:group",
      "minimum_should_match": 1,
      "should": [
        {
          "prefix": {
            "traversal_ids": {
              "_name": "filters:level:group:ancestry_filter:descendants",
              "value": "22-"
            }
          }
        }
      ]
    }
  },
  {
    "bool": {
      "should": [
        {
          "bool": {
            "filter": [
              {
                "term": {
                  "namespace_visibility_level": {
                    "value": 20,
                    "_name": "filters:namespace_visibility_level:public"
                  }
                }
              }
            ]
          }
        },
        {
          "bool": {
            "filter": [
              {
                "term": {
                  "namespace_visibility_level": {
                    "value": 10,
                    "_name": "filters:namespace_visibility_level:internal"
                  }
                }
              }
            ]
          }
        },
        {
          "bool": {
            "filter": [
              {
                "term": {
                  "namespace_visibility_level": {
                    "value": 0,
                    "_name": "filters:namespace_visibility_level:private"
                  }
                }
              },
              {
                "terms": {
                  "namespace_id": [
                    22
                  ]
                }
              }
            ]
          }
        }
      ],
      "minimum_should_match": 1
    }
  },
  {
    "bool": {
      "_name": "filters:level:group",
      "minimum_should_match": 1,
      "should": [
        {
          "prefix": {
            "traversal_ids": {
              "_name": "filters:level:group:ancestry_filter:descendants",
              "value": "22-"
            }
          }
        }
      ]
    }
  }
]
by_search_level_and_membership

project_idtraversal_id 필드가 필요합니다. *_access_level 필드를 지원합니다. search_level로 쿼리하고 선택적으로 project_ids, group_ids, features, 및 current_user를 옵션에 넣습니다.

필터링은 다음에 적용됩니다:

  • 글로벌, 그룹 또는 프로젝트에 대한 검색 수준
  • 그룹 및 프로젝트에 대한 직접적인 멤버십 또는 그룹에 대한 직접 액세스를 통한 공유 멤버십
  • features를 통해 전달된 모든 기능 액세스 수준

참고: 예시는 로그인한 사용자에 대해 표시됩니다. JSON은 권한이 있는 사용자, 관리자, 외부 사용자 또는 익명 사용자에게는 다를 수 있습니다.

글로벌
{
  "bool": {
    "_name": "filters:permissions:global",
    "should": [
      {
        "bool": {
          "must": [
            {
              "terms": {
                "_name": "filters:permissions:global:visibility_level:public_and_internal",
                "visibility_level": [
                  20,
                  10
                ]
              }
            }
          ],
          "should": [
            {
              "terms": {
                "_name": "filters:permissions:global:repository_access_level:enabled",
                "repository_access_level": [
                  20
                ]
              }
            }
          ],
          "minimum_should_match": 1
        }
      },
      {
        "bool": {
          "must": [
            {
              "bool": {
                "should": [
                  {
                    "terms": {
                      "_name": "filters:permissions:global:repository_access_level:enabled_or_private",
                      "repository_access_level": [
                        20,
                        10
                      ]
                    }
                  }
                ],
                "minimum_should_match": 1
              }
            }
          ],
          "should": [
            {
              "prefix": {
                "traversal_ids": {
                  "_name": "filters:permissions:global:ancestry_filter:descendants",
                  "value": "123-"
                }
              }
            },
            {
              "terms": {
                "_name": "filters:permissions:global:project:member",
                "project_id": [
                  456
                ]
              }
            }
          ],
          "minimum_should_match": 1
        }
      }
    ],
    "minimum_should_match": 1
  }
}
그룹
[
  {
    "bool": {
      "_name": "filters:level:group",
      "minimum_should_match": 1,
      "should": [
        {
          "prefix": {
            "traversal_ids": {
              "_name": "filters:level:group:ancestry_filter:descendants",
              "value": "123-"
            }
          }
        }
      ]
    }
  },
  {
    "bool": {
      "_name": "filters:permissions:group",
      "should": [
        {
          "bool": {
            "must": [
              {
                "terms": {
                  "_name": "filters:permissions:group:visibility_level:public_and_internal",
                  "visibility_level": [
                    20,
                    10
                  ]
                }
              }
            ],
            "should": [
              {
                "terms": {
                  "_name": "filters:permissions:group:repository_access_level:enabled",
                  "repository_access_level": [
                    20
                  ]
                }
              }
            ],
            "minimum_should_match": 1
          }
        },
        {
          "bool": {
            "must": [
              {
                "bool": {
                  "should": [
                    {
                      "terms": {
                        "_name": "filters:permissions:group:repository_access_level:enabled_or_private",
                        "repository_access_level": [
                          20,
                          10
                        ]
                      }
                    }
                  ],
                  "minimum_should_match": 1
                }
              }
            ],
            "should": [
              {
                "prefix": {
                  "traversal_ids": {
                    "_name": "filters:permissions:group:ancestry_filter:descendants",
                    "value": "123-"
                  }
                }
              }
            ],
            "minimum_should_match": 1
          }
        }
      ],
      "minimum_should_match": 1
    }
  }
]
프로젝트
[
  {
    "bool": {
      "_name": "filters:level:project",
      "must": {
        "terms": {
          "project_id": [
            456
          ]
        }
      }
    }
  },
  {
    "bool": {
      "_name": "filters:permissions:project",
      "should": [
        {
          "bool": {
            "must": [
              {
                "terms": {
                  "_name": "filters:permissions:project:visibility_level:public_and_internal",
                  "visibility_level": [
                    20,
                    10
                  ]
                }
              }
            ],
            "should": [
              {
                "terms": {
                  "_name": "filters:permissions:project:repository_access_level:enabled",
                  "repository_access_level": [
                    20
                  ]
                }
              }
            ],
            "minimum_should_match": 1
          }
        },
        {
          "bool": {
            "must": [
              {
                "bool": {
                  "should": [
                    {
                      "terms": {
                        "_name": "filters:permissions:project:repository_access_level:enabled_or_private",
                        "repository_access_level": [
                          20,
                          10
                        ]
                      }
                    }
                  ],
                  "minimum_should_match": 1
                }
              }
            ],
            "should": [
              {
                "prefix": {
                  "traversal_ids": {
                    "_name": "filters:permissions:project:ancestry_filter:descendants",
                    "value": "123-"
                  }
                }
              }
            ],
            "minimum_should_match": 1
          }
        }
      ],
      "minimum_should_match": 1
    }
  }
]

Elasticsearch에 쿼리 전송하기

쿼리는 Gitlab::Elastic::SearchResults에서 ::Gitlab::Search::Client로 전송됩니다.

결과는 Elasticsearch로부터 응답을 변환하기 위해 Search::Elastic::ResponseMapper를 통해 파싱됩니다.

모델 요구 사항

모델은 to_ability_name 메서드에 응답해야 하며, 이를 통해 레닥션 로직이 Ability.allowed?(current_user, :"read_#{object.to_ability_name}", object)?를 확인할 수 있습니다.

해당 메서드는 존재하지 않을 경우 추가되어야 합니다.

모델은 N+1을 방지하기 위해 preload_search_data 스코프를 정의해야 합니다.

권한 테스트

Search 코드에는 SearchService#redact_unauthorized_results에 최종 보안 검사가 있습니다.

이 검사는 권한이 없는 결과가 이를 볼 수 있는 권한이 없는 사용자에게 반환되지 않도록 방지합니다.

검사는 Elasticsearch 권한 데이터의 불일치로 인한 버그나 인덱싱 지연을 처리하기 위해 Ruby에서 수행됩니다.

새로운 스코프는 적절한 접근 제어를 보장하기 위해 가시성 스펙을 추가해야 합니다.

권한이 적절하게 시행되고 있는지 테스트하려면 EE 스펙 내에서 'search respects visibility' 공유 예제를 사용하여 테스트를 추가하세요:

  • ee/spec/services/search/global_service_spec.rb
  • ee/spec/services/search/group_service_spec.rb
  • ee/spec/services/search/project_service_spec.rb

새로운 스코프 테스트

Rails 콘솔에서 새로운 스코프를 테스트하세요.

search_service = ::SearchService.new(User.first, { search: 'foo', scope: 'SCOPE_NAME' })
search_service.search_objects

새로운 문서 유형에 대한 검색 구현 권장 프로세스

다음 MRs를 생성하고 Global Search 팀의 구성원에게 검토를 받으세요:

  1. 새로운 스코프 활성화.
  2. 쿼리 빌더 생성.
  3. 모든 모델 요구 사항 구현.
  4. 기능 플래그 뒤에 있는 Gitlab::Elastic::SearchResults새 스코프 추가.
  5. 권한 테스트를 포함해야 하는 스펙 추가.
  6. 새 스코프 테스트.
  7. 고급 검색검색 API(해당되는 경우)에 대한 문서 업데이트.

여러 인덱스를 이용한 제로 다운타임 재색인

note
이 기능은 현재 다중 인덱스 기능이 완전히 구현되지 않아서 아직 적용되지 않습니다.

현재 GitLab은 단일 버전의 설정만 처리할 수 있습니다.

모든 설정/스키마 변경은 처음부터 모든 것을 재색인해야 합니다. 재색인 작업은 오랜 시간이 걸릴 수 있어 검색 기능 중단을 초래할 수 있습니다.

다운타임을 피하기 위해 GitLab은 동시에 작동할 수 있는 여러 인덱스를 지원하기 위해 작업하고 있습니다.

스키마가 변경될 때, 관리자는 새로운 인덱스를 생성하고 그것으로 재색인할 수 있으며, 검색은 계속해서 구형 안정 인덱스로 향합니다.

모든 데이터 업데이트는 두 인덱스로 전달됩니다.

새 인덱스가 준비되면 관리자는 이를 활성화로 표시할 수 있으며, 이는 모든 검색을 해당 인덱스로 방향 전환하고 구형 인덱스를 제거합니다.

이는 새로운 서버로 마이그레이션하는 데도 유용합니다. 예를 들어, AWS로의 이동/이동 시 도움이 됩니다.

현재 우리는 이 새로운 디자인으로 마이그레이션하는 과정에 있습니다. 현재 모든 것은 단일 버전으로 작동하도록 하드와이어되어 있습니다.

성능 모니터링

Prometheus

GitLab은 Prometheus 메트릭을 내보내어 모든 웹/API 요청 및 Sidekiq 작업에 대한 요청 수와 시간과 관련된 정보를 제공하며, 이는 성능 추세를 진단하고 Elasticsearch 시간이 전체 성능에 미치는 영향을 비교하는 데 도움이 됩니다.

인덱싱 큐

GitLab은 인덱싱 큐에 대한 Prometheus 메트릭도 내보내어 성능 병목 현상을 진단하고 GitLab 인스턴스 또는 Elasticsearch 서버가 업데이트 양을 따라잡을 수 있는지를 판단하는 데 도움을 줍니다.

로그

모든 인덱싱은 Sidekiq에서 발생하므로 Elasticsearch 통합과 관련된 많은 로그를 sidekiq.log에서 찾을 수 있습니다. 특히, Elasticsearch에 요청을 하는 모든 Sidekiq 작업자는 요청 수와 Elasticsearch에 쿼리/쓰기하는 데 소요된 시간을 기록합니다. 이는 클러스터가 인덱싱을 따라잡고 있는지를 이해하는 데 유용할 수 있습니다.

Elasticsearch를 검색하는 것은 요청을 처리하는 일반 웹 작업자를 통해 이루어집니다. 페이지를 로드하거나 API 요청을 만들어 Elasticsearch에 요청을 하는 경우, 요청 수와 production_json.log에 기록된 소요 시간을 기록합니다. 이 로그는 데이터베이스 및 Gitaly 요청에 소요된 시간을 포함하여, 검색의 어느 부분이 성능이 저조한지를 진단하는 데 도움을 줄 수 있습니다.

Elasticsearch에 특정한 추가 로그는 elasticsearch.log로 전송되며, 이는 성능 문제를 진단하는 데 도움이 될 수 있는 정보를 포함할 수 있습니다.

성능 바

Elasticsearch 요청은 Performance Bar에 표시되며, 이는 개발 중 로컬 및 배포된 GitLab 인스턴스 모두에서 저조한 검색 성능을 진단하는 데 사용될 수 있습니다. 이는 실행된 정확한 쿼리를 보여주어, 검색 속도가 느린 이유를 진단하는 데 유용합니다.

상관 ID 및 X-Opaque-Id

우리의 상관 ID는 Rails에서 Elasticsearch로 모든 요청과 함께 전달되며, X-Opaque-Id 헤더로, 이는 클러스터 내에서 요청을 GitLab로 추적하는 데 도움이 됩니다.

문제 해결

Elasticsearch 쿼리 디버깅

ELASTIC_CLIENT_DEBUG 환경 변수는 개발 또는 테스트 환경에서 Elasticsearch 클라이언트의 디버그 옵션을 활성화합니다. 코드 또는 테스트에서 생성된 Elasticsearch HTTP 쿼리를 디버그해야 하는 경우, 사양을 실행하기 전 또는 Rails 콘솔을 시작하기 전에 활성화할 수 있습니다:

ELASTIC_CLIENT_DEBUG=1 bundle exec rspec ee/spec/workers/search/elastic/trigger_indexing_worker_spec.rb

export ELASTIC_CLIENT_DEBUG=1
rails console

flood stage disk watermark [95%] exceeded 오류 해결

다음과 같은 오류가 발생할 수 있습니다:

[2018-10-31T15:54:19,762][WARN ][o.e.c.r.a.DiskThresholdMonitor] [pval5Ct]
   flood stage disk watermark [95%] exceeded on
   [pval5Ct7SieH90t5MykM5w][pval5Ct][/usr/local/var/lib/elasticsearch/nodes/0] free: 56.2gb[3%],
   all indices on this node will be marked read-only

이는 디스크 공간 한도를 초과했음을 의미합니다 - 기본 95% 한도에 따라 남은 디스크 공간이 부족하다고 판단합니다.

또한, read_only_allow_delete 설정이 true로 설정됩니다. 이는 인덱싱, forcemerge 등을 차단합니다.

curl "http://localhost:9200/gitlab-development/_settings?pretty"

다음 내용을 elasticsearch.yml 파일에 추가하세요:

# 디스크 할당기 끄기
cluster.routing.allocation.disk.threshold_enabled: false

혹은

# 자체 한도 설정
cluster.routing.allocation.disk.threshold_enabled: true
cluster.routing.allocation.disk.watermark.flood_stage: 5gb   # ES 6.x 전용
cluster.routing.allocation.disk.watermark.low: 15gb
cluster.routing.allocation.disk.watermark.high: 10gb

Elasticsearch를 재시작하면 read_only_allow_delete는 자동으로 해제됩니다.

_“Disk-based Shard Allocation Elasticsearch Reference” 5.66.x_