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
ongoing @grzesiek @ayufan @grzesiek @jreporter @cheryl.li devops verify 2022-05-31

파이프라인 데이터 분할 설계

어떤 문제를 해결하려고 하는가?

CI/CD 데이터셋을 분할하려는 이유는 몇몇 데이터베이스 테이블이 매우 크기 때문에 CI/CD 데이터베이스 분해를 출시한 후에도 단일 노드 읽기를 확장하는 데 어려움이 될 수 있기 때문입니다.

CI/CD 데이터베이스 선언적 분할을 사용하여 가장 큰 데이터베이스 테이블 중 일부를 더 작은 데이터베이스 테이블로 변환하여 데이터베이스 성능 저하의 위험을 줄이고자 합니다.

이 과정에 대한 더 자세한 내용은 상위 청사진에서 확인하세요.

파이프라인 데이터 시간 감쇠

CI/CD 데이터 분해, 분할 및 시간 감쇠의 관련성은 무엇인가요?

CI/CD 데이터 분해는 “주요” 데이터베이스 클러스터에서 CI/CD 데이터베이스 클러스터를 추출하여 다른 주 데이터베이스를 받는 것을 가능케 하기 위한 것입니다. 주요 이점은 적기와 데이터 저장에 대한 용량을 두 배로 하는 것입니다. 새로운 데이터베이스 클러스터에는 CI/CD 데이터베이스 테이블 이외에도 읽기/쓰기를 제공할 필요가 없으므로 읽기에 대한 추가적인 용량을 제공합니다.

CI/CD 분할은 큰 CI/CD 데이터베이스 테이블을 더 작은 단위로 나누는 것입니다. 이로써 모든 CI/CD 데이터베이스 노드에서의 읽기 용량이 향상될 것이며, 작은 테이블에서 데이터를 읽는 것은 대규모 테이블에서 데이터를 읽는 것보다 훨씬 비용 효율적입니다. SQL 쿼리 수가 증가함에 따라 CI/CD 데이터베이스 복제본을 더 추가할 수 있지만, 효율적인 단일 읽기를 수행하기 위해 분할이 필요합니다. 그 외에도 PostgreSQL은 매우 큰 데이터베이스 테이블을 유지보수하는 것보다 여러 개의 작은 테이블을 유지보수하는 것이 효율적이므로 다른 측면에서 성능이 향상될 것입니다.

CI/CD 시간 감쇠는 파이프라인 데이터의 강력한 시간 감쇠 특성을 활용할 수 있도록 합니다. 다양한 방법으로 구현할 수 있지만, 시간 감쇠를 구현할 때 분할을 사용하는 것이 특히 유익할 수 있습니다. 시간 감쇠를 구현할 때 보통 데이터를 보존 및 필요 없어진 경우 데이터베이스에서 다른 곳으로 이동시킵니다. 저희 데이터셋은 매우 크기 때문에(수십 테라바이트), 이러한 대량의 데이터를 이동하는 것은 어렵습니다. 분할을 사용하여 시간 감쇠를 구현할 때, 하나의 데이터베이스 테이블에서 단일 레코드를 업데이트하여 전체 분할(또는 분할 집합)을 보관할 수 있습니다. 데이터베이스 수준에서 시간 감쇠 패턴을 구현하는 가장 적은 비용이 드는 방법 중 하나입니다.

분해 및 분할 비교

왜 CI/CD 데이터를 분할해야 하나요?

CI/CD 데이터베이스 테이블에는 파이프라인, 빌드 및 아티팩트를 저장하는 테이블이 너무 크기 때문에 CI/CD 데이터를 분할해야 합니다. ci_builds 데이터베이스 테이블의 크기는 현재 약 2.5TB이며, 인덱스 크기는 약 1.4GB입니다. 이는 너무 많은 양이므로 100GB 최대크기 원칙을 위반합니다. 또한 이 숫자를 초과할 때 경고를 발생시키고자 합니다.

대규모 SQL 테이블은 새롭게 삭제된 튜플을 autovacuum에 의해 청소할 수 없는 색인 유지 시간을 증가시킵니다. 따라서 작은 테이블이 필요합니다. 매우 큰 테이블을 (재)인덱싱할 때 얼마나 많은 블로트가 누적되는지 측정할 것입니다. 이 분석을 토대로 (재)인덱싱과 관련된 SLO(죽은 튜플/블로트)를 설정할 수 있을 것입니다.

지난 몇 달 동안 S1 및 S2 데이터베이스 관련 생산 환경 사고가 많이 발생했습니다. 예를 들어:

약 50개의 ci_* 접두사가 붙은 데이터베이스 테이블이 있으며, 일부 테이블은 분할을 통해 이점을 얻을 수 있습니다.

이 데이터를 얻기 위한 간단한 SQL 쿼리는 다음과 같습니다:

WITH tables AS (SELECT table_name FROM information_schema.tables WHERE table_name LIKE 'ci_%')
  SELECT table_name,
    pg_size_pretty(pg_total_relation_size(quote_ident(table_name))) AS total_size,
    pg_size_pretty(pg_relation_size(quote_ident(table_name))) AS table_size,
    pg_size_pretty(pg_indexes_size(quote_ident(table_name))) AS index_size,
    pg_total_relation_size(quote_ident(table_name)) AS total_size_bytes
  FROM tables ORDER BY total_size_bytes DESC;

2022년 3월에 얻은 데이터:

테이블 이름 총 크기 인덱스 크기
ci_builds 3.5 TB 1 TB
ci_builds_metadata 1.8 TB 150 GB
ci_job_artifacts 600 GB 300 GB
ci_pipelines 400 GB 300 GB
ci_stages 200 GB 120 GB
ci_pipeline_variables 100 GB 20 GB
(…약 40개 더 있음)    

위의 테이블을 토대로 많은 양의 데이터가 있는 것을 알 수 있습니다.

CI/CD 관련 데이터베이스 테이블이 거의 50개나 되지만, 우리는 처음에는 6개의 테이블에만 분할을 적용하려고 합니다. 우리는 궁극적으로 나머지 테이블에 대한 분할 전략도 마련해야 합니다. 이 문서는 이 전략을 기록하고 가능한 많은 세부 정보를 서술하여 이 지식을 공유하기 위한 것입니다.

