glibc 2.31을 적자 2.0.0 대신 1.0.0을 골랐다: uv 0.12.17의 universal lockfile

개발 노트북에서는 잠금 파일이 잘 만들어졌는데, 오래된 Linux 서버에서는 wheel을 설치할 수 없다는 오류가 난다. Python 패키징에서 드물지 않은 장면이다. 문제는 운영체제가 Linux인지 아닌지만으로 끝나지 않는다. 같은 x86-64 Linux라도 시스템의 glibc 버전이 wheel이 요구하는 하한보다 낮을 수 있다.

uv 0.12.17은 이 간극을 다루는 preview 설정 minimum-libc-version을 넣었다. 이름만 보면 설치 단계의 호환성 검사처럼 들리지만, 핵심은 universal resolution이다. 잠금 파일을 만들 때부터 "우리가 지원해야 하는 가장 오래된 libc"를 resolver의 조건에 포함한다.

기존 marker로는 libc 하한을 적을 수 없었다

uv의 required-environments는 소스 배포본 없이 wheel만 제공되는 패키지가 어떤 환경을 반드시 지원해야 하는지 지정한다. Linux와 CPU 아키텍처는 PEP 508 marker로 표현할 수 있다. 그러나 glibc와 musl의 종류와 버전은 그 marker 집합에 없다.

이 빈칸은 실제 장애로 이어졌다. uv 이슈 #12597의 보고자는 glibc 2.17 시스템에서 JAX CUDA 의존성을 설치하려 했다. resolver가 선택한 최신 NVIDIA wheel은 glibc 2.27 이상을 요구했고, 동기화 단계에서 실패했다. 호환되는 이전 의존성 버전은 있었지만 사용자가 직접 상한을 찾아 추가해야 했다. 유지관리자는 2025년 10월 당시 "glibc나 musl 버전을 기준으로 backtracking을 강제할 방법이 없다"고 답했다.

0.12.17의 새 설정은 이 문제를 별도 환경 marker를 발명해서 해결하지 않는다. required-environments가 플랫폼과 아키텍처를 정하고, minimum-libc-version이 그 환경에서 충족해야 할 libc 하한을 보탠다.

required-environments와 minimum-libc-version이 wheel coverage 검사 및 이전 버전 backtracking으로 이어지는 uv universal resolver 흐름

그림 1. uv는 플랫폼·libc 하한·wheel 또는 sdist coverage를 함께 보고, 필수 환경을 만족하지 못하면 이전 버전을 검사한다. 출처: github.com/astral-sh/uv/pull/21651.

manylinux 숫자를 거꾸로 읽으면 안 된다

PyPA의 플랫폼 태그 명세는 manylinux_x_y_arch의 x.y를 wheel이 지원하는 최소 glibc 버전으로 정의한다. manylinux_2_17_x86_64 wheel은 glibc 2.17 이상에서 쓸 수 있다. manylinux_2_34_x86_64는 2.34 이상이 필요하다.

배포 하한을 glibc 2.31로 정했다면 2.17 wheel은 조건을 충족하지만 2.34 wheel은 충족하지 못한다. 숫자가 더 큰 wheel이 더 폭넓게 호환되는 것이 아니다. 더 최신 glibc를 요구하므로 오래된 배포 대상에서는 오히려 쓸 수 없다.

glibc 2.31 배포 하한에서 manylinux 2.17 wheel은 조건을 충족하고 manylinux 2.34 wheel은 충족하지 않는 비교

그림 2. manylinux 태그는 wheel의 최소 glibc 요구 조건이며, 더 큰 숫자가 더 넓은 호환성을 뜻하지 않는다. 출처: packaging.python.org/en/latest/specifications/platform-compatibility-tags/#manylinux.

여기서 중요한 점이 하나 있다. uv는 2.34 wheel을 잠금 파일에서 지우지 않는다. 공식 resolution 문서와 PR #21651은 2.17과 2.34 wheel, 그리고 그 해시를 모두 보존한다고 설명한다. glibc 2.31을 만족할 수 있는 버전을 고르되, 더 최신 시스템이 설치할 때는 같은 패키지 버전의 2.34 wheel을 선택할 여지를 남기는 방식이다.

설정은 플랫폼 목록과 한 쌍이다

x86-64와 ARM64 Linux에서 glibc 2.31을 지원하려면 다음처럼 적는다.

[tool.uv]
preview-features = ["minimum-libc-version"]
required-environments = [
  "sys_platform == 'linux' and platform_machine == 'x86_64'",
  "sys_platform == 'linux' and platform_machine == 'aarch64'",
]
minimum-libc-version = { glibc = "2.31" }

