Dev

하위 버전 CentOS Linux에 배포할 때의 glibc, libstdc++ 라이브러리 버전 문제

prostars 2022. 8. 17. 12:53

배경

최근에 읽은 ‘구글 엔지니어는 이렇게 일한다.’에서 컴파일러 버전업에 대한 이야기가 나와서 예전에 작업했던 GCC 컴파일러 버전업에 대한 내용을 정리해본다. 몇 년 전 새로운 팀에 합류했을 때 서비스 중인 서버군들 중에서 C++ 구현된 서버군이 GCC 4.4.7을 사용하고 있었고, 컴파일러 버전이 낮아서 모던 C++의 시작인 C++ 11를 사용할 수 없었다. GCC 4.4.7은 C++ 0x까지 지원한다. C++ 11을 사용하려면 최소한 gcc 4.7 이상이 필요하다.
버전업 타깃은 CentOS 7 Base repo에 있는 GCC 4.8.5 버전으로 맞추고 진행했었다. 작업 당시 오랜 세월의 흔적으로 개발, 배포, 서비스 환경 등 각각 OS 버전이 달랐다. OS 버전이 다르다는 것은 OS가 기본적으로 가지고 있는 C 표준 라이브러리 glibc 버전과 C++ 표준 라이브러리 libstdc++ 버전이 다를 수 있다는 것과 같다.
이 글에서는 이와 같은 OS 버전 차이에서 발생하는 표준 라이브러리 버전 문제를 해결했던 내용을 정리한다. 직접 작업했던 내용을 배경으로 최대한 단순하게 예제를 구성했다. 오래된 버전들로 예제가 구성되었는데, 이와 같은 버전 문제는 계속 존재하는 것이라 큰 틀을 벗어나지는 않는다. 문제 해결에 도움이 되길 바란다.

이 글을 읽는데 필요한 배경 지식으로 C++, GCC, Docker, Linux, vi를 사용할 수 있다고 가정하고, 호스트 OS는 macOS를 기준으로 진행한다.

간단한 해결 방법은 정적 라이브러리를 사용하여 실행 파일에서 외부 의존성을 제거하는 방법이 있고, 문제가 되는 함수를 개별적으로 다른 버전에 의존하도록 처리하는 방법이 있다. 이 글에서 두 가지를 모두 정리한다. 그러면 이제 문제를 생성하고, 확인하고, 해결해보자.

CentOS 버전마다 다른 glibc, libstdc++ 라이브러리 버전 확인

Docker를 사용해서 6.10, 7.5.1804에 설치된 glibc 버전과 libstdc++ 버전을 확인해보자.
터미널을 3개 열고 진행한다. 2개의 터미널(이후 1, 2번 터미널)에서 각각 CentOS 6, 7 컨테이너를 실행하고 나머지 1개의 터미널(이후 3번 터미널)은 각 컨테이너 간에 파일을 복사할 것이다. 참고로 ‘docker run’으로 바로 이미지를 받고 바로 컨테이너에 연결해서 진행할 것이므로 이 글 끝에서 뒷정리할 때까지 터미널을 닫거나 컨테이너에서 나오지 말자.
이후 글에서 등장하는 프롬프트 ‘컨테이너# 컨테이너에서 실행, ‘호스트$’는 호스트에서 실행하는 것을 뜻한다.

CentOS 6.10 (1번 터미널)

호스트$ docker run --name centos6 -it centos:6.10 
…생략…

컨테이너# cat /etc/centos-release
CentOS release 6.10 (Final)

컨테이너# getconf -a | grep glibc
GNU_LIBC_VERSION                   glibc 2.12

컨테이너# ls -al /usr/lib64/libstd*
lrwxrwxrwx 1 root root     19 Aug  4  2018 /usr/lib64/libstdc++.so.6 -> libstdc++.so.6.0.13
-rwxr-xr-x 1 root root 987096 Jun 19  2018 /usr/lib64/libstdc++.so.6.0.13

컨테이너# strings /usr/lib64/libstdc++.so.6 | grep GLIBCXX
GLIBCXX_3.4
GLIBCXX_3.4.1
…생략…
GLIBCXX_3.4.12
GLIBCXX_3.4.13
GLIBCXX_FORCE_NEW
GLIBCXX_DEBUG_MESSAGE_LENGTH