CI/CD 데이터를 어떻게 파티션으로 분할하고 싶으신가요?

반복적으로 CI/CD 테이블을 파티션으로 나누고 싶습니다. 초기 6개 테이블을 한꺼번에 모두 파티션으로 나누는 것은 현실적이지 않을 수 있으므로, 반복적인 전략이 필요할 수 있습니다. 나머지 데이터베이스 테이블을 파티션으로 나눌 필요가 있을 때 그에 대한 전략을 갖고 싶습니다.

또한 대규모 데이터 마이그레이션을 피하는 것이 중요합니다. 가장 큰 CI/CD 테이블에는 여러 가지 열과 인덱스에 걸쳐 거의 6테라바이트의 데이터가 저장되어 있습니다. 이러한 양의 데이터를 마이그레이션하는 것은 어려울 수 있으며 프로덕션 환경에서 불안정성을 일으킬 수 있습니다. 따라서 우리는 이 우려 때문에 첫 번째 컨셉 증명 중 하나에 이미 나타낸 대로 downtime 및 과도한 데이터베이스 잠금 없이 기존 데이터베이스 테이블을 파티션 제로로 첨부할 수 있는 방법을 개발했습니다. 이를 통해 파티션 제로로 첨부할 수 있는 파티션 스키마의 생성이 downtime 없이 가능해집니다 (예: 라우팅 테이블 p_ci_pipelines 사용) 그에 따라, 기존의 ci_pipelines 테이블을 파티션 제로로 첨부할 수 있게 됩니다. 이렇게 하면 이전 테이블을 평소처럼 사용할 수 있지만, 필요할 때마다 다음 파티션을 만들고 p_ci_pipelines 테이블을 라우팅 쿼리용으로 사용할 수 있습니다. 라우팅 테이블을 사용하기 위해서는 좋은 파티션 키를 찾아야 합니다.

우리의 계획은 논리적인 파티션 ID를 사용하는 것입니다. 우리는 ci_pipelines 테이블로 시작하고 partition_id 열을 생성하고 DEFAULT 값을 100 또는 1000으로 설정하고 싶습니다. DEFAULT 값을 사용함으로써 각 행에 대해 이 값을 백필링할 필요가 없다는 어려움을 피할 수 있습니다. 첫 번째 파티션을 첨부하기 전에 CHECK 제약 조건을 추가함으로써 PostgreSQL에 이미 일관성을 보장했음을 알리고, 이 테이블을 파티션으로 첨부할 때 전용 테이블 잠금을 보유하면서 해당 일치를 확인할 필요가 없음을 알릴 수 있습니다 (파티션 스키마 정의). 이 값을 계속 증가시키며 p_ci_pipelines를 위한 새로운 파티션을 만들 때마다 파티셔닝 전략은 “LIST” 파티셔닝이 될 것입니다.

또한 우리가 반복적으로 파티셔닝하고 싶어하는 초기 6개 데이터베이스 테이블에도 partition_id 열을 만들 것입니다. 새로운 파이프라인이 생성되면 partition_id가 할당되며 빌드 및 아티팩트와 같은 모든 관련 리소스는 동일한 값으로 공유할 것입니다. 우리는 이러한 데이터에 대한 백필링을 피할 수 있기 때문에 필요할 때 이러한 데이터를 파티셔닝하기로 결정할 수 있을 때 6개의 문제가 있는 테이블에 partition_id 열을 추가하고 싶습니다.

우리는 단계적으로 CI/CD 데이터를 파티션으로 나누고 싶습니다. 가장 빨리 성장하는 테이블이기 때문에 ci_builds_metadata 테이블로 시작할 계획입니다. 이 테이블은 가장 단순한 엑세스 패턴을 가지고 있으며, 빌드가 러너에 노출될 때 이 테이블의 행이 읽히고, 다른 엑세스 패턴도 상대적으로 간단합니다. 이처럼 p_ci_builds_metadata를 시작함으로써 더 빨리 구체적이고 정량적인 결과를 달성할 수 있을 것으로 기대하며, 가장 큰 테이블을 파티셔닝하는 새로운 패턴이 될 것입니다. LIST 파티셔닝 전략을 사용하여 빌드 메타데이터를 파티셔닝할 계획입니다.

p_ci_builds_metadata에 많은 파티션이 첨부되고 많은 partition_id가 사용될 때, 그 다음 CI 테이블을 다음과 같이 파티션으로 나누기로 결정할 것입니다. 그 경우 다음 테이블에 대해 RANGE 파티셔닝을 사용할 수 있습니다. p_ci_builds_metadata를 파티셔닝한 후에, ci_builds를 다음 분할 후보로 선택할 경우, ci_builds.partition_id에 여러 다른 값이 이미 저장되어 있을 것입니다. 이러한 경우, RANGE 파티셔닝을 사용하는 것이 더 쉬울 수 있습니다.

물리적 파티셔닝과 논리적 파티셔닝은 분리되어 있을 것이며, 각각의 데이터베이스 테이블에 대해 물리적 파티셔닝을 구현할 때 전략을 결정할 것입니다. 데이터베이스 테이블에서 LIST 파티셔닝을 사용하는 것과 유사하게 RANGE 파티셔닝을 사용하는 것도 작동하지만, partition_id 값의 연속성을 보장할 수 있기 때문에 RANGE 파티셔닝을 사용하는 것이 더 나은 전략일 수 있습니다.

멀티 프로젝트 파이프라인

부모-자식 파이프라인은 자식 파이프라인이 부모 파이프라인의 리소스로 간주되기 때문에 항상 같은 파티션에 있을 것입니다. 이들은 프로젝트 파이프라인 목록 페이지에서 개별적으로 볼 수 없습니다.

