대규모 Rust 코드베이스에서 Cargo의 한계를 극복하고 Bazel로 빌드 시스템을 이전한 배경, CI 문제, 마이그레이션 과정, 그리고 남은 과제를 설명합니다.
2023년 3월 기준으로 Internet Computer 저장소에는 약 60만 줄의 Rust 코드가 있습니다. 우리는 지난해부터 Bazel을 주 빌드 시스템으로 사용하기 시작했고, 이 전환에 매우 만족하고 있습니다. 이 글에서는 이러한 전환의 배경이 된 동기와 마이그레이션 과정의 세부 사항을 설명합니다.
많은 Rust 입문자들, 특히 C++ 배경을 가진 사람들은 cargo를 강하게 선호합니다. Rust 도구 생태계는 초보자에게는 훌륭하지만, 프로젝트 규모가 커지면서 우리는 cargo에 불만을 가지게 되었습니다. 우리의 불만 대부분은 두 가지 범주로 나뉩니다.
Cargo는 Rust의 패키지 관리자 입니다. Cargo는 Rust 패키지의 의존성을 내려받고, 패키지를 컴파일하고, 배포 가능한 패키지를 만들고, 그것을 crates.io에 업로드합니다.
우선 방 안의 코끼리부터 인정합시다. cargo는 빌드 시스템이 아니라 Rust 패키지를 빌드하고 배포하기 위한 도구입니다. cargo는 단일 호출로 특정 플랫폼용 Rust 코드를 주어진 기능 집합으로 빌드할 수 있습니다. Cargo는 일반성과 확장성보다 단순성과 사용 편의성을 택했으며, 의존성을 잘 추적하지 못하고 임의의 빌드 그래프도 지원하지 않습니다.
이러한 절충은 cargo를 쉽게 익히게 해 주지만, 복잡한 프로젝트에서는 심각한 제약을 부과합니다. xtask 같은 우회책도 있지만, 거기에도 한계가 있습니다. 우리의 많은 테스트가 해야 하는 일을 예로 들어 봅시다.
이 단순한 시나리오만 해도 서로 다른 인자로 cargo를 세 번 호출하고, 빌드 산출물을 적절히 후처리하고 연결해야 합니다. cargo만으로는 이런 테스트를 표현할 방법이 없으며, 다른 빌드 시스템이 테스트 실행을 조율해야 합니다.
악명 높은 Make처럼 cargo는 증분 빌드에 파일 수정 시각에 의존합니다. 코드 주석을 업데이트하거나 git 브랜치를 전환하는 것만으로도 cargo의 캐시가 무효화되어 긴 재빌드가 발생할 수 있습니다. sccache 도구는 캐시 적중률을 개선할 수 있지만, 우리의 지속적 통합(CI) 서버에서는 이를 사용해도 개선을 보지 못했습니다.
Cargo의 의존성 추적 기능은 비교적 단순합니다. 예를 들어 어떤 입력 파일이나 환경 변수가 바뀌면 cargo가 build.rs를 다시 실행하도록 지시할 수는 있습니다. 하지만 테스트가 어떤 파일이나 다른 자원에 접근할지 cargo는 알지 못하므로 캐싱에 대해 보수적으로 행동할 수밖에 없습니다. 그 결과 우리는 필요한 것보다 훨씬 더 많이 빌드하는 경우가 많았고, 때로는 cargo clean을 하고 나면 사라지는 혼란스러운 오류로 빌드가 실패하기도 했습니다.
프로젝트 수명 동안 우리는 cargo의 한계를 완화하기 위해 여러 도구를 사용했지만, 성공은 엇갈렸습니다.
2019년 중반에 Rust 구현을 시작했을 때 우리는 모든 소프트웨어를 빌드하고 개발 환경을 크로스플랫폼 방식으로 설정하기 위해 nix에 의존했습니다(우리는 macOS와 Linux 모두에서 개발합니다).
코드베이스가 커지면서 우리는 nix의 한계를 체감하기 시작했습니다. nix에서 캐싱의 단위는 derivation입니다. nix의 캐싱 기능을 최대한 활용하려면 외부 의존성과 내부 Rust 패키지를 모두 “nix화”해야 했습니다(Rust 패키지당 하나의 derivation). 빌드 재현성 문제와 오랫동안 싸운 끝에, 우리의 훌륭한 dev-infra 팀은 cargo2nix 프로젝트를 사용해 세밀한 캐싱을 구현했습니다.
안타깝게도 팀의 대부분 개발자는 nix를 편안하게 느끼지 못했습니다. nix는 끊임없는 혼란과 개발자 생산성 손실의 원천이 되었습니다. nix는 학습 곡선이 가파르기 때문에, 소수의 nix 마법사만이 빌드 규칙을 이해하고 수정할 수 있었습니다. 이런 nix 소외는 우리의 빌드 환경을 둘로 갈라놓았습니다. CI 서버는 nix-build로 코드를 빌드했고, 개발자들은 nix-shell에 들어가 cargo를 호출해 코드를 빌드했습니다.
nix 이야기에 마지막 일격을 가한 것은 2020년 말, 네트워크 출시 직전이었습니다. 우리 보안 팀은 배포 대상으로 Ubuntu를 선택했고, 프로덕션 바이너리가 배포 플랫폼이 제공하는 정기적으로 업데이트되는 시스템 라이브러리(libc, libc++, openssl 등)에 링크되어야 한다고 강하게 요구했습니다. 이런 구성은 정확성을 훼손하지 않고는 nix에서 달성하기 어렵습니다. 우리는 patchelf 사용도 고려했지만, 일반적으로 좋은 생각은 아닙니다. nix 패키지의 libc++는 배포 플랫폼에 설치된 것과 호환되지 않을 수 있기 때문입니다.
게다가 인프라 팀에 nix에 익숙하지 않은 신규 구성원이 몇 명 합류했고, 더 익숙한 기술인 Docker 컨테이너로 전환하기로 결정했습니다. 팀은 프로덕션 환경과 동일한 버전의 동적 라이브러리를 가진 docker 컨테이너 안에서 cargo 빌드를 실행하는 새로운 빌드 시스템을 구현했습니다.
이 새 시스템은 유기적으로 성장했고, 결국 올바른 순서로 셸과 python 스크립트를 호출하는 100개의 GitLab Yaml 설정 파일로 이루어진 난장판으로 발전했습니다. 이 스크립트들은 알려진 파일시스템 위치와 환경 변수를 사용해 빌드 산출물을 이리저리 전달했습니다. 대부분의 통합 테스트는 CI 파이프라인이 생성하는 특정 입력을 기대하는 셸 스크립트가 되었습니다.
새로운 Docker 기반 빌드 시스템은 nix-build의 세밀한 캐싱 기능을 잃었습니다. 인프라 팀은 맞춤형 캐싱 시스템을 만들려고 시도했지만 결국 프로젝트를 포기했습니다. 캐시 무효화는 정말 어려운 문제입니다.
새 시스템에서는 CI 환경과 개발 환경 사이의 간극이 더 깊어졌습니다. nix-shell은 어디 가지 않았기 때문입니다. 개발자들은 일상적인 개발을 위해 계속 nix-shell을 사용했습니다. 정확한 이유를 꼬집어 말하기는 어렵습니다. 내 생각에는 nix-shell에 들어가는 것이 docker 컨테이너에 들어가는 것보다 덜 침습적이고, macOS에서 가상 머신 안에서 실행할 필요도 없기 때문입니다(Rust 컴파일 시간은 느립니다). 또한 인프라 팀은 빌드 시스템을 다시 쓰느라 너무 바빠서 일상적인 개발자 경험을 개선할 여력이 없었습니다.
나는 이 구성을 “빙산”이라고 부릅니다. 겉으로 보기에는 개발자가 코드를 작업하기 위해 nix와 cargo만 있으면 되는 것처럼 보였지만, 실제로는 그것이 이야기의 10%에 불과했습니다. 대부분의 테스트가 CI 환경을 필요로 했기 때문에, 개발자들은 기본 단위 테스트를 넘어 자신의 코드가 제대로 동작하는지 확인하려면 merge request를 만들어야 했습니다. CI는 개발자가 특정 테스트를 실행하고 싶어 한다는 사실을 알지 못했고, 전체 테스트 스위트를 실행해 귀한 계산 자원을 낭비하고 개발 주기를 늦췄습니다.
테스트는 시간이 지나며 쌓였고, CI 시스템의 부하는 커졌으며, 결국 빌드는 견딜 수 없을 정도로 느리고 불안정해졌습니다. 또 한 번의 변화가 필요했습니다.
내가 다뤄 본 대략 열두 개의 빌드 시스템 중에서, Bazel만이 나에게 말이 되는 시스템이었습니다. 어쩌면 내가 protocol buffers를 끼우지 않고는 아무것도 하는 법을 배우지 못했기 때문일 수도 있습니다. 내가 Bazel에서 가장 좋아하는 특징 중 하나는 일상적인 사용에서 얼마나 명시적이고 직관적인가 하는 점입니다.
Bazel은 좋은 비디오게임과 같습니다. 배우기는 쉽고, 마스터하기는 어렵습니다. 빌드 타깃을 정의하고 연결하는 일은 쉽습니다(대부분의 엔지니어가 하는 일입니다). 하지만 새로운 빌드 규칙을 추가하려면 어느 정도 전문성이 필요합니다. Google의 모든 엔지니어는 Blaze(Bazel의 Google 내부 변형)에 대해 많이 알지 못해도 올바른 빌드 파일을 작성할 수 있습니다. 빌드 파일은 장황해서 거의 지루할 정도지만, 그것은 좋은 일입니다. 모듈의 산출물과 의존성이 무엇인지 독자에게 정확히 알려주기 때문입니다.
Bazel은 많은 기능을 제공하지만, 우리가 주로 관심을 가진 것은 다음과 같습니다.
더 중요한 것은 Bazel이 우리의 개발 환경과 CI 환경을 통합한다는 점입니다. 이제 우리의 모든 테스트는 Bazel 테스트이므로, 모든 개발자가 어떤 테스트든 로컬에서 실행할 수 있습니다. 본질적으로 우리 CI 작업은 bazel test --config=ci //...입니다.
우리 Bazel 설정의 좋은 점 하나는 외부 의존성의 버전을 단일 파일에서 구성할 수 있다는 것입니다. 아이러니하게도 cargo 개발자들은 우리가 마이그레이션을 마친 몇 주 뒤에 워크스페이스 의존성 상속 지원을 구현했습니다.
당신은 정말 순진한 학자군요. 내가 어떻게 해야 하냐고 물었더니, 당신은 내가 무엇을 해야 하는지를 말해 줬어요. 내가 무엇을 해야 하는지는 알아요. 다만 그걸 어떻게 해야 하는지를 모를 뿐입니다.
Andrew Grove의 말로 전해짐. Jim Huling, Chris McChesney, Sean Covey의 The 4 Disciplines of Execution xx쪽 참조.
빌드 시스템 마이그레이션 아이디어는 긴 빌드 시간과 열악한 도구와 싸우는 데 지친 몇몇 엔지니어들(읽기: Xooglers)에게서 나왔습니다. 놀랍게도 아주 이른 단계부터 몇몇 자원자가 이 반란에 동참하고 싶다는 의사를 밝혔습니다. 우리는 전환을 실행하고 경영진의 승인을 얻기 위한 계획이 필요했습니다.
대규모 코드베이스의 첫 번째 규칙은 중요한 변화를 점진적으로 도입하는 것입니다. 이 절에서는 몇 달에 걸쳐 진행된 우리의 마이그레이션 과정을 설명합니다.
우리는 프로토타입을 만드는 것으로 마이그레이션을 시작했습니다. 우리는 코드베이스에서 가장 문제가 될 것으로 예상한 기능들을 모방한 샘플 저장소를 만들었습니다. 예를 들면 prost 라이브러리를 사용한 Protocol Buffer 타입 생성, 단일 호출에서 Rust를 WebAssembly와 네이티브 코드로 함께 컴파일하기, rust-analyzer 지원 설정 등이었습니다. 우리가 직면한 가장 복잡한 문제들이 작은 규모에서는 해결책이 있다는 것을 확인한 뒤, 우리는 경영진에게 이 안건을 제시하고, 최종 비전과 필요한 인원 및 시간을 설명해 승인을 받았습니다. 이제 진짜 작업이 시작되었습니다.
우리의 CI는 cargo를 소스 코드에서 바이너리를 만들어 내는 블랙박스로 취급하는 다단계 프로세스였습니다. 빌드 시간을 최소화하려는 우리의 임무에는 두 개의 주요 작업 흐름이 있었습니다.
이 두 작업 흐름은 서로 다른 기술 역량을 요구했고, 우리는 이들을 병렬로 진행하고 싶었습니다. 첫 번째 작업 흐름의 막힘을 풀기 위해, 우리는 cargo를 블랙박스로 취급하고 배포 및 테스트용 바이너리를 생성하는 간단한 Bazel 규칙 cargo_build를 만들었습니다. 이렇게 해서 인프라 전문가들은 Bazel로 OS 이미지를 빌드하는 방법을 알아낼 수 있었고, Rust 전문가들은 Rust 코드의 bazelification을 계속 진행할 수 있었습니다.
저장소에 첫 번째 BUILD 파일이 생기자마자 우리는 CI 파이프라인에 bazel test //... 작업을 추가했습니다. 이 추가 작업은 CI 대기 시간을 약간 늘렸지만, Bazel로 전환된 패키지들이 시간이 지나며 망가지지 않도록 보장해 주었습니다. 부수적인 이점으로, 개발자들은 코드 리팩터링 중 Bazel 관련 CI 실패를 경험하기 시작했습니다. 그들은 능동적으로 BUILD 파일 수정 방법을 배우며 새로운 세계에 점차 익숙해졌습니다.
두 번째 작업 흐름의 목표는 수백 개의 Rust 패키지를 새 빌드 규칙으로 변환하는 것이었습니다. 우리는 특별한 처리가 필요한 스택 하단의 핵심 패키지들부터 시작했고, 이후 프로젝트 자원자들이 시간이 날 때마다 몇 개의 패키지씩 bazelification을 진행했습니다. 이 지루한 작업에는 두 가지 작은 요령이 도움이 되었습니다.
Cargo.toml 파일을 우리의 가이드라인에 맞는 90% 완성도의 BUILD 파일로 변환하는 스크립트에 며칠을 투자했습니다. 많은 패키지는 수동 처리가 필요했고, 생성된 BUILD 파일도 최적과는 거리가 멀었지만, 이 스크립트는 전환 과정을 크게 가속했습니다.BUILD 파일이 있는 패키지와 없는 패키지를 찾아 마이그레이션 진행 상황을 시각화하는 유틸리티를 작성했습니다. 이 작은 도구는 우리의 사기에 엄청난 효과를 주었습니다.결국 우리는 Bazel로 Rust 코드의 모든 부분을 빌드하고 테스트할 수 있게 되었습니다. 그 뒤 우리는 OS 빌드를 cargo_build 기반 부트스트래핑에서 Bazel 규칙으로 소스에서 직접 빌드한 바이너리로 전환했습니다.
퍼즐의 마지막 조각은 테스트 동등성을 보장하는 일이었습니다. Cargo는 테스트를 자동으로 찾아내지만, Bazel BUILD 파일은 각 테스트 유형(crate 테스트, 문서 테스트, 통합 테스트)에 대해 명시적인 타깃을 요구합니다. 인프라 팀은 cargo와 Bazel 빌드 파이프라인의 출력을 분석해 실행된 테스트 목록을 비교하는 또 다른 작은 유틸리티를 작성했습니다. 이를 통해 자원자들이 마이그레이션 중 모든 테스트를 빠짐없이 반영했는지, 그리고 개발자들이 새 테스트를 추가할 때 BUILD 파일 업데이트를 잊지 않았는지를 보장할 수 있었습니다.
Bazel은 산출물 빌드에 관한 우리의 대부분 요구를 해결하지만, 개발자 흐름과 관련된 몇몇 cargo 기능은 아직 복제하지 못했습니다.
intellij-rust 저장소의 issue #5594를 보십시오.cargo-expand 같은 도구입니다.이러한 문제들 때문에 우리는 여전히 cargo 파일을 남겨 두고 있습니다. 다행히도 이것이 CI 시간에 큰 영향을 주지는 않습니다. 우리가 필요한 유일한 검사는 cargo check --tests --benches가 성공하는지 확인하는 것이기 때문입니다.
Bazel 마이그레이션 프로젝트는 명백한 성공이었습니다. 이 프로젝트에 기여한 재능 있는 인프라 팀과 모든 자원자들에게 감사드립니다.
특별한 감사는 rules_rust Bazel 플러그인의 개발자와 유지보수자들에게 전합니다. 그들은 마이그레이션 동안 여러 차례 우리의 막힌 길을 뚫어 주었습니다. 특히 Andre Uebel과 Daniel Wagner-Hall, 그리고 자신의 rust-analyzer 전문 지식을 기꺼이 공유해 준 Alex Kladov에게 감사드립니다.
이 글은 Reddit에서 토론할 수 있습니다.