CentOS 7.5.1804 (2번 터미널)

호스트$ docker run --name centos7 -it centos:7.5.1804
…생략…

컨테이너# cat /etc/centos-release
CentOS Linux release 7.5.1804 (Core)

컨테이너# getconf -a | grep glibc
GNU_LIBC_VERSION                   glibc 2.17

컨테이너# ls -al /usr/lib64/libstd*
lrwxrwxrwx 1 root root     19 May 31  2018 /usr/lib64/libstdc++.so.6 -> libstdc++.so.6.0.19
-rwxr-xr-x 1 root root 991616 May 15  2018 /usr/lib64/libstdc++.so.6.0.19

컨테이너# strings /usr/lib64/libstdc++.so.6 | grep GLIBCXX
GLIBCXX_3.4
GLIBCXX_3.4.1
…생략…
GLIBCXX_3.4.18
GLIBCXX_3.4.19
GLIBCXX_DEBUG_MESSAGE_LENGTH

각 CentOS 별 버전 내용을 정리하면 아래와 같다.

  • CentOS 6.10 : glibc 2.12, GLIBCXX_3.4.13
  • CentOS 7.5.1804 : glibc 2.17, GLIBCXX_3.4.19

이렇게 버전이 다른 경우에 어떤 문제가 생길 수 있는지 확인해보자.

glibc, libstdc++ 라이브러리 버전 의존성 생성

2번 터미널에서 gcc-c++를 설치한다.

컨테이너# yum install -y gcc-c++
…생략…

컨테이너# g++ --version
g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-44)
Copyright (C) 2015 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

4.8.5 버전이 설치된 것을 확인했다. 버전 에러를 만들 준비를 하자. 코드 구성은 glibc, libstdc++ 라이브러리에 대한 버전 의존성을 생성하는 것이 목적이다.

2번 터미널에서 아래 코드로 소스 파일을 생성하고 빌드한다.

컨테이너# vi liberror.cpp
#include <cstring>
#include <random>
#include <iostream>

int main()
{
	char str[100];
	char greeting[] = "Hello, World!";

	memcpy(str, greeting, strlen(greeting) + 1);
	std::random_device{}();
	std::cout << greeting << std::endl;
}
컨테이너# g++ -std=c++11 liberror.cpp -o liberror

glibc, libstdc++ 라이브러리 버전 문제 확인

3번 터미널에서 CentOS 6 컨테이너로 CentOS 7에서 빌드한 liberror 실행 파일을 복사한다.

호스트$ mkdir test_lib_ver
호스트$ cd test_lib_ver
호스트$ docker cp centos7:/liberror ./
호스트$ docker cp ./liberror centos6:/

1번 터미널에서 liberror를 실행한다.

컨테이너# ./liberror
./liberror: /usr/lib64/libstdc++.so.6: version `GLIBCXX_3.4.18' not found (required by ./liberror)
./liberror: /lib64/libc.so.6: version `GLIBC_2.14' not found (required by ./liberror)

CentOS 6에서 glibc, libstdc++ 버전 에러를 확인할 수 있다.

2번 터미널에서 liberror 실행 파일이 가진 라이브러리 의존성을 확인해보자.
먼저 ldd로 공유 라이브러리 의존성을 확인한다.

컨테이너# ldd liberror
	linux-vdso.so.1 =>  (0x00007ffcf8d1c000)
	libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007f69e98af000)
	libm.so.6 => /lib64/libm.so.6 (0x00007f69e95ad000)
	libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f69e9397000)
	libc.so.6 => /lib64/libc.so.6 (0x00007f69e8fc9000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f69e9bb7000)

1번 터미널에서 실행한 liberror에서 출력된 에러 메시지 ‘version XXXX not found’에서 liberror 실행 파일이 libc.so.6, libstdc++.so.6 공유 라이브러리에 의존성을 가지고 있는 것을 확인했지만, 세부 버전 정보가 나오지 않았다.
이번에는 nm으로 심볼 목록을 확인해보자.

컨테이너# nm liberror | grep GLIBCXX_3.4.18
                 U _ZNSt13random_device7_M_finiEv@@GLIBCXX_3.4.18
                 U _ZNSt13random_device7_M_initERKSs@@GLIBCXX_3.4.18
                 U _ZNSt13random_device9_M_getvalEv@@GLIBCXX_3.4.18

