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. As with all projects, the items mentioned on this page are subject to change or delay. The development, release, and timing of any products, features, or functionality remain at the sole discretion of GitLab Inc.
Status Authors Coach DRIs Owning Stage Created
proposed @pks-gitlab devops systems 2023-03-30

객체 풀의 디자인 반복

요약

포크된 저장소는 GitLab에 호스팅된 프로젝트의 현대적인 작업흐름에서 중심에 있습니다. 포크와 상위 프로젝트 사이의 대부분의 객체는 일반적으로 동일하므로, 이는 최적화의 가능성을 열어줍니다.

  • 만일 상위 저장소의 많은 부분을 재사용한다면, 포크 생성은 이론적으로 빠르게 이루어질 수 있습니다.

  • 공유되는 객체를 중복으로 저장함으로써 저장 공간을 절약할 수 있습니다.

이 구조는 현재 주 객체 저장소의 객체를 보유하는 객체 풀로 구현되어 있습니다. 그러나 객체 풀의 디자인은 유기적으로 성장하였으며 현재는 한계를 보여주고 있습니다.

본 청사진은 객체 풀의 디자인을 개선하여 장기간에 걸친 문제를 해결할 수 있는 방법에 대해 탐구합니다. 더불어, 객체 풀의 구체적인 구현 세부사항에 대해 보다 쉽게 반복할 수 있는 디자인에 도달하는 데 목적이 있습니다.

동기

객체 풀의 현재 디자인은 다양한 방식으로 확장성 문제를 보여주고 있습니다. 문제의 많은 부분은 객체 풀이 유기적으로 성장했기 때문이며 점진적으로 배우면서 발생한 것입니다.

객체 풀의 전반적인 디자인을 수리하는 것이 어려운 이유는 명확한 소유권이 없기 때문입니다. Gitaly는 객체 풀을 작동하게 하는 하위 수준의 구성 요소를 제공할 뿐이지만, 이를 통제하여 구현 세부사항에 대해 반복할 수 있는 충분한 통제권을 갖고 있지 않습니다.

따라서 두 가지 주요 목표가 있습니다: 객체 풀의 소유권을 보유하여 디자인을 보다 쉽게 반복할 수 있게하고, 반복할 수 있는 시점에 확장성 문제를 해결하는 것입니다.

라이프사이클 소유권

Gitaly는 객체 풀을 관리하기 위한 인터페이스를 제공하지만, 실제로 그들의 수명 주기는 클라이언트에 의해 통제됩니다. 객체 풀의 전형적인 수명 주기는 다음과 같습니다:

  1. CreateObjectPool()을 통해 객체 풀이 생성됩니다. 호출자는 객체 풀이 생성될 경로와 레포지토리의 원본을 제공합니다.

  2. 원본 레포지토리는 명시적으로 LinkRepositoryToObjectPool()을 호출하여 객체 풀에 연결되어야 합니다.

  3. 주 객체 풀 멤버에서 모든 변경 사항을 가져와 객체 풀에 반복적으로 업데이트해주는 FetchIntoObjectPool()가 필요합니다.

  4. 포크를 생성하기 위해 클라이언트는 CreateFork() 다음에 LinkRepositoryToObjectPool()을 호출해야 합니다.

  5. 포크의 레포지토리는 DisconnectGitAlternates()를 호출하여 연결을 해제해야 합니다. 이렇게 하면 개체가 중복됩니다.

  6. DeleteObjectPool()을 사용하여 객체 풀을 삭제합니다.

이러한 수명 주기는 복잡하며 이를 호출자에게 많은 구현 세부사항을 누설시킵니다. 초기에는 이것이 레일(서버) 측에 Git 객체의 가시성을 통제하고 관리하기 위해 일부로 수행되었습니다. GitLab 프로젝트 가시성 규칙은 복잡하며 Gitaly의 관심사가 아닙니다. 이러한 세부 정보를 노출함으로써 레일(서버)은 풀 구성원 연결이 언제 생성되고 해제되는지를 제어할 수 있습니다. 현재 시점에서 시스템이 완전히 어떻게 작동하는지 명확하지 않으며 그 한계가 명시적으로 문서화되어 있지 않습니다.

수명 주기의 복잡성 외에도 풀 구성원에 대한 여러 가지 정보원이 존재하고 있습니다. Gitaly는 풀 레포지토리의 구성원 집합을 추적하지 않고 특정 레포지토리가 해당 풀의 일부인지만 알 수 있습니다. 결과적으로, Rails는 이 정보를 데이터베이스에 유지해야 하지만 이를 상실되지 않도록 유지하는 것은 어렵습니다.

레포지토리 유지보수

수명 주기 소유권 문제와 관련된 문제는 저장소 유지보수 문제입니다. 객체 풀을 최신 상태로 유지하려면 정기적으로 FetchIntoObjectPool()을 호출해주어야 합니다. 이것은 클라이언트에게 구현 세부사항을 누설시키지만, 기본 레포지토리와 객체 풀을 동기화하는 통제권을 주기 위해 수행되었습니다. 이러한 통제로 개인 레포지토리를 동기화하지 못하게하여 다른 포크 네트워크의 레포지토리로 객체가 유출되는 것을 방지할 수 있습니다.

저장소 유지보수를 Gitaly로 이동하여 디스크 상세 정보에 대해 클라이언트가 알 필요가 없도록 하는 것에 대해 좋은 성과를 거뒀습니다. 이상적으로, 객체 풀의 기본 멤버인 레포지토리에 대해서도 동일한 작업을 수행할 수 있었으면 좋겠습니다: 디스크 상태를 최적화하면 자동으로 객체 풀이 업데이트될 것입니다.

이를 수행하지 못하는 두 가지 이슈가 있습니다:

  • Gitaly는 객체 풀과 그 멤버 간의 관계에 대해 알지 못합니다.

  • 객체 풀을 업데이트하는 것은 비용이 많이든다.

Gitaly를 객체 풀 멤버십의 단일 진실의 원천으로 만들면 두 가지 문제를 해결할 수 있을 것입니다.

빠른 포킹

