본문으로 건너뛰기

천고마비의 계절, 컨테이너 다이어트하기

· 약 15분
조만석

들어가며

대부분의 리눅스 배포판, 예를 들어 우분투(Ubuntu)나 레드햇(RedHat, CentOS)에서는 시스템의 표준 C 라이브러리로 glibc를 사용합니다. 우분투에서는 apt, 레드햇 계열에서는 rpm(yum)으로 OpenSSL과 같은 라이브러리 패키지를 설치하면 기본적으로 glibc와 동적으로 링크됩니다.

GNU(그누)는 운영체제(Operating System)이자 컴퓨터 소프트웨어의 넓은 범위를 포함하고 있습니다. GNU는 프리소프트웨어재단(FSF)에서 개발하고 유지보수하는 오픈소스입니다. GNU에서 만든 대표적인 것들로는 GCC, G++, Make 등의 컴파일러나 개발 도구가 있으며, GNU는 표준 C 라이브러리로 glibc를 사용합니다. glibc는 GNU Lesser General Public License를 사용합니다.

musl(머슬)은 MIT 라이선스로 배포되는 리눅스 표준 C 라이브러리입니다. 개발자는 리치 펠커(Rich Felker)이며, glibc가 동적 링크를 사용하는 반면, musl은 정적 링크를 사용하여 POSIX 표준을 준수하는 표준 C 라이브러리를 구현하는 것을 목표로 합니다. 또한, 리눅스, BSD, glibc의 비표준 기능도 구현합니다.

리눅스 환경에서 glibc와 musl의 차이

리눅스에서 패키지를 설치하면 기본적으로 glibc를 사용합니다. 보통 gcc를 이용해 C/C++ 프로그램을 빌드해본 경험이 있다면 높은 확률로 glibc 기반의 동적 링크 빌드를 진행하였을 것입니다. 하지만 이렇게 흔히 쓰이는 glibc 동적 빌드 외에도 musl 기반의 동적/정적 빌드를 할 수도 있습니다.

*-linux-gnu*-linux-musl 사이에는 다음과 같은 차이점이 있습니다.

빌드 타겟표준 C 라이브러리링크방식
*-linux-gnuglibc동적 링크
*-linux-muslmusl동적/정적 링크

Rust로 실행파일을 빌드하는 경우를 생각해봅시다. rustup을 이용해 리눅스 환경에 Rust를 설치하면 *-linux-gnu가 기본 타겟으로 선택됩니다.

별도의 옵션을 지정하지 않으면 Rust는 *-linux-gnu 타겟으로 바이너리를 빌드하고 glibc와 동적으로 링크합니다. 이렇게 빌드한 바이너리를 실행하려면 해당 리눅스 환경에 glibc가 설치되어 있어야 동작합니다. 만약 바이너리가 OpenSSL과 같은 외부 라이브러리에 의존하고 있다면(동적으로 링크되어 있다면), apt와 같은 패키지 관리자를 통해 해당 라이브러리도 설치해주어야 합니다. 이러한 동적 링크 바이너리를 일반 사용자가 실행하려면, 외부 라이브러리에 대한 의존성 정보가 기술된 DEB나 RPM 등의 패키지 형태로 묶어주면 됩니다. 그러면 패키지 관리자가 적절한 종속 라이브러리를 자동으로 찾아서 설치해줍니다. 하지만 패키지 관리자에 등록되지 않은 라이브러리를 사용하는 경우나 동일한 라이브러리더라도 설치된 버전과 개발할 때 사용한 버전 사이에 미묘한 호환성 문제가 있는 경우 빌드한 바이너리가 의도대로 실행되지 않을 가능성도 있습니다.

Rust는 *-linux-musl 타겟을 지정하면 바이너리를 빌드할 때 musl과 정적으로 링크합니다. OpenSSL과 같은 외부 라이브러리에 의존하는 경우 이것들과도 정적 링크를 사용하여 바이너리에 모두 내장시킵니다. 즉, Rust의 단일 바이너리 파일 안에 이 모든 라이브러리들이 모두 포함되는 상태가 됩니다. 이런 정적 바이너리라면 CPU 아키텍처와 리눅스 커널에서 제공하는 시스템콜 집합만 맞으면 어떤 리눅스 환경에서도 실행할 수 있습니다. DEB나 RPM 등의 패키지를 사용하지 않고도 단일 바이너리만 전달하면 실행할 수 있기 때문에 바이너리를 배포하는 것이 더욱 간편해집니다.

이렇게 바이너리 배포 과정을 쉽게 만들어주는 *-linux-musl 타겟을 왜 리눅스 환경에서는 기본값으로 사용하지 않는 것일까요?