반면에, 멀티 프로젝트 파이프라인은 파이프라인 목록 페이지에서 볼 수 있습니다. 또한 trigger 토큰이나 작업 토큰을 사용하여 생성된 경우에는 파이프라인 그래프에서 downstream/upstream 링크로 액세스할 수 있습니다. 또한 다른 파이프라인에서 트리거 토큰을 사용하여 생성할 수도 있지만, 이 경우에는 소스 파이프라인을 저장하지는 않습니다.

ci_builds를 파티션으로 나눌 때 ci_sources_pipelines 테이블에 대한 외래 키를 업데이트해야 합니다:

외래 키 제약 조건:
    "fk_be5624bf37" FOREIGN KEY (source_job_id) REFERENCES ci_builds(id) ON DELETE CASCADE
    "fk_d4e29af7d7" FOREIGN KEY (source_pipeline_id) REFERENCES ci_pipelines(id) ON DELETE CASCADE
    "fk_e1bad85861" FOREIGN KEY (pipeline_id) REFERENCES ci_pipelines(id) ON DELETE CASCADE

ci_sources_pipelines의 레코드는 두 개의 ci_pipelines 행 (부모 및 자식)을 참조합니다. 우리의 보통 전략은 테이블에 partition_id를 추가하는 것이었지만, 여기에 추가하면 모든 멀티 프로젝트 파이프라인을 동일한 파티션의 일부로 만들어야 합니다.

이 테이블에는 두 개의 partition_id 열, 즉 partition_idsource_partition_id를 추가해야 합니다:

외래 키 제약 조건:
    "fk_be5624bf37" FOREIGN KEY (source_job_id, source_partition_id) REFERENCES ci_builds(id, source_partition_id) ON DELETE CASCADE
    "fk_d4e29af7d7" FOREIGN KEY (source_pipeline_id, source_partition_id) REFERENCES ci_pipelines(id, source_partition_id) ON DELETE CASCADE
    "fk_e1bad85861" FOREIGN KEY (pipeline_id, partition_id) REFERENCES ci_pipelines(id, partition_id) ON DELETE CASCADE

이 솔루션은 두 가지 다른 결정사항에 대한 것입니다:

  • 서로 다른 파티션에 있는 파이프라인을 참조할 수 있는 능력을 유지합니다.
  • 나중에 멀티 프로젝트 파이프라인을 동일한 파티션으로 강제하고 싶을 경우에는 두 열이 동일한 값을 가지도록하는 제약 조건을 추가할 수 있을 것입니다.

명시적인 논리 파티션 ID를 사용하는 이유

논리 partition_id를 사용하여 CI/CD 데이터를 파티셔닝하면 여러 가지 이점이 있습니다. 기본 키로 파티셔닝할 수 있지만, 이렇게 하면 데이터가 어떻게 구조화되고 파티션에 저장되는지 이해하는 데 필요한 복잡성과 인지 부담이 훨씬 더 커집니다.

CI/CD 데이터는 분류 데이터입니다. 스테이지는 파이프라인에 속하고, 빌드는 스테이지에 속하며, 아티팩트는 빌드에 속합니다(드물게는 예외도 있음). 따라서 우리는 이러한 계층 구조를 반영하는 파티셔닝 전략을 설계하여 기여자들의 복잡성과 그에 따른 인지적 부담을 줄입니다. 파이프라인에 연관된 레코드를 모두 검색할 때 명시적인 partition_id와 연결되어 있는 경우, 파티션 ID 번호를 연쇄적으로 사용할 수 있습니다. 파이프라인 12345partition_id102로 있는 경우, 다른 경로 테이블의 번호 102에 논리적인 파티션에 연관된 리소스를 항상 찾을 수 있다는 것을 알 수 있습니다. 또한 PostgreSQL은 각 테이블의 레코드가 어떤 파티션에 저장되어 있는지 알고 있을 것입니다.

파이프라인 데이터에 대해 단일하고 증분식 partition_id 번호를 사용하는 것은 기본 키를 기반으로 한 파티셔닝보다 나중에 더 많은 선택지를 제공합니다.

파티셔닝된 테이블 변경

파티셔닝된 테이블에 대해 여전히 ALTER TABLE 문을 실행할 수 있습니다. PostgreSQL이 부모 파티셔닝된 테이블에 대해 ALTER TABLE 문을 실행하면 모든 하위 파티션에 동일한 잠금을 얻고 동기화를 유지하기 위해 갱신합니다. 이는 파티셔닝되지 않은 테이블에 ALTER TABLE을 실행하는 것과 몇 가지 중요한 점에서 다릅니다.

  • PostgreSQL은 더 많은 테이블에 대해 ACCESS EXCLUSIVE 잠금을 획득하지만 테이블이 파티셔닝되지 않은 경우보다 더 많은 데이터를 획득하지 않습니다. 각 파티션은 부모 테이블과 유사하게 잠겨지며 모두 한 트랜잭션에서 한꺼번에 업데이트됩니다.
  • 잠금 기간은 관련된 파티션의 수에 따라 증가합니다. GitLab 데이터베이스에서 실행되는 모든 ALTER TABLE 문(구성 제약 조건 확인을 제외한)은 수정된 테이블 당 작은 상수 시간이 소요됩니다. PostgreSQL은 각 파티션을 순차적으로 수정해야 하므로 잠금 실행 시간이 증가합니다. 이 시간은 많은 파티션이 포함될 때까지 매우 짧게 유지됩니다.
  • 만약 수천 개의 파티션이 ALTER TABLE에 관여한다면, 우리는 작업 중에 가져야하는 모든 잠금을 지원할 수 있는 max_locks_per_transaction의 값을 확인해야 합니다.

대규모 파티션을 작은 파티션으로 분할

우리는 초기 partition_id 번호로 100부터 시작하려고 합니다(또는 계산 및 추정에 따라 1000 등 더 높은 숫자로 시작할 수도 있습니다). 기존 테이블도 이미 크기 때문에 1부터 시작하고 이를 작은 파티션으로 분할할 수도 있습니다. 100부터 시작하면 partition_id1, 20, 45의 파티션을 생성하여 기존 레코드를 partition_id100에서 더 작은 숫자로 업데이트하여 해당 위치로 이동할 수 있습니다.