현재 구현에서 레일즈는 먼저 CreateFork()를 호출하고 결과적으로 포크 레포지토리를 생성하기 위해 완전한 git-clone(1)이 수행됩니다. 그런 다음 LinkRepositoryToObjectPool()이 호출되어 포크와 객체 풀을 연결합니다. 이후에야 포크 레포지토리에 대해 하우스키퍼핑이 수행되어 객체들이 중복으로 저장됩니다. 이것은 클라이언트에게 구현 세부사항을 누설시킬뿐만 아니라, 객체 풀의 최대 장점을 실현하는데 제약을 주고 있습니다.

특히, 포크를 생성하는 것은 링킹이 항상 수행되기 때문에 예상보다 훨씬 느리며, 초기 복제가 피해갈 수 있습니다. 포크를 생성하고 포크를 풀 레포지토리에 연결하는 단계가 통합되면 초기 복제를 피할 수 있을 것입니다.

클러스터화된 객체 풀

Gitaly 클러스터 및 객체 풀 개발이 겹쳐 발전되었습니다. 그 결과, 그들은 함께 잘 작동하지 않는 것으로 알려져 있습니다. Praefect는 객체 풀을 모든 노드에 보유하는 것을 보장하지 않을 뿐만 아니라, 객체 풀이 알려진 상태에 있는 것도 보장하지 않습니다. 실제로 객체 풀은 우연히만 작동합니다.

현재 상태는 객체 풀이 일부 노드에 누락되거나 각 노드마다 다른 내용을 가지고 있었던 경우로 이어졌습니다. 이로 인해 객체 풀 멤버의 관측되는 상태가 일관되지 않아지고, 객체 풀의 내용에 의존하는 쓰기 작업이 실패할 수 있습니다.

클러스터화된 Gitaly의 객체 풀을 다루는 한 가지 방법은 객체 풀과 관련된 저장소를 필요로 하는 노드에 객체 풀 저장소를 복제하는 것일 수 있습니다. 이렇게 하면 포크 네트워크의 멤버가 서로 다른 노드로 구성될 수 있습니다. 이를 위해 저장소 복제는 특정 노드에 객체 풀을 복제해야 하는 시점을 알아야 합니다.

요구 사항

특정 솔루션에 대한 일련의 요구 사항과 불변 조건이 있습니다.

개인 상위 저장소가 포크에게 개체 누출을 방지해야 함

프로젝트의 가시성 설정이 공개가 아닌 경우, 저장소의 객체들은 객체 풀에 가져와서는 안 됩니다. 객체 풀은 절대로 한 번이라도 공개되었던 상위 저장소의 객체만 포함해야 합니다. 이는 개인 상위 저장소의 개체가 공유된 객체 풀을 통해 포크로 유출되는 것을 방지합니다.

포크가 상위 프로젝트에 개체를 몰래 넣을 수 없음

포크 저장소에 업로드된 개체들이 공유된 객체 풀을 통해 상위 저장소에서 접근 가능하도록 만드는 것이 가능하면 안 됩니다. 그렇지 않으면 잠재적으로 인가되지 않은 사용자들이 단순히 포크를 만들어 저장소에 객체를 몰래 넣을 수 있습니다.

혼란을 일으키는 것 외에도 이로 인해 상위 저장소를 손상시키는 메커니즘으로 작용할 수 있습니다.

객체 풀 수명이 상위 저장소 수명을 초과함

상위 저장소가 삭제되면 해당 객체 풀은 다른 저장소들 사이에서 사용된 공유 객체들을 계속 중복 처리할 수 있어야 합니다. 따라서 객체 풀의 수명은 상위 저장소의 수명보다 길다고 말할 수 있습니다. 객체 풀은 더 이상 참조하는 저장소가 없는 경우에만 삭제되어야 합니다.

객체 수명

포크 네트워크의 객체들을 중복 처리함으로써, 저장소들은 객체 풀에 종속됩니다. 풀된 저장소에서 누락된 객체는 포크 네트워크의 저장소를 손상시킬 수 있습니다. 따라서 풀된 저장소의 객체는 해당 객체를 참조하는 저장소가 있을 때까지 계속 존재해야 합니다.

풀된 객체가 하나 이상의 저장소에 의해 참조되는지 정확하게 판단할 수 있는 메커니즘이 없다면, 풀된 저장소의 모든 객체가 계속 남아 있어야 합니다. 저장소가 객체 풀을 더 이상 참조하지 않을 때에만 풀된 저장소 및 따라서 그 모든 객체를 제거할 수 있습니다.

객체 공유

중복 처리된 객체는 특정 저장소의 모든 포크에서 접근 가능해집니다. 그 객체가 포크들 중 어느 것에서도 도달 가능하지 않았더라도 말입니다. 이로 인해 객체 풀에 대한 모든 쓰기가 즉시 그 멤버들에게 영향을 준다는 결과를 가져옵니다.

객체 풀에 연결된 저장소에 연결된 저장소가 복제될 때 이 속성을 더 잘 이해할 필요가 있습니다. 사용자가 복제본에서 관찰 가능한 상태는 모든 복제본에서 동일해야 하므로, 저장소 및 해당 객체 풀이 서로 다른 노드에서 일관되게 유지되도록 보장해야 합니다.

제안

현재의 설계에서 객체 풀의 수명 관리는 대부분 클라이언트 측에서 일어납니다. 그들은 객체 풀 관계를 기록하기 위해 Rails에 객체 풀 관련 정보를 저장해야 하며, 객체 풀의 모든 단계를 세심하게 관리하고 일관된 상태를 강제하기 위해 주기적인 Sidekiq 작업을 수행해야 합니다. 이러한 설계는 이미 복잡한 메커니즘을 더욱 복잡하게 만드는 결과를 초래합니다.

클라이언트 측에서 객체 풀의 전체 수명을 처리하는 대신, 이 문서는 객체 풀 수명 관리를 Gitaly 내부에 캡슐화하는 것을 제안합니다. 실제로 객체 풀을 유지하는데 필요한 저수준 작업을 수행하기 대신 클라이언트는 저장소와 해당 객체 풀 간의 업데이트된 관계에 대해 Gitaly에게만 알려주면 됩니다.

이로 인해 여러 가지 장점을 얻을 수 있습니다.

  • 수명 관리의 내재된 복잡성이 Gitaly와 같이 단일 위치에 캡슐화됩니다.

  • Gitaly는 “alternates”에 비해 더 나은 솔루션이 발견될 경우 객체 풀의 저수준 기술 설계를 개선할 수 있는 더 나은 위치에 있습니다.

  • Gitaly 클러스터, 객체 풀 및 저장소 유지 관리 간의 더 나은 상호 작용을 보장할 수 있습니다.

  • Gitaly가 객체 풀 관계의 단일 정보 원천이 되도록 해 관리를 더 잘 시작할 수 있습니다.

