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 데이터를 어떻게 파티션으로 분할하고 싶으신가요?
- 명시적인 논리 파티션 ID를 사용하는 이유
- 파티셔닝된 테이블 변경
- 대규모 파티션을 작은 파티션으로 분할
- 데이터베이스에 파티션 메타데이터 저장
- 파티션화를 사용한 시간 감소 패턴 구현
- 파티션화된 데이터 액세스
- 왜 프로젝트 또는 네임스페이스 ID를 사용하지 않나요?
- 파티션화하여 대기 중인 빌드 테이블 작성
- 리스크 감소를 위한 반복
- 반복
- 결론
- 누구
파이프라인 데이터 분할 설계
어떤 문제를 해결하려고 하는가?
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 데이터베이스 관련 생산 환경 사고가 많이 발생했습니다. 예를 들어:
- S1: 2022-03-17
ci_builds
테이블에서 쓰기 증가 - S1: 2021-11-22
ci_job_artifacts
복제본에서 과도한 버퍼 읽기 - S2: 2022-04-12 10분 이상 실행된 트랜잭션 감지
- S2: 2022-04-06
ci_builds
읽기로 인한 데이터베이스 경합 가능성 - S2: 2022-03-18
ci_builds
에서 외래 키를 제거할 수 없음 - S2: 2022-10-10
queuing_queries_duration
SLI apdex SLO 위반
약 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_id
및 source_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 번호를 연쇄적으로 사용할 수 있습니다. 파이프라인 12345
에 partition_id
가 102
로 있는 경우, 다른 경로 테이블의 번호 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_id
가 1
, 20
, 45
의 파티션을 생성하여 기존 레코드를 partition_id
를 100
에서 더 작은 숫자로 업데이트하여 해당 위치로 이동할 수 있습니다.
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_id
나 namespace_id
를 사용하여 파티션을 나누고 싶지 않습니다. 왜냐하면 샤딩(sharding) 및 팟팅(podding)은 애플리케이션의 다른 계층에서 해결해야 하는 다른 문제이기 때문입니다. 이것은 처음에는 거의 읽지 않는 데이터가 시간이 지남에 따라 성능이 더 나빠지는 원래 문제에 대한 해결책이 아닙니다. 미래에 팟을 도입하고, 데이터를 연관시키는 그룹이나 프로젝트를 기반으로 데이터를 분리하는 주요 메커니즘이 될 수도 있습니다.
이론적으로, 우리는 project_id
나 namespace_id
중 하나를 두 번째 파티션 차원으로 사용할 수 있지만, 이미 매우 복잡한 문제에 더 많은 복잡성을 추가할 것이며 원래 매우 복잡한 문제를 더 복잡하게 만들게 될 것입니다.
파티션화하여 대기 중인 빌드 테이블 작성
또한 우리는 대기 중인 빌드 테이블을 파티션화하고 싶습니다. 현재 ci_pending_builds
와 ci_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_name
을 p_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 쿼리는 대부분 변경되지 않을 것이지만, 데이터베이스 성능 저하가 발생할 경우 “제로 파티션”을 비상 분리할 수 있게 될 것입니다. 이것은 사용자들을 기존 데이터에서 분리시킬 수는 있지만, 응용 프로그램은 계속 가동되어 응용 프로그램 전체 다운타임에 비해 더 나은 대안이 될 것입니다.
- 단계 0: CI/CD 데이터 분할 전략 구축: 완료. ✅
-
단계 1: 6개의 가장 큰 CI/CD 데이터베이스 테이블 분할하기.
- 6개의 데이터베이스 테이블에 대한 분할된 스키마 생성.
- 모든 분할된 자원에
partition_id
를 연쇄적으로 적용하는 방법 설계. - 라우팅 테이블을 대상으로 하는지를 확인하는 초기 쿼리 분석기 구현.
- 분할된 데이터베이스 테이블에 제로 파티션 연결.
- 응용 프로그램을 라우팅 테이블과 분할된 테이블을 대상으로 하는 것으로 업데이트.
- 이 솔루션의 성능과 효율성 측정.
되돌리기 전략: 라우팅 테이블 대신 구체적인 파티션 사용으로 전환.
-
단계 2: SQL 쿼리에 파티션 키 추가하여 분할된 테이블을 대상으로 하는 쿼리 추가.
- 분할된 테이블을 대상으로 하는 쿼리가 올바른 파티션 키를 사용하는지 확인하는 쿼리 분석기 구현.
- 모든 기존 쿼리가 필터로 파티션 키를 사용하는지 확인하기 위해 기존 쿼리 수정.
되돌리기 전략: 피처 플래그, 쿼리별.
-
단계 3: 새로운 분할된 데이터 액세스 패턴 구축.
- 새로운 API를 구축하거나 기존 API를 확장하여 시간 감쇠 데이터 보존 정책에 따라 제외되어야 하는 파티션에 저장된 데이터에 액세스를 허용.
되돌리기 전략: 피처 플래그.
-
단계 4: 분할을 기반으로 한 시간 감쇠 메커니즘 도입.
- 시간 감쇠 정책 메커니즘 구축.
- GitLab.com에서 시간 감쇠 전략 활성화.
-
단계 5: 파티션을 자동으로 생성하는 메커니즘 도입.
- 파티션을 자동으로 생성할 수 있게 만듬.
- 새 아키텍처를 자체 관리 인스턴스에 전달.
아래 다이어그램은 Gantt 차트에서 이 계획을 시각화한 것입니다. 아래 차트의 날짜는 계획을 더 잘 시각화하기 위한 추정치일 뿐이며, 이는 기한이 아니며 언제든지 변경될 수 있습니다.
결론
CI/CD 데이터의 분할 전략을 확고하게 구축하고자 합니다. 멀티 테라바이트 PostgreSQL 인스턴스의 데이터베이스 스키마를 관리하는 과정에서의 실수는 잠재적인 다운타임 없이는 쉽게 되돌릴 수 없을 수도 있다는 점을 우리는 인식하고 있습니다. 이것이 우리가 파티셔닝 전략을 연구하고 정제하는 데 상당한 시간을 투자하는 이유입니다. 이 문서에 설명된 전략 또한 반복적인 과정의 대상입니다. 우리가 위험을 줄이고 계획을 개선할 수 있는 더 나은 방법을 찾을 때마다 이 문서를 업데이트해야 합니다.
대규모 데이터 이전을 피하는 방법을 찾아내었으며, CI/CD 데이터의 분할을 위한 반복적인 전략을 구축하고 있습니다. 우리는 다른 팀 멤버들로부터 피드백을 받기 위해 이곳에 우리 전략을 문서화했습니다.
누구
주체별 책임 담당자:
역할 | 담당자 |
---|---|
저자 | Grzegorz Bizon, 주요 엔지니어 |
추천자 | Kamil Trzciński, 시니어 공로 엔지니어 |
제품 리더십 | Jackie Porter, 제품 관리 이사 |
엔지니어링 리더십 | Caroline Simpson, 엔지니어링 매니저 / Cheryl Li, 시니어 엔지니어링 매니저 |
선도 엔지니어 | Marius Bobin, 시니어 백엔드 엔지니어 |
시니어 엔지니어 | Maxime Orefice, 시니어 백엔드 엔지니어 |
시니어 엔지니어 | Tianwen Chen, 시니어 백엔드 엔지니어 |