PostgreSQL은 이러한 레코드를 일관된 방식으로 해당 파티션으로 이동시킬 것이며, 모든 파이프라인 리소스에 대해 동일한 트랜잭션으로 수행한다면 대규모 파티션을 작은 파티션으로 이동시키는 경우에는 배경 마이그레이션을 사용하거나 직접 파티션 ID를 업데이트하는 것으로 충분할 수 있습니다.

명명 규칙

파티셔닝된 테이블을 경로 라우팅(routing) 테이블이라고 하며 p_ 접두어를 사용하여 쿼리 분석을 위한 자동화된 도구를 작성하는 데 도움이 될 것입니다.

테이블 파티션은 파티션이라고 하며 물리적 파티션 ID를 접미사로 사용할 수 있으며, 예를 들어 ci_builds_101입니다. 기존 CI 테이블은 새로운 routing 테이블의 제로 파티션(zero partitions)이 될 것입니다. 특정 테이블의 선택한 파티셔닝 전략에 따라 한 물리적 파티션 당 여러 논리적 파티션이 있을 수 있습니다.

첫 번째 파티션 연결 및 잠금 획득

우리는 테이블 파티셔닝을 처음 실행할 때 PostgreSQL이 외래 키를 통해 참조하는 테이블과 함께 테이블에 AccessExclusiveLock이 필요하다는 것을 배웠습니다. 이는 마이그레이션이 응용 프로그램 비즈니스 로직의 다른 순서로 잠금을 획득하려고 시도할 경우 교착 상태를 일으킬 수 있습니다.

이 문제를 해결하기 위해 우선 순위 잠금 전략을 도입하여 추가적인 교착 상황 오류를 피할 수 있습니다. 이를 통해 잠금 순서를 정의하고 잠금을 획득하거나 재시도가 더 이상 불가능할 때까지 적극적으로 재시도할 수 있습니다. 이 과정은 최대 40분이 소요될 수 있습니다.

이 전략을 통해 우리는 00:00 UTC 이후에 저 트래픽 기간 동안 ci_builds 테이블에 대한 잠금을 15번의 재시도 끝에 성공적으로 획득했습니다.

우리의 파티션 도구에서 이 전략의 예시를 확인하세요.

파티션 단계

데이터베이스 파티션 툴링 문서에는 테이블을 파티션으로 나누는 단계가 나열되어 있지만, 이러한 단계만으로는 저희의 반복적인 전략에는 충분하지 않습니다. 데이터셋이 계속 성장함에 따라 파티션 성능을 즉시 활용하고 모든 테이블이 파티션화될 때까지 기다리지 않으려 합니다. 예를 들어 ci_builds_metadata 테이블을 파티션으로 나눈 후에는 새로운 파티션으로부터 데이터를 쓰고 읽기를 시작하고자 합니다. 이는 partition_id 값을 기본값인 100에서 101로 증가시키는 것을 의미합니다. 이제 파이프라인 계층의 새로운 리소스는 모두 partition_id = 101로 지속될 것입니다. 다음으로 파티션화될 테이블에 대해 데이터베이스 툴링 지침을 계속 따를 수 있지만, 몇 가지 추가 단계가 필요합니다.

  • 대부분의 레코드가 해당 값을 가질 것으로 기대되는 FK 참조를 위한 partition_id 열 추가 (기본값은 100)
  • partition_id 값을 연쇄시키기 위해 응용 프로그램 로직 변경
  • 가장 최근 레코드에 대해 partition_id 값을 수정하는 배포 후/백그라운드 마이그레이션을 통한 수정 (예: 아래와 유사한 방식):

    UPDATE ci_pipeline_metadata
           SET partition_id = ci_pipelines.partition_id
           FROM ci_pipelines
                WHERE ci_pipelines.id = ci_pipeline_metadata.pipeline_id
                  AND ci_pipelines.partition_id in (101, 102);
    
  • 외래 키 정의 변경

데이터베이스에 파티션 메타데이터 저장

새로운 파티션을 생성하는데 책임이 있는 효율적인 메커니즘을 구축하고 시간 감소를 구현하기 위해 파티션 메타데이터 테이블인 ci_partitions을 도입하고자 합니다. 해당 테이블에는 많은 파이프라인이 있는 모든 논리적 파티션에 대한 메타데이터가 저장될 것입니다. 논리적 파티션 당 파이프라인 ID 범위를 저장해야 할 수도 있습니다. 이를 통해 특정 파이프라인 ID에 대한 partition_id 번호를 찾을 수 있을 뿐만 아니라 “활성” 또는 “보관된” 상태인 논리적 파티션에 대한 정보도 찾을 수 있어서 데이터베이스 선언적 파티션을 사용하여 시간 감소 패턴을 구현하는 데 도움이 될 것입니다.

이렇게 함으로써 파티션화된 리소스에 대한 통합 리소스 식별자를 사용할 수 있게 되며, 이를 통해 리소스가 저장된 파티션을 효율적으로 조회할 수 있습니다. UI나 API에서 리소스를 직접 참조할 수 있는 경우 이것이 중요할 수 있습니다. 파이프라인 123456, 빌드 23456를 위해 1e24-5ba0와 같이 ID를 사용할 수 있습니다. 하이픈 -을 사용하면 ID가 마우스 더블클릭으로 하이라이트되고 복사되는 것을 방지할 수 있습니다. 이 문제를 피하고 싶다면, 16진수 숫자 체계에 없는 어떤 문자를 사용할 수 있습니다. 라틴 알파벳의 g에서 z 사이의 문자 중 하나, 예를 들어 x를 사용할 수 있습니다. 이 경우 URI의 예는 1e240x5ba0과 같을 것입니다. 파티션화된 리소스의 기본 식별자를 업데이트하려면 (현재는 대규모 정수만 사용됨) 파티션 간 데이터 마이그레이션시 식별자를 변경하지 않도록 하는 시스템을 설계하는 것이 중요합니다.

ci_partitions 테이블은 파티션 식별자, 유효한 파이프라인 ID 범위, 해당 파티션이 보관된 상태인지 여부에 대한 정보를 저장할 것입니다. 타임스탬프와 관련된 추가적인 열을 추가하는 것도 도움이 될 것입니다.