컨테이너# nm liberror | grep GLIBC_2.14
                 U memcpy@@GLIBC_2.14

이제 CentOS 6에서 확인한 에러 메시지의 원인을 찾았다. memcpy 함수가 GLIBC_2.14 버전에 random_device 함수가 GLIBCXX_3.4.18 버전에 의존하고 있다. 위에서 확인했던 CentOS 6에 설치된 라이브러리 버전은 아래와 같았다.

  • CentOS 6.10 : glibc 2.12, GLIBCXX_3.4.13

liberror 실행 파일이 의존하고 있는 버전보다 낮은 것을 확인할 수 있다. 이 버전 문제를 해결할 방법이 몇 가지 있겠지만, 가장 쉬운 방법은 기본 설정인 공유 라이브러리를 사용하는 대신 정적 라이브러리로 링크 타임에 실행 파일에 묶는 것이다.

glibc, libstdc++ 라이브러리 버전 문제 해결

GLIBCXX_3.4.18 버전 문제부터 해결하자.
2번 터미널에서 libstdc++ 정적 라이브러리를 링크한다.

컨테이너# g++ -std=c++11 liberror.cpp -o liberror -static-libstdc++
/usr/bin/ld: cannot find -lstdc++
collect2: error: ld returned 1 exit status

에러가 발생한다. libstdc++ 정적 라이브러리를 링크하려면 링크할 libstdc++ 정적 라이브러리를 설치해야 한다.
2번 터미널에서 libstdc++-static를 설치한다.

컨테이너# yum install -y libstdc++-static
…생략…

컨테이너# g++ -std=c++11 liberror.cpp -o liberror -static-libstdc++

-static-libstdc++ 링크 옵션을 주고 에러 없이 빌드했다.

컨테이너# ldd liberror
	linux-vdso.so.1 =>  (0x00007fffcd1cb000)
	libm.so.6 => /lib64/libm.so.6 (0x00007fc95a472000)
	libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007fc95a25c000)
	libc.so.6 => /lib64/libc.so.6 (0x00007fc959e8e000)
	/lib64/ld-linux-x86-64.so.2 (0x00007fc95a774000)

libstdc++. so.6에 대한 의존성이 사라진 것을 확인할 수 있다.
다시 3번 터미널에서 CentOS 6 컨테이너로 liberror 바이너리를 복사한다.

호스트$ docker cp centos7:/liberror ./
호스트$ docker cp ./liberror centos6:/

1번 터미널에서 liberror를 실행한다.

컨테이너# ./liberror
./liberror: /lib64/libc.so.6: version `GLIBC_2.14' not found (required by ./liberror)

version `GLIBCXX_3.4.18' not found 에러가 사라진 것을 확인할 수 있다.
이제 2번 터미널로 가서 GLIBC_2.14 에러를 처리해보자.
2번 터미널에서 glibc 정적 라이브러리를 링크한다.

컨테이너# g++ -std=c++11 liberror.cpp -o liberror -static
/usr/bin/ld: cannot find -lm
/usr/bin/ld: cannot find -lc
collect2: error: ld returned 1 exit status

에러가 발생한다. libstdc++ 정적 라이브러리를 설치했듯이 glibc를 정적 링크하려면 glibc 정적 라이브러리를 설치해야 한다.
2번 터미널에서 glibc-static를 설치한다.

컨테이너# yum install -y glibc-static
…생략…

컨테이너# g++ -std=c++11 liberror.cpp -o liberror -static

-static 링크 옵션을 주고 에러 없이 빌드했다.

컨테이너# ldd liberror
	not a dynamic executable

공유 라이브러리 의존성이 모두 사라졌다.
3번 터미널에서 CentOS 6 컨테이너로 liberror 실행 파일을 복사한다.

호스트$ docker cp centos7:/liberror ./
호스트$ docker cp ./liberror centos6:/

1번 터미널에서 liberror를 실행한다.

컨테이너# ./liberror
Hello, World!

glibc, libstdc++ 라이브러리 버전 문제가 모두 사라졌다.

