기술자료

DBMS, DB 구축 절차, 빅데이터 기술 칼럼, 사례연구 및 세미나 자료를 소개합니다.

공유 라이브러리 분석 : 공유 라이브러리 이해하기

기술자료
DBMS별 분류
Etc
작성자
dataonair
작성일
2005-06-08 00:00
조회
6105





공유 라이브러리 분석

공유 라이브러리 이해하기

050608_se_01.jpg
Peter Seebach│프리랜스 작가

공유 라이브러리는 애플리케이션들이 사용하는 라이브러리에 업그레이드를 적용할 때 버전 숫자를 사용한다. 동시에 오래된 애플리케이션과의 호환성도 유지한다. 이 글에서는 일반적인 리눅스 시스템에 /usr/lib로의 심볼릭 링크가 많은 이유를 설명하겠다.

공유 라이브러리들은 현대적인 유닉스 시스템상에서 공간과 리소스를 효율적으로 사용하기 위한 기본 컴포넌트들이다. SUSE 9.1 시스템 상의 C 라이브러리는 약 1.3 MB를 차지하고 있다. /usr/bin(나의 경우는2,569 이다!)에서 모든 프로그램을 위한 라이브러리는 2 기가 바이트 공간을 차지한다.

물론 이 숫자는 과장된 것이다. 정적으로 링크 된 프로그램들은 자신들이 사용하고 있는 라리브러리의 일부하고만 결합한다. 그럼에도 불구하고, 이 모든 중복된 printf() 카피들과 묶인 공간들은 시스템을 팽창시킬 것이다.

공유 라이브러리를 사용하면 디스크 공간 뿐 아니라 메모리를 절약할 수 있다. 커널은 메모리에 한 카피의 공유 메모리를 보유하면서 이를 다중의 애플리케이션들과 공유한다. 따라서 디스크 상에 한 카피의 printf()를 가지고 있을 뿐만 아니라 메모리에도 한 개를 갖고 있는 것이다. 이것은 퍼포먼스에 뚜렷한 영향력을 갖고 있다.

이 글에서 공유 라이브러리에 사용되는 기본 기술을 검토하고 공유 라이브러리 버저닝(versioning)을 활용하여 과거의 단순한 공유 라이브러리 구현 때문에 겪었던 호환성 악몽을 어떻게 극복하는지 알게 될 것이다. 우선 공유 라이브러리가 어떻게 작동하는지 보자.

공유 라이브러리 작동 방법

개념은 이해하기 쉽다. 당신은 라이브러리를 갖고 있고 그 라이브러리를 공유한다. 하지만 프로그램이 printf()를 호출하려 할 때 실제로 어떤 일이 벌어지겠는가 실제 작동 방식은 약간 더 복잡하다.

동적 링크 시스템 보다 정적 링크 시스템에서 프로세스가 더 간단하다. 정적 링크 시스템에서, 생성된 코드는 함수에 대한 레퍼런스를 처리한다. 링커는 이 레퍼런스를 함수를 로딩했던 실제 주소로 교체한다. 따라서 결과 바이너리 코드는 올바른 주소를 적재적소에 갖게 되는 것이다. 그런 다음, 코드가 실행되면 관련 주소로 점프(jump)한다. 프로그램의 어떤 지점에서 실제로 레퍼런스 되는 객체들에서만 링크할 수 있기 때문에 관리가 간단하다.

하지만 대부분의 공유 라이브러리들은 동적으로 링크 된다. 이것이 의미하는 바는 크다. 함수가 호출될 때 실제로 어떤 주소에 그 함수가 있게 될지 미리 예견할 수 없다. (BSD/OS에 있는 것 처럼 정적으로 링크 된 공유 라이브러리 스키마가 있었지만 이 글에서는 다루지 않겠다.)

동적 링커(dynamic linker)는 링크 된 각 함수에 대해 상당량의 작업을 수행할 수 있기 때문에 대부분의 링커들은 게으르다. 이들은 함수가 호출될 때 그 작업을 실제로 끝낸다. C 라이브러리에는 천 개 이상의 외부에 보여지는 심볼들과, 거의 삼천 개 이상의 로컬 심볼 들이 있다. 많은 시간을 절약할 수 있다.

