복잡한 프로젝트의 빌드가 고려해야 할 지점과 다양한 빌드 시스템의 선택지와 트레이드오프를 개괄하고, 빌드 설계 아이디어를 논의합니다.
현재 저는 러스트 컴파일러의 빌드 시스템(흔히 x.py 혹은 bootstrap이라고 부름, https://rustc-dev-guide.rust-lang.org/building/bootstrapping/intro.html 참고)에서 일하고 있습니다. 그 결과, 대부분의 사람들이 신경 쓰지 않아도 되는 온갖 빌드 시스템의 이상한 점들을 많이 생각하게 됩니다. 이 글은 복잡한 프로젝트의 빌드가 무엇을 고려해야 하는지 개괄하고, 제가 좋아하는 빌드 시스템 아이디어의 방향을 어렴풋이 가리키는 것을 목표로 합니다.
이 글은 일반적으로 실무 개발자가 읽기 쉽게 쓰려 했지만, 이 분야에서는 제가 전문가 시야 장애를 많이 갖고 있고, 가끔은 “사람들이 장석이 뭔지야 당연히 알지!”(https://xkcd.com/2501/)라고 가정하곤 합니다. 이해하기 어렵다면 미리 사과드립니다.
무엇이 프로젝트의 빌드를 복잡하게 만들까요?
사람들이 보통 빌드에서 처음으로 원하게 되는 약간 복잡한 것은 통합 테스트를 작성하는 것입니다. 다음은 그 예로, 통합 테스트를 수행하는 러스트 프로그램입니다:
// tests/integration.rs
use std::process::Command;
fn assert_success(cmd: Command) {
assert!(cmd.status().unwrap().success());
}
#[test]
fn test_my_program() {
assert_success(Command::new("cargo").arg("build"));
assert_success(Command::new("target/debug/my-program")
.arg("assets/test-input.txt"));
}
이는 당신이 cargo test를 실행할 때, cargo가 tests/integration.rs를 독립 실행 프로그램으로 컴파일하고 실행하도록 지시합니다. 이때 진입점은 test_my_program입니다.
이 프로그램은 글 전반에서 여러 번 다시 등장할 것입니다. 지금은 우리가 cargo test 내부에서 cargo build를 호출하고 있다는 점에 주목하세요.
사실 이건 초안에서 까먹었는데, 일반적인 경우에 대해서 Cargo가 워낙 잘 처리하기 때문입니다1. 많은 수작업 빌드(에헴, make, 에헴)에서는 의존성을 수동으로 지정하는 것이 심각하게 부실하고, 병렬화를 위한 -j가 제대로 동작하지 않으며, 오류가 나면 make clean을 돌리는 일이 흔합니다. 말할 필요도 없이, 이는 형편없는 경험입니다.
다음 단계의 복잡성은 코드를 크로스 컴파일하는 것입니다. 이 시점에서 이미 일이 얼마나 복잡해지는지 감이 옵니다:
$ cargo test --target aarch64-unknown-linux-gnu
Compiling my-program v0.1.0 (/home/jyn/src/build-system-overview)
error[E0463]: can't find crate for `std`
|
= note: the `aarch64-unknown-linux-gnu` target may not be installed
= help: consider downloading the target with `rustup target add aarch64-unknown-linux-gnu`
= help: consider building the standard library from source with `cargo build -Zbuild-std`
크로스 컴파일의 난이도는 빌드 시스템뿐 아니라 사용하는 언어와 대상 플랫폼에 따라 크게 달라집니다. 여기서 특히 지적하고 싶은 점은, 표준 라이브러리는 어디선가 와야 한다는 것입니다. 러스트에서는 보통 컴파일러와 같은 곳에서 다운로드합니다. 자바, 자바스크립트, 파이썬과 같은 바이트코드 또는 인터프리터 언어에서는 대상이 하나뿐이라 크로스 컴파일이라는 개념이 없습니다. C에서는 보통 라이브러리 자체가 아니라 API를 기술한 헤더만 설치합니다2 자세한 논의는 선행 선언과 소스 전체 접근 요구의 트레이드오프에 대한 이 스택익스체인지 글을 참고하세요: https://langdev.stackexchange.com/questions/153/what-are-the-advantages-of-requiring-forward-declaration-of-methods-fields-like/.
. 다음 주제로 넘어가 봅시다:
사람들이 “libc”라고 할 때 보통 두 가지 중 하나를 의미합니다. C 표준 라이브러리인 libc.so 또는 C 런타임인 crt1.o입니다.
libc가 중요한 이유는 두 가지입니다. 첫째, 이제 C는 더 이상 (하나의) 언어가 아닙니다. 그래서 일반적으로 어떤 언어든 새로운 플랫폼으로 포팅할 때의 첫 단계는 C 툴체인을 갖추는 것입니다3. 둘째, libc는 사실상 플랫폼으로의 인터페이스이기 때문에 Windows, macOS, OpenBSD에는 안정적인 시스템 콜 경계가 없습니다—그들의 안정적인 라이브러리(libc, 그리고 Windows의 경우에는 그 외 몇 가지)를 통해서만 커널과 대화할 수 있습니다.
왜 그렇게 했는지 이야기하려면, 다음을 이야기해야 합니다:
많은언어는 모든 변수와 함수 참조를 컴파일 타임에 해석하는 “초기 바인딩”과 런타임에 해석하는 “지연 바인딩” 개념을 갖고 있습니다. C에도 이 개념이 있지만 “바인딩” 대신 “링킹”이라고 부릅니다. “지연 바인딩”은 “동적 링크”라고 부릅니다4 초기 바인딩은 “정적 링크”라고 부릅니다.
. 지연 바인딩된 변수 참조는 프로그램 시작 시 “동적 로더”에 의해 해석됩니다. 추가 바인딩은 런타임에 dlopen과 관련 함수들로 수행할 수 있습니다.
플랫폼 관리자는 동적 링크를 매우 선호합니다. 벤더링을 싫어하는 이유와 같습니다. 지연 바인딩은 시스템의 모든 애플리케이션에 대해 라이브러리를 한 번에 업데이트할 수 있게 해줍니다. 이는 보안 취약점 공지의 경우 매우 중요합니다. 패치·공지 시점과 공격자들이 실제로 악용하기 시작하는 시점 사이의 타임라인이 매우 짧기 때문입니다.
애플리케이션 개발자는 동적 링크를 싫어합니다. 이유도 거의 같습니다. 모든 의존성을 패키징하는 플랫폼 관리자가 일을 잘한다고 신뢰해야 하고, 자신들이 고려하거나 테스트하지 않은 시나리오에서 애플리케이션이 배포되기 때문입니다. 예를 들어, Windows에 openssl을 설치하는 것은 꽤 어렵습니다. 실제로 이 글을 쓰는 동안 제가 “openssl을 동적 링크한다”고 말하는 걸 듣고 한 친구가 “아 제발, 악몽이 떠오르네”라고 했습니다.
널리 쓰이는 동적 링크를 생각하는 좋은 방식은, _컴파일된 프로그램에서 라이브러리를 디벤더링(devendoring)하는 메커니즘_이라고 보는 것입니다. 동적 링크에는 다른 용도도 있지만 상대적으로 드뭅니다.
프로그램을 동적으로 링크하는 것을 쉽게 혹은 어렵게 만드는지 여부는 빌드 시스템(또는 언어)을 구분 짓는 주요 요인 중 하나입니다. 이에 대해서는 나중에 더 이야기하겠습니다.
좋습니다. 다시 크로스 컴파일로 돌아가 봅시다.
프로그램을 크로스 컴파일하려면 다음이 필요합니다:
--target을 넘기는 것만으로 충분합니다. gcc를 사용한다면 아예 별도의 컴파일러 한 벌을 추가로 설치해야 합니다..
툴체인은 어디에서 오나요? ...
...
...
... 알고 보니 이건 어려운 문제입니다. 대부분의 빌드 시스템은 “신경 쓰지 않기”로 이를 회피합니다. 즉, 컴파일러를 업데이트한 뒤 make clean을 실행하지 않으면, 웬만한 Makefile은 끔찍하게 망가집니다. Cargo는 훨씬 똑똑합니다—출력을 target/.rustc_info.json에 캐시하고, rustc --version --verbose가 바뀌면 리빌드합니다. “툴체인 무효화를 어떻게 처리하느냐” 또한 나중에 보겠지만 빌드 시스템을 구분 짓는 중요한 요소입니다.
툴체인은 더 일반적인 문제의 특수 사례입니다. 빌드는 컴파일러 입력으로 전달된 파일뿐 아니라, _전체 빌드 환경_에 의존합니다. 이는 예컨대 인터넷에서 무엇인가를 다운로드하고, 이전 빌드 산출물을 이후 산출물에 임베드하거나, 완전히 중첩된 컴파일러 호출을 실행하는 것이 가능하고 흔하다는 뜻입니다.
복잡성의 상위 레벨로 갈수록, 사람들은 캐싱으로 아주 복잡한 일을 하고 싶어합니다. 캐싱이 건전하려면, 같은 컴파일러 호출이 매번 동일한 출력을 내야 하는데, 이를 재현 가능한 빌드라고 합니다. 이건 생각보다 훨씬 어렵습니다! 해시맵이나 디렉터리 나열을 순회하는 것처럼, 프로그램이 흔히 하지만 개발자가 잘 의식하지 않는 비결정성의 원인이 많습니다.
가장 높은 수준에서는, 여러 머신에서 빌드를 수행하고 그 산출물을 결합하기를 원합니다. 이때 머신마다 절대 경로가 다르므로, 절대 경로를 읽는 것조차 허용할 수 없습니다. 흔한 도구는 --remap-path-prefix라는 컴파일러 플래그로, 빌드 시스템이 절대 경로를 상대 경로로 매핑할 수 있게 해줍니다. --remap-path-prefix는 또한 rustc가 표준 라이브러리의 소스를 진단을 출력할 때 표시할 수 있게 해주는데, 이는 빌드된 머신과 다른 머신에서 실행 중일 때도 가능합니다.
이제 빌드 시스템의 트레이드오프 공간에 대해 이야기할 준비가 됐습니다.
설정을 YAML 파일에 넣는다고 해서 선언적이 되는 건 아닙니다! 튜링 불완전한 언어에 스스로를 가둔다고 해서 코드가 자동으로 읽기 쉬워지지 않습니다! — jyn
빌드 시스템에서 가장 흔한 불필요한 실수는 빌드 구성을 커스텀 언어로만 쓰게 강제하는 것입니다6 거의 모든 빌드 시스템이 이러니, 굳이 실명을 거론할 필요도 못 느끼겠습니다.
. 이렇게 하는 이유는 본질적으로 두 가지입니다:
좋습니다. 빌드를 “선언적”으로 만드는 것이 허상이라면, 차라리 프로그래머에게 진짜 언어를 줍시다. 흔한 선택지는 다음과 같습니다:
Starlark7 Starlark은 헤르메틱 빌드 시스템에 묶여 있지 않습니다. 흔한 사용처가 헤르메틱 빌드 시스템뿐인 것은 불운한 일입니다.
“빌드 시스템이 작성된 것과 같은 언어”(예: Clojure, Zig, JavaScript)
“하지만 잠깐만요, jyn!” 여러분이 말할 수 있겠죠. “무엇을 리빌드할지 알아낼 때마다 매번 전체 프로그램을 실행해야 하는 빌드 시스템을 제안하는 건 아니겠죠??”
그러니까 ... 실제로 그렇게 하는 사람들도 있습니다. 하지만 사람들이 한다고 해서 좋은 생각은 아니니, 대안을 보죠. 대안은 _빌드 그래프를 직렬화_하는 것입니다. 설명보다 예시가 쉬우니, Ninja 빌드 시스템8을 예로 들어보겠습니다:
rule svg2pdf
command = inkscape $in --export-text-to-path --export-pdf=$out
description = svg2pdf $in $out
build pdfs/variables.pdf: svg2pdf variables.svg
Ninjafile은 빌드 의존성을 표현하는 데 필요한 절대 최소 기능만 제공합니다. “규칙(rule)”은 출력을 어떻게 빌드할지를 설명하고, “빌드 엣지(build edge)”는 언제 빌드할지를 선언하며, “변수(variable)”는 무엇을 빌드할지를 말합니다9 사실 변수는 이보다 더 일반적이지만, $in과 $out에 대해서는 이 설명이 맞습니다.
. 대략 이게 전부입니다. 빌드 규칙 실행 중에 동적으로 빌드 엣지를 추가하는 “depfile”에 관한 미묘한 점들이 조금 더 있습니다. 기능이 극도로 최소이기 때문에 이 파일들은 보통 우리가 앞에서 말한 언어들로 작성한 configure 스크립트를 사용하여 생성하도록 되어 있습니다. 가장 흔한 생성기는 CMake와 GN이지만, 포맷이 워낙 단순해서 어떤 언어든 사용할 수 있습니다.
멋진 점은 파싱이 매우 쉽다는 것입니다. 즉, 원한다면 ninja의 자체 구현을 작성하기가 아주 쉽습니다. 또한 앞서 논의한 많은 속성을 실제로 얻을 수 있습니다. 예를 들어:
ninja -t commands)ninja -n -d explain)ninja -t query variables.svg)이 속성들은 실제로 매우 유용합니다.
“jyn, 언제 본론으로 가나요!” 알겠어요, 곧 갑니다.
이 접근의 주요 단점은 빌드 그래프를 _직렬화할 수 있어야 한다_는 점입니다. 어떤 의미에서 이는 오히려 좋다고 봅니다. 빌드가 하는 모든 일을 사전에 생각해야 하기 때문이죠. 하지만 다른 한편으로, 중첩된 ninja 호출이 있거나, 앞서 예로 든 cargo test -> cargo build 같은 상황(https://jyn.dev/build-system-tradeoffs/#running-generated-binaries)[10](https://jyn.dev/build-system-tradeoffs#fn-9)
또 다른 예로는 “빌드 그래프가 바뀌었을 때 build.ninja를 다시 빌드하기”가 있습니다. 언어가 너무 제한적이라, 그래프에 의존성 정보를 끼워 넣으려 애쓰는 것보다 configure 스크립트를 다시 실행하는 편이 더 쉽기 때문에 생각보다 흔합니다.
에서는, 빌드 그래프 질의 도구들이 기대하는 정보를 담지 못합니다.
소스 디렉터리의 모든 파일을 다시 stat하는 것은 비용이 큽니다. 파일 변경을 빌드 도구가 통지받고 꼭 필요한 파일만 리빌드하는, 푸시 모델 대신 풀 모델을 쓸 수 있다면 훨씬 좋을 것입니다. 여기에 네이티브로 통합한 도구도 조금은 있는데, Tup, Ekam, jj, Buck2 등이 있지만, 전반적으로는 드뭅니다.
그런데 리플렉션이 있다면 괜찮습니다! 직접 파일 모니터링 도구를 작성하고, 빌드 시스템에 변경된 입력에 대해 어떤 파일이 리빌드되어야 하는지 물어본 다음, 딱 그 파일들만 리빌드하도록 지시할 수 있습니다. 그러면 그래프의 모든 파일을 재귀적으로 stat할 필요가 없어집니다.
자세한 아이디어는 Tup 논문을 참고하세요.
좋습니다. 이제 어떤 프로그래밍 언어를 사용해 빌드 그래프를 생성하고, 입력의 변경에 대해 정확히 필요한 출력만 리빌드해 주는 빌드 시스템이 있다고 가정합시다. 그렇다면 정확히 우리의 입력은 무엇일까요?
빌드 분야에서 의존성 추적에는 크게 네 가지 접근이 있습니다.
이 유형의 빌드 시스템은 모든 책임을 여러분, 프로그래머에게 외주화합니다. “모든 책임을 외주화한다”는 말은, 의존성을 직접 손으로 다 써 넣어야 하고, 도구가 그것이 옳게 작성되었는지 도와주지 않는다는 뜻입니다. 예시:
make이 범주의 빌드 시스템에서 흔한 문제는, _빌드 규칙 자체_를 그래프의 입력으로 표시하는 것을 사람들이 잊어버려, 죽은 산출물이 여기저기 굴러다니게 되고 그 결과 비건전한 빌드가 된다는 점입니다.
제 겸손한 의견으로는, 이런 도구는 빌드 그래프의 직렬화 레이어로 쓰거나, 다른 선택지가 없을 때만 유용합니다. 여기 5센트 줄 테니, 더 나은 빌드 시스템을 사오세요.
가끔은 빌드 시스템(CMake, Cargo, 제가 모르는 다른 것도 있을 수 있음)이 좀 더 나아져, 컴파일러의 내장 의존성 추적 지원(예: gcc -M이나 rustc --emit=dep-info)을 사용하고, 자동으로 빌드 규칙 자체에 대한 의존성도 추가합니다. 이것은 아무것도 없는 것보다 훨씬 낫고, 수동으로 추적하는 것보다 훨씬 신뢰할 수 있습니다. 하지만 여전히 본질적으로 컴파일러가 올바르다고 신뢰하고, 환경 의존성은 추적하지 않습니다.
이 유형의 빌드 시스템은 항상 풀 빌드를 수행하고, 그 과정에서 환경을 임의로 수정할 수 있게 둡니다. 단순하고, 항상 올바르지만, 비용이 큽니다. 예시:
make clean && make (일종의—make clean이 신뢰할 수 있다고 가정할 때. 종종 그렇지 않습니다.)step: 규칙)cc file.c -o file)감당할 수 있다면 괜찮습니다. 하지만 비쌉니다! 대부분의 사람들이 Github Actions를 쓰는 이유는 GHA가 내일이 없는 듯 공짜 CI 시간을 퍼주기 때문입니다. 실제 비용을 고려해야 했다면, 낭비되는 CPU 시간이 훨씬 줄었을 거라고 생각합니다.
이건 빌드 시스템 쪽 사람들에게는 잘 알려져 있지만 그 밖에서는 거의 듣기 힘든 용어로, “모나딕 빌드”와 함께 등장합니다. “헤르메틱”은 _환경에 있는 것이 여러분이 명시적으로 넣은 것뿐_임을 의미합니다. 때때로 “샌드박싱”이라고 부르는데, 보안과 관련된 뉘앙스 때문에 항상 적절한 표현은 아닙니다. 예시:
장점이 많습니다! 입력을 절대 잊을 수 없다는 것을 정적으로 보장하며, 네트워크나 구현 도구 문제만 없다면 100% 신뢰할 수 있고 🙃, 실제 의존성이 무엇인지 아주 세밀한 통찰을 제공합니다. 헤르메틱 빌드 시스템으로 할 수 있는 것들:
주요 단점은 실제로 모든 의존성을 명시해야 한다는 것입니다(명시하지 않으면, 헤르메틱 시스템과 “그건 내 문제가 아님” 유형의 주요 차이점인, 비건전한 빌드 그래프 대신 하드 에러를 얻게 됩니다). Bazel과 Buck2는 Starlark를 제공하여 ~진짜11 실은 튜링 완전은 아닙니다
언어로 이를 작성할 수 있게 하지만, 그래도 여전히 일이 매우 많습니다. 둘 다 컴파일러 툴체인을 어디서 가져오는지만 정의한, 거대한 “프렐류드(prelude)” 모듈을 갖고 있습니다12 오픈소스 Bazel은 실제로 프렐류드 내부에서 기본 헤르메틱이 아니고 시스템 라이브러리를 사용한다고 들었습니다. 꽤 불행한 일입니다. 이런 방식으로 Bazel을 사용하면 단점은 많이 얻고 장점은 적게 얻게 됩니다. 제가 아는 대부분의 사용자는 헤르메틱 모드로 사용합니다.
.
Nix는 이 “프렐류드” 아이디어를 끝까지 밀어붙였다고 볼 수 있는데, “프렐류드”(nixpkgs)를 “NixOS에 패키징된 모든 것”으로 확장했기 때문입니다. import <nixpkgs>를 쓰면, 여러분의 nix 빌드는 논리적으로 nixpkgs 모노레포와 같은 빌드 그래프에 있게 됩니다. 단지 이미 엄청난 원격 캐시가 미리 빌드돼 있을 뿐이죠.
Bazel과 Buck2에는 nixpkgs 같은 것이 없습니다. 이것이 이들을 사용하려면 전담 빌드 엔지니어가 필요한 주된 이유입니다. 외부 의존성을 추가할 때마다 그 엔지니어가 처음부터 빌드 규칙을 계속 작성해야 합니다. 프렐류드에 없는 언어 툴체인도 패키징해야 합니다.
Nix에는 또 하나 흥미로운 속성이 있는데, 모든 패키지가 합성 가능하다는 것입니다. 같은 패키지의 서로 다른 버전을 동시에 설치해도 괜찮습니다. 서로 다른 스토어 경로를 사용하니까요. 레즈비언의 손가락이 서로 끼워 맞춰지는 것처럼 잘 맞물립니다.
이에 비해 docker는 합성되지 않습니다13
docker compose라고 불리는 것이 있긴 하지만, 그것은 이미지를 합성하는 것이 아니라 컨테이너를 합성합니다.
. docker에서는 “여러 서로 다른 소스 이미지에서 빌드 환경을 상속한다”라고 말할 방법이 없습니다. 가장 가까운 것은 “멀티 스테이지 빌드”로, 앞선 이미지에서 개별 파일을 뒤 이미지로 명시적으로 복사하는 것입니다. 맹목적으로 모든 파일을 복사할 수는 없습니다. 몇몇은 동일한 경로에 놓이길 원할 수 있고, 손가락이 서로 닿으면 ‘게이’가 되어버리니까요.
제가 아는 마지막 유형이며 가장 보기 드문 것은 트레이싱 빌드 시스템입니다. 이들은 헤르메틱 빌드 시스템과 같은 목표를 갖습니다. 여전히 100%의 의존성이 명시되기를 원합니다. 하지만 접근이 다릅니다. 코드를 샌드박싱하여 여러분이 명시한 의존성에만 접근을 허용하기보다는, 코드를 _계측_하여 파일 접근을 추적하고, 각 빌드 단계의 의존성을 기록합니다. 예시:
장점은, 의존성을 모두 적어야 하는 비용 없이도 헤르메틱 빌드 시스템의 모든 이점을 얻을 수 있다는 것입니다.
첫 번째 주요 단점은 커널이 시스템 콜 트레이싱을 지원해야 한다는 것입니다. 사실상 리눅스에서만 동작한다는 뜻입니다. SIP를 끄지 않고 macOS에서 이를 구현하는 방법에 대한 아이디어가 있긴 하지만, 아직 미완성이고 완전히 일반화되지는 못했습니다. 이에 대해서는 후속 글을 쓸지도 모르겠습니다. Windows에서 가능한 방법에 대해서는 아직 아이디어가 없지만, 가능해 보이긴 합니다.
두 번째 주요 단점은 그래프를 미리 알 수 없다는 점이 빌드 시스템에 많은 문제를 야기한다는 것입니다. 특히:
<system> include 헤더, 혹은 JVM 언어의 임의의 import), 실제 의존성을 명시하는 일이 매우 고통스러워집니다. ninja가 할 수 있는 최선은 “그 파일을 담고 있는 디렉터리 전체에 의존”하게 하는 것인데, 이건 새 파일이 추가될 때만이 아니라 그 디렉터리가 변경될 때마다 리빌드하게 만들어서 좋지 않습니다. (이론적으로) Ninja가 아닌 다른 직렬화 포맷으로 우회할 수는 있지만, 어쨌든 핫 패스에 많은 파일 stat() 호출을 추가하게 됩니다.저는 트레이싱이 빌드 그래프를 _생성_하기 위한 도구로는 유용하지만, 실제 실행 시 사용하는 도구로는 유용하지 않다는 데 설득되었습니다. gazelle도 비교해 볼 만합니다. Bazel을 위한 일종의 그런 도구인데, 시스템 콜 추적이 아니라 소스 파일 파싱을 기반으로 합니다.
이러한 패러다임을 결합하면 단순한 샌드박싱만으로는 하기 어려운 방식으로 헤르메틱 빌드를 검증할 수 있게 됩니다. 예를 들어, 트레이싱 빌드 시스템은 누락된 의존성을 잡아낼 수 있습니다:
또한 비재현적인 빌드도 감지할 수 있습니다:
이야기할 게 더 많습니다—빌드 시스템이 업스트림 유지보수자와 배포판 패키저 사이의 역학에 어떻게 영향을 미치는지, .a 파일이 나쁜 파일 포맷인 이유, mtime 비교가 대체로 나쁜 이유, 설정 옵션이 트레이드오프를 어떻게 훨씬 더 복잡하게 만드는지, FUSE가 얕은 체크아웃에서 불필요한 파일 다운로드를 피하기 위해 빌드 시스템이 VCS와 통합되도록 어떻게 도와줄 수 있는지 등. 하지만 이 글은 이미 충분히 깁니다.