총괄적으로 클라이언트가 기술적 세부 사항에 대해 덜 걱정해도 되도록 추상화 수준을 높이는 것입니다. 그러면 Gitaly가 그 기술적 세부 사항에 대해 더 나은 반복 가능성을 가질 수 있을 것입니다.

객체 풀의 수명 관리를 Gitaly로 이동

객체 풀의 수명 관리는 클라이언트에게 너무 많은 세부 사항을 노출시키며, 그러면서 몇 가지 부분을 이해하기 어렵고 비효율적으로 만듭니다.

현재 솔루션은 저장소와 해당 객체 풀 간의 관계를 처리하는 세밀한 RPC 세트에 의존합니다. 반면에 클라이언트에게 포크의 고수준 개념만을 노출시키려고 노력하고 있습니다. 이것은 세 가지 RPC 형태로 이루어질 것입니다.

  • ForkRepository()는 특정 저장소의 포크를 생성합니다. 상위 저장소에 객체 풀이 아직 없는 경우, Gitaly가 해당 풀을 생성합니다. 그런 다음 새로운 저장소를 생성하고 자동으로 해당 객체 풀과 연결합니다. 상위 저장소는 객체 풀의 주요 멤버로 기록되고, 포크는 객체 풀의 보조 멤버로 기록됩니다.

  • UnforkRepository()는 저장소가 연결된 객체 풀에서 제거됩니다. 이로써 개체들의 중복이 중지됩니다. 주요 객체 풀 멤버인 경우 Gitaly는 새로운 객체들을 객체 풀로 끌어들이지 않도록 합니다.

  • GetObjectPool()는 특정 저장소의 객체 풀을 반환합니다. 풀 설명은 객체 풀의 주요 객체 풀 멤버와 모든 보조 객체 풀 멤버에 대한 정보를 포함합니다.

또한 다음과 같은 변경 사항이 이루어질 것입니다.

  • RemoveRepository()는 저장소를 해당 객체 풀에서 제거할 것입니다. 마지막 객체 풀 멤버였다면, 풀이 제거될 것입니다.

  • OptimizeRepository()는 주요 객체 풀 멤버에서 실행될 경우 객체 풀을 업데이트하고 최적화할 것입니다.

  • ReplicateRepository()는 객체 풀을 인식하고 올바르게 복제해야 합니다. 필요에 따라 저장소는 객체 풀에 연결되거나 연결이 해제되어야 합니다. 이는 Praefect 세계를 고치기 위한 단계이지만, 우리가 결국 Praefect를 폐기하기로 계획하고 있기 때문에 불필요해 보일 수 있지만, 이 RPC 호출은 저장소 리밸런싱과 같은 다른 사용 사례에도 사용됩니다.

이러한 변경으로 Gitaly는 객체 풀의 수명을 더 밀접하게 제어하게 될 것입니다. 또한 저장소의 객체 풀에 대한 멤버십을 추적하기 시작함으로써 포크 네트워크에 대한 단일 정보 원천이 될 수 있을 것입니다.

비효율적인 객체 풀 유지 관리 수정

Gitaly는 객체 풀을 업데이트하기 위해 기존 객체 풀의 새로운 객체를 가져오는 작업을 수행합니다. 이러한 가져오기 작업은 기본 객체 풀 멤버에서 새로운 객체로 이뤄진 객체들을 불필요하게 중복적으로 협의해야 하므로 비효율적입니다. 그러나 기본 객체 풀 멤버에서 이미 중복된 객체들이 해결되었기 때문에, 객체 풀 멤버는 객체 풀에 아직 존재하지 않는 객체만 가지고 있어야 합니다. 따라서 우리는 협상을 완전히 스킵하고 대신 소스 리포지토리에 있는 모든 객체들을 객체 풀에 연결할 수 있어야 합니다.

현재의 설계에서 이러한 객체들은 방금 가져온 객체들에 대한 참조를 만들어 살아 있도록 유지됩니다. 가져오기 작업에서 참조를 삭제하거나 강제로 업데이트한다면, 이전에 참조된 객체들이 참조되지 않게 될 수 있습니다. 따라서 Gitaly는 이러한 참조가 삭제되지 않도록 하기 위해 keep-around 참조를 생성합니다. 더구나 이러한 참조들은 복제가 참조를 기반으로 하기 때문에 객체 풀을 적절하게 복제하기 위해 필수적으로 필요합니다.

이 두 가지 문제는 다음과 같은 방법으로 해결할 수 있습니다:

  • preciousObjects 리포지토리 확장을 설정할 수 있습니다. 이것은 이 확장을 이해하는 Git의 모든 버전에 대해 git-prune(1) 또는 유사한 명령이 실행되었더라도 어떤 객체라도 삭제하지 말라고 지시합니다. 이 확장을 이해하지 못하는 Git 버전은 이 리포지토리에서 작동을 거부합니다.

  • git-fetch(1)를 통해 객체 풀을 복제하는 대신, 객체 데이터베이스의 모든 객체를 보내어 복제할 수 있습니다.

이 모든 것을 종합하면, 객체 풀에 대한 참조 작성을 완전히 중단할 수 있습니다. 이로써 기존 객체 풀을 효율적으로 업데이트할 수 있게 되며, 객체 풀에서 참조의 무한한 증가 문제를 해결할 수 있습니다.

디자인 및 구현 세부 정보

객체 풀의 라이프사이클 관리를 Gitaly로 이동

목표는 객체 풀의 소유권을 Gitaly로 이전하는 것입니다. 이상적으로는 객체 풀의 개념은 호출자에게 노출되어서는 안 되며, 대신 상호간에 객체를 공유하는 리포지토리 네트워크의 고수준 개념만 노출되어야 합니다.

다음 하위 섹션에서는 현재 객체 풀 기반 아키텍처를 검토한 다음 새로운 객체 중복 제거 네트워크 기반 아키텍처를 제안합니다.

객체 풀 기반 아키텍처