작동 마법은 Procedure Linkage Table (PLT)라고 하는 데이터 청크이다. 이것은 프로그램이 호출하는 모든 함수를 나열하고 있는 프로그램의 테이블이다. 프로그램이 시작되면 PLT는 각 함수용 코드를 포함하여 함수를 로딩했던 주소에 대한 런타임 링커를 쿼리한다. 그런 다음 테이블의 모든 엔트리를 채우고 그곳으로 옮겨간다. 각 함수가 호출될 때 PLT의 엔트리는 로딩된 함수로 단순히 직접 점프한다.

하지만 여분의 인다이렉션 레이어를 남겨둔다는 것을 기억해야 한다. 각 함수 호출은 점프를 통해 테이블로 바뀐다.

호환성은 관계만을 위한 것은 아니다!

링크하는 것으로 완료한 라이브러리는 이것이 호출하는 코드와 호환되도록 해야 한다. 정적으로 링크 된 실행파일이 있다면 변경될 것이 없다. 동적 링크라면 보장 못한다.

새로운 버전의 라이브러리가 나왔다면 특히 새로운 버전이 기존 함수의 호출 순서를 변경했다면

버전 숫자가 구원자다. (공유 라이브러리는 버전을 갖게 될 것이므로). 프로그램이 라이브러리로 링크 되면 버전 숫자를 갖게 된다. 동적 링커는 매칭(matching) 버전 숫자를 검사할 수 있다. 라이브러리가 변경되면 버전 숫자는 맞지 않을 것이고 프로그램은 새로운 버전의 라이브러리로 링크 되지 않을 것이다.

하지만 동적 링크의 강력한 장점들 중 하나는 버그를 픽스하는데 있다. 라이브러리에서 버그를 픽스하고 그 픽스를 활용하기 위해 많은 프로그램들을 재컴파일 할 필요가 없다면 참 좋은 것이다. 가끔은 새 버전으로 링크해야 할 때도 있다.

불행히도 새로운 버전으로 링크해야 하는 경우도 있고, 어떤 경우는 오래된 버전을 구수해야 한다. 해결책은 있다. 두 종류의 버전 숫자이다:

  • 메이저 넘버(major number)는 라이브러리 버전들 간 잠재적 비호환성을 나타낸다.
  • 마이너 넘버(minor number)는 버그 픽스들만을 나타낸다.

대부분의 경우 같은 메이저 넘버와 더 높은 마이너 넘버를 가진 라이브러리를 로딩하는 것이 안전하다; 높은 메이저 넘버를 가진 라이브러리를 로딩하는 것은 안전하지 못하다.

사용자들(프로그래머들)이 라이브러리 넘버와 업데이트를 트래킹하지 않도록 하려면 시스템은 많은 심볼릭 링크들이 있어야 한다. 일반적일 패턴은 다음과 같다.

libexample.so

는 다음으로 링크 된다.

libexample.so.N

이 시스템에서 N은 가장 높은 메이저 버전 숫자이다.

지원되는 모든 메이저 버전 넘버들은,

libexample.so.N

다음으로 링크 된다.

libexample.so.N.M 

M은 가장 큰 마이너 버전 넘버이다.

따라서 -lexample을 링커에 지정하면 최근 버전에 대한 심볼릭 링크인 libexample.so를 찾는다. 한편, 기존 프로그램이 로딩되면 libexample.so.N을 로딩할 것이다. N은 원래 링크 되었던 버전이다.

디버그 하기 위해서는 우선 컴파일 방법을 알아야 한다!

공유 라이브러리로 문제들을 디버깅하기 위해서는 이들이 어떻게 컴파일 되는지를 알아두는 것도 유용하다.

전통적인 정적 라이브러리에서, 생성된 코드는 일반적으로 .a로 끝나는 이름을 가진 라이브러리 파일로 바인딩 되고 링커로 전달된다. 동적 라이브러리에서, 라이브러리 파일의 이름은 일반적으로 .so로 끝난다. 파일 구조는 약간 다르다.

일반적인 정적 라이브러리는 ar 유틸리티에서 만들어진 포맷에 있다. 이는 기본적으로 매우 단순한 아카이브 프로그램으로서 tar 와 비슷하지만 더 단순하다. 반면 공유 라이브러리들다.

