GitLab 이벤트 스토어
배경
모놀리식 GitLab 프로젝트가 더 커지고 더 많은 도메인이 정의되고 있습니다.
그 결과, 이러한 도메인들은 시간적 결합으로 인해 서로 얽히고 있습니다.
상징적인 예는 PostReceive
워커로, 여러 도메인에서 많은 일이 발생합니다. 새로운 커밋이 푸시될 때 새로운 동작이 반응하면, 우리는 PostReceive
또는 그 하위 컴포넌트(Git::ProcessRefChangesService
, 예를 들어) 어딘가에 코드를 추가합니다.
이러한 유형의 아키텍처는:
- 단일 책임 원칙(Single Responsibility Principle)을 위반합니다.
- 익숙하지 않은 코드베이스에 코드를 추가할 위험을 증가시킵니다. 알지 못하는 미세한 차이가 버그나 성능 저하를 초래할 수 있습니다.
- 도메인 경계를 위반합니다. 특정 네임스페이스(예:
Git::
) 내부에서 다른 도메인의 클래스(Ci::
,MergeRequests::
등)가 갑자기 끼어드는 것을 확인할 수 있습니다.
이벤트 스토어란 무엇입니까?
Gitlab:EventStore
는 기존의 Sidekiq 워커와 오늘날 우리가 갖고 있는 관찰 가능성을 기반으로 구축된 기본적인 pub-sub 시스템입니다.
이 시스템을 사용하여 도메인을 모델링할 때 이벤트 기반 접근 방식을 적용하면서 결합도를 최소화합니다.
본질적으로 기존의 Sidekiq 워커는 기존의 비동기 작업을 수행하도록 그대로 두면서도 의존성을 반전시킵니다.
이벤트 스토어 예제
CI 파이프라인이 생성될 때, 우리는 파이프라인의 ref
와 일치하는 모든 병합 요청의 헤드 파이프라인을 업데이트합니다. 그런 다음 병합 요청에서 최신 파이프라인의 상태를 표시할 수 있습니다.
이벤트 스토어 없이
우리는 Ci::CreatePipelineService
를 변경하고 파이프라인이 생성되었는지 확인하는 로직(예: if
문)을 추가합니다. 그런 다음 MergeRequests::
도메인에 대한 사이드 효과를 실행할 워커를 예약합니다.
이 스타일은 개방-폐쇄 원칙(Open-Closed Principle)을 위반하고 다른 도메인의 사이드 효과 로직을 불필요하게 추가하여 결합도를 증가시킵니다:
이벤트 스토어와 함께
Ci::CreatePipelineService
는 이벤트 Ci::PipelineCreatedEvent
를 발행하고 그 책임은 여기서 멈춥니다.
MergeRequests::
도메인은 워커 MergeRequests::UpdateHeadPipelineWorker
로 이 이벤트를 구독할 수 있으므로:
- 사이드 효과는 비동기적으로 예약되며 도메인 이벤트를 발생시키는 주요 비즈니스 트랜잭션에 영향을 미치지 않습니다.
- 주요 비즈니스 트랜잭션을 수정하지 않고도 더 많은 사이드 효과를 추가할 수 있습니다.
- 어떤 도메인이 관련되어 있고 그 소유권이 무엇인지 분명히 볼 수 있습니다.
- 시스템에서 어떤 이벤트가 발생하는지를 명시적으로 선언하여 식별할 수 있습니다.
Gitlab::EventStore
에서는 구독자(Sidekiq 워커)와 도메인 이벤트의 스키마 간에 여전히 결합이 존재합니다. 이 수준의 결합은 주요 트랜잭션(Ci::CreatePipelineService
)이:
- 여러 구독자에게 결합되는 것보다 훨씬 작습니다.
- 구독자를 호출하는 여러 방법(조건부 호출 포함)과
- 매개변수를 전달하는 여러 방법에 결합되는 것보다 훨씬 작습니다.
각 구독자는 그들이 책임지는 작업 유형과 관련된 모든 속성을 지정할 수 있습니다. 예를 들어, 한 구독자는 urgency: high
를 정의할 수 있고, 다른 덜 중요한 구독자는 urgency: low
로 설정할 수 있습니다.
이벤트 스토어는 의존성 역전(Dependency Inversion)을 가능하게 하는 추상화입니다. 이는 비즈니스 트랜잭션과 사이드 효과(종종 다른 도메인에서 실행되는) 간의 분리를 도와줍니다.
이벤트가 발행되면, 이벤트 스토어는 각 구독된 워커에서 perform_async
를 호출하며 이벤트 정보를 인수로 전달합니다. 이는 본질적으로 각 구독자의 큐에 Sidekiq 작업을 예약합니다.
이는 구독자의 작업 방식과 관련하여 다른 변경 사항이 없음을 의미합니다. 구독자는 단지 Sidekiq 워커입니다. 예를 들어, 어떤 워커(구독자)가 작업 실행에 실패하면, 작업은 Sidekiq에 다시 넣어져 재시도됩니다.
EventStore의 장점
-
구독자(Sidekiq 작업자)는 부작용이 중요한 경우 작업자 가중치를 변경하여 더 빠르게 실행되도록 설정할 수 있습니다.
-
부작용이 비동기적으로 실행되는 사실을 자동으로 강제합니다.
이는 다른 도메인이 주요 비즈니스 거래의 성능에 영향을 주지 않고 이벤트에 구독할 수 있도록 안전하게 만듭니다.
EventStore의 단점
-
EventStore
는 Sidekiq 위에 구축되어 있습니다.
Sidekiq 작업자는 재시도 및 지수 백오프를 지원하지만, 작업자가 재시도 한도를 초과하면 Sidekiq 작업이 손실되는 경우가 있습니다.
또한, 사고 및 재해 복구의 일환으로 Sidekiq 작업이 Dropped될 수 있습니다.
많은 중요한 GitLab 기능들이 Sidekiq 내 내구성에 의존하고 있지만, 이는 일부 중요한 데이터 무결성 기능에 대해서는 허용될 수 없습니다.
작업이 결국 완료되기를 확실히 하려면, 작업이 Sidekiq 크론 작업자에 의해 수집되는 Postgres에서 큐잉 메커니즘을 구현해야 할 수 있습니다.
이 접근 방식의 예는::LooseForeignKeys::CleanupWorker
및::BatchedGitRefUpdates::ProjectCleanupWorker
에서 볼 수 있습니다.
일반적으로 파티셔닝된 테이블이 생성되고, 데이터를 삽입한 후 크론 작업자가 이를 처리하고 데이터베이스에 표시하는 방식으로 작동합니다.
Redis에서 신뢰할 수 있는 큐를 구현하기 위한 전략도 있으며, 이는::Elastic::ProcessBookkeepingService
에서 사용됩니다.
코드베이스에서 큐잉을 위한 새로운 패턴을 도입하려는 경우, 초기 단계에서 유지 관리자의 조언을 구하는 것이 좋습니다. -
논리가 주요 비즈니스 거래의 일환으로 처리되어야 하고 부작용이 아니라면
EventStore
를 사용하지 않는 것을 고려하십시오. -
Sidekiq 작업자는 기본적으로 제한이 없지만, 공유 리소스의 포화 위험이 있을 경우 동시성 제한을 구성하는 것을 고려해야 합니다.
이벤트 정의
Event
객체는 경계 컨텍스트 내에서 발생한 도메인 이벤트를 나타냅니다.
생산자는 이벤트를 게시하여 다른 경계 컨텍스트에 발생한 일에 대해 알릴 수 있으며, 그들이 이에 반응할 수 있도록 합니다.
이벤트는 <domain_object><action>Event
형태로 명명되어야 하며, 여기서 action
은 과거형입니다. 예: ReviewerAddedEvent
대신 AddReviewerEvent
.
domain_object
는 경계 컨텍스트에 따라 명확할 때 생략할 수 있습니다. 예: MergeRequest::ApprovedEvent
대신 MergeRequest::MergeRequestApprovedEvent
.
좋은 이벤트에 대한 안내
이벤트는 API나 UI와 같은 공개 인터페이스입니다.
제품 및 디자인 팀과 협력하여 새로운 이벤트가 구독자의 요구를 충족할 수 있도록 하십시오.
가능한 경우, 새로운 이벤트는 다음 원칙을 충족하는 것을 목표로 해야 합니다.
-
의미적: 이벤트는 경계 컨텍스트 내에서 발생한 일을 설명해야 하며, _구독자를 위한 의도된 동작_을 설명해서는 안 됩니다.
-
특정: 이벤트는 지나치게 세밀하지 않도록 좁게 정의해야 합니다.
이는 구독자가 수행해야 하는 이벤트 필터링의 양과 구독해야 하는 고유 이벤트의 수를 최소화합니다.
추가 정보를 전달하기 위해 속성을 사용하는 것을 고려하십시오. -
범위: 이벤트는 서로의 경계 컨텍스트에 맞춰야 합니다.
자신의 경계 컨텍스트에 포함되지 않는 도메인 객체에 대한 이벤트 게시를 피하십시오.
예시
원칙 | 좋은 사례 | 나쁜 사례 |
---|---|---|
의미론적 | MergeRequest::ApprovedEvent |
MergeRequest::NotifyAuthorEvent |
구체적 | MergeRequest::ReviewerAddedEvent |
• MergeRequest::ChangedEvent • MergeRequest::CodeownerAddedAsReviewerEvent
|
범위 한정 | MergeRequest::CreatedEvent |
Project::MergeRequestCreatedEvent |
이벤트 스키마 생성
이전의 어떤 사건을 나타내는 이름으로 app/events/<namespace>/
아래에 새로운 이벤트 클래스를 정의하세요:
class Ci::PipelineCreatedEvent < Gitlab::EventStore::Event
def schema
{
'type' => 'object',
'required' => ['pipeline_id'],
'properties' => {
'pipeline_id' => { 'type' => 'integer' },
'ref' => { 'type' => 'string' }
}
}
end
end
스키마는 유효한 JSON 스키마여야 하며,
JSONSchemer
젬에 의해 검증됩니다.
이 검증은 이벤트 객체를 초기화할 때 즉시 발생하여 게시자가 구독자와의 계약을 준수하도록 합니다.
가능한 한 선택적 속성을 사용해야 하며, 이는 스키마 변경을 위한 배포를 줄입니다.
그러나 required
속성은 이벤트 주체의 고유 식별자로 사용될 수 있습니다. 예를 들어:
-
pipeline_id
는Ci::PipelineCreatedEvent
의 필수 속성이 될 수 있습니다. -
project_id
는Projects::ProjectDeletedEvent
의 필수 속성이 될 수 있습니다.
필수 속성 없이 구독자에게 필요한 속성만을 게시해야 하며, 특정 구독자에 맞춰 페이로드를 조정하면 안 됩니다. 페이로드는 이벤트를 완전히 표현해야 하며, 느슨하게 관련된 속성을 포함해서는 안 됩니다. 예를 들어:
Ci::PipelineCreatedEvent.new(data: {
pipeline_id: pipeline.id,
# 모든 구독자가 병합 요청 ID가 필요하지 않는 한,
# 이는 구독자가 가져올 수 있는 데이터입니다.
merge_request_ids: pipeline.all_merge_requests.pluck(:id)
})
더 많은 속성을 가진 이벤트를 게시하면 구독자가 처음부터 필요한 데이터를 제공받을 수 있습니다. 그렇지 않으면 구독자는 추가 데이터를 데이터베이스에서 가져와야 합니다. 그러나 이로 인해 스키마에 지속적인 변경이 생기고 단일 진실 공급원을 나타내지 않을 수 있는 속성이 추가될 수 있습니다. 이 기술은 성능 최적화로 사용하는 것이 가장 좋습니다. 예를 들어, 이벤트에 많은 구독자가 있으며 모두 동일한 데이터를 데이터베이스에서 다시 가져오는 경우입니다.
스키마 업데이트
스키마 변경은 여러 번의 배포가 필요합니다. 새로운 버전이 배포되는 동안:
- 기존 게시자는 이전 버전을 사용하여 이벤트를 게시할 수 있습니다.
- 기존 구독자는 이전 버전을 사용하여 이벤트를 소비할 수 있습니다.
- 이벤트는 작업 인수로 Sidekiq 큐에 지속되므로 배포 중에 2개의 스키마 버전을 가질 수 있습니다.
스키마 변경이 궁극적으로 Sidekiq 인수에 영향을 미치므로, 여러 번의 배포와 관련하여 Sidekiq 스타일 가이드를 참조하세요.
속성 추가
- 배포 1:
- 새 속성을 선택적으로 추가합니다 (필수가 아님).
- 구독자를 업데이트하여 새 속성이 있든 없든 이벤트를 소비할 수 있도록 합니다.
- 배포 2:
- 게시자를 변경하여 새 속성을 제공하도록 합니다.
- 배포 3: (속성이 필수여야 하는 경우):
- 스키마와 구독자 코드를 변경하여 항상 이를 기대하도록 합니다.
속성 제거
- 롤아웃 1:
- 속성이
필수(required)
인 경우, 선택사항으로 변경합니다. - 구독자를 업데이트하여 속성이 항상 예상되지 않도록 합니다.
- 속성이
- 롤아웃 2:
- 이벤트 게시에서 속성을 제거합니다.
- 속성을 처리하는 구독자의 코드를 제거합니다.
기타 변경 사항
속성의 이름 변경과 같은 기타 변경 사항에 대해서는 동일한 단계를 사용하세요:
- 이전 속성을 제거합니다.
- 새 속성을 추가합니다.
이벤트 게시하기
이전 예제에서 이벤트를 게시하려면:
Gitlab::EventStore.publish(
Ci::PipelineCreatedEvent.new(data: { pipeline_id: pipeline.id })
)
이벤트는 가능한 경우 관련 서비스 클래스에서 전송되어야 합니다. 상태 전환과 같은 일부 예외가 있어 모델이 이벤트를 게시할 수 있도록 허용할 수 있습니다.
예를 들어, Ci::BuildFinishedWorker
를 예약하는 대신 부작용의 모음을 실행하는 대신 Ci::BuildFinishedEvent
를 게시하고 다른 도메인이 비동기적으로 반응하도록 할 수 있습니다.
ActiveRecord
콜백은 도메인 이벤트를 표현하기에는 너무 저수준입니다. 그들은 더 많은 데이터베이스 레코드 변경을 나타냅니다. 그럴만한 경우가 있을 수 있지만, 그러한 예외를 고려해야 합니다.
구독자 생성하기
구독자는 Gitlab::EventStore::Subscriber
모듈을 포함한 Sidekiq 작업자입니다.
이 모듈은 perform
메서드를 처리하고 handle_event
메서드를 통해 이벤트를 안전하게 처리하기 위한 더 나은 추상화를 제공합니다. 예를 들어:
module MergeRequests
class UpdateHeadPipelineWorker
include Gitlab::EventStore::Subscriber
def handle_event(event)
Ci::Pipeline.find_by_id(event.data[:pipeline_id]).try do |pipeline|
# ...
end
end
end
end
이벤트에 구독자 등록하기
lib/gitlab/event_store.rb
에서 특정 이벤트에 작업자를 구독하려면 Gitlab::EventStore.configure!
메서드에 다음과 같은 줄을 추가합니다:
참고: 새 작업자는 카나리 배포와의 호환성 보장을 위해 기능 플래그와 함께 도입되어야 합니다.
module Gitlab
module EventStore
def self.configure!(store)
# ...
store.subscribe ::Sbom::ProcessTransferEventsWorker, to: ::Projects::ProjectTransferedEvent,
if: ->(event) do
actor = ::Project.actor_from_id(event.data[:project_id])
Feature.enabled?(:sync_project_archival_status_to_sbom_occurrences, actor)
end
# ...
end
end
end
EE 코드베이스에만 정의된 작업자는 ee/lib/ee/gitlab/event_store.rb
에서 구독을 선언하여 동일한 방식으로 이벤트를 구독할 수 있습니다.
구독은 Rails 앱이 로드될 때 메모리에 저장되며 즉시 고정됩니다.
런타임에 구독을 수정하는 것은 불가능합니다.
이벤트의 조건부 전송
구독은 이벤트를 수신할 조건을 지정할 수 있습니다:
store.subscribe ::MergeRequests::UpdateHeadPipelineWorker,
to: ::Ci::PipelineCreatedEvent,
if: -> (event) { event.data[:merge_request_id].present? }
이는 조건을 충족할 경우 이벤트 저장소가 Ci::PipelineCreatedEvent
를 구독자에게 전송하도록 명령합니다.
이 기술은 구독자가 소수의 이벤트에만 관심이 있는 경우 Sidekiq 작업을 예약하는 것을 피할 수 있습니다.
경고: 조건부 전송을 사용할 때는 주어진 이벤트가 게시될 때마다 동기적으로 실행되므로 오직 저비용 조건만 포함해야 합니다.
복잡한 조건의 경우 모든 이벤트에 구독한 다음 구독자 작업자의 handle_event
메서드에서 로직을 처리하는 것이 가장 좋습니다.
이벤트 지연 발송
구독자는 이벤트를 수신할 지연 시간을 지정할 수 있습니다:
store.subscribe ::MergeRequests::UpdateHeadPipelineWorker,
to: ::Ci::PipelineCreatedEvent,
delay: 1.minute
delay
매개변수는 이벤트 발송을 perform_async
대신 구독자의 Sidekiq 작업에 대해 perform_in
메서드를 사용하도록 전환합니다.
이 기술은 많은 이벤트를 발행하고 Sidekiq 중복 제거를 활용할 때 유용합니다.
이벤트 그룹 발행
일부 시나리오에서는 단일 비즈니스 거래에서 동일한 유형의 여러 이벤트를 발행합니다.
이것은 각 이벤트에 대해 작업을 호출함으로써 Sidekiq에 추가적인 부하를 가하게 됩니다. 이런 경우에는 Gitlab::EventStore.publish_group
을 호출하여 이벤트 그룹을 발행할 수 있습니다. 이 메서드는 유사한 유형의 이벤트 배열을 받습니다. 기본적으로 구독자 작업자는 최대 10개의 이벤트 그룹을 수신하지만, 구독을 생성할 때 group_size
매개변수를 정의하여 구성할 수 있습니다. 발행된 이벤트의 수는 구성된 group_size
에 따라 배치로 구독자에게 발송됩니다. 그룹 수가 100을 초과하면 각 그룹은 Sidekiq의 부하를 줄이기 위해 10초의 지연으로 예약됩니다.
store.subscribe ::Security::RefreshProjectPoliciesWorker,
to: ::ProjectAuthorizations::AuthorizationsChangedEvent,
delay: 1.minute,
group_size: 25
구독자 작업자의 handle_event
메서드는 그룹의 각 이벤트에 대해 호출됩니다.
테스트
발행자 테스트
발행자의 책임은 이벤트가 올바르게 발행되었는지 확인하는 것입니다.
이벤트가 올바르게 발행되었는지 테스트하기 위해 RSpec 매처 :publish_event
를 사용할 수 있습니다:
it 'publishes a ProjectDeleted event with project id and namespace id' do
expected_data = { project_id: project.id, namespace_id: project.namespace_id }
# 매처는 블록이 호출될 때 블록이 예상 이벤트와 데이터를 발행하는지 확인합니다.
expect { destroy_project(project, user, {}) }
.to publish_event(Projects::ProjectDeletedEvent)
.with(expected_data)
end
:publish_event
매처 내에서도 매처를 조합할 수 있습니다.
이는 특정 유형의 값으로 이벤트가 생성되었는지 확인하고자 할 때 유용하지만, 미리 값을 알지 못할 경우에 해당합니다. 예를 들어, 새로운 레코드를 생성한 후 이벤트를 발행하는 경우가 있습니다.
it 'publishes a ProjectCreatedEvent with project id and namespace id' do
# 프로젝트 ID는 `create_project`가
# 기대 블록에서 호출될 때만 생성됩니다.
expected_data = { project_id: kind_of(Numeric), namespace_id: group_id }
expect { create_project(user, name: 'Project', path: 'project', namespace_id: group_id) }
.to publish_event(Projects::ProjectCreatedEvent)
.with(expected_data)
end
여러 이벤트를 발행할 때는 비발행 이벤트를 확인할 수도 있습니다.
it 'publishes a ProjectCreatedEvent with project id and namespace id' do
# 프로젝트 ID는 `create_project`
# 기대 블록에서 호출될 때 생성됩니다.
expected_data = { project_id: kind_of(Numeric), namespace_id: group_id }
expect { create_project(user, name: 'Project', path: 'project', namespace_id: group_id) }
.to publish_event(Projects::ProjectCreatedEvent)
.with(expected_data)
.and not_publish_event(Projects::ProjectDeletedEvent)
end
구독자 테스트
구독자는 게시된 이벤트가 올바르게 소비될 수 있도록 보장해야 합니다. 이를 위해 구독자를 테스트하는 방식을 표준화하기 위해 도우미와 공유 예제를 추가했습니다:
RSpec.describe MergeRequests::UpdateHeadPipelineWorker do
let(:pipeline_created_event) { Ci::PipelineCreatedEvent.new(data: ({ pipeline_id: pipeline.id })) }
# 이 공유 예제는 이벤트가 게시되고
# 현재 구독자(`described_class`)에 의해 올바르게 처리되는지 확인합니다. 또한 작업자가
# 아이도모텀(idempotent)임을 보장합니다.
it_behaves_like 'subscribes to event' do
let(:event) { pipeline_created_event }
end
# 이 공유 예제는 게시된 이벤트가 무시되는지 확인합니다. 이는
# 조건부 배포 테스트에 유용할 수 있습니다.
it_behaves_like 'ignores the published event' do
let(:event) { pipeline_created_event }
end
it 'do something' do
# 이 도우미는 `perform`을 직접 실행하여 `handle_event`가 올바르게 호출되도록 보장합니다.
consume_event(subscriber: described_class, event: pipeline_created_event)
# run expectations
end
end
모범 사례
-
CE 및 EE 분리 및 호환성을 유지합니다:
- 이벤트 클래스 정의 및 이벤트 게시를 항상 발생하는 동일한 코드에서 수행합니다 (CE 또는 EE).
- 이벤트가 CE 기능의 결과로 발생하는 경우, 이벤트 클래스는 CE에서 정의 및 게시해야 합니다. 마찬가지로, 이벤트가 EE 기능의 결과로 발생하는 경우, 이벤트 클래스는 EE에서 정의 및 게시해야 합니다.
- 이벤트에 의존하는 구독자를 의존 기능이 있는 동일한 코드에서 정의합니다 (CE 또는 EE).
- CE에서 게시된 이벤트가 있을 수 있습니다 (예:
Projects::ProjectCreatedEvent
) 그리고 이 이벤트에 의존하는 구독자는 EE에서 정의될 수 있습니다 (예:Security::SyncSecurityPolicyWorker
).
- CE에서 게시된 이벤트가 있을 수 있습니다 (예:
- 이벤트 클래스 정의 및 이벤트 게시를 항상 발생하는 동일한 코드에서 수행합니다 (CE 또는 EE).
- 이벤트 클래스 정의 및 이벤트 게시를 동일한 경계(context) 내에서 수행합니다 (최상위 Ruby 네임스페이스).
- 특정 경계는 자신의 컨텍스트와 관련된 이벤트만 게시해야 합니다.
- 이벤트에 구독할 때 신호/소음 비율을 평가합니다. 구독자 내에서 처리하는 이벤트의 수와 무시하는 이벤트의 수는 얼마인가요? 관심 있는 이벤트의 작은 하위 집합만 있으면 조건부 배포 사용을 고려하십시오. 동기 검사 실행과 조건부 배포 또는 잠재적으로 중복 작업자 일정을 조정하는 것 사이의 균형을 맞추세요.