Alpine 계열까지 보장하려면 musl = "1.2"를 함께 넣을 수 있다. 생략한 libc는 금지되는 것이 아니라 필수 조건에서 빠진다. 즉 glibc만 적었다고 musl wheel이 잠금 파일에서 사라지는 것은 아니다. glibc와 musl을 둘 다 적으면 각 하한을 만족하는 coverage가 필요하다.

또 하나의 예외는 일반 Linux wheel과 소스 배포본이다. uv 0.12.17의 구현은 linux_x86_64처럼 libc를 제한하지 않는 일반 Linux wheel을 어느 쪽 하한에도 쓸 수 있는 것으로 본다. 사용 가능한 source distribution도 모든 환경의 coverage를 제공할 수 있다. 따라서 최신 버전에 2.34 wheel만 있어도 빌드 가능한 sdist가 함께 있으면 resolver가 그 버전을 유지할 수 있다.

이 설정은 uv lock과 uv pip compile --universal에 적용된다. 특정 --python-platform 하나를 대상으로 컴파일하는 경로와는 의미가 다르다. 그리고 아직 preview 기능이다. 경고를 숨기는 플래그가 곧 안정성 보장을 뜻하지는 않는다.

uv 0.12.17 바이너리로 다시 계산했다

공식 테스트만 옮겨 적지 않고, 0.12.17 릴리스 바이너리로 작은 로컬 wheel index를 만들었다. SHA-256 체크섬을 공식 배포 파일과 대조한 뒤 같은 demo 패키지의 두 버전만 넣었다.

  • demo 1.0.0: manylinux_2_17_x86_64
  • demo 2.0.0: manylinux_2_34_x86_64

libc 하한이 없을 때 uv lock --offline은 최신인 2.0.0을 골랐다. 같은 프로젝트에 minimum-libc-version = { glibc = "2.31" }을 추가하자 1.0.0으로 돌아갔고, 그 설정도 uv.lock의 options에 기록됐다. 두 실행의 종료 코드는 모두 0이었다.

uv 0.12.17 synthetic wheel 재현에서 libc 하한 없음은 2.0.0, glibc 2.31 하한은 1.0.0을 선택한 결과표

그림 3. 동일한 로컬 wheel index에서 glibc 하한 하나가 resolver의 버전 선택을 2.0.0에서 1.0.0으로 바꿨다. 출처: github.com/astral-sh/uv/blob/0.12.17/crates/uv/tests/lock/minimum_libc.rs.

이 재현은 resolver의 버전 선택만 확인한다. 실제 JAX와 CUDA 의존성을 설치하거나, 모든 배포판에서 동작한다고 검증한 것은 아니다. 반대로 새 기능이 특정 이슈 하나에만 맞춘 특수 처리라는 뜻도 아니다. 공식 테스트는 여러 아키텍처, glibc와 musl 전환, sdist fallback, 호환 버전 부재, 잠금 무효화까지 나눠 검사한다.

운영에서는 배포 하한을 제품 요구사항으로 다뤄야 한다

minimum-libc-version은 서버에서 감지한 값을 잠금 파일에 자동으로 복사하는 옵션이 아니다. 팀이 지원할 배포 하한을 명시하는 설정이다. 그래서 먼저 실제 운영 이미지의 libc와 아키텍처를 정해야 한다. CI에서 최신 Ubuntu만 쓰면서 "Linux 지원"이라고 적는다면, 잠금 단계가 오래된 운영 서버의 실패를 막아 주지 못한다.

실무에서는 세 가지를 함께 관리하는 편이 낫다.

  1. 지원할 Linux 아키텍처를 required-environments에 정확히 적는다.
  2. 운영 이미지의 glibc 또는 musl 하한을 minimum-libc-version에 고정한다.
  3. 하한을 바꾼 커밋에서는 uv lock --locked가 실패하는지 확인한 뒤 잠금 파일을 의도적으로 갱신한다.

uv의 공식 테스트는 libc 하한이 달라지면 선택된 wheel이 우연히 그대로여도 lockfile 갱신이 필요하다고 본다. 이 동작은 번거로운 부수효과가 아니다. 배포 호환성 정책이 바뀌었다는 기록을 잠금 파일에 남기는 장치다.

정리하면 0.12.17의 변화는 "오래된 Linux에서도 무조건 설치된다"는 약속이 아니다. universal resolver가 지원 대상의 libc 하한을 미리 알고, 호환되지 않는 최신 wheel만 있는 버전에서 이전 버전으로 돌아갈 수 있게 된 것이다. 설치 시점에야 발견하던 실패를 잠금 시점으로 당긴다는 점이 실용적이다.

참고 자료

댓글

이 블로그의 인기 게시물

체크아웃은 분리됐는데 Git 상태는 공유된다: Codex 0.154 worktree의 세 경계

잠근 작업이 커밋 뒤 다시 나온다: PostgreSQL 에이전트 큐의 소유권 설계

코딩 에이전트는 API 문서보다 AGENTS.md를 먼저 읽었다