모노레포 성능 개선
GitLab v19.5요약
모노레포는 하위 프로젝트를 포함하는 리포지터리입니다. GitLab 자체도 Git을 기반으로 합니다. Git은 공간을 덜 사용하기 위해 객체를 팩파일로 압축합니다. 거대한 모노레포는 작은 리포지터리보다 커밋, 파일, 브랜치, 태그가 많습니다.
모노레포는 하위 프로젝트를 포함하는 리포지터리입니다. 하나의 애플리케이션은 서로 의존하는 여러 프로젝트로 구성되는 경우가 많습니다. 예를 들어 백엔드, 웹 프론트엔드, iOS 애플리케이션, Android 애플리케이션이 있습니다. 모노레포는 흔하지만 성능 위험을 안고 있습니다. 대표적인 문제는 다음과 같습니다:
- 대용량 바이너리 파일.
- 히스토리가 긴 다수의 파일.
- 동시에 발생하는 다수의 클론과 푸시.
- 수직 확장의 한계.
- 네트워크 대역폭의 한계.
- 디스크 대역폭의 한계.
GitLab 자체도 Git을 기반으로 합니다. GitLab의 Git 스토리지 서비스인 Gitaly 역시 모노레포에 따르는 성능 제약을 겪습니다. GitLab 이 얻은 경험은 사용자가 모노레포를 더 잘 관리하는 데 도움이 됩니다.
- 성능에 영향을 줄 수 있는 리포지터리 특성.
- 모노레포를 최적화하는 도구와 단계.
모노레포를 위한 Gitaly 최적화#
Git은 공간을 덜 사용하기 위해 객체를 팩파일로 압축합니다. 클론, 페치, 푸시할 때 Git은 팩파일을 사용합니다. 팩파일은 디스크 공간과 네트워크 대역폭을 줄이지만, 팩파일 생성에는 많은 CPU와 메모리가 필요합니다.
거대한 모노레포는 작은 리포지터리보다 커밋, 파일, 브랜치, 태그가 많습니다. 객체가
커지고 전송에 더 오래 걸리면 팩파일 생성 비용이 커지고
속도도 느려집니다. Git에서 git-pack-objects 프로세스는
다음을 수행하므로 자원을 가장 많이 쓰는 작업입니다:
- 커밋 히스토리와 파일을 분석합니다.
- 클라이언트로 돌려보낼 파일을 결정합니다.
- 팩파일을 생성합니다.
git clone과 git fetch 트래픽은 서버에서 git-pack-objects 프로세스를 시작합니다.
GitLab CI/CD와 같은 자동화된 지속적 통합 시스템이 이 트래픽의 상당 부분을 만들어 냅니다.
자동화된 CI/CD 트래픽이 많으면 클론과 페치 요청이 다수 발생해 Gitaly 서버에
부하를 줍니다.
Gitaly 서버의 부하를 줄이려면 다음 전략을 사용합니다.
Gitaly pack-objects 캐시 활성화#
Gitaly pack-objects 캐시를 활성화하면
클론과 페치에 따르는 서버 부하가 줄어듭니다.
Git 클라이언트가 클론 또는 페치 요청을 보내면 git-pack-objects가 생성한 데이터를
캐시해 재사용할 수 있습니다. 모노레포가 자주 클론된다면
Gitaly pack-objects 캐시를
활성화해 서버 부하를 줄입니다. 활성화하면 Gitaly는 클론 또는 페치 호출마다 응답 데이터를 다시 만들지 않고
메모리 내 캐시를 유지합니다.
자세한 내용은 Pack-objects 캐시를 참고합니다.
Git 번들 URI 구성#
Git 번들을 만들어 CDN처럼 지연 시간이 짧은 서드파티 스토리지에 저장합니다. Git은 먼저 번들에서 패키지를 내려받은 다음, 남은 객체와 참조를 Git 원격에서 페치합니다. 이 방식은 객체 데이터베이스를 더 빠르게 준비하고 Gitaly의 부하를 줄입니다.
- GitLab 서버와의 네트워크 연결 상태가 좋지 않은 사용자의 클론과 페치 속도가 빨라집니다.
- 번들을 미리 로드해 CI/CD job을 실행하는 서버의 부하를 줄입니다.
자세한 내용은 번들 URI를 참고합니다.
Gitaly 협상 타임아웃 구성#
리포지터리를 페치하거나 아카이브할 때 다음과 같은 경우 fatal: the remote end hung up unexpectedly 오류가
발생할 수 있습니다:
- 대용량 리포지터리인 경우.
- 다수의 리포지터리를 병렬로 처리하는 경우.
- 동일한 대용량 리포지터리를 병렬로 처리하는 경우.
이 문제를 완화하려면 기본 협상 타임아웃 값을 늘립니다.
하드웨어 크기 적절히 조정#
모노레포는 대개 사용자가 많은 대규모 조직에서 사용합니다. 모노레포를 지원하려면 GitLab 환경이 GitLab Test Platform 및 Support 팀이 제공하는 참조 아키텍처 중 하나와 일치해야 합니다. 이 아키텍처는 성능을 유지하면서 GitLab을 대규모로 배포하는 권장 방식입니다.
Git 참조 수 줄이기#
Git에서 참조는
특정 커밋을 가리키는 브랜치와 태그 이름입니다. Git은 참조를 리포지터리의
.git/refs 폴더에 느슨한 파일로 저장합니다. 리포지터리의 모든 참조를 보려면
git for-each-ref를 실행합니다.
리포지터리의 참조 수가 늘어나면 특정 참조를 찾는 데 필요한 탐색 시간도 함께 늘어납니다. Git 이 참조를 파싱할 때마다 늘어난 탐색 시간이 지연 시간 증가로 이어집니다.
이 문제를 해결하기 위해 Git은 pack-refs를 사용해 해당 리포지터리의
모든 참조를 담은 단일 .git/packed-refs 파일을 만듭니다. 이 방식은 참조에 필요한 저장
공간을 줄입니다. 또한 디렉터리의 모든 파일을 탐색하는 것보다 단일 파일을 탐색하는 쪽이 빠르므로
탐색 시간도 줄어듭니다.
Git은 새로 만들어지거나 업데이트된 참조를 느슨한 파일로 처리합니다. 이 파일들은 git pack-refs를 실행하기 전까지
정리되어 .git/packed-refs 파일에 추가되지 않습니다. Gitaly는
하우스키핑 중에 git pack-refs를 실행합니다. 이 방식은
많은 리포지터리에 도움이 되지만, 쓰기가 많은 리포지터리에는 다음 성능 문제가 남습니다:
- 참조를 만들거나 업데이트하면 새로운 느슨한 파일이 생깁니다.
- 참조를 삭제하려면 기존
packed-refs파일을 편집해 해당 참조를 제거해야 합니다.
리포지터리를 페치하거나 클론하면 Git은 모든 참조를 순회합니다. 서버는 각 참조의 내부 그래프 구조를 검토("워크")해 누락된 객체를 찾아 클라이언트로 보냅니다. 순회와 워크 과정은 CPU를 많이 사용하고 지연 시간을 늘립니다. 이 지연은 활동이 많은 리포지터리에서 도미노 효과를 일으킵니다. 각 작업이 느려지고, 각 작업이 뒤따르는 작업을 지연시킵니다.
모노레포에서 참조가 많을 때의 영향을 완화하려면 다음을 수행합니다:
-
오래된 브랜치를 정리하는 자동화 프로세스를 만듭니다.
-
특정 참조를 클라이언트에 노출할 필요가 없다면
transfer.hideRefs구성 설정으로 숨깁니다. Gitaly는 서버의 Git 구성을 무시하므로/etc/gitlab/gitlab.rb에서 Gitaly 구성 자체를 변경해야 합니다:gitaly['configuration'] = { # ... git: { # ... config: [ # ... { key: "transfer.hideRefs", value: "refs/namespace_to_hide" }, ], }, }
Git 2.42.0 이상에서는 여러 Git 작업이 객체 그래프를 워크할 때 숨겨진 참조를 건너뛸 수 있습니다.
리포지터리 최적화 작업 예약#
Git 리포지터리의 객체 데이터베이스에 데이터가 저장되는 방식은 시간이 지나면서 비효율적으로 변해 Git 작업 속도를 떨어뜨립니다. Gitaly가 최대 실행 시간을 두고 매일 백그라운드 작업을 실행하도록 예약해 이러한 항목을 정리하고 성능을 개선할 수 있습니다.
모노레포를 위한 CI/CD 최적화#
모노레포에서 GitLab의 확장성을 유지하려면 CI/CD job 이 리포지터리와 상호작용하는 방식을 최적화합니다. 규모가 크고 오래 걸리는 파이프라인은 모노레포에서 흔한 문제입니다. 모노레포의 파이프라인 구성에서는 변경 유형을 감지하는 빌드 규칙을 사용해 다음을 수행합니다:
- 불필요한 job을 건너뜁니다.
- 하위 파이프라인에서 관련 job만 실행합니다.
CI/CD에서 동시 클론 줄이기#
CI/CD 파이프라인의 동시성을 줄이려면 예약된 파이프라인의 실행 시각을 분산해 서로 다른 시각에 실행합니다. 몇 분 차이만 두어도 도움이 됩니다.
CI/CD 부하는 파이프라인이 특정 시각에 예약되기 때문에 동시에 몰리는 경우가 많습니다. 이 시간대에는 리포지터리에 대한 Git 요청이 급증해 CI/CD 프로세스와 사용자 성능에 영향을 줄 수 있습니다.
CI/CD 프로세스에서 얕은 클론과 필터 사용#
CI/CD 시스템의 git clone 및 git fetch 호출에서는 다음 옵션으로 전송되는
데이터 양을 제한할 수 있습니다:
CI/CD 에서의 얕은 클론#
--depth 필터는 이른바 얕은 클론 을 만듭니다.
GitLab과 GitLab Runner는 기본적으로
얕은 클론을
수행합니다.
클론 깊이는 GitLab CI/CD 파이프라인 구성에서 GIT_DEPTH로
설정합니다. 예를 들면 다음과 같습니다:
variables:
GIT_DEPTH: 10
test:
script:
- ls -al
CI/CD 에서의 부분 클론#
--filter 옵션을 사용하면 부분 클론 을 만듭니다. 이 인수를
git-clone에 전달하려면 GIT_CLONE_EXTRA_FLAGS 변수를 설정합니다. 예를 들어 블롭의
최대 크기를 1MB로 제한하려면 다음을 추가합니다:
variables:
GIT_CLONE_EXTRA_FLAGS: --filter=blob:limit=1m
경로와 객체 유형 필터링#
특정 유형의 객체나 특정 경로의 객체를 걸러내려면 git sparse-checkout 옵션을 사용합니다.
자세한 내용은 파일 경로로 필터링을 참고합니다.
CI/CD 작업에서 git fetch 사용#
리포지터리의 작업 복사본을 유지할 수 있다면 CI/CD 시스템에서 git clone 대신
git fetch를 사용합니다. git fetch는 서버가 처리할 작업이 더 적습니다:
git clone은 리포지터리 전체를 처음부터 요청합니다.git-pack-objects가 모든 브랜치와 태그를 처리해 전송해야 합니다.git fetch는 리포지터리에 없는 Git 참조만 요청합니다.git-pack-objects는 전체 Git 참조 중 일부만 처리합니다. 이 전략은 전송되는 총 데이터 양도 줄입니다.
GitLab은 기본적으로 대용량 리포지터리에 권장되는
fetch Git 전략을 사용합니다.
git clone 경로 설정#
모노레포를 포크 기반 워크플로에서 사용한다면
GIT_CLONE_PATH를 설정해
리포지터리를 클론할 위치를 제어하는 방안을 검토합니다.
Git은 포크를 별도의 워크트리를 가진 별도 리포지터리로 저장합니다. GitLab Runner는 워크트리 사용을 최적화하지 못합니다. GitLab Runner executor는 해당 프로젝트에만 구성해 사용합니다. 프로세스를 더 효율적으로 만들려면 여러 프로젝트에서 공유하지 않습니다.
GIT_CLONE_PATH는
$CI_BUILDS_DIR에 설정된 디렉터리 안에 있어야 합니다. 디스크의 임의 경로를 지정할 수는 없습니다.
CI/CD job에서 git clean 비활성화#
git clean 명령은 워킹 트리에서 추적되지 않는 파일을 제거합니다. 대용량 리포지터리에서는 디스크 I/O를
많이 사용합니다. 기존 머신을 재사용하고 기존 워크트리도 재사용할 수 있다면 CI/CD job에서
이 명령을 비활성화하는 방안을 검토합니다. 예를 들어 GIT_CLEAN_FLAGS: -ffdx -e .build/를 사용하면 실행 사이에
워크트리의 디렉터리가 삭제되지 않습니다. 이렇게 하면 증분 빌드 속도가 빨라집니다.
CI/CD job에서 git clean을 비활성화하려면 해당 job의
GIT_CLEAN_FLAGS를 none으로 설정합니다.
GitLab은 기본적으로 다음을 보장합니다:
- 워크트리가 지정된 SHA에 있습니다.
- 리포지터리가 깨끗한 상태입니다.
GIT_CLEAN_FLAGS가 허용하는 정확한 매개변수는 Git 문서의
git clean 항목을 참고합니다. 사용 가능한 매개변수는 Git 버전에 따라 다릅니다.
플래그로 git fetch 동작 변경#
CI/CD job에 필요 없는 데이터를 제외하도록 git fetch 동작을 변경합니다. 프로젝트에 태그가 많고
CI/CD job에 태그가 필요 없다면 GIT_FETCH_EXTRA_FLAGS로
--no-tags를 설정합니다. 이 설정은
페치를 더 빠르고 간결하게 만듭니다.
리포지터리에 태그가 많지 않더라도 --no-tags가 성능을 개선하는 경우가 있습니다.
자세한 내용은 이슈 746과
GIT_FETCH_EXTRA_FLAGS Git 문서를 참고합니다.
러너에 롱 폴링 사용#
러너는 새 CI/CD job 이 있는지 GitLab 인스턴스를 주기적으로 폴링합니다. 폴링 간격은 다음 두 가지에 따라 달라집니다:
check_interval설정.- 러너 구성 파일에 구성된 러너 수.
서버가 많은 러너를 처리하면 이 폴링이 대기 시간 증가와 CPU 사용량 상승 같은 성능 문제를 GitLab 인스턴스에 일으킵니다. 롱 폴링은 새 job 이 준비될 때까지 러너의 job 요청을 보류합니다.
구성 방법은 롱 폴링을 참고합니다.
모노레포를 위한 Git 최적화#
모노레포에서 GitLab의 확장성을 유지하려면 리포지터리 자체를 최적화합니다.
개발에 얕은 클론 사용 피하기#
개발에는 얕은 클론을 사용하지 않습니다. 얕은 클론은 변경 사항을 푸시하는 데 걸리는 시간을 크게 늘립니다. CI/CD job에서는 체크아웃 이후 리포지터리 콘텐츠가 바뀌지 않으므로 얕은 클론이 잘 맞습니다.
로컬 개발에는 대신 부분 클론을 사용해 다음을 수행합니다:
git clone --filter=blob:none으로 블롭을 걸러냅니다.git clone --filter=tree:0으로 트리를 걸러냅니다.
자세한 내용은 클론 크기 줄이기를 참고합니다.
리포지터리 프로파일링으로 문제 찾기#
대용량 리포지터리는 대체로 Git에서 성능 문제를 겪습니다.
git-sizer 프로젝트는 리포지터리를 프로파일링해 잠재적인 문제를
파악하도록 돕습니다. 성능 문제를 예방할 완화 전략을 세우는 데에도 도움이 됩니다.
리포지터리를 분석하려면 모든 Git 참조가 존재하도록 전체 Git 미러 또는 bare 클론이
필요합니다.
git-sizer로 리포지터리를 프로파일링하려면 다음을 수행합니다:
-
git-sizer를 설치합니다. -
다음 명령을 실행해
git-sizer와 호환되는 bare Git 형식으로 리포지터리를 클론합니다:git clone --mirror <git_repo_url> -
Git 리포지터리 디렉터리에서 모든 통계를 포함해
git-sizer를 실행합니다:git-sizer -v
처리가 끝나면 git-sizer의 출력은 다음 예시와 같습니다. 각 행에는 리포지터리의 해당 항목에 대한
Level of concern 이 들어 있습니다. 우려 수준이 높을수록 별표가 더 많이
표시됩니다. 우려 수준이 매우 높은 항목은 느낌표로 표시됩니다. 이 예시에서는
몇몇 항목의 우려 수준이 높습니다:
Processing blobs: 1652370
Processing trees: 3396199
Processing commits: 722647
Matching commits to trees: 722647
Processing annotated tags: 534
Processing references: 539
| Name | Value | Level of concern |
| ---------------------------- | --------- | ------------------------------ |
| Overall repository size | | |
| * Commits | | |
| * Count | 723 k | * |
| * Total size | 525 MiB | ** |
| * Trees | | |
| * Count | 3.40 M | ** |
| * Total size | 9.00 GiB | **** |
| * Total tree entries | 264 M | ***** |
| * Blobs | | |
| * Count | 1.65 M | * |
| * Total size | 55.8 GiB | ***** |
| * Annotated tags | | |
| * Count | 534 | |
| * References | | |
| * Count | 539 | |
| | | |
| Biggest objects | | |
| * Commits | | |
| * Maximum size [1] | 72.7 KiB | * |
| * Maximum parents [2] | 66 | ****** |
| * Trees | | |
| * Maximum entries [3] | 1.68 k | * |
| * Blobs | | |
| * Maximum size [4] | 13.5 MiB | * |
| | | |
| History structure | | |
| * Maximum history depth | 136 k | |
| * Maximum tag depth [5] | 1 | |
| | | |
| Biggest checkouts | | |
| * Number of directories [6] | 4.38 k | ** |
| * Maximum path depth [7] | 13 | * |
| * Maximum path length [8] | 134 B | * |
| * Number of files [9] | 62.3 k | * |
| * Total size of files [9] | 747 MiB | |
| * Number of symlinks [10] | 40 | |
| * Number of submodules | 0 | |
대용량 바이너리 파일에 Git LFS 사용#
바이너리 파일(패키지, 오디오, 비디오, 그래픽 등)은 Git Large File Storage(Git LFS) 객체로 저장합니다.
사용자가 파일을 Git에 커밋하면 Git은 블롭
객체 유형으로 콘텐츠를 저장하고 관리합니다.
Git은 대용량 바이너리 데이터를 효율적으로 처리하지 못하므로 큰 블롭은 Git에 부담이 됩니다. git-sizer가
10MB가 넘는 블롭을 보고한다면 보통 리포지터리에 대용량 바이너리 파일이 있습니다. 대용량 바이너리 파일은
서버와 클라이언트 양쪽에 문제를 일으킵니다:
- 서버 측: 텍스트 기반 소스 코드와 달리 바이너리 데이터는 이미 압축된 경우가 많습니다. Git은 바이너리 데이터를 더 압축하지 못하므로 팩파일이 커집니다. 큰 팩파일은 생성과 전송에 더 많은 CPU, 메모리, 대역폭을 필요로 합니다.
- 클라이언트 측: Git은 블롭 콘텐츠를 팩파일(보통
.git/objects/pack/)과 일반 파일(워크트리) 양쪽에 저장합니다. 바이너리 파일은 텍스트 기반 소스 코드보다 훨씬 많은 공간을 차지합니다.
Git LFS는 객체를 오브젝트 스토리지 등 외부에 저장합니다. Git 리포지터리에는 바이너리 파일 자체가 아니라 객체 위치를 가리키는 포인터가 들어갑니다. 이렇게 하면 리포지터리 성능이 개선됩니다. 자세한 내용은 Git LFS 문서를 참고합니다.