프로젝트 게이팅 (Project Gating)

전통적으로 많은 소프트웨어 개발 프로젝트는 개발자의 변경 사항을 저장소에 병합(merge)한 다음, 해당 변경 사항으로 인해 발생하는 회귀(regressions)를 식별하고(아마도 지속적 통합 시스템으로 테스트 스위트를 실행하여), 그 버그들을 수정하기 위해 더 많은 패치를 후속으로 적용합니다. 개발 메인라인(mainline)이 손상되면 개발자들에게 매우 좌절감을 줄 수 있으며, 특히 기여자나 기여 건수가 많을 때 생산성 저하를 초래할 수 있습니다.

게이팅(gating) 프로세스는 회귀를 유발하는 변경 사항이 병합되는 것을 방지하고자 합니다. 이를 통해 개발 메인라인을 열어두고 모든 개발자가 정상적으로 작업할 수 있도록 유지하며, 변경 사항이 문제없이 작동하는 것으로 확인되었을 때만 병합합니다.

많은 프로젝트에서 메인라인 커밋 권한을 가진 개발자가 변경 사항을 병합하기 전에 테스트 스위트가 실행되도록 보장하는 비공식적인 게이팅 방식을 실행합니다. 하지만 개발자가 늘어나고, 변경 사항이 많아지며, 테스트 스위트가 더 포괄적이게 되면 이러한 방식은 제대로 확장(scale)되지 않으며 개발자의 시간을 가장 효율적으로 사용하는 방법도 아닙니다. Zuul은 대량의 변경 사항이 올바르게 테스트되도록 보장하는 데 특별히 중점을 두어 이 프로세스를 자동화하는 데 도움을 줄 수 있습니다.

병렬 테스트 (Testing in parallel)

Zuul이 특별히 초점을 맞추는 부분은 변경 사항들을 병렬로, 올바른 순서대로 테스트하도록 보장하는 것입니다. 게이팅 시스템은 항상 브랜치의 끝(tip)에 적용되는 각 변경 사항을 병합될 상태 그대로 정확하게 테스트해야 합니다. 이를 위한 간단한 방법은 한 번에 하나의 변경 사항만 테스트하고, 테스트를 통과했을 때만 병합하는 것입니다. 이 방법은 매우 잘 작동하지만, 변경 사항을 테스트하는 데 오랜 시간이 걸린다면 개발자들은 자신의 변경 사항이 저장소에 반영될 때까지 긴 시간을 기다려야 할 수도 있습니다. 일부 프로젝트에서는 변경 사항을 테스트하는 데 몇 시간이 걸릴 수 있으며, 개발자들이 테스트되고 병합되는 속도보다 더 빠르게 변경 사항을 만들어내기 쉽습니다.

Zuul의 dependent pipeline manager 는 게이팅을 위한 테스트 잡의 병렬 실행을 허용하면서도, 마치 한 번에 하나씩 테스트한 것과 똑같이 변경 사항이 올바르게 테스트되도록 보장합니다. 이는 테스트 잡의 추측 기반 실행(speculative execution)을 수행함으로써 이루어집니다. 즉, 모든 잡이 성공할 것이라고 가정하고 그에 따라 병렬로 테스트합니다. 만약 모두 성공한다면 전부 병합될 수 있습니다. 하지만 하나라도 실패하면, 해당 잡의 성공을 기대하고 있던 후속 변경 사항들은 실패한 변경 사항을 제외하고 다시 테스트됩니다. 최상의 경우, 사용 가능한 실행 컨텍스트 수만큼 많은 변경 사항이 병렬로 테스트되고 한 번에 병합될 수 있습니다. 최악의 경우, 변경 사항은 한 번에 하나씩 테스트됩니다(후속 변경 사항이 각각 실패함에 따라 그 뒤에 있는 변경 사항들이 다시 시작되기 때문입니다).

예를 들어, 리뷰어가 5개의 변경 사항을 빠르게 연달아 승인한다고 가정해 보겠습니다:

A, B, C, D, E

Zuul은 승인된 순서대로 해당 변경 사항들을 큐에 추가하고, 각 후속 변경 사항이 그보다 앞선 변경 사항의 병합에 의존한다는 점을 기록합니다:

digraph foo {
  bgcolor="transparent";
  rankdir="LR";
  node [shape=box];
  edge [dir=back];
  A -> B -> C -> D -> E;
}

그런 다음 Zuul은 즉시 모든 변경 사항을 병렬로 테스트하기 시작합니다. 하지만 다른 변경 사항에 의존하는 변경 사항의 경우, 앞선 변경 사항들이 통과할 것이라는 가정하에 이를 포함하도록 테스트 시스템에 지시합니다. 즉, 변경 사항 B를 테스트하는 잡에는 변경 사항 A도 포함됩니다:

Jobs for A: merge change A, then test
Jobs for B: merge changes A and B, then test
Jobs for C: merge changes A, B and C, then test
Jobs for D: merge changes A, B, C and D, then test
Jobs for E: merge changes A, B, C, D and E, then test

따라서 변경 사항 A를 테스트하도록 트리거된 작업은 A만 테스트하고 B, C, D는 무시합니다:

digraph foo {
  bgcolor="transparent";
  rankdir="LR";
  node [shape=box];
  edge [dir=back];

  subgraph cluster_merged {
    label="Merged Changes for A";
    style=filled;
    color=orange;
    node [style=filled,color=black,fillcolor=white];
    master -> A;
  }

  subgraph cluster_ignored {
    label="Ignored Changes";
    style=filled;
    color=lightgrey;
    node [style=filled,color=black,fillcolor=white];
    B -> C -> D -> E;
  }

  A -> B;
}

변경 사항 E에 대한 잡은 A, B, C, D, E라는 전체 종속성 체인(dependency chain)을 포함하게 됩니다. E는 A, B, C, D가 통과했다는 가정하에 테스트됩니다:

digraph foo {
  bgcolor="transparent";
  rankdir="LR";
  node [shape=box];
  edge [dir=back];

  subgraph cluster_merged {
    label="Merged Changes for E";
    style=filled;
    color=orange;
    node [style=filled,color=black,fillcolor=white];
    master -> A -> B -> C -> D -> E;
  }
}

만약 변경 사항 A와 B가 테스트를 통과하고(녹색), C, D, E가 실패한다면(빨간색):

digraph foo{
  bgcolor="transparent";
  rankdir="LR";
  node [shape=box];
  edge [dir=back];

  A [style=filled,color=black,fillcolor=lightgreen];
  B [style=filled,color=black,fillcolor=lightgreen];
  C [style=filled,color=black,fillcolor=lightpink];
  D [style=filled,color=black,fillcolor=lightpink];
  E [style=filled,color=black,fillcolor=lightpink];
  master -> A -> B -> C -> D -> E;
}

Zuul은 변경 사항 A를 병합한 뒤에 변경 사항 B를 병합하며, 큐는 다음과 같이 남습니다:

digraph foo {
  bgcolor="transparent";
  rankdir="LR";
  node [shape=box];
  edge [dir=back];

  C [style=filled,color=black,fillcolor=lightpink];
  D [style=filled,color=black,fillcolor=lightpink];
  E [style=filled,color=black,fillcolor=lightpink];
  C -> D -> E;
}

D는 C에 의존하고 있었으므로, D의 실패가 D 자체의 결함 때문인지 아니면 C의 결함 때문인지 명확하지 않습니다:

digraph foo {
  bgcolor="transparent";
  rankdir="LR";
  node [shape=box];
  edge [dir=back];

  C [style=filled,color=black,fillcolor=lightpink];
  D [label="D\n?"];
  E [label="E\n?"];
  C -> D -> E;
}

C가 실패했기 때문에 Zuul은 그 실패를 보고하고 큐에서 C를 제외(drop)하며, D와 E는 유지합니다:

digraph foo {
  bgcolor="transparent";
  rankdir="LR";
  node [shape=box];
  edge [dir=back];

  D [label="D\n?"];
  E [label="E\n?"];
  D -> E;
}

이 큐는 방금 2개의 새로운 변경 사항이 도착한 것과 동일한 상태이므로, Zuul은 브랜치의 끝(tip)을 기준으로 D를, D를 기준으로 E를 테스트하는 프로세스를 다시 시작합니다:

digraph foo {
  bgcolor="transparent";
  rankdir="LR";
  node [shape=box];
  edge [dir=back];

  subgraph cluster_merged {
    label="Merged Changes for D";
    style=filled;
    color=orange;
    node [style=filled,color=black,fillcolor=white];
    master -> D;
  }

  subgraph cluster_skip {
    label="Skip";
    style=filled;
    color=lightgrey;
    node [style=filled,color=black,fillcolor=white];
    E;
  }

  D -> E;
}
digraph foo {
  bgcolor="transparent";
  rankdir="LR";
  node [shape=box];
  edge [dir=back];

  subgraph cluster_merged {
    label="Merged Changes for E";
    style=filled;
    color=orange;
    node [style=filled,color=black,fillcolor=white];
    master -> D -> E;
  }
}

파이프라인 윈도우 (Pipeline Window)

Zuul은 이 프로세스에 대한 어느 정도의 제어를 허용합니다. 파이프라인에는 잡(jobs) 실행이 허용되는 파이프라인의 일부인 window 가 있습니다. 윈도우는 Zuul이 잡을 시작할 큐(queue)의 선두에 있는 변경 사항의 개수입니다. 이 개수를 초과하는 변경 사항은 작업이 실행되지 않은 채 큐에 보류됩니다. 변경 사항이 큐의 선두를 빠져나가면, 윈도우 밖에 있던 변경 사항이 위로 이동하여 최종적으로 잡을 시작하게 됩니다.

digraph foo {
  bgcolor="transparent";
  rankdir="LR";
  node [shape=box];
  edge [dir=back];

  subgraph cluster_active {
    label="Pipeline Active Window";
    style=filled;
    color=lightblue1;
    node [style=filled,color=black,fillcolor=white];
    A -> B -> C;
  }

  subgraph cluster_inactive {
    label="Waiting to run jobs";
    style=filled;
    color=lightgrey;
    node [style=filled,color=black,fillcolor=white];
    D -> E;
  }

  master -> A;
  C -> D;
}

윈도우는 병렬 테스트에 사용되는 리소스의 양을 제어하도록 설계되었습니다. 앞서 설명한 것처럼, 종속 파이프라인(dependent pipeline)에서 변경 사항이 테스트에 실패하면 빌드 결과는 폐기되고 실패한 변경 사항을 제외한 새로운 빌드가 시작됩니다. 이런 일이 자주 발생하면 Zuul은 거의 이득 없이 점점 더 많은 양의 테스트 리소스를 사용하게 될 수 있습니다. 이상적으로는 빌드가 자주 성공할 경우 처리량을 극대화하기 위해 윈도우를 크게 설정하고, 자주 실패할 경우 낭비를 최소화하기 위해 윈도우를 작게 설정해야 합니다.

기본적으로 Zuul은 TCP(전송 제어 프로토콜)의 흐름 제어(flow control)에서 영감을 받은 알고리즘을 사용하여 윈도우 크기를 결정합니다. 윈도우를 특정 값(기본값 20개 변경 사항)으로 설정하여 시작합니다. 변경 사항이 성공적으로 병합될 때마다 윈도우가 1씩 증가합니다. 변경 사항이 실패할 때마다 윈도우는 반으로 줄어듭니다. 이를 통해 변경 사항이 실패하기 시작하면 윈도우가 빠르게 줄어들고, 성공하면 천천히 회복될 수 있습니다. (기본적으로) 항상 최소한의 병렬 테스트가 이루어지도록 하한선(floor)이 설정되며, 매우 성공적인 파이프라인이 다른 파이프라인의 리소스를 고갈시키는 것을 방지하기 위해 상한선(ceiling)을 설정할 수 있습니다.

위의 모든 매개변수는 로컬 요구 사항에 맞게 사용자 정의할 수 있지만, 기본값이 좋은 출발점입니다. 자세한 내용은 pipeline.window 를 참조하세요.

