Banzai 파이프라인 및 파싱
GitLab v19.4요약
GitLab Flavored Markdown을 파싱해 HTML로 렌더링하는 과정에는 여러 컴포넌트가 관여합니다. GLFM을 HTML로 바꾸는 처리는 모두 백엔드가 담당합니다. 프론트엔드는 표시 단계에서 다음 항목을 처리합니다.
GitLab Flavored Markdown을 파싱해 HTML로 렌더링하는 과정에는 여러 컴포넌트가 관여합니다.
- Banzai 파이프라인과 그 안의 여러 필터
- Markdown 파서
GLFM을 HTML로 바꾸는 처리는 모두 백엔드가 담당합니다. 이 방식에는 다음과 같은 이점이 있습니다.
- 보안: 알 수 없는 태그, 클래스, id를 제거하는 강도 높은 새니타이제이션을 수행합니다.
- 참조: 참조 구문은 이슈를 해석하는 등의 작업과 사용자가 접근 권한이 없는 참조를 가리는 작업을 위해 데이터베이스 접근이 필요합니다.
- 일관성: 사용자에게 일관된 경험을 제공하고자 하며, 여기에는 GLFM 구문과 스타일링의 완전한 지원이 포함됩니다. 처리를 한 곳에서 수행하면 이를 제공할 수 있습니다.
- 캐싱: 이슈나 머지 리퀘스트 설명, 댓글처럼 가능한 경우 HTML을 데이터베이스에 캐시합니다.
- 퀵 액션: Markdown 텍스트에서 퀵 액션을 더 잘 탐지하기 위해 전용 파이프라인으로 이를 처리합니다.
프론트엔드는 표시 단계에서 다음 항목을 처리합니다.
- 수식 블록
- Mermaid 블록
- 수식이나 mermaid 블록이 지나치게 많은 경우 등 일정한 제한의 적용
Banzai 파이프라인#
Banzai 파이프라인은 하와이의 서프 리프 브레이크에서 이름을 따왔으며, Markdown과 HTML을 단계마다 변환하는 여러 필터(lib/banzai/filters)로 구성됩니다. AsciiDocPipeline, EmailPipeline처럼 서로 다른 필터 순서를 가진 여러 파이프라인(lib/banzai/pipeline)이 정의되어 있습니다.
파이프라인과 필터 메커니즘은 html-pipeline gem 이 구현합니다.
주 파이프라인은 PlainMarkdownPipeline과 GfmPipeline을 결합한 FullPipeline 입니다.
PlainMarkdownPipeline#
이 파이프라인에는 원시 Markdown을 HTML로 변환하는 필터가 들어 있으며, 주로 Filter::MarkdownFilter가 처리합니다.
Filter::MarkdownFilter#
이 필터는 실제 Markdown 파서와 연결됩니다. 파서는 comrak Rust 크레이트를 사용하는 gitlab-glfm-markdown Ruby gem을 사용합니다.
이 필터에 텍스트가 전달되면 지정된 파서 엔진을 호출해 그에 대응하는 기본 HTML을 생성합니다.
GfmPipeline#
이 파이프라인에는 원시 HTML을 렌더링된 GLFM으로 만들기 위한 추가 변환을 수행하는 모든 필터가 들어 있습니다.
각 필터에는 Nokogiri 문서가 전달되며, 필터가 여러 변환을 수행합니다.
EmojiFitler, CommitTrailersFilter, SanitizationFilter가 그 예입니다.
초기 Markdown 파싱에서 처리할 수 없는 항목은 이 필터들이 처리합니다.
특히 SanitizationFilter에 주목할 필요가 있습니다. 악의적일 수 있는 입력으로부터 안전한 HTML을 제공하는 데 매우 중요합니다.
PostProcessPipeline#
FullPipeline의 출력은 데이터베이스에 캐시됩니다. 그러나 이 시점에는 참조가 이미 해석된 상태입니다. 사용자의 권한에 따라
그 참조를 볼 수 없을 수도 있습니다. PostProcessPipeline은 사용자 권한에 따라 기밀 정보를 가리는 역할을 합니다. 이 변경 사항은
표시할 때마다 다시 계산해야 하므로
캐시되지 않습니다.
SingleLinePipeline#
SingleLinePipeline은 이슈류 제목처럼 한 줄짜리 텍스트 필드에 사용됩니다. Issuable concern에서
cache_markdown_field :title, pipeline: :single_line로 구성됩니다.
FullPipeline과 달리 이 파이프라인은 Markdown 파서(MarkdownFilter)를 실행하지 않습니다. 최소한의 필터 집합으로 일반 텍스트를 처리합니다.
HtmlEntityFilter- 입력을 일반 텍스트로 간주해 HTML 엔터티를 이스케이프합니다.EmojiFilter-:emoji:숏코드를 변환합니다.CustomEmojiFilter- 커스텀 이모지 숏코드를 변환합니다.AutolinkFilter- URL을 자동으로 링크합니다.ExternalLinkFilter- 외부 링크를 처리합니다.- 참조 필터 -
#123,@user,!456과 같은 GitLab 참조를 해석합니다.
따라서 제목에서는 굵게, 기울임, 코드 스팬, Markdown 링크를 비롯한 표준 Markdown 서식을 사용할 수 없습니다. 제목에서 사용할 수 있는 서식에 대한 자세한 내용은 작업 항목 및 머지 리퀘스트 제목을 참고합니다.
성능#
필터를 가능한 한 빠르게 실행하는 것도 중요하지만, 전반적으로 지나치게 오래 걸리지 않도록 하는 것도 중요합니다. 이를 위해 다음과 같은 기법을 사용합니다.
-
오래 걸릴 수 있는 일부 필터에는 TimeoutFilterHandler의
Gitlab::RenderTimeout.timeout으로 Ruby 타임아웃을 적용합니다. 이렇게 하면 처리가 너무 오래 걸릴 때 실제 처리를 중단할 수 있습니다. 일반적으로 Ruby의timeout사용은 안전하다고 보지 않습니다. 따라서 꼭 필요할 때만 사용하며, 타임아웃을 두기보다 실제 성능 문제를 해결하는 쪽을 우선합니다. -
PipelineTimingCheck를 사용하면 파이프라인이 소비한 누적 시간을 추적할 수 있습니다. 최대치에 도달하면 남은 필터를 건너뜁니다. 거의 모든 필터는 이런 상황에서 건너뛰어도 괜찮습니다. 사용자에게 아무것도 보여 주지 않는 것보다 무언가 를 보여 주는 편이 낫기 때문입니다.
다만 그렇게 하면 안 되는 경우도 몇 가지 있습니다. 예를 들어
SanitizationFilter가 완료되지 않으면 새니타이즈되지 않은 HTML 이 남아 있을 수 있으므로 사용자에게 HTML을 보여 줄 수 없습니다. 이런 경우에는 오류 메시지를 표시해야 합니다.
벤치마킹에 사용할 수 있는 rake 태스크도 있습니다. 성능 가이드라인을 참고합니다
Markdown 파서#
comrak Rust 크레이트를 사용하는 gitlab-glfm-markdown Ruby gem을 사용합니다.
comrak은 GFM 및 CommonMark와 100% 호환되면서도 확장을 추가할 수 있습니다. 예를 들어 여러 줄 인용문과 위키링크 구문을 comrak에 직접 구현할 수 있었습니다. 목표는 Ruby 필터를 (적합한 경우) comrak 또는 gitlab-glfm-markdown으로 더 많이 옮기는 것입니다.
comrak에 전달되는 여러 옵션에 대한 자세한 내용은 glfm_markdown.rb를 참고합니다.
캐싱#
주 파이프라인의 출력은 데이터베이스에, 경우에 따라서는 Redis에 캐시됩니다. _html 칼럼 관리는 CacheMarkdownField가
담당합니다. 예를 들어 description 칼럼이 있으면 description_html 칼럼이 함께 관리됩니다. description_html 이 비어 있으면
아직 description에 대해 계산되지 않은 것입니다. 비어 있지 않다면 description_html 이 description의 렌더링 결과임이
보장됩니다.
Markdown 필드를 포함하는 각 테이블에는 cached_markdown_version 칼럼도 있습니다. 이 칼럼은 해당 내용이 어떤
Markdown "버전" 으로 렌더링되었는지를 나타냅니다. 이 값은 이미 캐시된 HTML을 다시 렌더링해야 하는지 여부를 결정합니다.
예를 들어 HTML 렌더링 방식이 바뀌어 캐시된 HTML을 모두 다시 만들어야 할 때 이런 상황이 발생합니다.
이를 제어하는 값은 두 가지입니다. 하나는 기본 애플리케이션 버전인
Gitlab::MarkdownCache::CACHE_COMMONMARK_VERSION 입니다. 파일에서 이 값을 변경하면 모든 설치 환경에 걸쳐
캐시된 HTML 필드가 모두 다시 렌더링됩니다.
관리자가 캐시를 무효화할 수 있는 애플리케이션 수준 설정 local_markdown_version도 있습니다.
이에 대한 내용은 Markdown 캐시에 정리되어 있습니다. 예를 들어 새 PlantUML
서버를 사용하게 되어 관리자가 모든 필드에 새 값을 적용하려는 경우처럼 시스템 설정이 바뀔 때 필요할 수 있습니다.
해당 문서에는 프로젝트 하나만 초기화하는 방법 등도 설명되어 있습니다.
CACHE_COMMONMARK_VERSION 증가의 단계적 롤아웃#
Gitlab::MarkdownCache::CACHE_COMMONMARK_VERSION을 올리면 과거에는 데이터베이스에 큰 부담이 갔습니다. 버전을 올린 뒤 처음 읽을 때 캐시된 Markdown 이 있는 모든 행이 다시 렌더링되고 다시 기록되었기 때문입니다. 롤아웃 메커니즘은 다음 두 가지를 사용해 부하를 조정 가능한 구간에 걸쳐 분산합니다.
lib/gitlab/markdown_cache.rb의CACHE_COMMONMARK_VERSION_PREVIOUS상수입니다. 정상 상태(롤아웃이 진행 중이 아닌 상태)에서는 이 값이nil이고 메커니즘은 비활성 상태입니다.latest_cached_markdown_version은 항상 현재 시프트된 버전을 반환하고, 아래의 기능 플래그는 무시되며, 오래된 행은 읽는 즉시 다시 기록됩니다. 이 메커니즘이 없던 때와 같습니다.markdown_cache_stochastic_rollout_<version>기능 플래그입니다. 정의 파일이 없는markdown_cache유형의 플래그로, 이름에 대상 캐시 버전이 들어갑니다(예:markdown_cache_stochastic_rollout_34).CACHE_COMMONMARK_VERSION_PREVIOUS가 설정되어 있는 동안에는 플래그의percentage_of_time값이 새 버전을 얼마나 적극적으로 노출할지 제어합니다.n%에서는 읽기의 약n%가 새 시프트 버전을 반환하고 나머지는 이전 시프트 버전을 반환합니다. 레코드마다 독립적으로 추첨되며, 추첨 결과는 모델 인스턴스 단위로 메모이즈됩니다.- 이전 버전인 행에 대해 "현재" 가 뽑히면
refresh_markdown_cache!가 실행되어 해당 행을 다시 기록합니다. - 이미 "현재" 버전인 행에 대해 "이전" 이 뽑히면
cached_html_up_to_date?가 이를 최신으로 판단합니다. 저장된 버전이 뽑힌 버전과 같거나 더 새로우면 할 일이 없기 때문입니다. - 쓰기는 추첨 결과와 관계없이 항상 새 시프트 버전으로 기록됩니다. 여기에는 플래그를 참조하지 않는
cached_markdown_version_for_write가 사용됩니다. 플래그는 어떤 읽기가 재기록을 유발할지만 제어할 뿐, 각 쓰기가 만들어 내는 버전은 제어하지 않습니다. 따라서 롤아웃 중에는 플래그가 제어하는 읽기 기반 업그레이드에 더해, 새 행과 내용 수정으로도 행이 자연스럽게 업그레이드됩니다.
- 클래스별로 레이블이 붙는
gitlab_markdown_cache_version_upgrades_total카운터는 쓰기가 실제로 행의cached_markdown_version을 올린 경우에만save_markdown안에서 증가합니다. 시도했지만 억제된 쓰기와 같은 버전의 내용 재동기화 쓰기는 제외됩니다.
gitlab_markdown_cache_version_upgrades_total 카운터는 Grafana의 Markdown Cache Version Upgrades 대시보드에서 확인할 수 있습니다.
percentage-of-time 무작위성을 사용하는 이유#
이 플래그는 의도적으로 액터 기반 게이트가 아니라 percentage_of_time("무작위" 게이트)을 사용합니다. 부하 분산의 목표는 n%에서 읽기 트래픽의 약 n%가 업그레이드 쓰기를 유발해, 마이그레이션이 읽기 부하에 비례하는 완만하고 조절된 흐름으로 진행되게 하는 것입니다. 다음 두 가지 대안을 검토했습니다.
- 요청 단위 액터(
Feature.current_request): 하나의 Rack 요청, Sidekiq job, ActionCable 실행이 지속되는 동안 고정됩니다. 캐시된 Markdown 행을 많이 다루는 단일 요청(이슈 목록, 머지 리퀘스트 목록, 검색 결과)은n%에서 그 행 전부 를 한 번에 업그레이드하므로, 부하가 고르게 분산되는 대신 행 수에 비례하는 요청 단위 쓰기 폭증이 발생합니다. - 레코드 단위 합성 액터(
def flipper_id = "...:#{record.id}"):n%에서는 고정된n%의 행만 업그레이드 대상이 되고, 나머지(100−n)%는 비율을 올리기 전까지 제외됩니다.1%에서는 자주 읽히는 행의99%가 영영 마이그레이션되지 않습니다. 이는 "비율이 낮으면 느리지만 결국 마이그레이션되고, 비율이 높으면 더 빠르다" 는 바람직한 성질을 깨뜨립니다. 대신 비율을 올릴 때마다 선택된(자주 읽히는) 행이 모두 기록되면서 진행이 멈추게 되고, 결국 100% 까지 아주 작은 폭으로 플래그를 계속 올려야 합니다.
percentage-of-time 무작위성은 양수 비율이면 어떤 값에서도 "느리지만 결국 전부를 커버한다" 는 동작을 제공합니다. 레코드마다 독립적으로 추첨되고, 한 요청에서 "이전" 이 뽑힌 레코드가 이후 요청에서 "현재" 로 뽑힐 수 있으므로 읽히는 행은 시간이 지나면서 수렴합니다. --random chatops 옵션은 일반적인 용도에서는 더 이상 권장되지 않습니다. 호출하는 쪽은 대개 액터마다 일정한 결과를 원하고, 호출마다 결과가 달라지면 예상과 어긋나기 때문입니다. 여기에서는 --random을 의도적으로 사용합니다. 레코드 간 독립적인 추첨이 바로 마이그레이션 부하를 분산하는 수단이므로 권장하지 않는 이유가 적용되지 않습니다.
CACHE_COMMONMARK_VERSION 증가를 위한 롤아웃 절차#
롤아웃에는 두 개의 머지 리퀘스트와 기능 플래그 조정이 필요합니다. 아래 예시는 33에서 34로 올리는 경우이므로 플래그는 markdown_cache_stochastic_rollout_34 입니다.
-
MR1: 롤아웃 시작.
lib/gitlab/markdown_cache.rb에서 다음을 수행합니다.CACHE_COMMONMARK_VERSION을 올립니다(예:33에서34로).CACHE_COMMONMARK_VERSION_PREVIOUS를nil에서 이전 버전(이 예시에서는33)으로 설정합니다.
버전이 붙은 플래그는 아직 설정된 적이 없으므로 비활성 상태입니다. 모든 읽기가 "이전" 으로 추첨되고 캐시 무효화가 강제되지 않습니다. 새 쓰기는 새 버전을 사용하기 시작합니다.
-
chatops를 통해 버전이 붙은 플래그의
percentage_of_time을 낮은 값(예:1)으로 활성화합니다./chatops gitlab run feature set markdown_cache_stochastic_rollout_34 1 --random이 플래그에는
--random옵션을 사용해야 합니다.--actors를 함께 쓰면 아무 효과가 없습니다. 이 플래그는--random옵션과 함께 사용하도록 설계되었습니다. -
비율 상향.
gitlab_markdown_cache_version_upgrades_total과 데이터베이스 쓰기 속도를 관찰합니다. 여유가 있는 만큼 비율을 점진적으로 올립니다(예:5,25,50,100)./chatops gitlab run feature set markdown_cache_stochastic_rollout_34 5 --random -
수렴 관찰.
100%에서는 이전 버전에 머물러 있는 행을 읽을 때마다 재기록이 발생합니다. 카운터가 올라갔다가 자주 읽히는 행이 수렴하면서 점차 줄어듭니다. 한 번도 읽히지 않는 행은 이전 버전에 그대로 남습니다. 이는 의도된 동작이며, 아무도 읽지 않으므로 문제가 되지 않고 다음 버전 증가 때 자동으로 정리됩니다. -
MR2: 롤아웃 마무리.
CACHE_COMMONMARK_VERSION_PREVIOUS를 다시nil로 설정합니다. 시스템은 정상 상태로 돌아갑니다.CACHE_COMMONMARK_VERSION_PREVIOUS가nil이면 플래그는 무시되고, 이전 버전에 남아 있는 행은 오래된 것으로 간주되어 자연스러운 읽기 속도에 맞춰 처음 읽힐 때 다시 기록됩니다. -
플래그 정리. MR2가 완전히 배포된 뒤 chatops로 버전이 붙은 플래그를 삭제합니다.
/chatops gitlab run feature delete markdown_cache_stochastic_rollout_34
조건 없이 즉시 무효화해야 하는 경우(예: 렌더러의 중대한 보안 수정)에는 관리자가 애플리케이션 설정에서 local_markdown_version을 올릴 수 있습니다. 이렇게 하면 애플리케이션 버전과 관계없이 모든 행이 다음 읽기에서 다시 렌더링됩니다.
자세한 내용은 이슈 330313과 이슈 597379를 참고합니다.
디버깅#
여러 파이프라인과 필터를 디버깅하는 가장 쉬운 방법은 보통 Rails 콘솔에서 실행하는 것입니다. 이렇게 하면 필터에 binding.pry를 설정하고 코드를 단계별로 따라갈 수 있습니다.
TimeoutFilterHandler와 PipelineTimingCheck 때문에 필터 디버깅이 까다로울 수 있습니다. 이를 위해 GITLAB_DISABLE_MARKDOWN_TIMEOUT이라는 전용 환경 변수가 있으며, 이를 설정하면 필터의 모든 타임아웃 검사가 비활성화됩니다. 드물게 GitLab Self-Managed 인스턴스에서 이 검사를 우회하려는 고객도 사용할 수 있습니다.
text = 'Some test **Markdown**'
html = Banzai.render(text, project: nil)
이렇게 하면 프로젝트와 무관하게 Markdown 이 렌더링됩니다. 또는 프로젝트 컨텍스트에서 렌더링할 수도 있습니다.
project = Project.first
text = 'Some test **Markdown**'
html = Banzai.render(text, project: project)
render 메서드는 text와 함께 렌더링 옵션을 제공하는 context 해시를 받습니다. 예를 들어 pipeline: :ascii_doc를 사용하면 AsciiDocPipeline을 실행할 수 있습니다. 기본값은 FullPipeline 입니다.
debug_timing: true를 지정하면 필터 목록과 각 필터의 소요 시간을 확인할 수 있습니다.
Banzai.render(text, project: nil, debug_timing: true)
D, [2024-12-20T13:35:24.246463 #34584] DEBUG -- : 0.000012_s (0.000012_s): NormalizeSourceFilter [PreProcessPipeline]
D, [2024-12-20T13:35:24.246543 #34584] DEBUG -- : 0.000007_s (0.000019_s): TruncateSourceFilter [PreProcessPipeline]
D, [2024-12-20T13:35:24.246589 #34584] DEBUG -- : 0.000028_s (0.000047_s): FrontMatterFilter [PreProcessPipeline]
D, [2024-12-20T13:35:24.246662 #34584] DEBUG -- : 0.000005_s (0.000005_s): IncludeFilter [FullPipeline]
D, [2024-12-20T13:35:24.246816 #34584] DEBUG -- : 0.000088_s (0.000101_s): MarkdownFilter [FullPipeline]
...
D, [2024-12-20T13:35:24.252338 #34584] DEBUG -- : 0.000013_s (0.004394_s): CustomEmojiFilter [FullPipeline]
D, [2024-12-20T13:35:24.252504 #34584] DEBUG -- : 0.000095_s (0.004489_s): TaskListFilter [FullPipeline]
D, [2024-12-20T13:35:24.252558 #34584] DEBUG -- : 0.000028_s (0.004517_s): SetDirectionFilter [FullPipeline]
D, [2024-12-20T13:35:24.252623 #34584] DEBUG -- : 0.000045_s (0.004562_s): SyntaxHighlightFilter [FullPipeline]
필터별로 더 자세한 내용을 보려면 debug: true를 사용합니다.