현대적인 리눅스 시스템에서, 이는 일반적으로 ELF 바이너리 포맷((Executable and Linkable Format)을 의미한다. ELF에서, 각 파일은 하나의 ELF 헤더, 0 또는 세그먼트 그리고 0 또는 몇몇 섹션 등으로 구성된다. 세그먼트는 파일의 런타임 실행에 필요한 정보를 포함하고 있고, 섹션에는 링크와 재배치를 위한 중요한 데이터가 포함된다. 전체 파일에서 각 바이트는 한번에 단 한 섹션을 차지한다. 하지만 섹션에 들어가지 않은 고아 바이트가 있을 수 있다. 일반적으로 유닉스 실행파일에서 한 개 이상의 섹션들은 하나의 세그먼트에 둘러 쌓인다.

ELF 포맷은 애플리케이션과 라이브러리를 위한 스팩을 갖고 있다. 이 라이브러리 포맷은 객체 모듈의 아카이브 보다 훨씬 더 복잡하다.

링커는 심볼에 대한 레퍼런스들을 통해 소팅하면서 그들이 어떤 라이브러리들을 발견했는지를 기록한다. 정적 라이브러리에서 온 심볼들은 최종 실행파일에 추가된다; 공유 라이브러리에서 온 심볼들은 PLT에 놓여지고 PLT에 대한 레퍼런스들이 만들어진다. 일단 이 작업들이 수행되면 결과 실행 파일들은 런타임 시 로딩 될 라이브러리에서 검색할 심볼 리스트를 갖게 된다.

런타임 시, 애플리케이션은 동적 링커를 로딩한다. 사실, 동적 링커 자체는 공유 라이브러리들과 같은 종류의 버저닝을 사용한다. 예를 들어, SUSE Linux 9.1에서 /lib/ld-linux.so.2파일은 /lib/ld-linux.so.2.3.3에 대한 심볼릭 링크이다. 반면 /lib/ld-linux.so.1을 찾는 프로그램은 새 버전을 사용하려고 하지 않는다.

동적 링커는 재미있는 작업을 수행해야 한다. 프로그램이 원래 어떤 라이브러리(그리고 어떤 버전)에 링크 되었는지를 찾아서 이들을 로딩한다. 라이브러리 로딩은 다음으로 구성된다:

  • 찾기(시스템 상의 여러 디렉토리들 중 하나에 있을 수 있다)
  • 프로그램의 어드레스 공간으로 매핑하기
  • 라이브러리에 필요한 제로 메모리 블록 할당하기
  • 라이브러리의 심볼 테이블 어태치하기

이 프로세스의 디버깅은 쉬운 일이 아니다. 여러 유형의 문제들을 경험하게 된다. 예를 들어, 동적 링커가 기존 라이브러리를 찾지 못하면 프로그램 로딩에 실패하게 된다. 원하는 모든 라이브러리를 찾았지만 심볼을 찾지 못하면 이 역시 실패한다. (하지만 그 심볼로 레퍼런스를 시도할 때까지 작동하지 않을 수도 있다)이것은 일반적으로 드문 경우이고, 심볼이 없다면 초기 링크 때 공지된다.

동적 링커 검색 경로 변경하기

프로그램을 링크할 때, 런타임 시 검색할 추가 경로를 지정할 수 있다. gcc에서 문법은 -Wl,-R/path이다. 프로그램이 이미 링크 되었다면 환경 변수 LD_LIBRARY_PATH를 설정하여 이 작동을 변경할 수 있다. 일반적으로 이것은 애플리케이션이 시스템 디폴트의 일부가 아닌 경로를 검색하려고 할 때에만 필요하다. 대부분의 리눅스 시스템에서는 드문 경우이다. 이론상으로는 Mozilla 사용자들은 이 경로 세트로 컴파일 된 바이너리를 배포했지만 실행 파일을 시작하기 전에 라이브러리 경로를 적절히 설정하는 래퍼 스크립트를 배포하는 것을 더 선호한다.

라이브러리 경로 설정은 두 애플리케이션들이 비 호환 버전의 라이브러리를 요구하는 드문 경우에 대안을 제공할 수 있다. 특별한 버전의 라이브러리를 사용하여 디렉토리에서 한 개의 애플리케이션 검색을 갖는데 래퍼 스크립트가 사용될 수 있다. 최상의 솔루션이라고는 볼 수 없지만 어떤 경우에는 이보다 더 나은 것이 없다.

많은 프로그램에 경로를 추가해야 하는 급박한 경우라면 시스템의 디폴트 검색 경로를 변경할 수 있다. 동적 링커는 /etc/ld.so.conf를 통해 제어된다. 여기에는 기본적으로 검색할 디렉토리 리스트가 포함되어 있다. LD_LIBRARY_PATH에서 지정된 모든 경로는 ld.so.conf에 나열된 경로에 앞서 검색된다. 따라서 사용자들은 이들 설정을 오버라이드 할 수 있다.

대부분의 사용자들은 시스템 디폴트 라이브러리 검색 경로를 변경할 이유가 없다; 일반적으로 환경 변수는 검색 경로를 변경하는데 더 알맞다. 툴킷의 라이브러리들과 링크하는 것 또는 새로운 버전의 라이브러리에 대해 프로그램을 테스트 하는 경우가 그 예이다.

ldd 사용하기

공유 라이브러리 문제를 해결하는 한 가지 유용한 툴은 ldd이다. 이 이름은 list dynamic dependencies의 앞 글자를 딴 것이다. 이 프로그램은 주어진 실행 파일 또는 공유 라이브러리를 찾아서 로딩해야 하는 공유 라이브러리가 무엇이고 어떤 버전이 사용되는 지를 파악한다. 아웃풋은 다음과 같다:

Listing 1. /bin/sh
$ ldd /bin/sh
linux-gate.so.1 => (0xffffe000)
libreadline.so.4 => /lib/libreadline.so.4 (0x40036000)
libhistory.so.4 => /lib/libhistory.so.4 (0x40062000)
libncurses.so.5 => /lib/libncurses.so.5 (0x40069000)
libdl.so.2 => /lib/libdl.so.2 (0x400af000)
libc.so.6 => /lib/tls/libc.so.6 (0x400b2000)
/lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)

