본문으로 건너뛰기

빌드와 크로스 컴파일

이 챕터에서 다루는 것

Go의 배포가 쉬운 이유는 정적 링크된 단일 바이너리이기 때문이다. 그 바이너리를 다른 플랫폼용으로 만드는 법, 버전 정보를 박아 넣는 법, 크기를 줄이는 법, 그리고 같은 소스에서 항상 같은 바이너리가 나오게 하는 법을 다룬다.

이 챕터의 명령과 출력은 전부 이 머신(darwin/arm64)에서 실제로 실행한 것이다.

문제 — "그 서버에 지금 뭐가 돌고 있는가"

장애 조사에서 가장 먼저 필요한 정보인데, 답하기가 의외로 어렵다.

  • 배포 태그는 v1.4.2인데 그게 어느 커밋인지 모른다
  • 핫픽스를 급하게 넣느라 태그 없이 나간 빌드가 있다
  • 빌드한 사람의 작업 트리가 깨끗했는지 알 수 없다

Go는 이 문제에 두 가지 답을 준다. 하나는 링커로 값을 박아 넣는 것이고, 다른 하나는 툴체인이 자동으로 남기는 정보를 읽는 것이다. 둘 다 쓴다.

-ldflags -X로 값 주입하기

examples/12-production/06-build-and-cross-compile/buildinfo/buildinfo.go
// 링커가 채워 넣는 값들.
//
// var여야 하고(const는 링커가 못 건드린다), 문자열이어야 하고,
// 패키지 경로를 포함한 전체 이름으로 지정해야 한다.
//
// go build -ldflags "-X 'example.com/production/06-build-and-cross-compile/buildinfo.Version=v1.2.3'"
//
// 기본값을 "dev"로 두면 -ldflags 없이 go run으로 돌렸을 때도 뭔가 나온다.
var (
// Version은 릴리스 태그다.
Version = "dev"
// Commit은 짧은 커밋 해시다.
Commit = "none"
// BuildTime은 빌드 시각이다. 재현 가능한 빌드를 원하면
// 이 값을 넣지 않거나 커밋 시각을 넣는다.
BuildTime = "unknown"
)

제약이 셋 있고, 어기면 에러 없이 조용히 무시된다.

  1. var여야 한다. const는 컴파일 시점에 값이 코드에 박혀서 링커가 손댈 수 없다.
  2. string이어야 한다. intbool은 안 된다.
  3. 전체 경로로 지정해야 한다. -X buildinfo.Version=...이 아니라 -X example.com/.../buildinfo.Version=...이다.

:::danger -X의 오타는 아무 말도 하지 않는다 경로를 틀리게 적어도 링커는 경고 한 줄 없이 넘어간다. 빌드는 성공하고, 실행하면 version: dev가 찍힌다. CI에 "빌드한 바이너리의 -v 출력에 dev가 있으면 실패" 같은 검사를 넣어 두는 것이 확실하다. :::

debug.ReadBuildInfo — 툴체인이 알아서 남기는 것

Go 1.18부터 go buildVCS 정보를 자동으로 바이너리에 박는다. -ldflags를 하나도 주지 않아도 그렇다.

examples/12-production/06-build-and-cross-compile/buildinfo/buildinfo.go
// Settings에는 vcs.revision, vcs.time, vcs.modified,
// GOOS, GOARCH, CGO_ENABLED, -trimpath 등이 들어 있다.
for _, s := range bi.Settings {
switch s.Key {
case "vcs.revision":
i.VCSRev = s.Value
case "vcs.time":
i.VCSTime = s.Value
case "vcs.modified":
i.VCSDirty = s.Value
case "CGO_ENABLED":
i.CGO = s.Value
case "-trimpath":
i.Trimpath = s.Value
}
}

go run으로 돌리면 이렇게 나온다.

version: dev
commit: none
built: unknown
go: go1.26.5
module: example.com/production
platform: darwin/arm64
cgo: 1
trimpath: (없음)
vcs.rev: (없음)
vcs.time: (없음)
vcs.dirty: (없음)

go run은 임시 디렉터리에서 빌드하므로 VCS 정보가 없다. go build로 만든 바이너리를 실행하면 다르다.

go build -trimpath \
-ldflags "-s -w \
-X 'example.com/production/06-build-and-cross-compile/buildinfo.Version=v1.2.3' \
-X 'example.com/production/06-build-and-cross-compile/buildinfo.Commit=abc1234' \
-X 'example.com/production/06-build-and-cross-compile/buildinfo.BuildTime=2026-08-12T00:00:00Z'" \
-o /tmp/p12app ./06-build-and-cross-compile
/tmp/p12app
version: v1.2.3
commit: abc1234
built: 2026-08-12T00:00:00Z
go: go1.26.5
module: example.com/production
platform: darwin/arm64
cgo: 1
trimpath: true
vcs.rev: 2a453920b77f955c4f4b22115643e856dd496dfe
vcs.time: 2026-08-12T10:03:58Z
vcs.dirty: true

