목차
호스트에 Go를 설치하지 마십시오.
GOPATH오염도, "제 컴퓨터에서는 되는데"도, 버전 차이도 없습니다. BigBlueButton Learning Analytics 보고서용 Java 에이전트를 다시 작성하고 보안을 강화한 Piler Go Agent를 빌드할 때 저희가 실제로 쓴 패턴입니다. Docker만으로 정적 바이너리를 배포하는 방법을 소개합니다.
글: Ghazi Triki, RIADVICE
"그냥 Go를 설치하면 된다"의 문제
BigBlueButton Learning Analytics 보고서용 Java 기반 에이전트를 관리하던 시절, 저희는 전형적인 "의존성 지옥"을 겪었습니다. Go로 옮기면 단순해질 것 같았지만, 그것도 같은 실수를 반복하지 않을 때의 이야기였습니다. 저희에게는 결과물인 바이너리만큼이나 격리된(hermetic) 빌드 프로세스가 필요했습니다.
어느 팀이든 결국 다음과 같은 벽에 부딪힙니다.
- 버전 차이. Ahmed는 1.24, Omar는 1.26을 쓰고, 프로덕션 서버는 1.25입니다. 컴파일러의 작은 변경 하나가 빌드를 깨뜨리는데, 하필 Ahmed에게서만 깨집니다.
- 오염된 호스트.
GOPATH,GOMODCACHE, 전역으로 설치한 도구(staticcheck,gofumpt)가 워크스테이션마다 다릅니다. - 온보딩의 걸림돌. 새로 합류한 엔지니어가 첫날을 환경 변수, 그리고
cgo를 위한 C 툴체인과 씨름하며 보냅니다.
RIADVICE는 규율과 질서를 중요하게 여깁니다. 저희는 그 가치를 그대로 담은 빌드 프로세스, 즉 깔끔하고 예측 가능하며 모두가 공유하는 프로세스를 원했습니다.
흔한 답은 "Docker를 쓰라"는 것이지만, 대부분의 튜토리얼은 컨테이너 이미지를 빌드하는 방법만 보여 줍니다. 로컬에 Go 컴파일러를 한 번도 설치하지 않고 호스트에 정적으로 링크된 순수한 바이너리를 얻는 방법은 알려 주지 않습니다. systemd 서비스나 에어갭(air-gapped) 배포에 실제로 필요한 결과물은 바로 그 바이너리인데도 말입니다.
docker run --rm -v "$(pwd):/app" -w /app golang:1.26.5-trixie \
go build -o dist/myapp ./cmd/myapp공식 golang 이미지가 곧 컴파일러입니다. 소스 코드는 /app에 바인드 마운트됩니다. 바이너리는 호스트의 dist/myapp에 기록됩니다. 명령이 끝나면 컨테이너는 삭제되므로(--rm), 호스트는 이전과 똑같이 깨끗하게 유지됩니다.
실전 경험: BigBlueButton에서 Piler까지
이것은 이론에 그치지 않습니다. 저희는 이 패턴을 두 가지 핵심 프로젝트에 적용했습니다.
- BigBlueButton Learning Analytics 보고서: 무거운 Java 에이전트를 가벼운 Go 바이너리로 다시 작성했습니다. Docker 기반 빌드 덕분에, 노트북에 그 환경을 갖추지 않고도 BBB 서버의 Debian 환경을 정확히 겨냥할 수 있었습니다.
- Piler Go Agent: 이메일 아카이빙 솔루션을 위해 보안을 강화한 에이전트를 만들었습니다. 빌드 프로세스도 코드만큼 안전해야 했습니다. 컴파일러를 특정 SHA에 고정함으로써 바이너리의 모든 비트가 어디서 왔는지 설명할 수 있게 했습니다.
Go를 직접 설치하는 것보다 나은 이유
| 관심사 | go install on host | docker run golang:... |
|---|---|---|
| Go 버전 | 마지막으로 설치한 버전 | 고정되어 모두에게 동일 |
| GOPATH / 캐시 | ~/go를 오염시킴 | 격리된 컨테이너 레이어 |
| cgo 툴체인 | 호스트 설정 필요 | 공식 이미지에 포함 |
| CI와의 일치 | "제 컴퓨터에서는 되는데" | 같은 이미지, 같은 바이너리 |
BINARY := mini-api
DIST := dist
# Version metadata derived from git
VERSION := $(shell tmp=$$(git describe --tags --always --dirty 2>/dev/null) && echo "$${tmp#v}" || echo dev)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X example.com/mini-api/internal/version.Version=$(VERSION) \
-X example.com/mini-api/internal/version.Commit=$(COMMIT) \
-X example.com/mini-api/internal/version.Branch=$(BRANCH) \
-X example.com/mini-api/internal/version.BuildDate=$(BUILD_DATE)
# Pinned toolchain image
GO_IMAGE := golang:1.26.5-trixie
# Run any command inside the Go toolchain container
define run_in_docker
docker run --rm -v "$(PWD):/app" -w /app $(GO_IMAGE)
endef
.PHONY: build test lint
build:
@mkdir -p $(DIST)
$(call run_in_docker) bash -c 'CGO_ENABLED=0 go build -buildvcs=false -ldflags="$(LDFLAGS)" -o $(DIST)/$(BINARY) ./cmd/$(BINARY)'
@echo "Built $(DIST)/$(BINARY) (version $(VERSION))"
test:
$(call run_in_docker) go test -race ./...
lint:
$(call run_in_docker) go vet ./...Makefile: 규율의 원천
저희는 Makefile로 빌드 단계를 코드로 정리합니다. GO_IMAGE가 특정 버전에 고정되어 있다는 점에 주목하십시오. 저희는 절대 latest를 쓰지 않습니다.
중요한 기술적 세부 사항
1. 정적 바이너리를 위한 CGO_ENABLED=0
Piler 에이전트에는 최신 Debian부터 오래된 레거시 서버까지 어디서나 실행되는 바이너리가 필요했습니다. CGO를 끄면 바이너리가 필요한 모든 것을 자체적으로 포함하게 되어, 호스트 라이브러리에 대한 동적 의존성이 사라집니다.
2. 출처 추적을 위한 -ldflags -X
바이너리는 자신이 정확히 어디서 왔는지 알려 줄 수 있어야 합니다. 저희는 링크 시점에 git 커밋과 빌드 날짜를 주입합니다. 수십 개의 서로 다른 클러스터에 걸쳐 BigBlueButton 에이전트를 디버깅할 때 이 정보가 결정적인 역할을 했습니다.
3. 컴파일러 이미지 고정
저희 문화는 일관성을 중시합니다. latest 대신 golang:1.26.5-trixie로 고정하면, Ahmed가 오늘 바이너리를 빌드하고 Omar가 6개월 뒤에 빌드해도 결과물은 비트 단위까지 동일합니다.
결론
컴파일러는 도구이며, 모든 도구가 그렇듯 제자리에 두어야 합니다. Go 툴체인을 Docker 안으로 옮김으로써 저희는 "제 환경에서는 됩니다" 유형의 버그를 통째로 없앴고, RIADVICE에 합류하는 모든 새 엔지니어의 온보딩을 단순하게 만들었습니다.
Java 에이전트를 다시 작성하든 Piler용 보안 API를 구축하든, 이 패턴은 프로덕션 시스템에 필요한 규율과 신뢰성을 제공합니다.
Ghazi Triki는 RIADVICE의 설립자입니다. 저희는 BigBlueButton, Piler 등을 위한 고성능의 안전한 연동을 구축합니다. 함께 멋진 것을 만들어 보십시오.