윈도우 매개변수는 파이프라인에 설정되지만, 해당 파이프라인 내의 각 project queue 는 자체 윈도우를 유지하므로 한 프로젝트 큐의 신뢰할 수 없는 테스트가 다른 프로젝트 큐의 윈도우에 영향을 미치지 않습니다.

모든 파이프라인에는 윈도우가 있지만, dependent 파이프라인 관리자를 사용하는 파이프라인만 윈도우 구성을 허용합니다. 다른 파이프라인 관리자들은 특정한 동작을 구현하기 위해 고정된 값을 사용합니다. 예를 들어, independent 파이프라인은 항상 무제한 윈도우를 가지며, serial 파이프라인은 고정된 윈도우 크기 1을 가집니다.

윈도우는 웹 인터페이스에서 변경 사항의 왼쪽에 있는 아이콘을 검사하여 시각적으로 확인할 수 있습니다. 변경 사항이 윈도우 밖에 있는 경우 모래시계 아이콘이 표시되며, 마우스를 올리면 표시되는 텍스트(mouseover text)는 변경 사항이 큐의 선두에 가까워지면 잡이 시작될 것임을 알려줍니다.

크로스 프로젝트 테스트 (Cross Project Testing)

프로젝트들이 서로 밀접하게 결합되어 있는 경우, 게이트(gate)에 들어오는 변경 사항이 현재 게이트 큐에 있는 다른 프로젝트의 버전과 함께 테스트되도록 보장하고 싶을 것입니다 (이들은 결국 병합될 것이며 이전 버전과 호환되지 않는 기능 고장(breaking features)을 도입할 수 있기 때문입니다).

이러한 관계는 종속 파이프라인 내의 공유 큐(shared queue)에 프로젝트들을 배치함으로써 Zuul 구성에서 정의할 수 있습니다. 프로젝트의 변경 사항이 이러한 공유 큐가 있는 파이프라인에 들어갈 때마다, 큐에서 앞서 있는 변경 사항의 커밋이 그 뒤에 있는 변경 사항의 잡에 자동으로 포함되도록 이들은 함께 테스트됩니다. 자세한 내용은 Project 를 참조하세요.

주어진 종속 파이프라인은 필요한 만큼 많은 공유 변경 큐(shared change queues)를 가질 수 있으므로, 관련 프로젝트 그룹이 관련 없는 프로젝트에 영향을 주지 않고 변경 큐를 공유할 수 있습니다. Independent pipelines 은 공유 변경 큐를 사용하지 않지만, 크로스 프로젝트 종속성(cross-project dependencies)을 사용하여 프로젝트 전반에 걸친 변경 사항을 테스트하는 데 여전히 사용될 수 있습니다.

크로스 프로젝트 종속성 (Cross-Project Dependencies)

Zuul은 사용자가 프로젝트 전반에 걸친 종속성(dependencies)을 지정할 수 있도록 허용합니다. 사용자는 특수한 푸터(footer)를 사용하여 변경 사항이 Zuul에 알려진 다른 저장소의 변경 사항에 의존함을 지정할 수 있습니다. Gerrit 기반 프로젝트의 경우 이 푸터는 git 커밋 메시지에 추가되어야 합니다. GitHub 기반 프로젝트의 경우 이 푸터는 풀 리퀘스트(pull request) 설명에 추가되어야 합니다.

Zuul의 크로스 프로젝트 종속성은 서로 다른 git 저장소의 변경 사항 간의 단방향 종속성 관계를 나타내기 위해 git 자체와 마찬가지로 방향성 비순환 그래프(DAG, Directed Acyclic Graph)처럼 동작합니다. 변경 사항 A가 B에 의존할 수는 있지만, B가 A에 의존할 수는 없습니다.

