This page contains information related to upcoming products, features, and functionality. It is important to note that the information presented is for informational purposes only. Please do not rely on this information for purchasing or planning purposes. The development, release, and timing of any products, features, or functionality may be subject to change or delay and remain at the sole discretion of GitLab Inc.
Status Authors Coach DRIs Owning Stage Created
proposed @qmnguyen0711 devops enablement 2023-06-15

Gitaly - 순수 HTTP/2 서버에서 업로드 팩 트래픽 처리

Summary

HTTP/SSH를 사용하는 모든 Git 데이터 전송 작업은 Gitaly의 upload-pack RPC에 의해 처리됩니다.

이러한 RPC는 Sidechannel이라는 고유한 애플리케이션 계층 프로토콜을 사용하여 작동하며 클라이언트가 Gitaly gRPC 서버를 다이얼하는 동안 핸드셰이킹 프로세스를 대신합니다. 이 프로토콜은 gRPC 연결을 보통처럼 제공하면서 클라이언트로부터 대량의 데이터를 전송하는 외부 연결을 허용합니다.

순수 gRPC 스트리밍 호출을 사용하는 것보다 훨씬 더 성능을 향상시키지만, 이 프로토콜은 변칙적이고 혼란스럽고 복잡하며 Gitaly의 다음 아키텍처에 통합하거나 통합하기 어려운 문제가 있습니다. 이에 대응하기 위해 본 청사진에서는 업로드 팩 트래픽을 처리하는 새로운 “지루한” 기술 솔루션을 제안합니다.

Motivation

본 섹션에서는 Git 데이터 전송 작동 방식을 탐구하며 Sidechannel 최적화의 역할에 특히 주목하고 이의 장단점을 논의합니다. 주요 목표는 Git 데이터 전송을 간단하게 만들면서도 Sidechannel 최적화가 제공한 성능 향상을 유지하는 것입니다. 시스템을 완전히 다시 작성할 필요가 없으며 다른 시스템 및 다른 RPC에 영향을 미치지 않는 솔루션이 필요합니다.

Git 데이터 전송 작동 방식

Git 데이터 전송은 Git 서버가 제공할 수 있는 중요한 서비스 중 하나입니다. 리눅스 커널 개발용으로 개발된 Git의 기본 기능으로 시작되었으며 분산 시스템으로 인식되어 왔습니다. Git이 인기를 얻자 중앙집중식 Git 서비스인 GitHub 또는 GitLab와 같은 서비스의 등장으로 사용 패턴이 변화하였습니다. 따라서 호스팅된 Git 서버에서의 Git 데이터 전송은 도전적인 과제가 되었습니다.

Git은 packfiles로 데이터를 다양한 프로토콜을 통해 전송할 수 있습니다. 특히 HTTP 및 SSH를 통해 데이터를 전송하는 방법에 대한 자세한 내용은 pack-protocolhttp-protocol을 참조하십시오.

간단히 말해서 일반적인 흐름은 다음 단계를 포함합니다:

  1. 참조 발견: 서버가 클라이언트에게 자신의 참조를 알립니다.
  2. 팩 파일 협상: 클라이언트가 서버와 전송에 필요한 팩 파일을 협상하기 위해 “보유한” 참조 디렉터리 등을 보내어 협상합니다.
  3. 팩 파일 데이터 전송: 서버가 요청된 데이터를 구성하고 팩 프로토콜을 사용하여 클라이언트로 다시 전송합니다.

이러한 단계에는 추가적인 세부 정보 및 최적화가 기반이 될 수 있습니다. Git 서버는 참조 발견 및 팩 파일 협상에 대한 이슈가 거의 없습니다. 프로세스에서 가장 필요로 하는 부분은 팩 파일 데이터 전송으로 실제 데이터 교환이 발생하는 부분입니다.

GitLab의 경우, Gitaly가 모든 Git 작업을 관리합니다. 그러나 외부 소스에서는 액세스할 수 없습니다. 외부 통신 모두 Workhorse 및 GitLab Shell이 처리하기 때문입니다. Gitaly은 SmartHTTPSSH와 같은 특정 RPC를 통해 gRPC 서버를 제공합니다. 또한 GitLab Rails 및 기타 서비스를 위해 다양한 다른 RPC도 제공합니다. 다음 다이어그램은 SSH를 사용하여 클론하는 것을 보여줍니다:

