시스템에 설치된 것과 다른 glibc로 바이너리를 실행하는 방법을, 동적 링커(ld-linux)와 ELF 인터프리터 필드를 활용해 설명합니다. 컨테이너 대신 버전된 sysroot와 인터프리터 지정/패치 기법으로 테스트와 배포에서 일관된 glibc를 보장하는 접근을 제안합니다.
최근 업무 논의 중에 어딘가 석연치 않은 주장과 맞닥뜨렸습니다. 최신 glibc로 테스트를 실행하려면 개발자 머신에 컨테이너를 구성해야 한다는 것이었습니다. 그 근거로는 다른 glibc를 로드하기 위해 LD_LIBRARY_PATH를 쓰는 것은 동작하지 않고, glibc 정적 링크는 역시 불가능하다는 점이 제시됐죠.
하지만… 시스템에 설치된 것과 다른 버전의 glibc로 프로그램을 실행하는 건 꽤 흔한 요구 아닌가요? 생각해 보세요. glibc 개발자들은 자신의 변경을 어떻게 테스트할까요? glibc는 컨테이너보다 훨씬 오래되었습니다. 컨테이너가 생기기 전에는 시스템 전체의 glibc를 덮어쓰는 방식으로 YOLO 테스트를 했을 리가 없잖아요.
자, 정말 우리가 쓸 수 있는 선택지는 무엇일까요? 이 질문에 답하려면 동적 바이너리가 어떻게 동작하는지, 그리고 glibc가 무엇인지부터 살펴봐야 합니다.
정적 바이너리는 프로그램 코드가 하나의 실행 파일에 모두 들어있는 바이너리입니다. 이런 바이너리는 시스템 호출을 통해 시스템 서비스를 사용할 수 있습니다—그렇지 않다면 OS와 상호작용할 수 없어 쓸모가 거의 없겠죠—하지만 그 바이너리 코드는 어떤 의미에서 “완결”되어 있습니다. 즉, 실행 파일에 보이는 그대로가 실행 시 메모리에 배치됩니다.
아래는 샘플 정적 바이너리의 모습입니다. Go로 작성한 "hello world" 프로그램인데, Go는 거의 모든 곳에서 C 라이브러리를 우회하도록 설계되어 있어 가장 쉽게 준비할 수 있었습니다:
$ file hello_go
hello_go: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=2H_kXH_UFAOR5K6ibAke/aj_2VBvakhN2KujNxtMN/K5TtKP8HHiX65VdbA19s/KxdoZHQEiOxCEbwRE346, with debug_info, not stripped
$ █
위에서 보듯이, file은 우리의 샘플 hello_go 프로그램이 "정적으로 링크됨"(statically linked)이라고 알려줍니다.
셸에 이 hello_go 바이너리를 실행하라고 요청하면, 셸은 새 프로세스를 fork하고 exec(2) 계열 시스템 호출을 사용해 그 프로세스의 실행 이미지를 hello_go의 내용으로 바꿉니다. 그리고 hello_go가 정적 링크되어 있으므로, 바이너리의 텍스트 세그먼트가 그대로 프로세스에 로드됩니다.
정적 바이너리가 실행될 때 프로세스에 직접 매핑되는 방식의 개요.
위와 동적 링크된 바이너리를 비교해 봅시다. 이번에는 C로 작성하고 glibc에 링크한 동일한 “hello world” 프로그램을 보겠습니다:
$ file hello_c
hello_c: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=feeb95b014ef8151780e23fc7ca15de0599f05df, for GNU/Linux 3.2.0, not stripped
$ █
이제 file은 우리의 샘플 hello_c 프로그램이 "동적으로 링크됨"(dynamically linked)이라고 말합니다. 실제로 해당 라이브러리를 확인해 보면—간단히 상기: 신뢰할 수 없는 실행 파일에는 ldd를 사용하지 마세요!—glibc(libc.so.6)가 나타납니다:
$ ldd hello_c
linux-vdso.so.1 (0x00007f54f25aa000)
libc.so.6 => /lib64/libc.so.6 (0x00007f54f239b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f54f25ac000)
$ █
그런데 잠깐만요. ldd 출력에는 libc.so.6 외에도 다른 것이 있습니다. 특히 "/lib64/ld-linux-x86-64.so.2"라는 "라이브러리"가 보이는데, 앞서 file의 출력에 주의를 기울이면 이것이 "인터프리터"라고 되어 있습니다. 우리는 지금 기계어를 실행하는데 왜 "인터프리터"가 있죠?! C가 컴파일 언어라는 게 거짓말이었나요?
천만에요. 여기서 "인터프리터"는 "로더"로 읽어야 하지만, 이 용어는 프로그램에 저장된 ELF 헤더의 명명법에서 비롯됩니다:
$ readelf -l hello_c
Elf file type is EXEC (Executable file)
Entry point 0x401040
There are 13 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
...
INTERP 0x0000000000000318 0x0000000000400318 0x0000000000400318
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
...
$ █
이 인터프리터가 무엇인지 이해하려면 동적 링크된 실행 파일이 무엇인지 봐야 합니다. 본질적으로, 바이너리의 프로그램 코드는 "완결"되어 있지 않습니다. 즉, 바이너리 안의 코드는 실행을 시작하기 전에 다른 라이브러리가 제공하는 코드의 참조로 메워져야 하는 "빈틈"을 포함합니다. 이 "빈틈"을 들여다보면:
$ readelf -r hello_c
Relocation section '.rela.dyn' at offset 0x4c0 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000403fd8 000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.34 + 0
000000403fe0 000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
Relocation section '.rela.plt' at offset 0x4f0 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000404000 000200000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
$ █
readelf은 hello_c 바이너리에 서로 다른 "재배치"(relocation)가 세 개 있다고 알려줍니다. 다시 말해, 런타임에 해당 심볼을 제공하는 코드의 주소로 패치해야 하는 "빈틈"이 세 곳 있다는 뜻입니다. 이들이 메워지지 않으면 프로그램은 실행될 수 없습니다. 그렇다면 이 참조들이 해결되기 전에는 실행을 시작할 수 없는데, 프로그램은 도대체 어떻게 실행될까요?
커널이 동적 라이브러리를 처리한다고 생각할 수도 있지만, 그렇지 않습니다. 동적 라이브러리는 사용자 공간의 개념이며, 바로 여기서 인터프리터가 제 역할을 합니다.
커널에 동적 링크된 ELF 바이너리를 exec(2) 하라고 요청하면, 커널은 바이너리의 코드를 로드하는 대신 인터프리터를 프로세스 이미지에 로드하고, 바이너리의 경로를 인터프리터에 전달합니다.
동적 링커의 도움으로 동적 바이너리가 glibc와 함께 메모리에 로드되고 재배치가 보정되는 방식의 개요.
우리의 경우 인터프리터인 ld-linux.so가 이후 필요한 모든 공유 라이브러리를 프로세스에 로드하고, 재배치(즉, "빈틈 메우기")를 해결해 프로세스를 완성합니다. 특히 동적 링커가 glibc를 프로세스에 로드합니다.
이 사실은 LD_DEBUG=files를 설정해 동적 링커 자신의 파일 접근을 출력하도록 요청하면 확인할 수 있습니다:
$ LD_DEBUG=files /lib64/ld-linux-x86-64.so.2 ./hello_c
75369: file=./hello_c [0]; generating link map
75369: dynamic: 0x0000000000403e08 base: 0x0000000000000000 size: 0x0000000000004010
75369: entry: 0x0000000000401040 phdr: 0x0000000000400040 phnum: 13
75369:
75369:
75369: file=libc.so.6 [0]; needed by ./hello_c [0]
75369: file=libc.so.6 [0]; generating link map
75369: dynamic: 0x00007f9aad868960 base: 0x00007f9aad682000 size: 0x00000000001f0b70
75369: entry: 0x00007f9aad6ac260 phdr: 0x00007f9aad682040 phnum: 14
75369:
75369:
75369: calling init: /lib64/ld-linux-x86-64.so.2
75369:
75369:
75369: calling init: /lib64/libc.so.6
75369:
75369:
75369: initialize program: ./hello_c
75369:
75369:
75369: transferring control: ./hello_c
75369:
Hello, world!
75369:
75369: calling fini: [0]
75369:
75369:
75369: calling fini: /lib64/libc.so.6 [0]
75369:
75369:
75369: calling fini: /lib64/ld-linux-x86-64.so.2 [0]
75369:
$ █
마지막으로, 저는 동적 링커를 직접 호출했습니다—동적 링크된 바이너리를 실행하라는 요청을 받았을 때 커널이 하는 방식과 정확히 동일하게—hello_c를 곧바로 실행하는 대신 말이죠.
좋습니다. 이제 동적 링크된 ELF 실행 파일이 메모리에 어떻게 로드되는지 알았으니, glibc를 가지고 놀아봅시다.
먼저 glibc를 내려받아 빌드하고 /tmp/sysroot/ 같은 임시 디렉터리에 설치합니다:
$ git clone https://sourceware.org/git/glibc.git
$ cd glibc
glibc$ git checkout release/2.40/master
glibc$ mkdir build
glibc/build$ ../configure --prefix=/tmp/sysroot
glibc/build$ make -j $(nproc)
glibc/build$ make -j $(nproc) install
glibc/build$ █
이제 준비가 끝났으니 새로운 glibc를 쓸 수 있겠죠. 그런데…
$ LD_LIBRARY_PATH=/tmp/sysroot/lib ./hello_c
Floating point exception (core dumped)
$ █
펑.
정말로, 동료의 말대로 시스템이 제공하는 것과 다른 glibc를 런타임에 로드하는 건 불가능한 듯 보입니다. (경우에 따라 다를 수 있습니다(YMMV). 저는 다른 시스템에서는 이게 동작했습니다.) 코어 덤프의 스택 트레이스를 보면:
Stack trace of thread 75841:
#0 0x00007f227d93903b n/a (/tmp/sysroot/lib/libc.so.6 + 0x15403b)
#1 0x00007f227d9f19f6 _dl_sysdep_start (ld-linux-x86-64.so.2 + 0x1c9f6)
#2 0x00007f227d9f335e _dl_start_final (ld-linux-x86-64.so.2 + 0x1e35e)
#3 0x00007f227d9f2048 _start (ld-linux-x86-64.so.2 + 0x1d048)
우리 프로세스는 ld-linux.so가 glibc를 로드하자마자 glibc 내부에서 크래시가 납니다. 크래시의 상세는 여기서 중요한 주제가 아니니 넘어가고, 이걸 어떻게 할 수 있을까요?
더 흥미로운 것은 glibc가 무엇을 함께 제공하는지 들여다보는 것입니다. 앞서 만든 /tmp/sysroot/ 트리에 가서 살펴보면:
$ ls /tmp/sysroot/lib/
...
ld-linux-x86-64.so.2
...
libc.so
libc.so.6
...
$ █
그 밖에도 정말 많은 것이 있습니다. 하지만 중요한 점은 "동적 링커가 glibc와 함께 제공된다"는 사실입니다. 이 둘은 서로 긴밀하게 결합되어 있으며, 하나만 바꿔 쓰면 앞서 본 것 같은 크래시가 날 수 있습니다.
그리고 기억하세요. 위에서 보았듯이 동적 링커를 수동으로 실행하는 것이 가능합니다. 따라서 다음과 같이 하면:
$ LD_LIBRARY_PATH=/tmp/sysroot/lib /tmp/sysroot/lib/ld-linux-x86-64.so.2 ./hello_c
Hello, world!
$ █
더 이상 크래시가 나지 않습니다. 더 좋은 점은, LD_LIBRARY_PATH를 설정하지 않아도 기대한 대로 동작하는 것처럼 보인다는 겁니다:
$ /tmp/sysroot/lib/ld-linux-x86-64.so.2 ./hello_c
Hello, world!
$ █
방금 실행한 hello_c가 실제로 우리가 만든 glibc 버전을 사용했는지, 아니면 시스템 제공 버전을 썼는지 확인해야 합니다. 이를 위해 동적 링커를 strace로 추적해 볼 수 있습니다. 동적 링커도 결국 하나의 프로그램이니까요:
$ strace /tmp/sysroot/lib/ld-linux-x86-64.so.2 ./hello_c 2>&1 | grep libc.so
openat(AT_FDCWD, "/tmp/sysroot/lib/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
$ █
확실히, hello_c는 새로 빌드한 동적 로더에게 바이너리를 실행하라고 "요청"함으로써 새로 빌드한 glibc를 사용했습니다.
요약하자면, glibc는 ld-linux.so 동적 링커와 libc.so.6 공유 라이브러리를 함께 제공합니다. 이 둘은 긴밀하게 결합되어 있습니다. C 라이브러리에만 맞추고 일치하는 동적 링커를 함께 사용하지 않으면 크래시가 날 수 있습니다. 반면 특정 동적 링커로 바이너리를 직접 실행하면 해당하는 C 라이브러리가 안전하게 사용됩니다.
좋습니다. 이제 시스템이 제공하는 것이 아닌 glibc 버전으로 바이너리를 실행할 수 있다는 걸 확인했으니, 처음 문제였던 "최신 glibc로 테스트 실행하기"를 해결해 봅시다.
한 가지 방법은 테스트를 실행하는 명령을 수정해 그 앞에 .../lib/ld-linux-x86-64.so.2를 붙이는 것입니다. 이 방법은 동작하겠지만, 테스트 실행 환경에 통합하기 어렵다고 봅니다. 우리가 실제로 테스트 바이너리를 실행하는 명령을 항상 통제하는 것은 아니니까요.
또 다른 방법은 테스트 프로그램을 빌드하는 방식을 수정하는 것입니다. 전통적인 LDFLAGS 같은 수단으로 링커에 추가 인자를 주어, --Wl,--dynamic-linker 플래그로 다른 인터프리터를 지정하는 것이죠:
$ cc -o hello_c -Wl,--dynamic-linker=/tmp/sysroot/lib/ld-linux-x86-64.so.2 hello.c
$ strace ./hello_c 2>&1 | grep libc.so
openat(AT_FDCWD, "/tmp/sysroot/lib/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
$ █
더 낫습니다. 이제 빌드된 바이너리는 매번 프로그램을 실행할 때마다 명시적으로 지정하지 않아도 어떤 glibc를 써야 하는지 "알고" 있습니다.
만약 우리가 바이너리를 빌드하지 않는다면요? 이미 존재하는 바이너리를 재사용 중이라면요? patchelf가 해결책입니다:
$ cc -o hello_c hello.c
$ strace ./hello_c 2>&1 | grep libc.so
openat(AT_FDCWD, "/lib64/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
$ patchelf --set-interpreter /tmp/sysroot/lib/ld-linux-x86-64.so.2 ./hello_c
$ strace ./hello_c 2>&1 | grep libc.so
openat(AT_FDCWD, "/tmp/sysroot/lib/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
$ █
짜잔. 이제 기존 실행 파일을 우리가 원하는 glibc로 쓰도록 변경했습니다.
하지만 잠깐. 정말로 "원하는 모든" glibc를 쓸 수 있을까요? glibc 버전들이 서로 완전히 호환될까요? 안타깝지만 그렇지는 않습니다.
오래된 glibc에 맞춰 빌드된 바이너리는 재컴파일 없이 더 새로운 glibc에서도 실행되도록 보장됩니다. 하지만 그 반대는 성립하지 않습니다. 새로운 glibc에 맞춰 빌드된 바이너리는 오래된 glibc에서는 실행되지 않을 수 있습니다.
이건 문제를 야기합니다. 별도의 운영(프로덕션) 환경과 개발 환경에서 glibc 업그레이드를 어떻게 진행해야 할까요? 프로덕션을 먼저 업그레이드하면, 개발 환경에서 빌드된 바이너리는 계속 잘 동작하겠지만 개발자는 프로덕션에서 발견된 버그를 재현하지 못할 수 있습니다. 반대로 개발 환경을 먼저 업그레이드하면, 그 환경에서 빌드된 바이너리는 프로덕션과 호환되지 않아 나중에 예기치 않은 크래시를 유발할 수 있습니다.
여기 과거에 효과를 본 아이디어가 있습니다. 앞서의 sysroot 개념을 확장해서 버전 관리를 해보는 겁니다!
먼저 우리가 지원해야 하는 코어 시스템 라이브러리의 첫 번째 버전을 담은 /usr/sysroot/v1/을 준비합니다. 프로덕션 배포용으로 빌드하는 모든 바이너리는 이 디렉터리의 ld-linux.so에 명시적으로 링크됩니다. 따라서 항상 sysroot의 v1로 검증됩니다. v1은 변경 불가능(immutable)하게 유지되어, 새로 빌드한 바이너리도 과거에 테스트한 대로 계속 동작합니다.
glibc를 업그레이드하고 싶을 때마다 새로운 sysroot, 즉 /usr/sysroot/v2/를 만듭니다. 새로운 v2는 새 glibc를 써야 할 수 있는 모든 환경(개발과 프로덕션)으로 배포합니다. 이 배포 단계는 아무도 아직 v2에 의존하지 않으므로 위험이 없습니다. 그다음 실행 및 테스트할 모든 바이너리를 v2 내부의 ld-linux.so를 가리키도록 다시 빌드합니다. 이 새로운 바이너리를 배포하면, 그때부터 새 버전을 사용하게 됩니다.
/usr/sysroot/ 하위 디렉터리들이 불변이며, 바이너리가 실행되는 모든 환경 간에 동기화만 유지된다면, 특정 바이너리에 대해 항상 결정적인(deterministic) glibc 버전을 보장할 수 있습니다.
물론 이는 설계 스케치일 뿐입니다. 여러분에게 잘 맞을 수도, 아닐 수도 있습니다. 앞서 말했듯이 저는 대규모 기업 환경에서 이 방식이 잘 동작하는 것을 보았지만, 신경 써야 할 추가 복잡성이 생깁니다.
이 이야기의 교훈은 무엇일까요? 컨테이너는 보통 시스템 문제에 가장 적합한 해법이 아닙니다. 원하던 결과를 억지로 내게 할 수도 있지만 비용이 큽니다. 유닉스는 오래전부터 존재해 왔고, 완전한 시스템 복제 없이도 문제를 풀 수 있는 대안적 해법들이 있습니다. 오늘날 개발 도구와 배포의 거대한 비대함은 헤비급 컨테이너의 남용에서 비롯되는 경우가 많습니다. 그러니 “컨테이너!”라는 생각이 들 때마다 잠시 멈추고 대안을 탐색해 보세요—더 저렴하고, 더 이식성 높고, 더 단순한 해법을 찾을 수 있습니다.
이와 관련한 주제를 더 배우고 싶다면 고전인 “Linkers and Loaders” 책을 권합니다. 오래된 책이지만 여전히 매우 유의미합니다.