빌드와 크로스 컴파일
이 챕터에서 다루는 것
Go의 배포가 쉬운 이유는 정적 링크된 단일 바이너리이기 때문이다. 그 바이너리를 다른 플랫폼용으로 만드는 법, 버전 정보를 박아 넣는 법, 크기를 줄이는 법, 그리고 같은 소스에서 항상 같은 바이너리가 나오게 하는 법을 다룬다.
이 챕터의 명령과 출력은 전부 이 머신(darwin/arm64)에서 실제로 실행한 것이다.
문제 — "그 서버에 지금 뭐가 돌고 있는가"
장애 조사에서 가장 먼저 필요한 정보인데, 답하기가 의외로 어렵다.
- 배포 태그는
v1.4.2인데 그게 어느 커밋인지 모른다 - 핫픽스를 급하게 넣느라 태그 없이 나간 빌드가 있다
- 빌드한 사람의 작업 트리가 깨끗했는지 알 수 없다
Go는 이 문제에 두 가지 답을 준다. 하나는 링커로 값을 박아 넣는 것이고, 다른 하나는 툴체인이 자동으로 남기는 정보를 읽는 것이다. 둘 다 쓴다.
-ldflags -X로 값 주입하기
// 링커가 채워 넣는 값들.
//
// 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"
)
제약이 셋 있고, 어기면 에러 없이 조용히 무시된다.
var여야 한다.const는 컴파일 시점에 값이 코드에 박혀서 링커가 손댈 수 없다.string이어야 한다.int나bool은 안 된다.- 전체 경로로 지정해야 한다.
-X buildinfo.Version=...이 아니라-X example.com/.../buildinfo.Version=...이다.
:::danger -X의 오타는 아무 말도 하지 않는다
경로를 틀리게 적어도 링커는 경고 한 줄 없이 넘어간다. 빌드는 성공하고, 실행하면
version: dev가 찍힌다. CI에 "빌드한 바이너리의 -v 출력에 dev가 있으면 실패"
같은 검사를 넣어 두는 것이 확실하다.
:::
debug.ReadBuildInfo — 툴체인이 알아서 남기는 것
Go 1.18부터 go build는 VCS 정보를 자동으로 바이너리에 박는다. -ldflags를
하나도 주지 않아도 그렇다.
// 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 시작 로그 첫 줄에 넣는다
// 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를 고르는 이유 중 하나다.
# 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를 쓰기 시작해도 빌드가 조용히 바뀌지 않는다
net과os/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.mod의toolchain지시자로 고정한다(파트 1-2). - 같은 의존성.
go.sum이 보장한다. -ldflags에 시각을 넣지 않기.date를 쓰면 매번 달라진다. 예제 스크립트가git show -s --format=%cI HEAD로 커밋 시각을 쓰는 이유다.
# 커밋 시각을 빌드 시각으로 쓴다. 지금 시각을 쓰면 같은 소스에서
# 매번 다른 바이너리가 나와 재현 가능한 빌드가 깨진다.
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=false | VCS 정보 박지 않기 |
-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를 쓴다. 역시 조용히 무시된다.
-ldflags에 date를 넣는다. 재현 가능성이 깨진다. 커밋 시각을 쓴다.
개발 빌드에도 -s -w를 넣는다. 디버거가 붙지 않아 고생한다.
크로스 컴파일한 바이너리를 테스트 없이 배포한다. GOOS=linux로 빌드한 것을
macOS에서 실행해 볼 수는 없다. CI에서 대상 플랫폼의 컨테이너로 테스트하거나,
최소한 qemu로 한 번 띄워 본다.
vcs.dirty를 확인하지 않는다. 소스를 재구성할 수 없는 바이너리가 운영에 나간다.
go run으로 배포용 바이너리를 판단한다. go run은 VCS 정보를 남기지 않고
임시 파일로 컴파일한다. 위 출력의 차이가 그것이다.
정리
- 버전 정보는 두 출처를 함께 쓴다.
-ldflags -X(CI가 아는 것)와debug.ReadBuildInfo(툴체인이 아는 것). -X의 대상은var string이고 전체 경로로 지정한다. 틀리면 조용히 무시된다.vcs.dirty가true인 바이너리는 운영에 내보내지 않는다.- 크로스 컴파일은
GOOS/GOARCH두 변수.go tool dist list에 목록이 있다. CGO_ENABLED=1이어도 cgo를 실제로 쓰지 않으면 크로스 컴파일된다. 그래도0이 기본값으로 옳다 — 정적 링크와 순수 Gonet을 얻는다.-ldflags "-s -w"로 크기가 33% 준다. 대가는 디버깅 정보다.-trimpath는 다른 디렉터리에서 빌드해도 같은 해시를 만든다. 실제로 확인했다.- 빌드 시각에는 커밋 시각을 쓴다.
연습문제
-
buildinfo에Dirty() bool메서드를 만들고,main에서vcs.modified가"true"이고Version이"dev"가 아니면 경고를 찍게 해 보자. 이 검사를 프로그램이 아니라 CI에서 하려면 어떤 명령이 필요한가? -
build.sh에linux/386과freebsd/amd64를 추가하고, 각 바이너리의 크기를 비교해 보자. 32비트 바이너리가 더 작은가? 그리고GOARM이나GOAMD64환경 변수가 무엇을 하는지 찾아보자. (힌트:GOAMD64=v3) -
같은 소스를 두 번 빌드해 해시가 같은지 확인하는 CI 스텝을 써 보자.
-trimpath를 빼면 실패해야 한다. 그런데 이 검사가 항상 통과하도록 하려면go.mod의toolchain지시자가 왜 필요한지 생각해 보자. (1-2에서 다룬 그 지시자다)