Banzai 파이프라인 및 파싱
GitLab v19.2요약
GitLab Flavored Markdown을 HTML로 파싱하고 렌더링하는 작업에는 여러 컴포넌트가 관여합니다: Banzai 파이프라인과 다양한 필터들 백엔드에서 GLFM을 HTML로 변환하는 모든 처리를 담당합니다.
GitLab Flavored Markdown을 HTML로 파싱하고 렌더링하는 작업에는 여러 컴포넌트가 관여합니다:
-
Banzai 파이프라인과 다양한 필터들
-
Markdown 파서
백엔드에서 GLFM을 HTML로 변환하는 모든 처리를 담당합니다. 이를 통해 다음과 같은 이점을 얻을 수 있습니다:
-
보안: 알 수 없는 태그, 클래스, id를 제거하는 강력한 새니타이제이션(sanitization)을 수행합니다.
-
참조: 참조 구문은 이슈 등을 해석하기 위해 데이터베이스에 접근해야 하며, 사용자가 접근 권한이 없는 참조를 삭제하는 작업도 필요합니다.
-
일관성: 사용자에게 일관된 경험을 제공하고자 하며, 여기에는 GLFM 구문과 스타일링의 완전한 지원이 포함됩니다. 처리가 단일 위치에서 이루어지면 이를 보장할 수 있습니다.
-
캐싱: 이슈나 머지 리퀘스트 설명, 댓글 등 가능한 경우 HTML을 데이터베이스에 캐시합니다.
-
Quick actions: 전용 파이프라인을 사용하여 quick action을 처리하므로, Markdown 텍스트에서 더 효과적으로 감지할 수 있습니다.
프론트엔드는 다음과 같은 표시 관련 특정 측면을 처리합니다:
-
Math 블록
-
Mermaid 블록
-
과도한 수의 math 또는 mermaid 블록과 같은 특정 제한 사항 적용
Banzai 파이프라인#
하와이의 서핑 리프 브레이크의 이름을 따서 명명된 Banzai 파이프라인은 Markdown과 HTML이 파이프라인 방식으로 각 단계에서 변환되는 다양한 필터(lib/banzai/filters)로 구성됩니다. 다양한 파이프라인(lib/banzai/pipeline)이 정의되어 있으며, AsciiDocPipeline, EmailPipeline 등 각 파이프라인은 서로 다른 필터 시퀀스를 가집니다.
html-pipeline gem이 파이프라인/필터 메커니즘을 구현합니다.
기본 파이프라인은 FullPipeline이며, 이는 PlainMarkdownPipeline과 GfmPipeline의 조합입니다.
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 제목과 같은 단일 행 텍스트 필드에 사용됩니다. Issuable concern에서
cache_markdown_field :title, pipeline: :single_line으로 구성됩니다.
FullPipeline과 달리 이 파이프라인은 Markdown 파서(MarkdownFilter)를 실행하지 않습니다. 최소한의 필터 세트를 통해 일반 텍스트를 처리합니다:
-
HtmlEntityFilter- 입력을 일반 텍스트로 처리하여 HTML 엔티티를 이스케이프합니다. -
EmojiFilter-:emoji:단축코드를 변환합니다. -
CustomEmojiFilter- 사용자 지정 이모지 단축코드를 변환합니다. -
AutolinkFilter- URL을 자동으로 링크합니다. -
ExternalLinkFilter- 외부 링크를 처리합니다. -
reference filters -
#123,@user,!456와 같은 GitLab 참조를 해석합니다.
이는 제목이 볼드, 이탤릭, 코드 스팬, Markdown 링크, 또는 기타 표준 Markdown 서식을 지원하지 않음을 의미합니다. 제목에서 사용 가능한 서식에 대한 자세한 내용은 work item 및 머지 리퀘스트 제목을 참조하세요.
성능#
필터를 가능한 한 빠르게 실행하는 것뿐만 아니라, 일반적으로 너무 오래 걸리지 않도록 하는 것도 중요합니다. 이를 위해 여러 기법을 사용합니다:
오래 걸릴 수 있는 특정 필터의 경우, 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에 캐시됩니다. CacheMarkdownField는
적절한 _html 칼럼을 관리하는 데 사용됩니다. 예를 들어 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내부에서 증가합니다. 이는 시도되었지만 억제된 쓰기와 동일 버전의 콘텐츠 재동기화 쓰기는 제외합니다.
Grafana의 Markdown Cache Version Upgrades 대시보드를 사용하여 gitlab_markdown_cache_version_upgrades_total 카운터를 볼 수 있습니다.
percentage-of-time 무작위성을 사용하는 이유#
플래그는 설계상 액터 기반 게이트가 아닌 percentage_of_time("무작위" 게이트)를 사용합니다. 부하 분산의 목표는 n%에서 대략 n%의 읽기 트래픽이 업그레이드 쓰기를 트리거하여, 마이그레이션이 읽기 부하에 비례하는 매끄럽고 조절된 스트림으로 진행되도록 하는 것입니다. 두 가지 대안을 고려했습니다:
-
요청별 액터(
Feature.current_request): 하나의 Rack 요청/Sidekiq 작업/ActionCable 실행 기간 동안 고정됩니다. 캐시된 여러 Markdown 행(이슈 목록, MR 목록, 검색 결과)에 접근하는 단일 요청은n%에서 하나의 기간에 그 모두를 업그레이드하여, 매끄러운 부하 분산이 아니라 행 수에 비례하는 요청별 쓰기 급증을 일으킵니다. -
합성 레코드별 액터(
def flipper_id = "...:#{record.id}"):n%에서 고정된n%의 행이 업그레이드 대상이 되고, 나머지(100−n)%는 백분율이 올라갈 때까지 제외됩니다.1%에서는 핫 행의99%가 절대 마이그레이션되지 않습니다. 이는 "낮은 백분율은 느린(그러나 결국 완료되는) 마이그레이션을 의미하고, 높은 백분율은 더 빠름을 의미한다"는 바람직한 속성을 깨뜨립니다. 대신, 선택된(핫) 행이 모두 기록되면 백분율의 각 증가분이 결국 한계에 도달하므로, 100%까지 아주 작은 증가분으로 플래그를 올려야 합니다!
percentage-of-time 무작위성은 양의 백분율에서 원하는 "느리지만 결국 모든 것을 다룬다"는 의미론을 제공합니다. 서로 다른 레코드는 서로 독립적으로 롤되며, 한 요청에서 "이전"으로 롤되는 레코드가 이후 요청에서 "현재"로 롤될 수 있으므로, 읽힌 행은 시간이 지나면서 수렴합니다. --random chatops 플래그는 일반적인 사용에서는 더 이상 권장되지 않는데, 호출자가 보통 액터별로 안정적인 응답을 원하고 호출별 불일치가 대부분의 호출자를 놀라게 하기 때문입니다. 그러나 여기서는 --random을 의도적으로 사용합니다. 레코드 간 독립적인 롤이 마이그레이션 부하를 분산시키는 것이므로, 그 권장 중단 근거가 적용되지 않습니다.
CACHE_COMMONMARK_VERSION 증가를 위한 롤아웃 절차#
롤아웃에는 두 개의 MR과 기능 플래그 조정이 필요합니다. 예시는 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를 사용하세요.