sequenceDiagram participant User as User participant UserGit as git fetch participant SSHClient as User's SSH Client participant SSHD as GitLab SSHD participant GitLabShell as gitlab-shell participant GitalyServer as Gitaly participant GitalyGit as git upload-pack User ->> UserGit: git fetch 실행 UserGit ->> SSHClient: SSH 클라이언트 생성 Note over User,SSHClient: 사용자의 로컬 머신 상에서 SSHClient ->> SSHD: SSH 세션 Note over SSHClient,SSHD: 인터넷 상의 세션 SSHD ->> GitLabShell: gitlab-shell 생성 GitLabShell ->> GitalyServer: gRPC SSHUploadPack GitalyServer ->> GitalyGit: git upload-pack 생성 Note over GitalyServer,GitalyGit: Gitaly 서버 상에서 Note over SSHD,GitalyGit: GitLab 서버 상에서

출처: 이 문서에서 뻥튀기를 했음

Sidechannel과 함께한 Git 데이터 전송 최적화

과거에는 gRPC를 사용하여 대량의 데이터를 전송하는 경우 성능 문제에 직면했습니다. Epic 463에서 이 최적화의 작업을 추적하고 있습니다. 이에 대한 보다 많은 컨텍스트가 존재하지만, 요약하자면 두 가지 주요 문제점이 있습니다:

  • gRPC는 상대적으로 크기가 작은 다수의 메시지를 전송하는 데 이상적으로 설계되었습니다. 그러나 중간 크기의 리포지터리를 클론하는 경우, 기가바이트의 데이터를 전송할 수 있습니다. Protobuf는 큰 데이터를 다룰 때에 어려움을 겪고 있으며 이는 여러 소스에서 확인되었습니다 (예: https://protobuf.dev/programming-guides/techniques/#large-data, https://github.com/protocolbuffers/protobuf/issues/7968). 이는 상당한 오버헤드를 추가하며 인코딩 및 디코딩 중에 상당한 CPU 사용량을 필요로 합니다. 또한 protobuf는 메시지를 부분적으로 읽거나 쓸 수 없으며 이는 서버가 동시에 여러 요청을 받을 때 메모리 사용이 급증하는 원인이 됩니다.
  • 둘째, grpc-go 구현은 비슷한 목적을 위한 최적화도 제공합니다. gRPC 프로토콜은 HTTP/2 상에서 구축되었습니다. gRPC 서버가 와이어로 데이터를 쓸 때, 데이터를 HTTP/2 데이터 프레임 내에 래핑합니다. grpc-go 구현은 비동기 제어 버퍼를 유지합니다. 이는 새로운 메모리를 할당하고 데이터를 복사한 후 제어 버퍼에 추가합니다 (client, server). 그래서 우리가 사용자 정의 코덱으로 protobuf 문제를 우회할 수 있더라도 grpc-go는 여전히 해결되지 않은 문제입니다. 메모리 재사용에 대한 상류 논의(클라이언트용)는 아직 진행 중입니다. 일반적으로 사용 패턴과 충돌하는 것으로 밝혀져 풀드 메모리 추가 시도가 되었습니다.

우리는 클라이언트와 raw binary 데이터를 통신할 수 있는 Sidechannel 프로토콜을 개발했습니다. Sidechannel에 대한 자세한 내용은 이 문서를 참조하십시오. 간단히 말하자면, Sidechannel은 다음과 같이 작동합니다:

  • gRPC 서버가 클라이언트 TCP 연결 수락의 핸드셰이킹 프로세스 중간에, 우리는 Yamux를 사용하여 애플리케이션 계층에서 TCP 연결을 멀티플렉스합니다. 이를 통해 gRPC 서버는 Yamux 스트림이라는 가상 멀티플렉스된 연결에서 작동할 수 있습니다.
  • 서버가 데이터를 전송할 필요가 있을 때 추가적인 Yamux 스트림을 설정합니다. 데이터는 해당 채널을 통해 전송됩니다.
sequenceDiagram participant Workhorse participant Client handshaker participant Server handshaker participant Gitaly Note over Workhorse,Client handshaker: Workhorse 프로세스 Note over Gitaly,Server handshaker: Gitaly 프로세스 Workhorse ->> Client handshaker: - Client handshaker ->> Server handshaker: 1. 다이얼 Note over Server handshaker: Yamux 세션 생성 Server handshaker ->> Client handshaker: - Client handshaker ->> Workhorse: Yamux 스트림 1 Note over Workhorse: Yamux 세션 유지 par Yamux 스트림 1 Workhorse ->>+ Gitaly: 2. PostUploadWithSidechannel Note over Gitaly: 요청 유효성 검사 Gitaly ->> Workhorse: 3. Open Sidechannel end Note over Workhorse: Yamux 스트림 2 생성 par Yamux 스트림 2 Workhorse ->> Gitaly: 4. 팩 파일 협상 및 기타 작업 Workhorse ->> Gitaly: 5. 반쪽 닫힘 신호 Gitaly ->> Workhorse: 6. 팩 파일 데이터 Note over Workhorse: Yamux 스트림 2 닫기 end par Yamux 스트림 1 계속 Gitaly ->>- Workhorse: 7. PostUploadPackWithSidechannelResponse end

Sidechannel은 현재까지 원래 문제를 해결했습니다. Git 전송 활용 및 CPU 및 메모리 사용량이 크게 감소된 것이 확인되었습니다. 물론 이에는 어떤 희생을 해야합니다:

  • 너무 똑똑한 속임수일 수 있습니다. grpc-go는 인증 용도로만 사용하는 특별한 핸드셰이킹 후크를 제공합니다. 연결 수정 목적으로 사용해서는 안됩니다.
  • Sidechannel은 연결 수준에서 동작하기 때문에 해당 연결을 사용하는 모든 RPC들은 멀티플렉싱을 설정해야 합니다. 업로드 팩 대상이 아니더라도 모든 RPC들이 멀티플렉싱을 설정해야 합니다.
  • Yamux는 애플리케이션 수준의 멀티플렉서 입니다. 이는 HTTP/2를 참고하여 만들어졌으며, 바이너리 프레이밍, 플로우 제어 등에 많은 영감을 받았습니다. 우리는 gRPC를 Yamux 상에서 작동하는 것으로 보여집니다. 우리는 Yamux의 상세한 구현을 HTTP/2의 깔끔한 버전이라고 할 수 있습니다.
  • Sidechannel의 상세한 구현은 복잡합니다. 상기한 핸드셰이킹 외에 서버가 다이얼백할 때 클라이언트는 처리기로 다시 넘기기 전에 보이지 않는 gRPC 서버를 시작해야 합니다. 이는 pktline에서 영감을 받은 비대칭 프레이밍 프로토콜을 구현하는 것도 포함합니다. 이 프로토콜은 업로드 팩 RPC에 맞춰져 있으며 Yamux의 “반쪽 닫힘” 능력이 부족한 문제를 극복합니다.

모든 이러한 복잡성은 Gitaly의 유지보수 및 향후 발전에 부담으로 다가옵니다. Gitaly Raft 기반 아키텍처를 고려할 때 Sidechannel이 어려운 문제가 될 수 있습니다. 라우팅 전략 및 구현에 대한 추론은 Sidechannel과의 호환성을 고려해야 합니다. Sidechannel은 응용 계층 프로토콜이므로 대부분의 클라이언트 측 라우팅 라이브러리에서 잘 작동하지 않습니다. 또한 Sidechannel에 의해 얻은 성능을 보장해야 합니다. 최종적으로 Sidechannel을 맞출 방법을 찾을 수 있지만 선택지는 매우 제한되며 또 다른 교묘한 해킹으로 이어질 가능성이 높습니다.

Sidechannel을 같은 성능 특성을 유지하는 간단하고 널리 사용되는 기술로 교체하는 것은 유용할 것으로 판단됩니다. 모든 문제를 해결하기 위한 한 가지 잠재적인 솔루션은 모든 업로드 팩 RPC를 순수 HTTP/2 서버로 전송하는 것입니다.

목표

  • upload-pack RPC를 위한 Sidechannel의 대체안을 찾아야 합니다. 쉽게 사용할 수 있고, 복잡하지 않으며 널리 채택된 방법이어야 합니다.
  • 구현은 인기있는 라이브러리, 프레임워크 또는 Go의 표준 라이브러리 지원 API와 사용 사례를 사용해야 합니다. 더 이상 전송 계층에서 해킹하는 일이 없어야 합니다.
  • 새로운 솔루션은 Praefect와 잘 작동하고, 미래의 라우팅 메커니즘, 로드 밸런서 및 프록시에 친화적이어야 합니다.
  • Sidechannel과 동일한 성능 및 낮은 자원 이용률을 가져야 합니다.
  • 점진적으로 배포하고 클라이언트와 완전히 하위 호환되어야 합니다.

비목표

  • gRPC에 투자한 모든 것을 다시 구현해서는 안 됩니다. 예를 들어 인증, 관측 가능성, 동시성 제한, 메타데이터 전파 등. 두 시스템 간에 기능을 유지하거나 복제할 의향이 없습니다.
  • 다른 RPC를 수정하거나 클라이언트에서 사용하는 방식을 변경해서는 안 됩니다.
  • 모든 RPC를 새로운 HTTP/2 서버로 마이그레이션하면 안 됩니다.

제안

이 청사진의 큰 부분은 역사적 맥락과 우리가 왜 나아가야 하는지에 대해 설명합니다. 제안된 솔루션은 간단합니다: Gitaly는 모든 upload-pack RPC를 처리하기 위해 순수한 HTTP2 서버를 제공합니다. 다른 RPC는 그대로 유지되며 기존 gRPC 서버에서 처리됩니다.

flowchart LR Workhorse-- 다른 RPC --> Port3000{{":3000"}} Port3000 --o gRPCServer("gRPC 서버") Workhorse-- PostUploadPack --> Port3001{{":3001"}} Port3001 --o HTTP2Server("HTTP/2 서버") GitLabShell("GitLab Shell")-- SSHUploadPack --> Port3001 GitLabRails("GitLab Rails") -- 다른 RPC --> Port3000 subgraph Gitaly Port3000 gRPCServer Port3001 HTTP2Server end

앞서 언급한 바와 같이 Sidechannel은 Yamux 다중화 프로토콜을 활용하고, 이를 HTTP/2의 간소화된 버전으로 볼 수 있습니다. HTTP/2를 사용하면 핵심 기능인 다중화, 이진 프레임 프로토콜 및 흐름 제어가 변경되지 않고 유지됩니다. 이는 Workhorse와 같은 클라이언트가 프로토콜 버퍼 같은 사용자 정의 인코딩 및 디코딩 레이어를 필요로하지 않고도 동일한 TCP 연결을 통해 대량의 바이너리 데이터를 효율적으로 교환할 수 있다는 것을 의미합니다. 이것이 시작부터 HTTP/2의 의도된 사용 사례였으며, 이론적으로 Sidechannel과 동일한 수준의 성능을 제공할 수 있습니다. 더불어 이 교체로 다른 직접적인 RPC에 대한 오버헤드를 제거 가능합니다.

게다가, Gitaly는 공식으로 지원되는 API를 통해 고급 HTTP/2 기능에 접근할 수 있습니다. HTTP/2는 Go 표준 라이브러리에서 공식적으로 지원되며, 외장 로드 밸런서 및 프록시와 원활하게 통합되며 다양한 라이브러리에서도 지원됩니다.

마지막으로, upload-pack RPC와 다른 일반적인 RPC는 동일한 포트에서 함께 존재할 수 있도록 기술의 섹션에서 설명한 기술을 사용할 수 있습니다. 그러나 그들을 한꺼번에 모두 이주하는 것은 성능 및 기능적으로 위험합니다. 예상치 못한 결과가 있을 수 있습니다. 따라서 GitLab.com에서 점진적으로 마이그레이션하는 것이 현명할 것입니다. 다른 RPC는 신중한 고려 후에 마이그레이션할 수 있습니다. Self-Managed형 인스턴스는 기존의 단일 포트를 수정할 필요 없이 사용할 수 있으므로, 이 변경은 사용자에게 투명합니다.

다음 섹션에서는 상세한 구현 및 해당 접근 방식의 장단점을 설명합니다.

디자인 및 구현 세부 정보

디자인

요약하면, Gitaly가 HTTP2 서버를 노출하도록 하는 제안입니다. 처음에는 새로운 핸들러와 일련의 인터셉터를 구현해야 할 것으로 보입니다. 다행히도 gRPC 서버는 ServeHTTP를 제공하여 그것을 gRPC 방식으로 처리할 수 있습니다. 이는 http.Handler 인터페이스를 구현하여 HTTP/2 서버에 연결할 수 있도록 합니다. 코드 내에서 리디렉션을 사용할 수 있습니다:

if r.ProtoMajor == 2 && strings.HasPrefix(
    r.Header.Get("Content-Type"), "application/grpc") {
    grpcServer.ServeHTTP(w, r)
} else {
    yourMux.ServeHTTP(w, r)
}

이 접근법에는 다음과 같은 이점이 있습니다:

  • ServeHTTP는 gRPC-go의 HTTP/2 서버와 완전히 별도인 Go의 HTTP/2 서버 구현을 사용합니다. 둘 다의 구현을 분석한 결과, 내장 구현은 위에서 언급한 메모리 할당 문제를 해결해야 합니다.
  • ServeHTTP는 Go 표준 라이브러리의 http.Handler 인터페이스를 구현합니다. 이를 통해 gRPC 핸들러로 작성할 수 있으며, 여기에서 원시 이진 데이터를 전송하고 오류 세부 정보를 포함한 상태 코드를 반환할 수 있습니다. 클라이언트는 데이터 전송 프로세스에 문제가 발생했을 때 Sidechannel의 파이프 오류 대신 정확한 이유를 받을 수 있습니다.
  • 대부분, 혹은 모든, 인터셉터는 수정 없이 재사용할 수 있습니다. channelz를 비롯한 기타 내장 도구, 통계 보고서 및 클라이언트 측 로드 밸런싱과 같은 다른 내장 도구들도 작동합니다. 로깅 및 메트릭과 같은 관측 툴킷들도 잘 작동합니다.
  • 업로드 팩 요청은 한 연결에서 순수한 스트리밍 호출이 됩니다. 더 이상 다른 프로토콜을 열어 실외 전송이 필요하지 않습니다.
  • 클라이언트(Workhorse 및 GitLab Shell)는 그대로 gRPC 클라이언트를 사용합니다. 이 방법으로 요청은 일반 gRPC 호출로 간주됩니다. 따라서 Praefect와도 잘 작동해야 합니다.

물론, ServeHTTP 사용은 요청과 응답이 protobuf 구조체임을 고려해야 합니다. 사용자 정의 코덱을 사용하면 이러한 성능 페널티를 극복할 수 있습니다. 이상적인 해결책은 HTTP 핸들러로 핸들러를 구현하여 연결에 대한 원시 액세스를 얻을 수 있도록 하는 것입니다. 그러나 그런 경우에는 gRPC 특정 컴포넌트를 전부 다시 구현해야 합니다. 결과적으로 제안된 솔루션은 이러한 아키텍처의 진화를 용이하게 하는 합리적인 트레이드 오프를 합니다.

sequenceDiagram participant Workhorse participant HTTP2 Server participant UploadPack Handler Note over HTTP2 Server, UploadPack Handler: Gitaly 프로세스 Workhorse ->> HTTP2 Server: 1. Dial HTTP2 Server ->> Workhorse: Connection par PostUploadPackV3 Workhorse ->> UploadPack Handler: 2. PostUploadPackV3Request Note over UploadPack Handler: 요청 유효성 검사 UploadPack Handler ->> Workhorse: PostUploadPackV3Response (빈 값) Workhorse ->> UploadPack Handler: 3. 원시 []byte 전송 Workhorse ->> Workhorse: 송신 종료 UploadPack Handler ->> Workhorse: 4. 원시 []byte 전송 UploadPack Handler ->> UploadPack Handler: 연결 종료 end

제안된 솔루션은 다음을 포함합니다:

  • 입력 바이너리 데이터를 전달하는 “원시 코덱”를 구현합니다.
  • 새로운 PostUploadPackV3 gRPC 호출을 생성하고 다른 gRPC 핸들러와 유사한 서버 핸들러를 구현합니다.
  • gRPC의 ServeHTTP를 호출하는 HTTP/2 서버를 구현합니다. 이 서버는 또 다른 gRPC 서버로 처리됩니다. 사실, Gitaly는 이제 다른 전달 채널을 통해 내부적으로 및 외부적으로 최대 4개의 gRPC 서버를 시작합니다. 이 HTTP2 서버는 플리트에 적합하게 존재하며 Gitaly의 기존 프로세스 관리 및 우아한 종료 및 업그레이드를 사용할 수 있습니다.

나는 이 솔루션을 증명하기 위해 Gitaly와 Workhorse에서 두 개의 POC merge request를 구현했습니다:

이 POC MR은 Praefect 설정되지 않은 환경에서 테스트를 통과했습니다. 로컬 환경에서 초기 벤치마크는 이 접근법이:

  • Sidechannel 접근법보다 약간 빠릅니다.
  • CPU 소비를 10-20% 줄입니다.
  • 동일한 메모리 사용량을 유지합니다.

POC는 완전하지 않지만, 이러한 “지루한” 기술을 사용하면 단순화가 가능함을 보여줍니다. 성능 향상은 완전히 예상치 못한 것입니다.

기존의 Sidechannel 솔루션은 새로운 솔루션과 공존할 수 있습니다. 이것은 점진적인 채택이 실현 가능하게 합니다.

외부 트래픽 외에도, Gitaly 서버는 내부 요청을 처리합니다. 이는 Gitaly에 의해 생성된 Git 프로세스로부터 발생합니다. 하나의 전형적인 예는 Packfile 생성을 위해 git-upload-pack(1)에 의해 트리거되는 PackObjectsHookWithSidechannel RPC입니다. 제안된 솔루션은 이러한 내부 RPC에도 이점을 제공합니다.

고려 사항

제안된 솔루션이 주는 주요 문제점은 HTTP/2를 별도의 포트를 통해 열어야 한다는 것입니다. ServeHTTP를 사용하면 기존 HTTP/2 서버에 gRPC 핸들러를 통합할 수 있지만, 그 반대는 지원하지 않습니다. 새로운 포트 노출로 인해 방화벽, NAT 등 추가 설정이 제한적인 환경에서 필요합니다. 전형적인 설정에서는 인프라 변경이 필요하지 않습니다. 앞에서 언급했듯이, 이러한 두 포트 상황은 이주 중에만 발생합니다. 작업이 완료되면 두 포트를 하나로 통합하고 Self-Managed형 인스턴스에 릴리스할 수 있습니다. 사용자는 아무것도 변경할 필요가 없습니다.

이미 언급했듯이, 새롭게 도입된 HTTP/2 서버는 Gitaly 프로세스에서 관리됩니다. 기존 gRPC 서버와 일관되게 시작, 재시작, 종료 및 업그레이드됩니다. 이는 로드 밸런싱, 서비스 검색, 도메인 관리 및 기타 구성이 현재 gRPC 서버에 대해 설정된 경우 무리없이 작동한다는 것을 의미합니다. 새 서버는 현재 gRPC 서버의 대부분의 구성을 활용할 수 있으며, 이에는 TLS 및 인증을 포함합니다. 필요한 유일한 새 구성은 HTTP 서버가 바인딩될 주소입니다. 따라서 새로운 포트를 노출하는 것은 장애가 되지 않아야 합니다.

다른 고려 사항은 Workhorse와 GitLab Shell이 별도의 연결 풀을 유지해야 한다는 것입니다. 현재는 각 Gitaly 노드에 대해 하나의 연결을 유지합니다. 이 숫자는 각 Gitaly 노드에 두 개의 연결로 두 배로 늘어날 것입니다. 이것은 큰 문제가 되지 않아야 합니다. 결국 트래픽은 두 연결 사이에서 분할되며, 가장 무거운 작업은 대부분 HTTP/2 서버에서 처리됩니다. GitLab Rails는 UploadPack을 처리하지 않으므로 영향을 받지 않습니다.