현재 아키텍처에서 객체 풀 라이프사이클을 관리하려면 다수의 RPC 호출이 필요하며, 호출 측에서 많은 지식이 요구됩니다. 다음 순서도는 객체 풀의 라이프사이클의 단순화된 버전을 보여줍니다. 여기서는 단일 객체 풀 멤버만 있다고 가정했습니다.

시퀀스 다이어그램 내용은 특별히 번역되지 않습니다. 원문 그대로 유지됩니다.

객체 풀을 만드는 데 다음 단계가 관련됩니다:

  1. CreateObjectPool()을 호출하여 객체 풀을 생성합니다. 이 때 객체 풀은 생성 시점에 상위 리포지토리에 있는 모든 객체들을 포함합니다.
  2. LinkRepositoryToObjectPool()을 호출하여 상위 리포지토리를 객체 풀에 연결합니다. 객체들은 자동으로 중복 처리되지 않습니다.
  3. OptimizeRepository()를 호출하여 상위 리포지토리의 객체들이 중복 처리됩니다.
  4. CreateFork()를 호출하여 포크가 생성됩니다. 이 RPC 호출은 상위 리포지토리만 입력으로 사용하며 이미 생성된 객체 풀에 대해 알지 못합니다. 따라서 두 번째 전체 객체 복사를 수행합니다.
  5. LinkRepositoryToObjectPool()를 호출하여 포크와 객체 풀을 연결합니다. 이를 통해 포크의 info/alternates 파일에 추가 객체 데이터베이스가 인식되도록 하지만 객체들은 중복 처리되지 않습니다.
  6. OptimizeRepository()를 호출하여 포크의 객체들이 중복 처리됩니다.
  7. 이제 호출 측에서 정기적으로 FetchIntoObjectPool()을 호출하여 상위 리포지토리에서 새로운 객체를 객체 풀로 가져올 수 있습니다. 가져온 객체는 상위 리포지토리에서 자동으로 중복 처리되지 않습니다.
  8. 포크는 객체 풀에서 분리될 수 있습니다.
    • DisconnectGitAlternates()를 호출하여 명시적으로 info/alternates 파일을 제거하고 모든 객체를 다시 복제합니다.
    • 포크를 완전히 삭제하려면 RemoveRepository()를 호출합니다.
  9. 객체 풀이 비어 있을 때는 DeleteObjectPool()을 호출하여 제거해야 합니다.

전체 라이프사이클 관리가 잘 추상화되지 않았고 클라이언트들이 많은 세부 사항을 인지해야 하는 것은 분명합니다. 더구나 객체 풀 멤버십에 대해 여러 개의 진실의 원천이 있으며 (실제로 그러한 경우가 발생합니다), 이러한 원천들은 서로 다를 수 있습니다.

객체 중복 제거 네트워크 기반 아키텍처

제안된 새 아키텍처는 객체 풀이 공개 인터페이스에서 완전히 제거되어 이 프로세스를 단순화합니다. 대신, Gitaly는 “객체 중복 제거 네트워크”라는 고수준 개념을 노출합니다. 저장소는 두 가지 역할 중 하나로 이러한 네트워크에 가입할 수 있습니다:

  • 읽기-쓰기 객체 중복 제거 네트워크 멤버는 정기적으로 객체 중복 제거 네트워크에 포함된 객체 세트를 업데이트합니다.
  • 읽기 전용 객체 중복 제거 네트워크 멤버는 수동 멤버이며 객체 중복 제거 네트워크에 속한 객체 세트를 업데이트하지 않습니다.

따라서 객체 중복 제거 네트워크의 멤버 간에 중복을 제거할 수 있는 객체 세트는 읽기-쓰기 멤버에서 가져온 객체로만 구성됩니다. 역할에 관계없이 모든 멤버가 중복 제거의 이점을 누립니다. 일반적으로:

  • 원본 상위 저장소는 객체 중복 제거 네트워크의 읽기-쓰기 멤버로 지정됩니다.
  • 포크는 읽기 전용 객체 중복 제거 네트워크 멤버입니다.

객체 중복 제거 네트워크에 읽기 전용 멤버만 있는 것도 유효합니다. 이 경우 네트워크에 새로운 공유 객체가 업데이트되지 않지만 기존의 공유 객체는 계속해서 사용됩니다.

객체 풀은 여전히 기본 메커니즘이지만, 더 높은 수준의 추상화를 통해 필요한 경우 메커니즘을 교체할 수 있게 됩니다.

Gitaly의 클라이언트는 객체 풀 기반 아키텍처에서 세밀한 라이프사이클 관리를 수행해야 하지만, 객체 중복 제거 네트워크 기반 아키텍처는 객체 중복 제거 네트워크의 멤버십을 관리하는 것만으로 충분합니다. 다음 다이어그램은 객체 중복 제거 네트워크 기반 아키텍처에서 객체 풀 기반 아키텍처와 동등한 흐름을 보여줍니다:

sequenceDiagram Rails->>+Gitaly: CreateFork Gitaly->>+Object Pool: Create activate Object Pool Object Pool -->>-Gitaly: Success Gitaly->>+Fork: Create Fork->>+Object Pool: Join Object Pool-->>-Fork: Success Fork-->>-Gitaly: Success activate Fork Gitaly-->>-Rails: CreateFork loop 정기적으로 Rails->>+Gitaly: OptimizeRepository Gitaly->>+Fork: Optimize Gitaly->>+Object Pool: Optimize Object Pool-->>-Gitaly: Success Fork-->>-Gitaly: Success Gitaly-->>-Rails: Success end alt 포크 연결 끊기 Rails->>+Gitaly: RemoveRepositoryFromObjectDeduplicationNetwork Gitaly->>+Fork: Disconnect alt 마지막 멤버 Gitaly->>+Object Pool: Remove Object Pool-->>-Gitaly: Success end Fork-->>-Gitaly: Success Gitaly-->>-Rails: Success else 포크 삭제 Rails->>+Gitaly: RemoveRepository Gitaly->>+Fork: Remove alt 마지막 멤버 Gitaly->>+Object Pool: Remove Object Pool-->>-Gitaly: Success end Fork-->>-Gitaly: Success deactivate Fork Gitaly-->>-Rails: Success end

