태그 : Build Engineering

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

    By 조만석

    들어가며

    대부분의 리눅스 배포판, 예를 들어 우분투(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-gnu | glibc | 동적 링크 | | *-linux-musl | musl | 동적/정적 링크 |

    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를 기본 컨테이너 이미지로 사용하면, 더 작은 컨테이너 이미지를 배포하여 활용할 수 있습니다.

    20 September 2023

  • aiomonitor-ng: 복잡한 asyncio 애플리케이션을 위한 디버깅 도구

    By 김준기

    프로그램의 복잡도가 올라갈수록 소프트웨어 개발자에게는 좋은 디버깅 도구가 필요합니다. 가장 이상적인 디버깅 과정은 마음껏 실험해볼 수 있는 개발환경에서 문제를 안정적으로 재현하는 방법을 알아내고 이를 자동화된 테스트로 만드는 것이죠. 하지만 재현 시나리오 구성 자체가 너무 복잡하거나 프로덕션 환경에서만 가끔씩 랜덤하게 발생하는 종류의 버그들은 차선책으로 로그를 상세히 남겨서 사후에라도 어떤 문제가 있었는지 파악할 수 있도록 해야 합니다. 이번 글에서는 복잡한 asyncio 프로그램의 디버깅을 쉽게 하기 위해 개발한 aiomonitor-ng 도구를 소개합니다.

    asyncio 애플리케이션 디버깅은 고유한 어려움들이 있습니다. 파이썬에서 디버깅할 때 가장 자주 활용하는 것이 바로 프로그램이 어느 부분을 실행하다가 예외가 발생했는지 보여주는 stack trace입니다. 그런데 asyncio 애플리케이션은 여러 개의 코루틴 작업들이 각자의 스택을 가지고 동시에 엮여서 실행되기 때문에 특정 예외가 발생한 코루틴 작업의 스택뿐만 아니라 '관련된' 코루틴 작업들의 스택도 함께 관찰해야 다른 코루틴 작업으로부터 야기된 오류인지 아닌지를 정확하게 파악할 수 있습니다. 특히, 내 코드에서 사용한 외부 라이브러리가 암묵적으로 코루틴 작업을 생성하고 그 코루틴 작업이 다시 내 코드를 호출하는 상황이라면 더욱 중요한 문제가 됩니다. 게다가 개발환경에서는 잘 발생하지 않고 프로덕션 환경에서만 발생하는 코루틴 작업 폭주 문제나 지속적으로 실행되어야 하는 코루틴 작업이 조용하게 종료되어버린다거나 하는 종류의 버그들은 굉장히 잡기 어렵습니다. 이런 종류의 버그들은 명시적인 예외가 발생하는 것이 아니기 때문에 사후 로그를 통해 문제점을 간접적으로 유추하는 수밖에 없기 때문입니다.

    aiomonitor는 asyncio 코어 개발자들이 개발한 프로덕션용 라이브 디버깅 도구입니다. asyncio 기반 코드를 monitor 객체로 감싸두면, 해당 코드가 실행 중일 때 프로세스 외부에서 미리 설정된 TCP 포트로 텔넷 세션을 열어 간단한 명령어들을 통해 이벤트루프가 실행하고 있는 코루틴 작업들의 목록과 개별 스택 현황을 조회할 수 있게 해줍니다. Backend.AI에는 이미 이 aiomonitor가 적용되어 개별 서비스 프로세스마다 고유의 디버깅용 텔넷 포트가 할당되어 있습니다. (물론 보안 상 이유로 localhost로부터의 접속만 허용합니다.) 이를 통해 프로덕션에서만 발생하는 문제들을 디버깅하는 데 큰 도움을 받을 수 있었죠. 하지만 여전히 Backend.AI 자체의 코드가 아닌 외부 라이브러리에 의해서 발생하는 코루틴 작업 폭주 문제나 조용하게 종료되어버리는 코루틴 작업이 왜 죽는지 디버깅하는 것은 정확히 그 문제가 발생하는 시점을 특정하여 그 순간에 aiomonitor를 들여다보는 방식으로는 디버깅에 한계가 있었습니다.

    그래서 aiomonitor-ng라는 확장 버전을 개발하게 되었습니다. ng는 next-generation의 약자입니다. 크게 다음과 같은 기능들이 추가 및 개선되었습니다.:

    • Task creation tracker: 모든 실행 중인 코루틴 작업에 대해, 각 코루틴 작업을 생성(asyncio.create_task())한 작업들에 대해 그 순간의 stack trace를 모두 보존하여 연속된 작업 생성 체인을 모두 알 수 있도록 하였습니다. (ps, where 명령)
    • Task termination tracker: 최근 종료된 코루틴 작업들을 최대 N개까지 로그를 보존하고 조회할 수 있게 해줍니다. 특히 어떤 한 작업이 다른 작업을 취소(Task.cancel())한 경우, 취소를 트리거한 순간의 stack trace를 함께 보존하여 연속된 취소 체인을 모두 알 수 있도록 하였습니다. (ps-terminated, where-termianted 명령)
    • Persistent task marker: 기본값으로는 메모리 누수를 방지하기 위해 종료된 작업을 최근 N개까지만 추적하지만, 애플리케이션 수명 주기 동안 계속 실행되어야 하는 특정 작업들을 데코레이터로 표시해두면 해당 작업들은 이력 개수 제한과 관계 없이 항상 종료 로그를 보존해주고 종료 로그 조회 명령에서 별도 옵션으로 필터링하는 기능을 제공합니다. (aiomonitor.task.preserve_termination_log 데코레이터)
    • 세련된 terminal UI: 기존에 손으로 짜여진 명령어 파싱을 바탕으로 했던 단순 REPL (read-evaluate-print loop) 구성이었던 명령줄 처리를 개선하였습니다. Clickprompt_toolkit을 활용하도록 aiomonitor 서버측 구현을 재작성하고, 클라이언트도 asyncio로 native하게 동작하는 텔넷 클라이언트를 자체 구현하여 명령어 및 task ID 등의 인자 자동완성을 제공합니다.

    실제 사용 화면은 다음과 같습니다.:

    aiomonitor-ng 도구를 활용하여 grpcio 라이브러리에서 콜백으로 생성하는 코루틴 작업이 과다 생성되어 발생하는 리소스 누수 및 성능 저하 문제, docker 데몬이 발생시키는 이벤트를 모니터링하는 작업이 특정한 메시지 입력 패턴에 의해 조용하게 종료되어 버리는 바람에 컨테이너 생성이나 삭제 작업의 결과가 리턴되지 않아 시스템이 멈추는 문제 등을 성공적으로 디버깅할 수 있었습니다.

    앞으로 aiomonitor-ng를 통해 래블업뿐만 아니라 다양한 Python asyncio 애플리케이션을 개발하는 독자분들께서도 디버깅에 큰 도움을 받기를 바라며 글을 마칩니다.

    aiomonitor-ng는 PyPI를 통해 pip install aiomonitor-ng 명령으로 설치하실 수 있으며, 제 깃헙 계정에 오픈소스로 공개되어 있으므로 누구나 사용 및 기여가 가능합니다.

    28 November 2022

도움이 필요하신가요?

내용을 작성해 주시면 곧 연락 드리겠습니다.

문의하기

본사 및 HPC 연구소

서울특별시 강남구 선릉로 577 CR타워 8층

© Lablup Inc. All rights reserved.