파티션화를 사용한 시간 감소 패턴 구현

선언적 파티션을 사용하여 ci_partitions를 활용하여 시간 감소 패턴을 구현할 수 있습니다. PostgreSQL에게 어떤 논리적 파티션이 보관되었는지 알리는 것으로 이를 통해 다음과 같은 SQL 쿼리를 사용하여 이러한 파티션에서 읽기를 중지할 수 있습니다.

SELECT * FROM ci_builds WHERE partition_id IN (
  SELECT id FROM ci_partitions WHERE active = true
);

이 쿼리를 통해 우리는 읽을 파티션의 수를 제한함으로써 “보관된” 파이프라인 데이터에 대한 액세스를 제어할 수 있을 것이며, CI/CD 데이터의 데이터 보존 정책에 따라 PostgreSQL에 저장된 “보관된” 데이터의 비용을 크게 줄일 수 있습니다. 이상적으로 우리는 한 번에 두 개 이상의 파티션에서 읽지 않으려 하므로 자동 파티셔닝 메커니즘을 시간 감소 정책에 맞춰야 합니다. 여전히 보관된 데이터에 대한 새로운 액세스 패턴을 구현해야 할 것으로 보이며, 아마도 API를 통해 구현해야 할 것입니다. 그러나 이를 통해 PostgreSQL에 저장된 보관된 데이터의 비용을 크게 줄일 수 있을 것입니다.

여기에는 이 설명의 범위를 벗어나는 몇 가지 기술적 세부 사항이 있지만, 이러한 전략을 사용함으로써 데이터를 “보관”할 수 있게 되며, 불리언 열 값을 전환함으로써 PostgreSQL 클러스터에 더 적은 비용으로 보관될 수 있게 될 것입니다.

파티션화된 데이터 액세스

GitLab의 대부분의 곳에서 보관된 여부에 상관없이 파티션화된 데이터에 액세스할 수 있을 것입니다. 병합 요청 페이지에서는 병합 요청이 여러 년 전에 만들어졌더라도 파이프라인 세부 정보를 항상 표시할 것입니다. 이는 ci_partitions가 파이프라인 ID와 해당 partition_id를 연관시키는 룩업 테이블이기 때문에 가능할 것이며, 파이프라인 데이터가 저장된 파티션을 찾을 수 있을 것입니다.

파이프라인, 빌드, 아티팩트 등을 통해 검색을 제한할 필요가 있을 것입니다. 검색을 모든 파티션을 통해 수행할 수 없으므로 보관된 파이프라인 데이터를 검색하기 위한 더 나은 방법을 찾아야 할 것입니다. UI 및 API에서 보관된 데이터에 액세스하기 위한 다른 액세스 패턴을 가져야 할 것입니다.

PostgreSQL에서 partition_id 파티션 키의 사용을 강제하는 데 일부 기술적인 어려움이 있습니다. 이를 지원하기 위해 애플리케이션을 업데이트하는 것을 더 쉽게 만들기 위해 우리는 우리의 증명-of-concept 병합 요청에서 새로운 쿼리 분석기를 설계했습니다. 이를 통해 파티션 키를 사용하지 않는 쿼리를 찾는 데 도움이 되었습니다.

별도의 증명-of-concept 병합 요청관련 이슈에서 일관된 partition_id 사용이 가능해짐으로써 Rails 관련 조인을 추가하여 SQL 쿼리에 파티션 키를 제공할 수 있도록 하는 것을 시연했습니다.

이 접근 방식의 문제는 인스턴스 의존적인 관계를 사용하기 때문에 사전로드를 훨씬 더 어렵게 만든다는 것입니다.

ArgumentError: The association scope 'builds' is instance dependent (the
scope block takes an argument). Preloading instance dependent scopes is not
supported.

쿼리 분석기

우리는 분할된 테이블에서 계속 작동하도록 수정해야 하는 쿼리를 감지하기 위해 2가지 쿼리 분석기를 구현했습니다.

  • 라우팅 테이블을 통과하지 않는 쿼리를 감지하는 분석기
  • WHERE 절에서 partition_id를 지정하지 않고 라우팅 테이블을 사용하는 쿼리를 감지하는 분석기

먼저 기존에 깨진 쿼리를 감지하기 위해 첫 번째 분석기를 test 환경에서 활성화했습니다. 확장 가능성 때문에 production 환경에서도 활성화되었지만 일부 트래픽(0.1%)에 대해서만 활성화되었습니다.

두 번째 분석기는 향후 반복에서 활성화될 예정입니다.

기본 키

기본 키는 테이블을 분할하기 위해 분할 키 열을 포함해야 합니다.

우리는 먼저 (id, partition_id)를 포함하는 고유한 인덱스를 만듭니다. 그런 다음, 기본 키 제약을 삭제한 후 새로 생성된 인덱스를 사용하여 새 기본 키 제약을 설정합니다.

ActiveRecord는 복합 기본 키를 지원하지 않기 때문에, id 열을 기본 키로 취급하도록 강제해야 합니다:

class Model < ApplicationRecord
  self.primary_key = 'id'
end

응용 프로그램 레이어는 이제 데이터베이스 구조를 알지 못하며 기존의 ActiveRecord 쿼리는 데이터에 액세스하기 위해 계속해서 id 열을 사용합니다. 이 접근 방식에는 id 값이 같은 중복 모델을 결과로 얻을 수 있는 응용 프로그램 코드를 구성할 수 있기 때문에 일부 리스크가 있지만, partition_id 값을 포함하여 액세스 패턴을 다시 작성하고 중복 모델을 방지하기 위해 모든 삽입이 데이터베이스 시퀀스를 사용하여 id를 생성하도록 보장해야 합니다. 삽입 중에 id를 수동으로 할당하는 것을 피해야합니다.

외래 키

외래 키는 기본 키이거나 고유 제약 조건을 형성하는 열을 참조해야 합니다. 이를 사용하여 정의할 수 있는 전략은 다음과 같습니다:

같은 파티션 ID를 공유하는 라우팅 테이블 사이