"단순한" 프로그램이 많은 라이브러리들을 사용한다는 사실은 놀랍다. libhistorylibncurses를 위한 하나의 호출이다. 이를 찾아내려면 또 다른 ldd명령어를 실행한다:

Listing 2. libhistory
$ ldd /lib/libhistory.so.4
linux-gate.so.1 => (0xffffe000)
libncurses.so.5 => /lib/libncurses.so.5 (0x40026000)
libc.so.6 => /lib/tls/libc.so.6 (0x4006b000)
/lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x80000000)

어떤 경우, 애플리케이션은 여분의 라이브러리 경로를 지정해야 한다. 예를 들어, 첫 번째 몇 줄을 Mozilla 바이너리에서 ldd 를 실행하면 다음과 같다:

Listing 3. 검색 경로에 없는 아이템을 위한 ldd 결과
$ ldd /opt/mozilla/lib/mozilla-bin
linux-gate.so.1 => (0xffffe000)
libmozjs.so => not found
libplds4.so => not found
libplc4.so => not found
libnspr4.so => not found
libpthread.so.0 => /lib/tls/libpthread.so.0 (0x40037000)

왜 이들 라이브러리를 찾지 않는가 왜냐하면 일. 사실 /opt/mozilla/lib에 있기 때문에 이 디렉토리를 LD_LIBRARY_PATH에 추가하는 것이 한 가지 방법이 된다.

또 다른 옵션은 경로를 .으로 설정하고 이 디렉토리에서 ldd를 실행하는 것이다. 비록 이는 약간 위험하다. 현재 디렉토리를 라이브러리 경로에 둔다는 것은 실행 파일 경로에 두는 것에 버금가는 위험한 일이기 때문이다.

이 경우 이들이 속해있는 디렉토리를 시스템 검색 경로에 추가하는 것은 좋은 생각이 아니다. Mozilla만 이들 라이브러리를 필요로 한다.

Mozilla 링크하기

Mozilla와 관련하여 단지 몇 줄의 라이브러리 밖에 못 봤다고 생각한다면 다음을 보기 바란다. Mozilla를 시작하는데 왜 많은 시간이 걸리는지 알게 될 것이다.