위에서 정적 라이브러리를 링크할 때 아무런 경고 없이 링크되었다면 이렇게 모두 정적 라이브러리 링크로 문제를 해결하는 것도 하나의 방법이다. 프로젝트가 크다면, 정적 링크 옵션을 사용했을 때 사용 중인 외부 라이브러리의 의존성 문제로 에러 또는 경고가 발생할 수도 있다. 그럴 때는 버전 문제를 발생시키는 함수를 개별적으로 처리하는 방법을 사용할 수 있다.
가장 흔하게 사용하면서 glibc 공유 라이브러리 버전 문제를 발생시켰던 것으로 memcpy 함수가 있다. 그래서 예제에서 memcpy 함수를 사용했다. memcpy 함수 문서를 보면 memcpy 함수 사용 부분을 memmove 함수로 치환하면 문제가 해결될 것 같다. 실제로 liberror.cpp에서 memcpy 함수를 memmove 함수로 치환하고 빌드한 후에 nm으로 확인해보면 문제가 되었던 'memcpy@@GLIBC_2.14'가 'memmove@@GLIBC_2.2.5'로 교체된 것을 확인할 수 있다.
이 예제에는 없지만 실제 프로젝트에서 사용하는 외부 라이브러리가 memcpy 함수를 사용하고 있다면 이와 같은 방법으로는 해결할 수 없다. memcpy 함수에 대한 버전 의존성을 바꿔보자.

2번 터미널에서 아래 코드로 소스 파일을 추가 생성하고 빌드한다.

컨테이너# vi wrap_memcpy.cpp
#include <cstddef>

asm(".symver __memcpy_glibc_2_2_5, memcpy@GLIBC_2.2.5");
extern "C"
{
	void *__memcpy_glibc_2_2_5(void *, const void *, std::size_t);
	void *__wrap_memcpy(void *dest, const void *src, std::size_t n)
	{
		return __memcpy_glibc_2_2_5(dest, src, n);
	}
}
컨테이너# g++ -std=c++11 liberror.cpp wrap_memcpy.cpp -o liberror -static-libstdc++ -Wl,--wrap=memcpy
컨테이너# nm liberror | grep GLIBC_2.14

위에서 확인했던 GLIBC_2.14 버전 의존성이 사라진 것을 확인할 수 있다.

컨테이너# nm liberror | grep memcpy
0000000000402b04 T __wrap_memcpy
                 U memcpy@GLIBC_2.2.5
                 U wmemcpy@@GLIBC_2.2.5

위에서 'memcpy@@GLIBC_2.14'로 확인했던 memcpy 함수의 심볼이 'memcpy@GLIBC_2.2.5'로 바뀐 것을 확인할 수 있다. 대신 'T __wrap_memcpy'가 추가되었다. 위에서 빌드할 때 사용한 —wrap 옵션과 새로 추가한 wrap_memcpy.cpp 때문인데 내용을 살펴보자.
먼저, '-Wl,--wrap=memcpy' 옵션은 memcpy 함수 호출을 링크 타임에 __wrap_memcpy 함수로 연결해준다. 그래서 wrap_memcpy.cpp 코드에서 __wrap_memcpy 함수를 추가한 것이다.
__wrap_memcpy 함수는 내부적으로 __memcpy_glibc_2_2_5 함수를 호출하도록 했고, __memcpy_glibc_2_2_5 함수는 인라인 어셈블리에서 .symver 설정으로 __memcpy_glibc_2_2_5 함수를 'memcpy@GLIBC_2.2.5' 심볼로 바인딩했다.
계략적인 연결은 아래와 같다.

memcpy -> __wrap_memcpy -> __memcpy_glibc_2_2_5 -> memcpy@GLIBC_2.2.5

마지막으로 3번 터미널에서 CentOS 6 컨테이너로 liberror 실행 파일을 복사한다.

호스트$ docker cp centos7:/liberror ./
호스트$ docker cp ./liberror centos6:/

1번 터미널에서 liberror를 실행한다.

컨테이너# ./liberror
Hello, World!

glibc, libstdc++ 라이브러리 버전 문제가 모두 사라졌다.

뒷정리

1, 2번 터미널에서 각각 아래와 같이 exit로 컨테이너에서 나온다.

컨테이너# exit

그리고 아무 터미널에서나 아래와 같이 컨테이너 2개를 모두 삭제하고, 이어서 이미지도 삭제한다.

호스트$ docker rm centos6 centos7
호스트$ docker rmi centos:6.10 centos:7.5.1804

더 자세한 내용은 이 글에 포함된 링크들에서 확인할 수 있다.