다음 주요 단계가 관련됩니다:

  1. 포크가 생성되고, 해당 요청에 따라 Gitaly에게 상위 및 포크 저장소가 객체 중복 제거 네트워크에 가입하도록 지시합니다. 상위 프로젝트가 이미 객체 중복 제거 네트워크의 일부인 경우 포크도 해당 객체 중복 제거 네트워크에 가입합니다. 그렇지 않은 경우 Gitaly는 객체 풀을 생성하고 상위 저장소를 읽기-쓰기 멤버로, 포크를 읽기 전용 멤버로 가입시킵니다. 포크의 객체는 즉시 중복이 제거됩니다. Gitaly는 객체 풀에 두 저장소의 멤버십을 기록합니다.
  2. 클라이언트는 정기적으로 상위 프로젝트 또는 포크 프로젝트에서 OptimizeRepository()를 호출하며, 클라이언트가 이미 수행하는 작업입니다. 행동은 객체 중복 제거 네트워크 멤버의 역할에 따라 변경됩니다:
    • 읽기-쓰기 객체 중복 제거 네트워크 멤버에서 실행되면, 객체 풀이 일련의 휴리스틱을 기반으로 업데이트될 수 있습니다. 이를 통해 읽기-쓰기 객체 중복 제거 네트워크 멤버에서 새로 생성된 객체가 객체 풀로 가져와져서 객체 중복 제거 네트워크의 모든 멤버에게 사용 가능하게 됩니다.
    • 읽기 전용 객체 중복 제거 네트워크 멤버에서 실행되면, 객체 풀이 업데이트되지 않으므로 읽기 전용 객체 중복 제거 네트워크 멤버에만 속한 객체가 멤버들 간에 공유되지 않습니다. 객체 풀은 필요에 따라 여전히 최적화될 수는 있지만, 예를 들어 객체를 다시 패킹하는 등의 작업이 필요할 수 있습니다.
  3. 상위 및 포크 프로젝트 모두 RemoveRepositoryFromObjectNetwork()를 호출하여 객체 중복 제거 네트워크를 나갈 수 있습니다. 이로써 모든 객체가 다시 중복되고, 저장소는 객체 풀에서 연결이 해제됩니다. 또한, 저장소가 읽기-쓰기 객체 중복 제거 네트워크 멤버였다면, Gitaly는 해당 저장소를 풀을 업데이트하는 소스로 사용하지 않습니다.

    반대로 포크는 RemoveRepository()를 호출하여 삭제할 수도 있습니다.

    두 호출 모두 객체 풀의 멤버십을 업데이트하여 저장소가 풀에서 나갔음을 반영합니다. 멤버가 더 이상 없으면 Gitaly는 객체 풀을 삭제합니다.

이 제안된 흐름으로 객체 풀의 생성, 유지 및 제거가 Gitaly 내에서 불투명하게 처리됩니다. 또한 위의 내용 외에도 두 가지 지원 RPC가 제공될 수 있습니다:

  • AddRepositoryToObjectDeduplicationNetwork()로 기존 저장소가 지정된 역할로 객체 중복 제거 네트워크에 가입할 수 있습니다.
  • ListObjectDeduplicationNetworkMembers()로 저장소가 멤버인 객체 중복 제거 네트워크의 모든 멤버와 그들의 역할을 나열할 수 있습니다.

객체 중복 제거 네트워크 기반 아키텍처로의 이주

객체 중복 제거 네트워크 기반 아키텍처로의 이주에는 많은 작은 단계들이 포함됩니다:

  1. CreateFork()가 자동으로 기존 객체 풀에 링크를 시작합니다. 이를 통해 빠른 포킹이 가능해지고, 포크를 생성할 때 호출자에게 객체 풀이 없어지게 됩니다.
  2. AddRepositoryToObjectDeduplicationNetwork()RemoveRepositoryFromObjectDeduplicationNetwork()를 도입합니다. AddRepositoryToObjectPool()DisconnectGitAlternates()를 지원 중단하고, Rails를 새로운 RPC를 사용하도록 이주합니다. 객체 중복 제거 네트워크는 리포지토리를 통해 식별되므로, 멤버십을 처리할 때 객체 풀의 개념을 없앱니다.
  3. CreateFork(), AddRepositoryToObjectDeduplicationNetwork(), RemoveRepositoryFromObjectDeduplicationNetwork(), RemoveRepository()에서 객체 중복 제거 네트워크 멤버십을 기록하기 시작합니다. 이 정보는 Gitaly가 객체 풀 라이프사이클을 제어하는 데 도움이 됩니다.
  4. Gitaly가 모든 객체 풀 멤버의 최신 뷰를 가지고 있는지 확인할 수 있는 마이그레이션을 구현합니다. Gitaly가 객체 풀의 라이프사이클을 자동으로 처리할 수 있도록 마이그레이션이 필요합니다. 마이그레이션은 다음을 가능하게 합니다:
    • OptimizeRepository()가 읽기-쓰기 객체 풀 멤버에서 자동으로 객체를 가져오게 됩니다.
    • Gitaly가 빈 객체 풀을 자동으로 제거할 수 있게 됩니다.
  5. OptimizeRepository()를 변경하여 리포지토리에 연결된 객체 풀도 최적화하도록 합니다. 이를 통해 FetchIntoObjectPool()을 지원 중단하고, 최종적으로 제거할 수 있습니다.
  6. RemoveRepositoryFromObjectDeduplicationNetwork()RemoveRepository()를 조정하여 빈 객체 풀을 제거합니다.
  7. CreateFork()를 조정하여 자동으로 객체 풀을 생성하도록 합니다. 이를 통해 CreateObjectPool() RPC를 제거할 수 있습니다.
  8. Gitaly 공개 API에서 ObjectPoolService 및 객체 풀의 개념을 제거합니다.

물론 이 계획은 변경될 수 있습니다.

Gitaly 클러스터 관련 사항

리포지토리 생성

리포지토리가 처음으로 포크될 때, Rails는 CreateObjectPool() RPC를 통해 객체 풀을 생성합니다. 이는 객체 풀 생성이 Gitaly 외부에서 처리된다는 것을 의미합니다. 그 후, 객체 풀은 상위 및 포크 리포지토리에 연결됩니다. 리포지토리의 Git alternates 파일이 다른 리포지토리에 링크되도록 구성된 경우, 이 두 리포지토리는 동일한 물리적 스토리지에 존재해야 합니다.