그 이유는 musl을 사용하면 빌드 준비가 다소 복잡해지기 때문입니다. 개발자가 만든 바이너리 패키지가 *-linux-musl를 사용하면서 동시에 외부 라이브러리에 의존하는 경우, 그 외부 라이브러리 또한 glibc와 동적으로 링크하는 대신 musl과 정적으로 링크된 것이어야 하기 때문입니다. 따라서 musl용 컴파일러를 사용해서 빌드하고자 하는 프로그램의 본체뿐만 아니라 모든 의존 라이브러리를 소스 코드부터 정적 링크로 빌드해야 합니다.

다행히도, Rust에서 자주 사용되는 외부 라이브러리라면 처음부터 모든 것을 다 새로 빌드할 필요는 없습니다. 자주 사용되는 라이브러리와 Rust 컴파일러/gcc를 묶은 Docker 이미지를 활용하면 간편하게 musl 기반 정적 빌드를 만들 수 있습니다. (이제부터 등장하는 명령어 예제에서, 각 리눅스 배포판별 컨테이너 환경을 구분하기 위해 임의로 <배포판이름># 프롬프트를 사용하겠습니다.)

$ docker run -it --name ubuntu ubuntu:22.04 bash
ubuntu# apt update && apt install -y curl gcc vim

개발에 주로 사용되는 Rust 언어 환경에서 동적 링크인 glibc와 정적 링크인 musl 환경을 구성해보겠습니다. 우선, 우분투 환경에 Rust를 설치합니다.

ubuntu# curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
ubuntu# source $HOME/.cargo/env

Rust의 기본 예제인 "Hello World" 출력을 통해 동적 링크와 정적 링크를 비교해보겠습니다.

먼저, glibc를 이용하여 "Hello World"를 빌드해봅시다.

ubuntu# cd
ubuntu# cargo new --bin hello && cd $_
Created binary (application) `hello` package
ubuntu# cargo build --release
Compiling hello v0.1.0 (/root/hello)
Finished release [optimized] target(s) in 0.35s

ldd 명령을 사용하여 glibc 환경에서 라이브러리가 동적 링크로 구성된 것을 확인해봅시다. linux-vdso, libgcc_s, libc 등이 동적 링크로 구성된 것을 확인할 수 있습니다.

ubuntu# ldd target/release/hello
linux-vdso.so.1 (0x00007fffe87df000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007fdce9c3f000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fdce9a17000)
/lib64/ld-linux-x86-64.so.2 (0x00007fdce9cc2000)

그러면 musl 정적 링크로 rust 타겟 구성을 변경해봅시다.

ubuntu# rustup target add x86_64-unknown-linux-musl
info: downloading component 'rust-std' for 'x86_64-unknown-linux-musl'
info: installing component 'rust-std' for 'x86_64-unknown-linux-musl'
34.7 MiB / 34.7 MiB (100 %) 8.6 MiB/s in 4s ETA: 0s

ubuntu# rustup show
Default host: x86_64-unknown-linux-gnu
rustup home: /root/.rustup

installed targets for active toolchain
--------------------------------------

x86_64-unknown-linux-gnu
x86_64-unknown-linux-musl

active toolchain
----------------

stable-x86_64-unknown-linux-gnu (default)
rustc 1.72.0 (5680fa18f 2023-08-23)

ubuntu#

"Hello World"를 빌드하여 정적 링크가 올바르게 구성되었는지 확인하겠습니다.

ubuntu# cargo build --release --target=x86_64-unknown-linux-musl
Compiling hello v0.1.0 (/root/hello)
Finished release [optimized] target(s) in 0.37s

ubuntu# ldd target/x86_64-unknown-linux-musl/release/hello
statically linked

"Hello World"가 musl 환경을 사용하여 정적 링크로 구성된 것을 확인할 수 있습니다.

이제 동적 링크와 정적 링크로 빌드된 'Hello World'를 CentOS와 Alpine 환경에서 바이너리를 복사하여 실행해보겠습니다. CentOS 8은 glibc 동적 링크를 사용하고, Alpine 리눅스는 musl 정적 링크를 사용합니다.

CentOS 컨테이너 환경

$ docker run -it --name centos centos:centos8 bash
centos#

Alpine 컨테이너 환경

Alpine 배포판은 glic가 아닌 musl을 기본으로 사용합니다.

$ docker run -it --rm alpine:3.18
alpine#

'Hello World'를 glibc 환경과 musl 환경으로 복사하여 동작을 확인하겠습니다.

$ docker cp ubuntu:/root/hello/target/x86_64-unknown-linux-musl/release/hello .
$ docker cp hello centos:/root/
$ docker cp hello alpine:/root/

centOS에서 동작을 확인하겠습니다.

centos# ./hello
Hello, world!

alpine에서 동작을 확인하겠습니다.

alpine# ./hello
Hello, world!

Rust 어플리케이션 'slice'를 사용한 glibc와 musl 비교

Rust 어플리케이션 'slice'를 가지고 glibc와 musl을 적용해서 만든 컨테이너 이미지를 비교해 보겠습니다.

Python의 'slice'와 같이 Rust로 구현된 'slice'는 GitHub 저장소 https://github.com/ChanTsune/slice 에 공개되어 있습니다. 'slice'는 'head'나 'tail'처럼 파일의 앞 혹은 뒤에서부터 내용을 출력해주는 도구입니다. 예를 들어, 아래의 명령은 'file.txt'에서 10번째 줄부터 20번째 줄까지 출력하게 됩니다.

$ slice 10:20 file.txt

'slice'를 Rust 환경에서 빌드하고 컨테이너를 만들어 사용할 때는 다음과 같이 사용할 수 있습니다.

$ docker run -i --rm -v `pwd`:`pwd` -w `pwd` slice

Ubuntu 22.04 환경에서 glibc를 사용한 컨테이너를 빌드해보겠습니다.

FROM rust:latest as builder

WORKDIR /work
RUN git clone https://github.com/ChanTsune/slice /work/.
RUN cargo build --release
RUN strip /work/target/release/slice -o /slice

FROM ubuntu:22.04
COPY --from=builder /slice /usr/local/bin/

ENTRYPOINT ["slice"]

이번에는 musl 정적 링크를 사용하여 Ubuntu 22.04 기반의 컨테이너 이미지를 만들어 보겠습니다.

FROM rust:latest as builder

RUN rustup target add "$(uname -m)"-unknown-linux-musl
WORKDIR /work
RUN git clone https://github.com/ChanTsune/slice /work/.
RUN cargo build --release --target "$(uname -m)"-unknown-linux-musl
RUN strip /work/target/"$(uname -m)"-unknown-linux-musl/release/slice -o /slice

FROM ubuntu:22.04
COPY --from=builder /slice /usr/local/bin/

ENTRYPOINT ["slice"]

musl 정적 링크를 사용하여 Alpine 배포판 기반의 컨테이너 이미지를 만들어 보겠습니다.

FROM rust:latest as builder

RUN rustup target add "$(uname -m)"-unknown-linux-musl
WORKDIR /work
RUN git clone https://github.com/ChanTsune/slice /work/.
RUN cargo build --release --target "$(uname -m)"-unknown-linux-musl
RUN strip /work/target/"$(uname -m)"-unknown-linux-musl/release/slice -o /slice

FROM alpine
COPY --from=builder /slice /

ENTRYPOINT ["slice"]

Ubuntu 22.04 기반의 glibc 컨테이너 이미지와 musl 컨테이너 이미지, 그리고 Alpine 기반의 musl 컨테이너 이미지의 크기를 비교해보면 musl을 사용한 컨테이너 이미지의 크기가 더 작은 것을 확인할 수 있습니다.

$ docker images 
REPOSITORY TAG IMAGE ID CREATED SIZE
slice distroless-musl d38a74f8568a 11 seconds ago 3.52MB
slice alpine-musl e3abb5f0aace 39 seconds ago 8.4MB
slice ubuntu22.04-musl 467edd130e79 About a minute ago 78.9MB
slice ubuntu22.04-glibc 09fe5ad40d56 3 minutes ago 78.8MB

우분투 환경에서는 glibc나 musl을 사용하더라도 컨테이너 이미지 크기에 큰 차이가 없지만, Alpine 배포판에서는 컨테이너 이미지 크기가 약 10분의 1로 줄어든 것을 확인할 수 있습니다. 이를 통해 정적 빌드를 사용하는 Alpine 리눅스를 활용하면 컨테이너 이미지를 가볍게 만들고 배포 시간을 단축할 수 있음을 알 수 있습니다.

맺음말

표준 C 라이브러리를 사용하는 프로그램에서 정적 링크를 사용하면 리눅스 바이너리 배포 과정을 단순화할 수 있습니다. 또한 컨테이너 이미지 크기가 동적 링크에 비해 작아지며, 배포판에 관계 없이 배포가 편리해집니다. glibc를 musl로 대체했을 때, 컨테이너 이미지 크기의 차이뿐만 아니라 musl에서 새롭게 지원하는 mDNS(a multicast-DNS-based zero config system), NUMA cluster와 같은 기능을 사용할 수 있는 이점이 있습니다. 더 나아가, musl을 보다 잘 활용하기 위해 구글에서 배포하는 distroless를 기본 컨테이너 이미지로 사용하면, 더 작은 컨테이너 이미지를 배포하여 활용할 수 있습니다.