동일한 파이프라인 계층 구조의 일부인 관계에 대해 partition_id 열을 공유하여 외래 키 제약 조건을 정의할 수 있습니다:

p_ci_pipelines:
 - id
 - partition_id

p_ci_builds:
 - id
 - partition_id
 - pipeline_id

이 경우, p_ci_builds.partition_id는 빌드와 함께 파티션을 나타내며 파이프라인에도 적용됩니다. 라우팅 테이블에 FK를 추가할 수 있습니다:

ALTER TABLE ONLY p_ci_builds
    ADD CONSTRAINT fk_on_pipeline_and_partition
    FOREIGN KEY (pipeline_id, partition_id)
    REFERENCES p_ci_pipelines(id, partition_id) ON DELETE CASCADE;

다른 파티션 ID를 가진 라우팅 테이블 간

모든 CI 도메인의 관계에 partition_id를 재사용하는 것은 불가능하기 때문에, 여기서는 값이 다른 속성으로 저장해야 합니다. 예를 들어, 중복되는 파이프라인을 취소할 때 이전 파이프라인 행에 이를 취소한 새 파이프라인의 ID를 auto_canceled_by_id로 저장합니다:

p_ci_pipelines:
 - id
 - partition_id
 - auto_canceled_by_id
 - auto_canceled_by_partition_id

이 경우, 우리는 취소하는 파이프라인이 취소된 파이프라인과 동일한 계층에 속하지 않을 수 있으므로 auto_canceled_by_partition_id에 그 파티션을 저장해야 하며, FK는 다음과 같습니다:

ALTER TABLE ONLY p_ci_pipelines
    ADD CONSTRAINT fk_cancel_redundant_pipelines
    FOREIGN KEY (auto_canceled_by_id, auto_canceled_by_partition_id)
    REFERENCES p_ci_pipelines(id, partition_id) ON DELETE SET NULL;

라우팅 테이블과 일반 테이블 간

모든 CI 도메인의 테이블이 분할되는 것은 아니기 때문에, 비분할 테이블을 참조하는 라우팅 테이블이 있을 것입니다. 예를 들어, ci_pipelines에서 external_pull_requests를 참조합니다:

FOREIGN KEY (external_pull_request_id)
REFERENCES external_pull_requests(id)
ON DELETE SET NULL

이 경우, 새로운 파이프라인 파티션에서 사용할 수 있도록 외래 키 정의를 분할 수준에서 라우팅 테이블로 이동하면 됩니다:

ALTER TABLE p_ci_pipelines
  ADD CONSTRAINT fk_external_request
  FOREIGN KEY (external_pull_request_id)
  REFERENCES external_pull_requests(id) ON DELETE SET NULL;

일반 테이블과 라우팅 테이블 간

CI 도메인의 대부분 테이블은 적어도 하나의 라우팅 테이블을 참조할 것입니다. 예를 들어 ci_pipeline_messages에서 ci_pipelines를 참조합니다. 이러한 정의는 라우팅 테이블을 사용하도록 업데이트해야 하므로 partition_id 열이 필요합니다:

p_ci_pipelines:
 - id
 - partition_id

ci_pipeline_messages:
 - id
 - pipeline_id
 - pipeline_partition_id

이를 사용하여 외래 키를 정의할 수 있습니다:

ALTER TABLE ci_pipeline_messages ADD CONSTRAINT fk_pipeline_partitioned
  FOREIGN KEY (pipeline_id, pipeline_partition_id)
  REFERENCES p_ci_pipelines(id, partition_id) ON DELETE CASCADE;

기존의 FK 정의를 제거해야하며, 그렇지 않으면 0이 아닌 파티션의 파이프라인 ID로 ci_pipeline_messages에 새로운 삽입이 참조 오류로 실패할 것입니다.

색인

우리는 알아낸 바에 따르면 PostgreSQL은 테이블의 모든 파티션을 대상으로 한 하나의 인덱스(고유 또는 그 외)를 생성하는 것을 허용하지 않는다.

이 문제를 해결하는 한 가지 방법은 고유성 제약 조건 내부에 파티션 키를 포함시키는 것입니다.

이는 미래에 가질 수 있는 최대한 많은 파티션을 수용하기 위해 토큰 자체 앞에 16진수 형식으로 파티션 ID를 붙이고 이를 데이터베이스에 저장하는 것을 의미할 수 있습니다. 이를 위해 미래에 가질 수 있는 최대한 많은 파티션 수를 고려하여 토큰에 적절한 수의 선행 바이트를 예약해야 할 것입니다. 16진수로 16비트 숫자가 되는 4자릿수를 예약하는 것이 적당해 보입니다. 이 방법으로 인코딩할 수 있는 최대 숫자는 FFFF이며, 이는 십진수로 65535입니다.

이렇게 하면 각 파티션별로 고유한 제약 조건이 생기게 되고, 이것이 전역적인 고유성에 충분합니다.

또한, 우리는 질의 분석기를 설계하여 바로 사용되지 않는 파티션, 레거시 테이블, 파티션 라우팅 테이블에 첨부된 첫 번째 파티션을 감지할 수 있는 기능을 개발했습니다. 이를 통해 모든 쿼리가 파티션화된 스키마나 파티션화된 라우팅 테이블, 예를 들어 p_ci_pipelines,를 대상으로 하도록 보장하고 있습니다.

왜 프로젝트 또는 네임스페이스 ID를 사용하지 않나요?

우리는 project_idnamespace_id를 사용하여 파티션을 나누고 싶지 않습니다. 왜냐하면 샤딩(sharding) 및 팟팅(podding)은 애플리케이션의 다른 계층에서 해결해야 하는 다른 문제이기 때문입니다. 이것은 처음에는 거의 읽지 않는 데이터가 시간이 지남에 따라 성능이 더 나빠지는 원래 문제에 대한 해결책이 아닙니다. 미래에 팟을 도입하고, 데이터를 연관시키는 그룹이나 프로젝트를 기반으로 데이터를 분리하는 주요 메커니즘이 될 수도 있습니다.