vcs.dirty: true가 가장 값진 한 줄이다. 이 바이너리를 만들 때 작업 트리에 커밋되지 않은 변경이 있었다는 뜻이다(이 챕터를 쓰는 중이었으니 맞다). 운영 바이너리에서 이 값이 true면, 그 바이너리의 소스는 세상 어디에도 없다. CI에서 이 조건으로 빌드를 실패시키는 것이 좋은 방어선이다.

vcs.rev-X로 넣은 Commit과 달리 거짓말을 할 수 없다. 손으로 넣는 값은 스크립트가 틀리면 틀린 값이 들어가지만, 이쪽은 툴체인이 직접 읽는다.

:::tip 시작 로그 첫 줄에 넣는다

examples/12-production/06-build-and-cross-compile/buildinfo/buildinfo.go
// Short는 로그 한 줄에 넣기 좋은 형식이다.
func (i Info) Short() string {
rev := i.VCSRev
if len(rev) > 7 {
rev = rev[:7]
}
if rev == "" {
rev = i.Commit
}
return fmt.Sprintf("%s (%s) %s %s/%s", i.Version, rev, i.GoVersion, i.GOOS, i.GOARCH)
}

12-3에서 시작할 때 설정을 통째로 로깅했다. 그 옆에 이 한 줄을 두면 "어느 커밋이 어떤 설정으로 떠 있었나"에 로그만으로 답할 수 있다. :::

크로스 컴파일

환경 변수 두 개면 끝이다.

GOOS=linux GOARCH=amd64 go build -o app ./cmd/app

C 툴체인도, 도커도, 대상 머신도 필요 없다. 다른 언어에서 이 작업이 얼마나 번거로운지 아는 사람에게는 이것이 Go를 고르는 이유 중 하나다.

examples/12-production/06-build-and-cross-compile/build.sh
# CGO_ENABLED=0이 정적 링크의 핵심이다. 크로스 컴파일에서는
# C 툴체인이 없으므로 어차피 0이어야 한다.
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \
go build -trimpath -ldflags "$LDFLAGS" -o "$OUT/$name" "$PKG"
sh ./06-build-and-cross-compile/build.sh /tmp/p12out
app-linux-amd64 1704098 bytes
app-linux-arm64 1704098 bytes
app-darwin-arm64 1759394 bytes
app-windows-amd64.exe 1790976 bytes

file로 확인하면 진짜 그 플랫폼의 실행 파일이다.

/tmp/p12out/app-darwin-arm64: Mach-O 64-bit executable arm64
/tmp/p12out/app-linux-amd64: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=hBlG5zAgQAkrjJAbIUiC/-9oRTItoZGiBlt37Goy2/xQTYcCPaPGL6kA8wkpC-/AZcRbaDJ3ClLkLCdcEkT, BuildID[sha1]=b0893322c5afd2aa672f6b6d35840f7173f68f6c, stripped
/tmp/p12out/app-linux-arm64: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, Go BuildID=drPhyyQ4mO5_xxO8k7ms/QZT8S4Azp4IfVvNMdJFp/jr08iwT-Z67bNVj09eyv/pQl_D5NhpeMEuLjh5Knh, BuildID[sha1]=23fb52b43129876bb9803f617b70e63b2971b4ec, stripped
/tmp/p12out/app-windows-amd64.exe: PE32+ executable (console) x86-64, for MS Windows

statically linked가 12-7에서 scratch 이미지를 쓸 수 있게 해 주는 조건이다. linux/amd64와 linux/arm64의 크기가 같은 것은 우연이다.

지원하는 조합 전체는 go tool dist list로 본다. 100개가 넘는다.

CGO_ENABLED — 흔히 잘못 알려진 부분

"크로스 컴파일하려면 CGO_ENABLED=0이어야 한다"는 말을 자주 보는데, 정확하지 않다. 실제로 이 예제를 CGO_ENABLED=1로 크로스 빌드해 보면 성공한다.

CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o /tmp/cgo_fail ./06-build-and-cross-compile
file /tmp/cgo_fail
/tmp/cgo_fail: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=6_LSPUfYCsAC1fyOIjum/reZD57OxpK_l45sJRlAT/FsDEuh2GsmPxszS0RrVN/Oc7t3XMFillmdnxchfTl, BuildID[sha1]=6099f73896a077cab62f8a15b3da15456ab81e4f, with debug_info, not stripped

정적 링크까지 됐다. CGO_ENABLED=1은 "cgo를 쓸 수 있다"이지 "쓴다"가 아니기 때문이다. 실제로 C 코드를 import하는 패키지가 하나도 없으면 C 컴파일러는 불리지 않는다.