리포지토리 및 해당 객체 풀이 동일한 물리적 스토리지에 있는 것은 Praefect에게 중요합니다. 이는 리플리케이션 팩터에 의존하기 때문입니다. 리플리케이션 팩터는 Praefect 가상 저장소에서 리포지토리가 복제되는 수를 제어하는 구성입니다. 기본적으로, 리플리케이션 팩터는 Praefect 내의 스토리지 수와 동일합니다. 따라서 기본 리플리케이션 팩터를 사용하면 리포지토리가 클러스터 내 모든 스토리지에서 사용 가능합니다. 사용자 정의 리플리케이션 팩터를 사용하면 레플리카 수를 줄여서 리포지토리가 Praefect의 일부 스토리지에만 존재하도록 할 수 있습니다.

Gitaly 클러스터는 리포지토리 및 할당된 스토리지를 Praefect PostgreSQL 데이터베이스에 영구적으로 저장합니다. 새 리포지토리가 가상 저장소에 생성될 때, 데이터베이스가 업데이트됩니다. 새 리포지토리가 생성되면 리플리케이션 팩터는 리포지토리에 무작위로 몇 개의 스토리지가 할당되도록 지정합니다. 다음 시나리오는 객체 풀에 대한 사용자 정의 리플리케이션 팩터가 문제가 될 수 있는 방법을 설명합니다:

  1. 5개의 스토리지 노드를 보유한 Gitaly 클러스터에 새 리포지토리가 생성됩니다. 리플리케이션 팩터는 3으로 설정됩니다. 따라서 3개의 스토리지가 새 객체 풀 리포지토리에 Praefect에 무작위로 선택되어 할당됩니다. 예를 들어, 할당은 스토리지 1, 2 및 3일 수 있습니다. 스토리지 4와 5에는 이 리포지토리의 사본이 없습니다.
  2. 리포지토리가 첫 번째로 포크되어 CreateObjectPool() RPC를 통해 객체 풀 리포지토리가 생성되어야 합니다. 리플리케이션 팩터가 3으로 설정되어 있으므로, 또 다른 무작위로 선택된 3개의 스토리지가 Praefect에서 새 객체 풀 리포지토리에 할당됩니다. 예를 들어, 객체 풀 리포지토리는 스토리지 3, 4 및 5에 할당됩니다. 이러한 할당은 상위 리포지토리와 완전히 일치하지 않음을 유의하십시오.
  3. 포크된 리포지토리가 CreateFork() RPC를 통해 생성되고 또한 세 개의 무작위로 선택된 스토리지에 할당됩니다. 예를 들어, 포크 리포지토리는 스토리지 1, 3 및 5에 할당될 것입니다. 이러한 할당 또한 상위 및 객체 풀 리포지토리의 스토리지 할당과 완전히 일치하지 않습니다.
  4. 상위 및 포크된 리포지토리 모두가 LinkRepositoryToObjectPool()의 별도 호출을 통해 객체 풀에 연결됩니다. 이 RPC가 성공하려면 객체 풀은 그것에 링크되는 리포지토리와 동일한 스토리지에 존재해야 합니다. 상위 리포지토리는 스토리지 1과 2에서 연결에 실패합니다. 포크 리포지토리는 스토리지 2에서 연결에 실패합니다. LinkRepositoryToObjectPool() RPC는 트랜잭션 처리되지 않으므로 어떤 스토리지에서 RPC의 단일 실패로 인해 오류가 클라이언트로 반환됩니다. 따라서 이 시나리오에서 LinkRepositoryToObjectPool()은 상위 및 포크 리포지토리 모두에서 항상 오류 응답을 내보냅니다.

이 문제를 해결하려면, Praefect가 항상 CreateObjectPool()CreateFork() RPC 요청을 상위 리포지토리와 동일한 스토리지 집합으로 라우팅하도록 보장해야 합니다. 이렇게 하면 이러한 리포지토리가 항상 요구되는 객체 풀 리포지토리를 가지고 있어 연결이 성공할 수 있습니다.

이 방법의 주요 단점은 객체 중복 제거 네트워크의 리포지토리가 동일한 스토리지 집합에 고정된다는 것입니다. 이는 객체 중복 제거 네트워크가 커짐에 따라 개별 스토리지에 불균형한 부하를 주는 경우가 있을 수 있습니다. 미래에 Praefect가 필요하지만 아직 존재하지 않는 스토리지에 객체 풀을 생성할 수 있는 능력을 갖추면이러한 문제를 완전히 피할 수 있습니다.

저장소 복제

ReplicateRepository() RPC는 객체 풀을 인식하지 않으며 소스 저장소에서만 복제합니다. 이는 소스 저장소에 연결된 객체 풀 저장소를 복제하는 경우, 대상 저장소에는 Git alternates 파일이 없고 결과적으로 객체의 중복이 없는 대상 저장소가 생성된다는 것을 의미합니다.

ReplicateRepository() RPC에는 두 가지 주요 용도가 있습니다.

  • GitLab API에서 수행되는 저장소 이동은 ReplicateRepository() RPC에 의존하여 저장소를 한 곳에서 다른 곳으로 복제합니다. 현재 이 RPC는 객체 풀을 인식하지 않기 때문에 대상 저장소의 복제본에는 소스 저장소에서 Git alternates 파일을 복제하거나 객체 풀을 다시 생성하지 않습니다. 대신, 복제본은 항상 소스 저장소의 완전한 독립형 복사본입니다. 결과적으로 Rails의 저장소 프로젝트에서 객체 풀 관계도 제거됩니다. 객체 중복 제거 네트워크에서 저장소를 한 곳에서 다른 곳으로 이동할 때, 복제된 저장소는 대상 저장소에서 객체의 중복이 더 이상 없기 때문에 저장 공간 사용량이 증가할 수 있습니다.
  • Praefect에서 저장소 복제본이 오래된 경우 ReplicateRepository() RPC는 Praefect 복제 작업에 의해 오래된 복제본에서 최신 복제본으로 복제되도록 내부적으로 사용됩니다. 복제 작업은 복제본이 오래되면 Praefect 복제 관리자에 의해 대기열에 추가됩니다. 그러나 ReplicateRepository() RPC는 객체 풀을 인식하지 않지만, 복제 작업은 소스 저장소가 객체 풀에 연결되어 있는지 확인합니다. 소스 저장소가 연결된 경우, 작업은 대상 저장소에서 해당 Git alternates 파일을 다시 생성합니다. 그러나 현재로서는 복제본이 동일 저장소에 객체 풀이 존재하지 않을 수 있습니다. 이런 경우, 복제는 불가능하여 복제본이 영구적으로 오래될 수 있습니다.