이를 사용하려면 커밋 메시지나 풀 리퀘스트의 푸터에 Depends-On: <change-url> 을 포함하세요. 예를 들어, GitHub 풀 리퀘스트(PR #4)에 의존하는 변경 사항은 다음과 같은 푸터를 가질 수 있습니다:

Depends-On: https://github.com/example/test/pull/4

참고

GitHub의 경우 Depends-On: 푸터는 풀 리퀘스트 설명(description) 안에 있어야 하며, 이는 커밋 메시지(즉, git commit과 함께 제출된 텍스트)와 분리되어 있고 종종 다릅니다. 이는 변경 사항 설명이 항상 커밋 메시지인 Gerrit과는 대조적입니다.

Gerrit 변경 사항(변경 번호 3)에 의존하는 변경 사항:

Depends-On: https://review.example.com/3

변경 사항은 다른 어떤 프로젝트의 변경 사항에도 의존할 수 있으며, 심지어 동일한 시스템에 있지 않은 프로젝트에도 의존할 수 있습니다(즉, Gerrit 변경 사항이 GitHub 풀 리퀘스트에 의존할 수 있습니다).

참고

Gerrit change-id를 사용하여 종속성을 지정하는 이전 문법(syntax)도 여전히 지원되지만, 이는 더 이상 권장되지 않으며(deprecated) 향후 버전에서 제거될 예정입니다.

종속 파이프라인 (Dependent Pipeline)

Zuul이 크로스 프로젝트 종속성이 있는 변경 사항을 발견하면, 파이프라인 큐에 추가(enqueuing)할 때 일반적인 방식으로 직렬화(serialize)합니다. 즉, 변경 사항 A가 B에 의존하는 경우, 이들이 종속 파이프라인(dependent pipeline)에 추가될 때 B가 먼저 나타나고 A가 그 뒤를 따릅니다:

digraph crd {
  bgcolor="transparent";
  stat_B [shape=circle,style=filled,color=black,fillcolor=forestgreen,label=""];
  stat_A [shape=circle,style=filled,color=black,fillcolor=forestgreen,label=""];
  stat_B -> stat_A [arrowhead="none"];

  change_B [shape=box,fixedsize=true,width=1.75,height=0.75,label="Change B\nURL: .../4"];
  change_A [shape=box,fixedsize=true,width=1.75,height=0.75,label="Change A\nDepends-On: .../4"];

  change_B -> change_A [dir=back];
}

만약 B에 대한 테스트가 실패하면, B와 A 모두 파이프라인에서 제거되며, B가 병합될 때까지 A는 병합될 수 없습니다.

참고

크로스 프로젝트 종속성이 있는 변경 사항들이 변경 큐(change queue)를 공유하지 않으면 Zuul은 이들을 함께 큐에 추가할 수 없으며, 두 번째 변경 사항이 큐에 추가되기 전에 첫 번째 변경 사항이 먼저 병합되어야 합니다. 첫 번째 변경 사항이 병합되기 전에 두 번째 변경 사항이 승인(approved)되면, Zuul은 해당 승인에 대해 조치를 취할 수 없어 두 번째 변경 사항을 자동으로 큐에 추가하지 않으며, 첫 번째 변경 사항이 병합된 후 이를 큐에 추가하기 위한 새로운 승인 이벤트가 필요합니다.

독립 파이프라인 (Independent Pipeline)

변경 사항이 독립 파이프라인(independent pipeline) 큐에 추가되면, 종속 파이프라인에서와 마찬가지로 모든 관련 종속성(상위 커밋에서 오는 일반적인 git 종속성과 크로스 프로젝트 종속성 모두)이 종속성 그래프에 나타납니다. 즉, 독립 파이프라인에서도 변경 사항은 그 종속성들과 함께 테스트됩니다. 이전에는 관련된 변경 사항이 다른 저장소에 반영(land)될 때까지 완전히 테스트될 수 없었던 변경 사항들도 이제 처음부터 함께 테스트될 수 있습니다.

모든 변경 사항은 여전히 독립적이지만(종속 파이프라인처럼 전체 파이프라인이 단일 그래프를 공유하지 않는다는 점에 유의하세요), 테스트되는 각 변경 사항에 대해 모든 종속성이 시각적으로 연결되어 표시되며, 이는 Zuul이 테스트 시 사용할 git 저장소를 구성하는 데 사용됩니다.

상태 페이지(status page)에서 이 그래프를 보면, 종속성은 회색 점으로 나타나는 반면 실제로 테스트된 변경 사항은 (잡 결과에 따라) 빨간색 또는 초록색으로 나타납니다:

digraph crdgrey {
  bgcolor="transparent";
  stat_B [shape=circle,style=filled,color=black,fillcolor=grey,label=""];
  stat_A [shape=circle,style=filled,color=black,fillcolor=forestgreen,label=""];
  stat_B -> stat_A [arrowhead="none"];

  change_B [shape=box,fixedsize=true,width=1.75,height=0.75,label="Change B\nURL: .../4"];
  change_A [shape=box,fixedsize=true,width=1.75,height=0.75,label="Change A\nDepends-On: .../4"];

  change_B -> change_A [dir=back];
}

이는 회색 변경 사항이 오직 종속성을 설정하기 위해 존재함을 나타냅니다. 종속성 중 하나가 테스트되고 있더라도, 종속성으로 사용될 때는 회색 점으로 표시되지만, 해당 테스트에 대해서는 별도로 빨간색 또는 초록색 점으로 추가 표시됩니다.

다중 변경 사항 (Multiple Changes)

변경 사항은 커밋 메시지 푸터에 더 많은 ‘’Depends-On:’’ 줄을 추가하는 것만으로 두 개 이상의 종속성을 나열할 수 있습니다. 프로젝트 A의 변경 사항이 프로젝트 B의 변경 사항과 프로젝트 C의 변경 사항에 모두 의존하는 것도 가능합니다.

digraph crdmultichanges {
  bgcolor="transparent";
  splines=ortho;
  stat_B [shape=circle,style=filled,color=black,fillcolor=forestgreen,label=""];
  stat_C [shape=circle,style=filled,color=black,fillcolor=forestgreen,label=""];
  stat_A [shape=circle,style=filled,color=black,fillcolor=forestgreen,label=""];
  stat_B -> stat_C -> stat_A [arrowhead="none"];

  subgraph cluster_deps {
    label="Dependencies";
    style=filled;
    color=lightgrey;
    node [style=filled,color=black,fillcolor=white];
    repo_B [shape=box,fixedsize=true,width=1.75,height=0.75,label="Repo B\nURL: .../3",group=redir];
    repo_C [shape=box,fixedsize=true,width=1.75,height=0.75,label="Repo C\nURL: .../4",group=redir];
    {rank=same;repo_B;redir_B}
    // We use the redirect point, group redir, and ortho splines to keep
    // repo A,B,C nodes in a vertical line then draw lines from A around
    // C to B.
    redir_B [label="",shape=point,height=.005];
    // This is an invisible edge because we want them vertically aligned
    // and ordered but there is no git/zuul dependency between the changes
    // so we don't draw the edge.
    repo_B -> repo_C [style=invis];
  }

  repo_A [shape=box,fixedsize=true,width=1.75,height=0.75,label="Repo A\nDepends-On: .../3\nDepends-On: .../4",group=redir];
  repo_B -> redir_B [dir=back];
  redir_B -> repo_A [arrowhead=none];
  repo_C -> repo_A [dir=back];
}

순환 (Cycles)

Zuul은 크로스 프로젝트 종속성 사용으로 인해 생성되는 순환(cycles)을 지원합니다. 하지만 이 기능은 선택 사항(opt-in)이며 큐에서 구성할 수 있습니다. 구성 방법에 대한 정보는 queue.allow-circular-dependencies 를 참조하세요.

전역 저장소 상태 (Global Repo State)

git 저장소가 큐 항목의 잡 중 최소 하나에서 사용되는 경우, Zuul은 저장소 상태(즉, 브랜치 헤드(heads)와 태그)를 고정(freeze)하고 해당 큐 항목에 대해 실행되는 모든 잡에 동일한 상태를 사용합니다. 모든 잡이 모든 저장소의 git 저장소 체크아웃(checkout)을 가져오는 것은 아니지만, 체크아웃된 모든 저장소는 동일한 상태를 갖게 됩니다. 이 때문에 작성자는 하나의 잡이 다른 잡보다 훨씬 나중에 실행을 시작하더라도, 동일한 큐 항목에서 실행되는 잡들이 관련된 모든 git 저장소에 대해 일관된 뷰(view)를 가진다고 확신할 수 있습니다.