진짜로 cgo를 쓰는 패키지가 있으면 그때 실패한다.

package main

/*
#include <stdio.h>
*/
import "C"

func main() { C.puts(C.CString("hi")) }
# runtime/cgo
gcc_amd64.S:27:8: error: unknown token in expression
pushq %rbx

macOS의 clang이 linux/amd64용 어셈블리를 이해하지 못해 터진다. 대상 플랫폼용 C 크로스 컴파일러(CC=x86_64-linux-gnu-gcc 같은)를 붙여야 넘어간다.

그래서 CGO_ENABLED=0은 여전히 옳은 기본값이다. 이유가 "안 그러면 크로스 컴파일이 안 돼서"가 아니라 이것들이다.

  • 의존성이 하나 늘어나 cgo를 쓰기 시작해도 빌드가 조용히 바뀌지 않는다
  • netos/user가 시스템 라이브러리 대신 순수 Go 구현을 쓴다 — 이것이 scratch/distroless 이미지에서 DNS 조회가 되게 하는 조건이다
  • 빌드가 빨라진다

11장에서 SQLite 드라이버로 cgo가 필요 없는 modernc.org/sqlite를 고른 것이 정확히 이 이야기였다. mattn/go-sqlite3를 쓰면 CGO_ENABLED=0으로는 아예 빌드가 안 되고, 첫 빌드에 11.5초를 C 컴파일에 쓴다.

바이너리 크기 줄이기

sz_plain 2572155 bytes
sz_sw 1708194 bytes
sz_all 1704098 bytes
  • sz_plain — 옵션 없음
  • sz_sw-ldflags "-s -w"33.6% 감소
  • sz_all — 거기에 -trimpath → 추가로 0.2%

-s는 심볼 테이블을, -w는 DWARF 디버그 정보를 뺀다. 둘의 대가는 명확하다. delve로 디버깅할 수 없고, 패닉 스택 트레이스의 정보가 줄어든다. 함수 이름은 스택 트레이스에 필요한 별도 테이블에 있어서 남지만, 줄 번호 정보 일부와 인자 값이 사라진다.

운영 컨테이너 이미지라면 붙일 만하다. 문제를 조사할 때 쓰는 빌드에는 붙이지 않는다.

:::note UPX 같은 압축기는 권하지 않는다 바이너리를 더 줄일 수 있지만, 실행할 때마다 메모리에서 압축을 풀어야 해서 시작이 느려지고 메모리를 두 배로 쓴다. 백신 소프트웨어가 오탐하는 일도 잦다. Go 바이너리 2MB는 컨테이너 이미지 크기에서 대개 문제가 아니다 — 12-7에서 보듯 베이스 이미지 쪽이 훨씬 크다. :::

재현 가능한 빌드와 -trimpath

같은 소스에서 항상 같은 바이너리가 나오는가? 공급망 보안에서 중요한 질문이다. "이 이미지가 정말 그 커밋에서 빌드된 것인가"를 검증하려면 재현 가능해야 한다.

같은 디렉터리에서 두 번 빌드하면 -trimpath 없이도 해시가 같다.

1390cc95474993337f76dac98c4464cad2c3da46864bc01ee086f57a9b053d2b /tmp/rep1
1390cc95474993337f76dac98c4464cad2c3da46864bc01ee086f57a9b053d2b /tmp/rep2

문제는 다른 디렉터리에서 빌드할 때다. 모듈을 통째로 /tmp/p12copy에 복사해 같은 명령으로 빌드해 봤다(VCS 정보가 달라지지 않도록 양쪽 다 -buildvcs=false).

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath \
-ldflags "-s -w -X ${LDPKG}.Version=v1.0.0" -o /tmp/t_orig ./06-build-and-cross-compile
=== -trimpath 있음: 원본 vs 복사본 ===
e0017de54b28a89fdb26003a0fa0b44d668137f65e86471b68c492f4f9f44129 /tmp/t_orig
e0017de54b28a89fdb26003a0fa0b44d668137f65e86471b68c492f4f9f44129 /tmp/t_copy
=== -trimpath 없음: 원본 vs 복사본 ===
77acbb4109c2e9d1f1c4e5b2d08e5cceba8333969ebd2b88cbf57751e15da751 /tmp/n_orig
3ab8463c6cf5e051a07bc4be139b8c243d56619077dfb9fad599bbf34f118aea /tmp/n_copy

-trimpath가 있으면 해시가 같고, 없으면 다르다. 없을 때 다른 이유는 바이너리 안에 소스 파일의 절대 경로가 박히기 때문이다. 개발자 A의 /Users/a/work/proj/main.go와 CI의 /home/runner/work/proj/main.go가 다른 바이트가 된다.