소스 저장소에서 필요한 객체 풀은 ReplicateRepository() RPC를 통해 대상 저장소로 복제될 필요가 있습니다. 이를 통해 객체 중복 제거 네트워크에서 저장소에 대한 객체 중복 제거가 보존됩니다. GitLab API에서 수행되는 저장소 이동은 객체 풀 관계를 제거하기 때문에 대상 저장소에 객체 풀을 다시 생성하면 고립된 객체 풀이 생성됩니다. ReplicateRepository() RPC의 새로운 객체 풀 복제 동작은 클라이언트가 파괴적인 변경을 방지하기 위해 제어해야 합니다. 저장소 이동을 위한 객체 풀 복제는 다음 중 하나가 이루어지면 활성화될 수 있습니다.

  • Rails 측면이 객체 풀 관계를 보존하도록 업데이트됨.
  • 객체 풀 수명 주기가 Gitaly 내에서 관리됨.

객체 풀을 복제할 때, Praefect가 처리할 수 있어야 하는 시나리오가 있습니다. 이러한 경우에는 Praefect가 모든 관리되는 저장소를 추적하여 PostgreSQL 데이터베이스에 유지할 수 있도록 특별한 고려가 되어야 합니다.

  • 객체 풀에 연결된 외부 소스 저장소를 Gitaly 클러스터로 복제하는 경우, 대상 가상 저장소의 Praefect는 새로운 객체 풀 저장소를 생성해야 할 수 있습니다. 이를 처리하기 위해서 소스 저장소가 객체 풀을 사용하는지 알아야 합니다. 거기서 Praefect는 객체 풀 저장소에 대한 항목이 repositories 데이터베이스 테이블에 있는지 확인하고, 그렇지 않은 경우에는 새로 생성해야 합니다. 그다음, 객체 풀에 대한 Praefect 저장소 할당이 생성되고 repository_assignments 데이터베이스 테이블에 지속되어야 합니다.
  • 대상 저장소 저장소에 필요한 객체 풀이 이미 포함되어 있는지 보장할 수 없습니다. 따라서 각 저장소가 해당 객체 풀에 할당될 수 있습니다. 이 새로운 할당은 또한 Praefect에 의해 추적되어야 합니다. 이를 처리하기 위해서 Praefect는 대상 저장소에 해당 객체 풀이 포함되어 있지 않을 때 감지하고, 새로운 저장소 할당을 repository_assignments 데이터베이스 테이블에 지속해야 합니다.

디자인의 문제점

이미 언급했듯이, 객체 풀은 완벽한 해결책이 아닙니다. 이 섹션에서는 가장 중요한 문제를 살펴봅니다.

수명 주기 관리의 복잡성

객체 풀의 수명 주기는 완전히 Gitaly에서 소유되면 관리가 더 쉬워지지만 여전히 복잡하며 여러 측면에서 고려해야 합니다. 객체 풀을 그들의 저장소와 함께 처리하는 것은 적어도 두 개의 다른 리소스에 걸쳐진 동작이기 때문에 원자적인 작업이 아닙니다.

성능 이슈

객체 풀은 객체를 중복으로 저장하기 때문에 결과적으로 객체 풀 멤버는 단일 팩 파일에 모든 객체의 전체 클로저를 가지지 않습니다. 이것은 주로 기본 객체 풀 멤버에 대한 문제가 아니며, 정의에 따라 객체 풀의 내용과 분리될 수 없습니다. 그러나 보조 객체 풀 멤버는 원본 저장소의 내용과 다를 수 있습니다.

이로 인해 보조 객체 풀 멤버에는 두 가지 다른 세트의 연결된 객체가 있습니다. 불행하게도 Git 자체의 제한으로 인해 이미 압축된 객체들을 가져오기 위해 팩 파일을 효율적으로 재사용할 수 없습니다.

  • 이미 압축된 객체들을 반환하는 경우 팩 파일은 효율적으로 재사용할 수 없습니다. 이는 Git이 객체 풀에서 이미 분기된 객체를 제공하기 위해 즉석에서 델타를 계산해야 함을 의미합니다.
  • 팩 파일 비트맵은 여러 객체 데이터베이스에 대해 커버하는 것이 불가능하고 쉽게 실행 가능하지 않기 때문에 객체 풀만큼 효율적으로 사용될 수 있습니다. 이는 많은 작업 및 특히 가져오기를 제공할 때 객체 그래프의 더 큰 부분을 순회해야 함을 의미합니다.

다중 저장소 간 종속 쓰기

객체 풀의 설계는 모든 저장소의 변경 사항에 대한 미리 쓰기 로그를 사용하는 Raft 세계에 중요한 복잡성을 도입합니다. 이상적인 경우에는 Raft 기반 설계는 요청을 고려할 때 단일 저장소의 미리 쓰기 로그에만 신경 써야 할 것입니다. 하지만 객체 풀의 경우, 풀된 저장소의 모든 쓰기와 읽기를 종속적으로 적용해야 합니다.

대안 솔루션

제안된 솔루션은 생명주기 관리(복잡성)와 성능(비효율적으로 제공되는 풀 멤버의 fetch)의 문제를 가지고 있기 때문에 명백히 최선의 선택은 아닙니다.

이 섹션에서는 객체 풀에 대한 대안과 새로운 대상 아키텍처로 선택되지 않은 이유에 대해 탐구합니다.

전혀 객체 풀 사용 중지

복잡성을 피하는 명백한 방법은 전혀 객체 풀을 사용하는 것을 중지하는 것입니다. 아키텍처를 크게 단순화할 수 있는 엔지니어링 적인 측면에서 매력적이지만, 효율적인 포킹 워크플로우를 지원할 수 없게 되므로 제품적인 측면에서 실현 가능한 접근 방법이 아닙니다.

주요 저장소를 객체 풀로 사용

