golang - full static binary 실행파일 만들기(with. cgo)


📖 요약

  • golang은 cgo를 사용하면 생성되는 binary가 glibc 의존성을 가지게 됩니다.
    • ubuntu 최신버전에서 빌드한 go binary는 이전 버전의 ubuntu에서 실행되지 않을 수 있습니다. (glibc 버전이 차이가 클 경우)
    • cgo를 명시적으로 사용하지 않더라도 net, os/user 패키지를 사용하면 glibc 의존성을 가질 수 있습니다.
  • glibc는 LGPL로 static link하면 코드 공개가 필요합니다.
  • golang에서 cgo를 포함해 full static binary를 만들고 싶을 경우, MIT 라이센스로 배포되는 glibc의 대체제인 musl를 사용하면 됩니다.
    • 단, dependency graph 상의 모든 코드가 glibc대신 musl을 쓰도록 다시 빌드되어야 되므로 OS자체가 musl을 기반으로 하는 alpine linux에서 빌드하는 방법을 추천 합니다.

😄 golang은 기본적으로 statically linked된 binary를 만들어냅니다.

hello world의 코드를 아무 옵션없이 그냥 빌드하면 static binary가 생성됩니다.

ubuntu linux에서 빌드 후 file 명령으로 해당 파일의 정보를 확인 해보면 statically linked 라고 표시되는 것을 확인 할 수 있음.

$ go build -o hello.noopt hello.go
$ file hello.noopt
hello.noopt: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, with debug_info, not stripped

😓 net 또는 os/user 패키지를 사용하면…

domain의 ip를 출력하는 간단한 코드를 작성하고 빌드 옵션없이 빌드 합니다.

package main

import (
        "fmt"
        "net"
)

func main() {
        ipaddrs, err := net.LookupHost("google.com")
        fmt.Printf("ipaddrs = %v, err = %v", ipaddrs, err)
}

hello world의 경우와 동일하게 빌드 후 file 명령으로 확인합니다.

$ go build -o getip.noopt getip.go
$ file getip.noopt
getip.noopt: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, with debug_info, not stripped

dynamically linked라고 표시됩니다. 즉 이 실행파일이 참조하고 있는 동적 라이브러리가 있고, 이 동적 라이브러리가 없는 환경에서는 실행이 불가능하다는 의미입니다.

원인은 os/user, net go package 문서에 나와 있습니다. 공식 문서 확인의 중요성

os/user, net 두 패키지는 cgo를 사용하도록 구현되어 있습니다. 두 패키지를 사용하더라도 static binary로 빌드하는 방법은 2가지가 있습니다.

  • osusergo, netgo 빌드 tag를 사용하면 pure go로 구현 된 코드를 사용합니다.
  • 또는 go env 의 CGO_ENABLED=0으로 빌드해도 동일하게 pure go로 구현된 코드를 사용합니다.

위에 작성했던 코드를 코드 변경없이, netgo tag를 추가해서 빌드해서 file 명령으로 확인 해보면, statically linked 문구를 확인 할 수 있습니다.

$ go build -tags netgo -o getip.netgo getip.go
$ test file getip.netgo
getip.netgo: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, with debug_info, not stripped

🤔 cgo 모듈이 꼭 필요한 경우라면?

os/usernet 처럼 pure go로 구현하는 방법이 불가능 한 custom 모듈이 libc 의존성을 가진 경우라면 다른 접근이 필요합니다.

C로 작성된 코드의 경우 대부분 libc에 대한 의존성을 가집니다.

glibc도 libc를 구현한 하나의 구현체 중 하나 일 뿐이므로, MIT 라이센스로 배포되는 다른 구현체인 musl을 사용 할 수 있습니다.

다만, 이를 해결 하기 위해서는 2가지 문제를 해결 해야 합니다.

  • glibc는 LGPL(GNU Library General Public License)로 배포되므로, static link시 소스코드를 무조건 공개해야 합니다.
  • musl 같은 대체 라이브러리를 사용 할 경우 dependency graph 상의 모든 라이브러리들을 musl을 사용 하도록 다시 빌드 해야 합니다.

🔑 musl을 사용해서 빌드하기

대부분의 linux는 glibc를 사용합니다. musl을 설치하고 빌드시 musl을 사용해서 빌드하는 것도 가능하지만 이미 OS에 설치 된 라이브러리에 대한 의존성이 있는 경우라면 그 라이브러리도 musl을 사용하도록 다시 빌드해야 합니다.

glibc 대신 musl을 사용하는 OS라면 모든 라이브러리가 musl을 참조하도록 빌드되어 있으므로, 그런 OS에서 빌드를 하는 것이 가장 좋은 방법입니다.

alpine linux가 musl을 사용하는 OS 중 하나입니다.

alpine linux의 docker image에 build-base 패키지등을 설치 한 image를 만들어서 사용하면 됩니다.

FROM golang:1.21-alpine3.18

RUN apk update
RUN apk add alpine-sdk build-base swig cmake unzip bash ninja 
RUN apk add openssl-dev bzip2-dev zlib-dev readline-dev sqlite-dev \
    llvm ncurses-dev xz xz-dev tk-dev \
    libxml2-dev xmlsec-dev libffi-dev 

docker image를 만듭니다.

$ docker build -t alpine-builder .

docker run으로 빌드합니다. (build.sh는 예시 입니다.)

  • --user 옵션을 미지정시 docker 내부에서 root로 빌드가 진행고 build output 디렉토리들이 root 권한으로 생성되므로 --user 옵션을 지정하는 편이 좋습니다.
$ docker run -v .:/app -w /app --user $(id -u):$(id -g) alpine-builder sh build.sh

참고 문서

comments powered by Disqus