Listing 4. mozilla-bin
linux-gate.so.1 =>  (0xffffe000)
libmozjs.so => ./libmozjs.so (0x40018000)
libplds4.so => ./libplds4.so (0x40099000)
libplc4.so => ./libplc4.so (0x4009d000)
libnspr4.so => ./libnspr4.so (0x400a2000)
libpthread.so.0 => /lib/tls/libpthread.so.0 (0x400f5000)
libdl.so.2 => /lib/libdl.so.2 (0x40105000)
libgtk-x11-2.0.so.0 => /opt/gnome/lib/libgtk-x11-2.0.so.0 (0x40108000)
libgdk-x11-2.0.so.0 => /opt/gnome/lib/libgdk-x11-2.0.so.0 (0x40358000)
libatk-1.0.so.0 => /opt/gnome/lib/libatk-1.0.so.0 (0x403c5000)
libgdk_pixbuf-2.0.so.0 => /opt/gnome/lib/libgdk_pixbuf-2.0.so.0 (0x403df000)
libpangoxft-1.0.so.0 => /opt/gnome/lib/libpangoxft-1.0.so.0 (0x403f1000)
libpangox-1.0.so.0 => /opt/gnome/lib/libpangox-1.0.so.0 (0x40412000)
libpango-1.0.so.0 => /opt/gnome/lib/libpango-1.0.so.0 (0x4041f000)
libgobject-2.0.so.0 => /opt/gnome/lib/libgobject-2.0.so.0 (0x40451000)
libgmodule-2.0.so.0 => /opt/gnome/lib/libgmodule-2.0.so.0 (0x40487000)
libglib-2.0.so.0 => /opt/gnome/lib/libglib-2.0.so.0 (0x4048b000)
libm.so.6 => /lib/tls/libm.so.6 (0x404f7000)
libstdc++.so.5 => /usr/lib/libstdc++.so.5 (0x40519000)
libgcc_s.so.1 => /lib/libgcc_s.so.1 (0x405d5000)
libc.so.6 => /lib/tls/libc.so.6 (0x405dd000)
/lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)
libX11.so.6 => /usr/X11R6/lib/libX11.so.6 (0x406f3000)
libXrandr.so.2 => /usr/X11R6/lib/libXrandr.so.2 (0x407ef000)
libXi.so.6 => /usr/X11R6/lib/libXi.so.6 (0x407f3000)
libXext.so.6 => /usr/X11R6/lib/libXext.so.6 (0x407fb000)
libXft.so.2 => /usr/X11R6/lib/libXft.so.2 (0x4080a000)
libXrender.so.1 => /usr/X11R6/lib/libXrender.so.1 (0x4081e000)
libfontconfig.so.1 => /usr/lib/libfontconfig.so.1 (0x40826000)
libfreetype.so.6 => /usr/lib/libfreetype.so.6 (0x40850000)
libexpat.so.0 => /usr/lib/libexpat.so.0 (0x408b9000)

공유 라이브러리에 대해 더 자세히 배우기

리눅스에서 동적 링크에 대해 더 자세히 알고 싶다면 여러 방법들이 있다. GNU 컴파일러와 링커 툴 체인 문서화는 잘 되어있다. 하지만 핵심적인 부분은 info포맷으로 저장되어 있고 표준 man 페이지에는 언급되지 않았다.

ld.so의 매뉴얼 페이지에는 동적 링커의 작동을 변경하는 변수 리스트와, 과거에 사용되었던 동적 링커의 다양한 버전들에 대한 설명이 되어 있다.

대부분의 리눅스 문서에는 리눅스 시스템상에서는 일반적으로 모든 공유 라이브러리들은 동적으로 링크 되는 것으로 간주하고 있다. 정적으로 링크된 공유 라이브러리를 만드는 작업은 중요한데, 대부분의 사용자들은 여기에서 어떤 것도 얻지 못한다. 이 기능을 지원하는 시스템에서 퍼포먼스 차이는 확연히 드러난다.

프리패키지(pre-packaged) 시스템을 사용하고 있다면 그렇게 많은 공유 라이브러리 버전이 필요하지 않을 것이다. 링크 되었던 라이브러리를 가진 시스템이기 때문이다. 반면 많은 업데이트와 소스 구현을 해야 한다면 많은 버전의 공유 라이브러리가 생긴다.

언제나 강조하지만 직접 해보는 것이 중요하다. 시스템상의 거의 모든 것들은 같은 몇 개의 공유 라이브러리들을 참조하기 때문에 시스템의 핵심 라이브러리들 중 하나를 고장내면 시스템 복구 툴을 가지고 고생을 해야 한다.

참고자료

Linkers and Loaders (Morgan Kauffman, October 1999) , manuscript chapters
ELF binary format
why a versioning scheme for shared libraries is important
Override the GNU C library -- painlessly (developerWorks, April 2002)
Writing DLLs for Linux apps (developerWorks, October 2001)
Shared objects for the object disoriented! (developerWorks, April 2001)
Use shared objects on Linux (developerWorks, May 2004)
developerWorks Linux zone
developerWorks blogs
Linux books at discounted prices