명시적인 객체 풀 저장소를 만드는 대신 상위 저장소를 모든 포크의 대체 객체 데이터베이스로 사용할 수 있습니다. 이렇게 하면 객체 풀의 수명을 관리하는 데 관한 복잡성이 많이 줄어들며, 표면적으로는 그 문제인 객체 풀을 어떻게 업데이트할 지를 우회합니다. 그러나 이에는 몇 가지 단점이 있습니다:

  • 저장소가 이제 다른 상태를 가질 수 있으며, 일부 저장소는 객체를 가지치기할 수 있고, 다른 저장소는 그렇게 할 수 없습니다. 이것은 불확실성의 원인이 될 뿐 아니라 실수로 저장소의 객체를 삭제하여 포크를 손상시킬 수 있는 쉬운 소스가 됩니다.

  • 상위 저장소가 비공개로 전환되면 포크 네트워크의 멤버간에 중복 제거된 객체 집합을 동결하기 위해 이후에 여전히 객체 풀을 만들어야 한다는 문제가 발생합니다.

  • 저장소를 삭제하는 것이 더 복잡해지게 됩니다. 저장소가 포크에 의해 링크되었는지 여부를 고려해야 합니다.

참조 네임스페이스

gitnamespaces(7)를 사용하면 Git은 참조를 다른 네임스페이스 집합으로 분할하는 메커니즘을 제공합니다. 이를 통해 모든 포크를 포함하는 단일 저장소에서 모든 포크를 제공할 수 있습니다.

이 점이 좋은 것은 모든 포크에 의해 참조되는 객체들의 전역 뷰를 단일 객체 데이터베이스에서 한 번에 수행할 수 있다는 것입니다. 따라서 우리는 한꺼번에 모든 포크를 통한 공유 housekeeping을 쉽게 수행할 수 있으며, 더 이상 사용되지 않는 객체를 삭제할 수 있습니다. 객체에 대해서는 이것이 우리가 잠재적으로 추구할 수 있는 가장 효율적인 솔루션이 될 것입니다.

그러나 이에도 몇 가지 단점이 있습니다:

  • 사용량 할당량을 계산하는 것은 반드시 실제 객체의 도달 가능성을 고려해야 하며, 이는 비용이 많이 드는 계산입니다. 이것은 중요한 것은 아니지만 염두에 둘 사항입니다.

  • 명시된 요구 사항 중 하나는 포크에서 다른 저장소의 객체를 도달할 수 없게 해야 한다는 점입니다. 이 속성은 이제 객체에만 액세스 가능하도록 함으로써 이론적으로 시행할 수 있습니다. 객체는 이제 그 참조에서 도달 가능할 때 가상 저장소를 통해 액세스할 수 있습니다. 도달 가능성 확인은 계산 부담이 많기 때문에 이것이 실용적으로되기는 어렵습니다.

  • 참조가 분할되더라도 큰 포크 네트워크는 여전히 쉽게 수백만 개의 참조를 얻을 수 있습니다. 성능에 미치는 영향이 불명확합니다.

  • 저장소 수준의 공격의 범위가 상당히 증가하게 됩니다. 여러분의 저장소뿐만 아니라 모든 포크에도 영향을 미치게 됩니다.

  • 사용자 정의 후크는 각 가상 저장소에 대해 격리되어야 합니다. Git 후크의 실행이 제어되기 때문에 이를 각 네임스페이스에 대해 처리할 수 있어야 합니다.

파일 시스템 기반 중복 제거

파일 시스템 레벨에서 객체를 중복으로 처리하는 아이디어는 여러 번 공유되어 왔습니다. 이를 다른 구성 요소로 이전할 수 있다면 좋을 것이지만, Git 작동 방식의 특성으로 인해 구현하기가 어렵습니다.

저장소 크기에 가장 중요한 기여 요인 중 하나는 Git 객체입니다. 객체를 느슨한 표현으로 저장하고 이를 통해 중복 처리하는 것이 가능할 수 있지만, 이는 실현 가능하지 않습니다:

  • Git은 객체를 델타화할 수 없을 것입니다. 이는 디스크 크기를 줄이는 매우 중요한 메커니즘입니다. 디스크 사이즈를 줄이는데 있어서 중복 처리로 인한 크기 감소가 델타화 메커니즘으로 얻은 크기 감소보다 작을 것입니다.

  • 느슨한 객체는 저장소에 액세스할 때 더 불편합니다.

  • fetch를 제공하기 위해서는 packfile를 클라이언트에게 보내야 합니다. 보통 Git은 이미 존재하는 packfile의 큰 부분을 재사용할 수 있어서 계산 오버헤드를 크게 줄일 수 있습니다.

따라서 느슨한 객체 수준에서의 중복 처리는 실현 가능하지 않습니다.

다른 단위로 중복 처리를 시도할 수 있는 것은 packfile일 것입니다. 하지만 packfile은 Git에 의해 결정적으로 생성되지 않으며 더 나아가 저장소들이 서로 다르게 분기되는 경우에도 다를 것입니다. 따라서 packfile은 파일 시스템 레벨에서 중복 처리하기에는 자연스럽지 않습니다.

대안으로는 저장소 간에 packfile의 하드 링크를 사용하는 것일 수 있습니다. 이로 인해 객체를 리팩하는 것에 대한 저장 공간을 복제하게 되며, 이는 예측 불가능하고 관리하기 어려워질 것입니다.

사용자 지정 객체 백엔드

이론적으로는 사용자 지정 객체 백엔드를 구현하여 포크 간에 객체를 중복으로 저장할 수 있는 방법이 있을 것입니다. 그러나 현재는 상당한 상위 투자 없이는 이 작업을 수행하는 데 장애물이 있습니다.

  • 현재 Git은 객체에 대해 서로 다른 백엔드를 가질 수 있는 방식으로 설계되어 있지 않습니다. 객체 데이터베이스의 파일에 대한 액세스는 추상화 수준이 없이 코드 기반에 흩어져 있습니다. 이는 최소한 일부 수준의 추상화가 있는 참조 데이터베이스와 대조적입니다.

  • 사용자 지정 객체 백엔드를 구현하는 것은 Git 프로젝트를 포크해야 할 가능성이 높습니다. 자원이 충분하더라도 이는 상당한 위험 요소를 도입할 것입니다. 상위 변경 사항과의 호환성 문제로 인해 순정 Git을 사용하는 것이 불가능해질 것입니다. 이는 종종 GitLab을 패키지화하는 리눅스 배포의 요구 사항 중 하나입니다.

이러한 접근 방식을 현재로서는 정말로 정당화할만한 초기 및 운영 중 리스크가 너무 높습니다. 우리는 앞으로 이 접근 방식을 다시 고려할 수 있을 것입니다.