|
| 1 | +# Zuul Pipeline의 실행 흐름과 Build Node lifecycle |
| 2 | + |
| 3 | +## 1. 주제 선정 이유 |
| 4 | + |
| 5 | +Zuul은 speculative gating 기능을 갖춘 이벤트 기반 CI/CD 시스템이다. |
| 6 | +그러나 내부 실행 모델은 실제 런타임 관점에서 바라보지 않으면 추상적으로 |
| 7 | +느껴질 수 있다. |
| 8 | + |
| 9 | +노드가 어떻게 요청되고, 프로비저닝되며, 사용되고, 정리·삭제되는지를 |
| 10 | +추적하면서 Zuul 프로세스의 흐름을 이해하고자 한다. |
| 11 | + |
| 12 | +## 2. 시스템 구조: node는 어디에 위치하는가 |
| 13 | + |
| 14 | +Zuul의 주요 구성 요소는 다음과 같다: |
| 15 | + |
| 16 | +- Scheduler (Control Plane) |
| 17 | +- Merger (Speculative Git 상태 생성) |
| 18 | +- Executor (job 실행) |
| 19 | +- Launcher (node Provisioning 담당) |
| 20 | +- ZooKeeper (분산 이벤트 큐 및 상태 머신 저장, 조정) |
| 21 | +- SQL Database (빌드 이력 저장) |
| 22 | +- 외부 코드 리뷰 시스템 (예: Gerrit) |
| 23 | + |
| 24 | +Zuul 프로세스의 개념적 흐름은 다음과 같다: |
| 25 | + |
| 26 | + Event |
| 27 | + → Scheduler |
| 28 | + → job Graph |
| 29 | + → node Request |
| 30 | + → node Provision |
| 31 | + → Executor 연결 |
| 32 | + → job 실행 |
| 33 | + → Cleanup |
| 34 | + → node 삭제 |
| 35 | + |
| 36 | +## 3. node lifecycle의 상태 전이 모델 |
| 37 | + |
| 38 | +Zuul에서의 node는 일회성 연산 단위로 Ephemeral node 모델을 따른다. |
| 39 | + |
| 40 | +이로 인해 다음과 같은 효과를 얻을 수 있다: |
| 41 | + |
| 42 | +- Secret 잔존 방지 |
| 43 | +- 상태 오염 방지 |
| 44 | +- 재현성 보장 |
| 45 | + |
| 46 | +node lifecycle은 다음과 같은 상태 모델로 이해할 수 있다: |
| 47 | +``` |
| 48 | +state: |
| 49 | + INIT |
| 50 | + → BUILDING |
| 51 | + → READY |
| 52 | + → IN_USE |
| 53 | + → USED / HOLD |
| 54 | + → DELETING |
| 55 | + → DELETED |
| 56 | + → FAILED |
| 57 | + → DELETING |
| 58 | + → DELETED |
| 59 | +``` |
| 60 | +각 상태 전이는 이벤트 기반이며, ZooKeeper를 통해 조정된다. |
| 61 | + |
| 62 | +## 4. nodeset - job이 요구하는 실행 환경 정의 |
| 63 | + |
| 64 | +Zuul에서 job은 직접 node를 요청하지 않고 nodeset을 정의한다. |
| 65 | +nodeset은 job의 실행 환경을 추상화한 객체로, job이 ansible을 실행하기 위해 필요로 하는 노드들의 집합을 의미한다. |
| 66 | + |
| 67 | +nodeset은 다음과 같은 정보를 포함한다: |
| 68 | + |
| 69 | +- 노드의 이름 |
| 70 | +- 노드에 필요한 label |
| 71 | +- 노드의 역할 (예: controller, worker 등) |
| 72 | + |
| 73 | +예시: |
| 74 | +``` |
| 75 | +nodeset: |
| 76 | + nodes: |
| 77 | + - name: controller |
| 78 | + label: ubuntu-22.04 |
| 79 | + - name: worker |
| 80 | + label: ubuntu-22.04 |
| 81 | +``` |
| 82 | +이 경우와 같이 job은 단일 노드가 아니라 두 개 이상의 노드를 동시에 |
| 83 | +요구하기도 한다. |
| 84 | + |
| 85 | +## 5. node Request 생성 - Scheduler |
| 86 | + |
| 87 | +Scheduler는 job을 실행하기 위해 nodeset을 기반으로 nodeRequest를 |
| 88 | +생성한다. |
| 89 | + |
| 90 | +node Request는 하나의 객체로, nodeset이라는 추상화된 실행 환경을 실제 |
| 91 | +리소스로 변환하는 요청이다. |
| 92 | + |
| 93 | +node Request는 다음과 같은 상태 모델을 가진다: |
| 94 | +``` |
| 95 | + state: |
| 96 | + REQUESTED |
| 97 | + → PENDING |
| 98 | + → FULFILLED |
| 99 | + → FAILED |
| 100 | +``` |
| 101 | + |
| 102 | +request 객체를 생성하여 Zookeeper에 전송하는 Scheduler 코드 일부: |
| 103 | +``` |
| 104 | +#zuul/nodepool.py |
| 105 | +def requestnodes(self, build_set, job, relative_priority): |
| 106 | + # Create a copy of the nodeset to represent the actual nodes |
| 107 | + # returned by nodepool. |
| 108 | + nodeset = job.nodeset.copy() |
| 109 | + req = model.nodeRequest(self.sched.hostname, build_set, job, |
| 110 | + nodeset,relative_priority) |
| 111 | + self.requests[req.uid] = req |
| 112 | +
|
| 113 | + if nodeset.nodes: |
| 114 | + self.sched.zk.submitnodeRequest(req, self._updatenodeRequest) |
| 115 | + # Logged after submission so that we have the request id |
| 116 | + self.log.info("Submitted node request %s" % (req,)) |
| 117 | + self.emitStats(req) |
| 118 | + else: |
| 119 | + self.log.info("Fulfilling empty node request %s" % (req,)) |
| 120 | + req.state = model.STATE_FULFILLED |
| 121 | + self.sched.onnodesProvisioned(req) |
| 122 | + del self.requests[req.uid] |
| 123 | + return req |
| 124 | +``` |
| 125 | +1. Scheduler가 프로젝트 설정 평가 |
| 126 | +2. job 실행 순서에 대한 DAG(Directed Acyclic Graph) 생성 |
| 127 | +3. DAG의 각 job에 대해 node Request 생성 |
| 128 | + |
| 129 | +- 이 시점에 아직 실제 node는 존재하지 않음 |
| 130 | + |
| 131 | +## 6. 리소스 할당 (Provisioning) - Launcher |
| 132 | + |
| 133 | +Launcher 컴포넌트는 node Request를 감시하여 리소스를 할당한다: |
| 134 | + |
| 135 | +1. Zookeeper에 저장된 node request 감시 : |
| 136 | +``` |
| 137 | +requests = sorted(self.zk.nodeRequestIterator(), key=_sort_key) |
| 138 | +
|
| 139 | +if req.state != zk.REQUESTED: |
| 140 | + continue |
| 141 | +``` |
| 142 | +2. zk lock 획득: 분산 시스템 안정성 유지 |
| 143 | +``` |
| 144 | +self.zk.locknodeRequest(req, blocking=False) |
| 145 | +``` |
| 146 | +3. ProviderManager를 통한 프로비저닝 |
| 147 | +``` |
| 148 | +rh = pm.getRequestHandler(self, req) |
| 149 | +rh.run() |
| 150 | +``` |
| 151 | +이 과정에서 실제 node가 생성된다. |
| 152 | +생성된 node의 상태와 정보는 ZooKeeper를 통해 추적되고 제공된다. |
| 153 | + |
| 154 | +4. 각 객체의 상태 변환 |
| 155 | +``` |
| 156 | +nodeRequest: REQUESTED → PENDING → FULFILLED / FAILED |
| 157 | +node: INIT → BUILDING → READY |
| 158 | +``` |
| 159 | + |
| 160 | +## 7. Executor 연결 |
| 161 | + |
| 162 | +node가 READY 상태가 되면: |
| 163 | + |
| 164 | +1. Executor가 ZooKeeper가 제공하는 node에 대한 정보를 기반으로 ansible |
| 165 | + inventory 생성. |
| 166 | + |
| 167 | +2. ssh-agent 실행, 이후 해당 agent를 이용하여 ansible이 node와 연결 |
| 168 | +``` |
| 169 | +#zuul/executor/server.py |
| 170 | +class SshAgent(object): |
| 171 | + def __init__(self, zuul_event_id=None, build=None): |
| 172 | + self.env = {} |
| 173 | + self.ssh_agent = None |
| 174 | + self.log = get_annotated_logger( |
| 175 | + logging.getLogger(\"zuul.ExecutorServer\"), |
| 176 | + zuul_event_id, build=build) |
| 177 | +``` |
| 178 | +3. Secret 주입 |
| 179 | + |
| 180 | +4. Ansible Playbook 실행 |
| 181 | +``` |
| 182 | +#zuul/executor/server.py |
| 183 | +cmd = [self.executor_server.ansible_manager.getAnsibleCommand( |
| 184 | + ansible_version), verbose, playbook.path\] |
| 185 | +
|
| 186 | +result, code = self.runAnsible( |
| 187 | + cmd, timeout, playbook, ansible_version) |
| 188 | +``` |
| 189 | +## 8. Speculative Execution과 node 증폭 효과 |
| 190 | + |
| 191 | +변경사항 큐가 다음과 같다고 가정하자: |
| 192 | + |
| 193 | + A → B → C |
| 194 | + |
| 195 | +Zuul은 변경사항을 테스트하기 위해 merge된 것으로 가정한 다음 상태를 |
| 196 | +병렬로 평가한다: |
| 197 | + |
| 198 | + State1 = base + A |
| 199 | + State2 = base + A + B |
| 200 | + State3 = base + A + B + C |
| 201 | + |
| 202 | +각 speculative state는 독립적인 job DAG를 실행한다. |
| 203 | + |
| 204 | +이때 A가 실패할 경우: |
| 205 | + |
| 206 | +1. B와 C의 speculative state 무효화. |
| 207 | +2. 실행 중이던 job 취소. |
| 208 | +3. 관련 node는 Cleanup 단계로 진입. |
| 209 | +4. Scheduler는 Queue 재구성 (B → C) |
| 210 | +5. B, B+C를 위한 새로운 node Request. |
| 211 | + |
| 212 | +이로 인해 병렬로 한번에 진행되는 평가가 많아질수록 node 수요가 증가하고 |
| 213 | +상위 변경 빌드의 실패가 많아질수록 node 수요가 급격하게 증가한다. |
| 214 | + |
| 215 | +## 9. Pipeline Window - speculative 폭 제어 메커니즘 |
| 216 | + |
| 217 | +Zuul에서는 Pipeline Window라는 병렬 테스트에 사용되는 리소스의 양을 |
| 218 | +제어하는 장치를 제공한다. |
| 219 | + |
| 220 | +Pipeline Window는 한번에 진행되는 Speculative Execution의 수를 제한하는 |
| 221 | +방법으로 리소스 수요를 제어한다. |
| 222 | +예를 들어 window크기를 3으로 제한할 경우, |
| 223 | + |
| 224 | + |
| 225 | + 큐가 A → B → C → D → E 일 때 |
| 226 | + |
| 227 | + State1 = base + A |
| 228 | + State2 = base + A + B |
| 229 | + State3 = base + A + B + C |
| 230 | + State4 = base + A + B + C + D |
| 231 | + State5 = base + A + B + C + D + E |
| 232 | + |
| 233 | +가 아닌 : |
| 234 | + |
| 235 | + State1 = base + A |
| 236 | + State2 = base + A + B |
| 237 | + State3 = base + A + B + C |
| 238 | + |
| 239 | +를 우선 실행하고 각 작업이 완료될 때마다 뒤에 남은 D, E를 앞으로 |
| 240 | +가져와서 추가적으로 실행하게 된다. 따라서 실패로 인해 새로운 리소스를 |
| 241 | +요청할 경우 낭비되는 리소스가 줄어드는 효과를 볼 수 있다. |
| 242 | + |
| 243 | +## 10. Cleanup |
| 244 | + |
| 245 | +job 완료 후: |
| 246 | + |
| 247 | +1. Artifact 업로드 |
| 248 | +2. Log 스트리밍 |
| 249 | + |
| 250 | +3. Cleanup 루틴 실행 |
| 251 | +``` |
| 252 | +pm = self._nodepool.getProviderManager(node.provider) |
| 253 | +pm.startnodeCleanup(node) |
| 254 | +``` |
| 255 | +4. node: Used → DELETING 상태 전이 |
| 256 | +5. 리소스 제거 |
| 257 | + |
0 commit comments