-trimpath는 그 경로를 모듈경로@버전/파일로 바꾼다. 부수 효과로 빌드 머신의 디렉터리 구조가 바이너리에 새어 나가지 않는다. 사용자 이름이 경로에 들어 있는 경우가 흔하다.

재현 가능한 빌드의 나머지 조건들이다.

  • 같은 Go 버전. go.modtoolchain 지시자로 고정한다(파트 1-2).
  • 같은 의존성. go.sum이 보장한다.
  • -ldflags에 시각을 넣지 않기. date를 쓰면 매번 달라진다. 예제 스크립트가 git show -s --format=%cI HEAD커밋 시각을 쓰는 이유다.
examples/12-production/06-build-and-cross-compile/build.sh
# 커밋 시각을 빌드 시각으로 쓴다. 지금 시각을 쓰면 같은 소스에서
# 매번 다른 바이너리가 나와 재현 가능한 빌드가 깨진다.
COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo none)
BUILT=$(git show -s --format=%cI HEAD 2>/dev/null || echo unknown)

알아 둘 만한 빌드 플래그

플래그하는 일
-o 경로출력 위치
-trimpath경로 제거. 사실상 항상 켠다
-ldflags "-s -w"심볼·디버그 정보 제거
-ldflags "-X 경로.변수=값"문자열 변수 주입
-tags 태그빌드 태그 (파트 6-7, 그리고 이 파트의 pprofserver)
-buildvcs=falseVCS 정보 박지 않기
-race레이스 검출기 포함. 운영에는 넣지 않는다
-gcflags "-m"이스케이프 분석 결과 출력
-cover커버리지 계측 바이너리 (파트 8)

-gcflags "-m"은 12-5와 이어진다. "이 값이 힙으로 갔는가 스택에 남았는가"를 컴파일러에게 직접 물어보는 방법이다.

go build -gcflags "-m" ./06-build-and-cross-compile 2>&1 | grep "escapes to heap"

흔한 실수

-X에 짧은 경로를 쓴다. 조용히 무시된다. 이 챕터의 첫 경고다.

const-X를 쓴다. 역시 조용히 무시된다.

-ldflagsdate를 넣는다. 재현 가능성이 깨진다. 커밋 시각을 쓴다.

개발 빌드에도 -s -w를 넣는다. 디버거가 붙지 않아 고생한다.

크로스 컴파일한 바이너리를 테스트 없이 배포한다. GOOS=linux로 빌드한 것을 macOS에서 실행해 볼 수는 없다. CI에서 대상 플랫폼의 컨테이너로 테스트하거나, 최소한 qemu로 한 번 띄워 본다.

vcs.dirty를 확인하지 않는다. 소스를 재구성할 수 없는 바이너리가 운영에 나간다.

go run으로 배포용 바이너리를 판단한다. go run은 VCS 정보를 남기지 않고 임시 파일로 컴파일한다. 위 출력의 차이가 그것이다.

정리

  • 버전 정보는 두 출처를 함께 쓴다. -ldflags -X(CI가 아는 것)와 debug.ReadBuildInfo(툴체인이 아는 것).
  • -X의 대상은 var string이고 전체 경로로 지정한다. 틀리면 조용히 무시된다.
  • vcs.dirtytrue인 바이너리는 운영에 내보내지 않는다.
  • 크로스 컴파일은 GOOS/GOARCH 두 변수. go tool dist list에 목록이 있다.
  • CGO_ENABLED=1이어도 cgo를 실제로 쓰지 않으면 크로스 컴파일된다. 그래도 0이 기본값으로 옳다 — 정적 링크와 순수 Go net을 얻는다.
  • -ldflags "-s -w"로 크기가 33% 준다. 대가는 디버깅 정보다.
  • -trimpath는 다른 디렉터리에서 빌드해도 같은 해시를 만든다. 실제로 확인했다.
  • 빌드 시각에는 커밋 시각을 쓴다.

연습문제

  1. buildinfoDirty() bool 메서드를 만들고, main에서 vcs.modified"true"이고 Version"dev"가 아니면 경고를 찍게 해 보자. 이 검사를 프로그램이 아니라 CI에서 하려면 어떤 명령이 필요한가?

  2. build.shlinux/386freebsd/amd64를 추가하고, 각 바이너리의 크기를 비교해 보자. 32비트 바이너리가 더 작은가? 그리고 GOARM이나 GOAMD64 환경 변수가 무엇을 하는지 찾아보자. (힌트: GOAMD64=v3)

  3. 같은 소스를 두 번 빌드해 해시가 같은지 확인하는 CI 스텝을 써 보자. -trimpath를 빼면 실패해야 한다. 그런데 이 검사가 항상 통과하도록 하려면 go.modtoolchain 지시자가 왜 필요한지 생각해 보자. (1-2에서 다룬 그 지시자다)