이론적으로, 우리는 project_idnamespace_id 중 하나를 두 번째 파티션 차원으로 사용할 수 있지만, 이미 매우 복잡한 문제에 더 많은 복잡성을 추가할 것이며 원래 매우 복잡한 문제를 더 복잡하게 만들게 될 것입니다.

파티션화하여 대기 중인 빌드 테이블 작성

또한 우리는 대기 중인 빌드 테이블을 파티션화하고 싶습니다. 현재 ci_pending_buildsci_running_builds 두 개의 테이블이 있습니다. 이러한 테이블은 다른 CI/CD 데이터 테이블과는 다르며, 제품에 비즈니스 규칙이 존재하여 해당 테이블에 저장된 모든 데이터가 24시간 후에 무효화됩니다.

따라서 이러한 데이터베이스 테이블을 파티션화하기 위해 다른 전략을 사용해야 합니다. 이는 이러한 파티션을 24시간 이상이 되면 완전히 제거하고, 항상 두 개의 파티션으로부터 읽어야 한다는 것을 의미합니다. 이러한 테이블을 파티션하기 위한 전략은 잘 이해되어 있지만, 이러한 파티션의 생성 및 삭제를 관리하기 위한 견고한 루비 기반의 자동화가 필요합니다. 이를 위해 우리는 데이터베이스 팀과 협력하여 기존 데이터베이스 파티셔닝 도구를 CI/CD 데이터 파티셔닝을 지원하도록 조정할 것입니다.

리스크 감소를 위한 반복

이 전략은 CI/CD 파티셔닝의 구현 리스크를 받아들일 수 있는 수준으로 감소시켜야 합니다. 또한 가장 먼저 두 파티션만으로부터 읽기만 가능하도록 파티셔닝을 구현하고, 문제가 발생한 경우 제로 파티션을 분리할 수 있도록 만드는 데 초점을 맞추고 있습니다. 아래에 설명된 각 반복 단계에는 되돌리기 전략이 있으며, 데이터베이스 변경을 배포하기 전에는 벤치마킹 환경에서 테스트하고자 합니다.

이를 통해 이러한 노력의 경우 리스크를 줄이는 주요 방법은 반복적인 지원과 일부를 되돌릴 수 있게 만드는 것입니다. 이 문서에 설명된 변경 사항들을 안전하고 신뢰할 수 있게 배포하는 것이 우리의 우선 순위입니다.

이러한 구현과 함께 더 많은 설계 반복 방법을 찾고, 점진적인 배포를 지원하고 어떠한 문제가 발생했을 경우 변경을 되돌리는 것에 더 많은 제어권을 갖고자 할 것입니다. 경우에 따라 데이터베이스 스키마 변경을 점진적으로 배포하는 것은 어려울 수 있으며, 생산 환경으로의 점진적인 배포를 지원하는 것은 더욱 어렵습니다. 그러나 가능하며 이를 위해서 추가적인 창의력이 필요할 것입니다. 이것이 어떻게 보이게 될 수 있는지에 대한 몇 가지 예시는 아래와 같습니다:

파티션화된 스키마의 점진적인 배포

첫 번째 파티션화된 라우팅 테이블(아마 p_ci_pipelines)을 도입하고 그것의 제로 파티션(ci_pipelines)을 연결하게 되면, 우리는 구체적인 파티션 제로 대신 새로운 라우팅 테이블과 상호 작용을 시작해야 할 것입니다. 보통 Ci::Pipeline 레일 모델이 사용할 데이터베이스 테이블을 self.table_name = 'p_ci_pipelines'와 같이 재정의할 것입니다. 불행히도, 이 방식은 점진적인 배포를 지원하지 않을 수 있으며, self.table_name은 애플리케이션 부팅 시에 읽히며, 나중에 응용 프로그램을 다시 시작하지 않으면이 변경 사항을 되돌릴 수 없을 수도 있습니다.

이러한 문제를 해결하는 한 가지 방법은 Ci::Partitioned::Pipeline 모델을 소개하는 것입니다. 이 모델은 Ci::Pipeline을 상속할 것이며, self.table_namep_ci_pipeline로 설정하고 그의 메타 클래스를 Ci::Pipeline.partitioned로 반환할 것입니다. 이렇게 하면 간단한 되돌리기 전략을 사용하여 ci_pipelines에서 p_ci_pipelines로의 읽기를 feature flags를 사용하여 라우팅할 수 있을 것입니다.

파티션화된 읽기의 점진적인 실험

다른 예는 또 다른 파티션을 연결하기로 결정한 시기와 관련이 있을 것입니다. 단계 1의 목표는 파티션화된 스키마/라우팅 테이블 당 두 개의 파티션을 가지는 것을 의미합니다. 따라서 p_ci_pipelines에 대해 ci_pipelines가 제로 파티션으로 첨부되고, 새로운 ci_pipelines_p1 파티션이 새 데이터를 위해 생성된다는 것을 의미합니다. p_ci_pipelines로부터의 모든 읽기는 p1 파티션에서의 데이터도 읽어야 하며, 우리는 또한 두 개 이상의 파티션을 대상으로하는 읽기에 대해 점진적인 실험을 진행하여 파티션화의 성능과 오버헤드를 평가해야 합니다.

우리는 이를 위해서 old 데이터를 반복적으로 ci_pipelines_m1 (마이너스 1) 파티션으로 이동함으로써 할 수 있을 것입니다. 아마 partition_id = 1을 생성하고 매우 오래된 파이프라인을 거기에 이동시킬 수 있을 것입니다. 그러면 우리는 m1 파티션으로 데이터를 반복적으로 이동하여 영향, 성능 및 새로운 파티션 p1을 생성하기 전에 신뢰도를 높일 것입니다.

반복

먼저 단계 1 반복에 중점을 두고 싶습니다. 이 반복의 목표와 주요 목적은 가장 큰 6개의 CI/CD 데이터베이스 테이블을 6개의 라우팅 테이블(분할된 스키마)과 12개의 파티션으로 분할하는 것입니다. 이로써 Rails SQL 쿼리는 대부분 변경되지 않을 것이지만, 데이터베이스 성능 저하가 발생할 경우 “제로 파티션”을 비상 분리할 수 있게 될 것입니다. 이것은 사용자들을 기존 데이터에서 분리시킬 수는 있지만, 응용 프로그램은 계속 가동되어 응용 프로그램 전체 다운타임에 비해 더 나은 대안이 될 것입니다.

  1. 단계 0: CI/CD 데이터 분할 전략 구축: 완료. ✅
  2. 단계 1: 6개의 가장 큰 CI/CD 데이터베이스 테이블 분할하기.
    1. 6개의 데이터베이스 테이블에 대한 분할된 스키마 생성.
    2. 모든 분할된 자원에 partition_id를 연쇄적으로 적용하는 방법 설계.
    3. 라우팅 테이블을 대상으로 하는지를 확인하는 초기 쿼리 분석기 구현.
    4. 분할된 데이터베이스 테이블에 제로 파티션 연결.
    5. 응용 프로그램을 라우팅 테이블과 분할된 테이블을 대상으로 하는 것으로 업데이트.
    6. 이 솔루션의 성능과 효율성 측정.

    되돌리기 전략: 라우팅 테이블 대신 구체적인 파티션 사용으로 전환.

  3. 단계 2: SQL 쿼리에 파티션 키 추가하여 분할된 테이블을 대상으로 하는 쿼리 추가.
    1. 분할된 테이블을 대상으로 하는 쿼리가 올바른 파티션 키를 사용하는지 확인하는 쿼리 분석기 구현.
    2. 모든 기존 쿼리가 필터로 파티션 키를 사용하는지 확인하기 위해 기존 쿼리 수정.

    되돌리기 전략: 피처 플래그, 쿼리별.

  4. 단계 3: 새로운 분할된 데이터 액세스 패턴 구축.
    1. 새로운 API를 구축하거나 기존 API를 확장하여 시간 감쇠 데이터 보존 정책에 따라 제외되어야 하는 파티션에 저장된 데이터에 액세스를 허용.

    되돌리기 전략: 피처 플래그.

  5. 단계 4: 분할을 기반으로 한 시간 감쇠 메커니즘 도입.
    1. 시간 감쇠 정책 메커니즘 구축.
    2. GitLab.com에서 시간 감쇠 전략 활성화.
  6. 단계 5: 파티션을 자동으로 생성하는 메커니즘 도입.
    1. 파티션을 자동으로 생성할 수 있게 만듬.
    2. 새 아키텍처를 자체 관리 인스턴스에 전달.

아래 다이어그램은 Gantt 차트에서 이 계획을 시각화한 것입니다. 아래 차트의 날짜는 계획을 더 잘 시각화하기 위한 추정치일 뿐이며, 이는 기한이 아니며 언제든지 변경될 수 있습니다.

gantt title CI 데이터 분할 타임라인 dateFormat YYYY-MM-DD axisFormat %m-%y section 단계 0 데이터 분할 전략 구축 :완료, 0_1, 2022-06-01, 90일 section 단계 1 가장 큰 CI 테이블 분할 :1_1, after 0_1, 200일 가장 큰 테이블 분할됨 :milestone, 메타데이터, 2023-03-01, 1분 100GB 이상의 테이블 분할됨 :milestone, 100gb, after 1_1, 1분 section 단계 2 SQL 쿼리에 파티션 키 추가 :2_1, 2023-01-01, 120일 비상 파티션 분리 가능 :milestone, 분리, 2023-04-01, 1분 모든 SQL 쿼리가 파티션으로 라우팅 :milestone, 라우팅, after 2_1, 1분 section 단계 3 새로운 데이터 액세스 패턴 구축 :3_1, 2023-05-01, 120일 비활성 데이터용으로 새로운 API 엔드포인트 생성 :milestone, api1, 2023-07-01, 1분 기존 API 엔드포인트에 필터 추가 :milestone, api2, 2023-09-01, 1분 section 단계 4 시간 감쇠 메커니즘 도입 :4_1, 2023-08-01, 120일 비활성 파티션이 읽히지 않음 :milestone, part1, 2023-10-01, 1분 데이터 클러스터의 성능 향상 :milestone, part2, 2023-11-01, 1분 section 단계 5 자동 파티션 생성 메커니즘 도입 :5_1, 2023-09-01, 120일 새로운 파티션을 자동으로 생성 가능 :milestone, part3, 2023-12-01, 1분 파티션을 자체 관리에서 사용 가능하도록 만듬 :milestone, part4, 2024-01-01, 1분

결론

CI/CD 데이터의 분할 전략을 확고하게 구축하고자 합니다. 멀티 테라바이트 PostgreSQL 인스턴스의 데이터베이스 스키마를 관리하는 과정에서의 실수는 잠재적인 다운타임 없이는 쉽게 되돌릴 수 없을 수도 있다는 점을 우리는 인식하고 있습니다. 이것이 우리가 파티셔닝 전략을 연구하고 정제하는 데 상당한 시간을 투자하는 이유입니다. 이 문서에 설명된 전략 또한 반복적인 과정의 대상입니다. 우리가 위험을 줄이고 계획을 개선할 수 있는 더 나은 방법을 찾을 때마다 이 문서를 업데이트해야 합니다.

대규모 데이터 이전을 피하는 방법을 찾아내었으며, CI/CD 데이터의 분할을 위한 반복적인 전략을 구축하고 있습니다. 우리는 다른 팀 멤버들로부터 피드백을 받기 위해 이곳에 우리 전략을 문서화했습니다.

누구

주체별 책임 담당자:

역할 담당자
저자 Grzegorz Bizon, 주요 엔지니어
추천자 Kamil Trzciński, 시니어 공로 엔지니어
제품 리더십 Jackie Porter, 제품 관리 이사
엔지니어링 리더십 Caroline Simpson, 엔지니어링 매니저 / Cheryl Li, 시니어 엔지니어링 매니저
선도 엔지니어 Marius Bobin, 시니어 백엔드 엔지니어
시니어 엔지니어 Maxime Orefice, 시니어 백엔드 엔지니어
시니어 엔지니어 Tianwen Chen, 시니어 백엔드 엔지니어