Stamp It! All Programs Must Report Their Version

Michael Stapelberg

스탬프를 찍자! 모든 프로그램은 버전을 보고해야 한다

최근 프로덕션 장애 대응 중에 저는 1시간도 채 되지 않아 장애의 근본 원인을 정확히 짚어냈고(멋지죠!) 혹시나 하는 마음에 바로 수정 패치를 제출했지만, 버전 번호와 배포 현황을 제대로 볼 수 없어 이후 몇 시간을 어둠 속에서 헤매야 했습니다… 😞

이 경험 덕분에 소프트웨어 버저닝, 더 정확히는 빌드 정보(빌드 버저닝, 버전 스탬핑 등 뭐라 부르든)와 버전 보고에 대해 다시 생각하게 되었습니다. i3 윈도우 매니저에서는 이미 10년도 더 전에 이 문제를 잘 해결해 두었기 때문에, 직장에서 이 문제가 전혀 해결되어 있지 않다는 사실이 정말 의외였습니다.

이 글에서는 단 3단계(찍어라! 연결하라! 보고하라!)만으로 장애 대응 시 수 시간의 지연과 스트레스를 어떻게 아낄 수 있는지 설명하겠습니다.

왜 우리의 버저닝 기준은 이렇게나 낮을까?!

모든 가전제품에는 믿을 수 없을 만큼 세세한 버저닝이 되어 있습니다! 이 식기세척기를 보세요:

정확한 버전 정보가 빼곡히 적힌 식기세척기

(멋진 사례를 보내주신 Feuermurmel님께 감사드립니다!)

가전제품 수리를 몇 번 지켜본 바로는, 수리 기사가 제품을 식별할 수 없으면 아예 손도 대려 하지 않을 것 같다는 인상을 받았습니다.

그렇다면 컴퓨터 분야에서는 왜 우리의 기준이 그에 비해 이렇게 낮을까요? 물론 소비자용 제품은 보통 어떻게든 버전이 붙어 있고 대개는 그 정도면 충분합니다(이를테면 USB 3.2 Gen 1×2 같은 경우는 예외지만요!). 하지만 최근에는 제대로 버전이 관리되지 않은 개발 빌드를 너무나 자주 마주했습니다!

소프트웨어 버저닝

금속 명판에 버전이 찍혀 있는 물리적인 가전제품과 달리, 소프트웨어는 끊임없이 업데이트되며 우리가 종종 볼 수조차 없는 곳과 구조 속에서 실행됩니다.

버저닝 기준을 높이기 위해 무엇이 필요한지 자세히 살펴보겠습니다!

일반적으로 소프트웨어는 이름과 세부 수준이 다른 버전 번호를 가집니다:

  • Chrome
  • Chrome 146
  • Chrome 146.0.7680.80
  • Chrome f08938029c887ea624da7a1717059788ed95034d-refs/branch-heads/7680_65@{#34}

이 모든 표현이 제 컴퓨터에 있는 Chrome 브라우저를 가리키지만, 각각의 세분화 수준은 다릅니다.

맥락에 따라 모두 정확하고 유용합니다. 각각에 대한 예시는 다음과 같습니다:

  1. “저는 Chrome에서 잘 되는데, Firefox에서는 테스트해 보셨나요?”
  2. “Chrome 146에는 가운데 클릭으로 붙여넣고 이동하는 기능이 망가져 있습니다”
  3. “저는 Chrome 146.0.7680.80을 쓰고 있는데 문제를 재현할 수 없습니다”
  4. “Chrome f08938029c887ea624da7a1717059788ed95034d-refs/branch-heads/7680_65@{#34} 위에 이 패치를 적용하고 다음 단계에 따라 재현해 보세요: […]”

i3 윈도우 매니저를 만든 뒤, 사용자 지원을 위해서는 프로그램이 자신을 명확히 식별하는 것이 매우 중요하다는 것을 금방 알게 되었습니다. 다음 사례 연구를 통해 설명해 보겠습니다.

사례 연구: i3의 --version--moreversion

i3 --version을 실행하면 다음과 같은 출력이 표시됩니다:

% i3 --version
i3 version 4.24 (2024-11-06) © 2009 Michael Stapelberg and contributors

각 단어는 신중하게 고민하여 배치한 것입니다. 하나씩 뜯어보겠습니다:

  1. i3 version 4.24: i3 4.24i3 v4.24로 줄일 수도 있었지만, i3라는 이름이 너무 짧기 때문에 명시적으로 쓰는 것이 도움이 될 것이라 생각했습니다. 사용자가 혼잣말로 “아이-쓰리-사-이-사”가 뭐지? 라고 중얼거릴 수도 있지만, “version”이라는 단어를 넣으면 i3가 버전 4.24로 존재하는 어떤 컴퓨터 관련 것(→ 컴퓨터 프로그램)이라는 의미가 전달됩니다.
  2. (2024-11-06)은 릴리스 날짜로, “4.24”가 최신 버전인지 바로 알 수 있도록 했습니다.
  3. © 2009 Michael Stapelberg는 프로젝트가 언제 시작되었고 누가 중심 인물인지 알려줍니다.
  4. and contributors는 도움을 준 많은 사람들에 대한 공로를 표합니다. i3는 한 사람의 프로젝트였던 적이 없으며, 언제나 공동 작업의 결과였습니다.

사용자 지원을 할 때, 해당 사용자에게 개념적으로 묻기 쉽고 개발자에게는 매우 가치 있는 답을 얻을 수 있는 질문이 몇 가지 있습니다:

  1. 질문: “어떤 버전의 i3를 사용하고 계신가요?”
    • i3는 창 안에서 실행되는 일반적인 프로그램이 아니라(윈도우 매니저/데스크톱 환경이기 때문에) 도움말 → 정보 메뉴가 없습니다.
    • 대신 저희는 이렇게 묻기 시작했습니다: i3 --version의 출력은 어떻게 되나요?
  2. 질문: “새로 생긴 문제를 보고하시는 건가요, 아니면 기존에 있던 문제인가요? 확인을 위해 이전에 사용하던 i3 버전으로 되돌려 보실 수 있을까요?” “되돌아가기”에 대한 기술 용어로는 downgrade, rollback 또는 revert가 있습니다.
    • 리눅스 배포판에 따라 이것은 아주 간단할 수도 있고 악몽이 될 수도 있습니다.
    • NixOS에서는 아주 간단합니다. 부트로더에서 이전 시스템 “세대”를 선택해 부팅하면 됩니다. 또는 설정 파일이 버전 관리되고 있다면 git에서 되돌리면 됩니다.
    • Debian 리눅스나 Arch 리눅스 같은 명령형 리눅스 배포판에서는 파일 시스템 수준의 스냅샷을 만들어 두지 않았다면 시스템을 업그레이드한 뒤에 쉽고 안정적으로 되돌아갈 방법이 없습니다. 운이 좋다면 그냥 apt install로 이전 버전의 i3를 설치할 수 있습니다. 하지만 의존성 충돌(“version hell”)에 부딪힐 수도 있습니다.
    • snapshot.debian.org을 이용하면 이전 버전의 Debian을 실행하는 것이 가능하다는 것을 알고 있지만, 적어도 제가 마지막으로 시도했을 때는 그다지 실용적이지 않았습니다.
  3. 최신 i3 개발 버전에서도 문제가 여전히 발생하는지 확인해 보실 수 있을까요?
    • 물론 저도 사용자의 문제를 최신 릴리스 버전으로 재현해 보고, 그리고 한 번 더 최신 개발 버전에서 재현해 볼 수 있습니다.
    • 하지만 이 검증 단계를 해당 사용자에게 옮기면 매우 적극적으로 버그를 제보하는 사람들을 걸러낼 수 있어(버그 보고가 실제로 수정으로 이어질 확률이 높아집니다!) 사용자가 버그를 두 번 재현하게 되면서 간헐적인 문제인지, 재현이 어려운지, 재현 절차가 정확한지 등을 파악하게 된다는 장점이 있습니다.
    • 자연스러운 후속 질문: “이 코드 변경으로 문제가 해결되나요?” 이제 개발 환경을 갖춘 해당 사용자가 이를 쉽게 테스트해 볼 수 있습니다.

이러한 질문을 여러 번 하며 디버깅을 진행한 경험을 바탕으로 몇 가지 패턴을 발견했고, 그에 대한 대응으로 i3 v4.3(2012년 9월 릴리스)에서 i3가 버전을 보고하는 또 다른 방법인 --moreversion 플래그를 도입했습니다! 이제 사용자에게 첫 번째 질문을 살짝 변형해 이렇게 물을 수 있게 되었습니다: i3 --moreversion의 출력은 어떻게 되나요? 이 질문은 예를 들어 컴퓨터 모임에서 구두로도 잘 전달됩니다:

Michael: 어떤 버전을 사용하고 계신가요?

User: 어떻게 확인하죠?

Michael: 이 명령어를 실행해 보세요: i3 --version

User: 4.24라고 나오네요.

Michael: 좋습니다, 버그 수정이 포함될 만큼 충분히 최신 버전이네요. 이제 더 자세한 버전 정보가 필요합니다! i3 --moreversion을 실행해서 무엇이 보이는지 알려주세요.

i3 --moreversion을 실행하면 호출한 i3 프로그램의 버전만 보고하는 것이 아니라, IPC(프로세스 간 통신) 인터페이스를 이용해 X11 세션에서 실행 중인 i3 윈도우 매니저 프로세스에 연결하여 실행 중인 i3 프로세스의 버전을 비롯해 로드된 설정 파일과 마지막 변경 시점 등 사용자에게 보여주면 유용한 다른 핵심 정보도 함께 보고합니다:

% i3 --moreversion
Binary i3 version:  4.24 (2024-11-06) © 2009 Michael Stapelberg and…
Running i3 version: 4.24 (2024-11-06) (pid 2521)
Loaded i3 config:
  /home/michael/.config/i3/config (main)
  (last modified: 2026-03-15T23:09:27 CET, 1101585 seconds ago)

The i3 binary you just called:
/nix/store/0zn9r4263fjpqah6vdzlalfn0ahp8xc2-i3-4.24/bin/i3
The i3 binary you are running: i3

처음 보면 세부 정보가 많아 보일 수 있지만, 이 출력이 왜 그토록 가치 있는 디버깅 도구인지 설명해 보겠습니다:

  1. IPC 인터페이스를 통해 i3에 연결하는 것 자체가 흥미로운 테스트입니다. 사용자가 i3 --moreversion 출력을 볼 수 있다는 것은 i3-msg -t get_tree > /tmp/tree.json 같은 디버깅 명령어를 실행해 전체 레이아웃 상태를 캡처할 수도 있다는 뜻입니다.

  2. 디버깅 세션 중에 i3 --moreversion을 실행하면 방금 빌드한 버전이 실제로 적용되었는지 쉽게 확인할 수 있습니다(Running i3 version 줄을 보세요).

    • 이 검사는 프로덕션 장애 상황에서도 동일하게 중요합니다: 실제로 실행 중인 버전이 실행 중이어야 하는 버전과 일치하는지 확인하는 것입니다.
  3. 로드된 설정 파일의 전체 경로를 보여주면 사용자가 잘못된 파일을 수정하고 있었는지 바로 알 수 있습니다. 경로만으로 충분하지 않다면 수정 시각(절대 시각과 상대 시각 모두 표시)이 잘못된 파일을 편집하고 있음을 알려줍니다.

저는 NixOS를 사용하므로, i3의 특정 빌드에 대해 안정적인 식별자(0zn9r4263fjpqah6vdzlalfn0ahp8xc2-i3-4.24)를 자동으로 얻습니다.

% ls -l $(which i3)
lrwxrwxrwx 1 root root 58 1970-01-01 01:00 /run/current-system/sw/bin/i3
-> /nix/store/0zn9r4263fjpqah6vdzlalfn0ahp8xc2-i3-4.24/bin/i3

이 Nix 스토어 출력(0zn9r4263…-i3-4.24)을 만든 빌드 레시피(Nix 용어로는 “derivation”)를 보려면 nix derivation show를 실행하면 됩니다:

% nix derivation show /nix/store/0zn9r4263fjpqah6vdzlalfn0ahp8xc2-i3-4.24
{
  "/nix/store/z7ly4kvgixf29rlz01ji4nywbajfifk4-i3-4.24.drv": {
[…]
궁금하시다면 전체 nix derivation show 출력을 보려면 여기를 클릭하세요
% nix derivation show /nix/store/0zn9r4263fjpqah6vdzlalfn0ahp8xc2-i3-4.24
{
  "/nix/store/z7ly4kvgixf29rlz01ji4nywbajfifk4-i3-4.24.drv": {
    "args": [
      "-e",
      "/nix/store/l622p70vy8k5sh7y5wizi5f2mic6ynpg-source-stdenv.sh",
      "/nix/store/shkw4qm9qcw5sc5n1k5jznc83ny02r39-default-builder.sh"
    ],
    "builder": "/nix/store/6ph0zypyfc09fw6hlc1ygjvk2hv4j9vd-bash-5.3p3/bin/bash",
    "env": {
      "NIX_MAIN_PROGRAM": "i3",
      "name": "i3-4.24",
      "out": "/nix/store/0zn9r4263fjpqah6vdzlalfn0ahp8xc2-i3-4.24",
      "version": "4.24"
    },
    […]}
}

안타깝게도 derivation에서 .nix 소스로 가는 방법을 저는 알지 못하지만, 적어도 특정 소스가 동일한 derivation을 만든다는 것은 확인할 수 있습니다.

개발 빌드

지금까지 설명한 버저닝은 중간 버전에는 관심이 없고 릴리스된 버전에만 관심이 있는 대부분의 사용자에게는 충분합니다.

하지만 개발자나 더 높은 정밀도가 필요한 사용자는 어떨까요?

git에서 i3를 빌드하면 git-describe(1)을 이용해 빌드된 git 리비전을 보고합니다:

~/i3/build % git describe
4.25-23-g98f23f54
~/i3/build % ninja
[110/110] Linking target i3
~/i3/build % ./i3 --version
i3 version 4.25-23-g98f23f54 © 2009 Michael Stapelberg and contributors

수정된 워킹 카피는 리비전 뒤에 +로 표시됩니다:

~/i3/build % echo '// dirty working copy' >> ../src/main.c && ninja
[104/104] Linking target i3bar
~/i3/build % ./i3 --version
i3 version 4.25-23-g98f23f54+ © 2009 Michael Stapelberg and contributors

git 리비전(또는 일반적으로 VCS 리비전)을 보고하는 것이 가장 유용한 선택입니다.

이렇게 하면 다음과 같은 흔한 실수를 잡아낼 수 있습니다:

  • 잘못된 리비전에서 빌드하는 경우.
  • 빌드는 했지만 설치하는 것을 잊은 경우.
  • 설치는 했지만 세션에서 반영되지 않은 경우(잘못된 위치?).

가장 유용한 것: VCS 리비전에 스탬프 찍기

위에서 살펴본 것처럼, 가장 유용한 버전 정보는 VCS 리비전입니다. 다른 모든 세부 정보(버전 번호, 날짜, 작성자 등)는 VCS 저장소에서 가져올 수 있습니다.

이제 Go가 어떻게 하는지 살펴보며 최선의 시나리오를 보여드리겠습니다!

Go는 항상 스탬프를 찍습니다! 🥳

Go는 훌륭한 취향과 스타일을 가진 Go 개발자들 덕분에, 그리고 물론 고품질 툴링 덕분에 수년 동안 제가 가장 좋아하는 프로그래밍 언어가 되었습니다:

왜 Go가 제가 가장 좋아하는 프로그래밍 언어인지

그래서 Go가 소프트웨어 버저닝과 관련해 황금 표준을 구현하고 있다는 점을 기쁘게 말씀드릴 수 있습니다. 기본적으로 VCS 빌드 정보를 스탬핑합니다! 🥳 이는 Go 1.18(2022년 3월)에서 도입되었습니다:

또한 go 명령은 빌드 및 툴 태그(-tags로 설정), 컴파일러, 어셈블러, 링커 플래그(-gcflags 등), cgo 활성화 여부, 그리고 활성화된 경우 cgo 환경 변수 값(CCO_CFLAGS 등)을 포함한 빌드 정보를 임베드합니다.

VCS와 빌드 정보는 모두 모듈 정보와 함께 go version -m file 또는 runtime/debug.ReadBuildInfo(현재 실행 중인 바이너리의 경우)나 새로운 debug/buildinfo 패키지를 이용해 읽을 수 있습니다.

참고: Go 1.18 이전에는 표준적인 접근 방식이 -ldflags -X main.version=$(git describe)와 같이 명시적으로 주입하는 것이었습니다. 이 설정도 동작하며(지금도 많은 곳에서 볼 수 있습니다) 애플리케이션 코드를 변경해야 하는 반면, Go 1.18 이상의 스탬핑은 별도의 추가 단계가 필요 없습니다.

실제로는 어떤 의미일까요? 흔한 경우, 즉 git에서 빌드하는 경우에 대한 다이어그램은 다음과 같습니다:

git 저장소에서 go build / go install을 호출해 바이너리로 가는 과정을 보여주는 다이어그램

이것이 제 취미 프로젝트 대부분을 다룹니다!

많은 도구는 그냥 go install하거나, 다른 컴퓨터로 쉽게 복사하려고 하면 CGO_ENABLED=0 go install로 설치합니다. 다만 점점 더 많은 소프트웨어를 NixOS에서 관리하고 있습니다.

아직 완전히 관리되지 않는 프로그램이 있으면 gopsgo 도구를 이용해 식별할 수 있습니다:

root@ax52 ~ % nix run nixpkgs#gops
2573594 1       dcs-package-importer  go1.26.1 /nix/store/clby54zb003ibai8j70pwad629lhqfly-dcs-unstable/bin/dcs-package-importer
2573576 1       dcs-source-backend    go1.26.1 /nix/store/clby54zb003ibai8j70pwad629lhqfly-dcs-unstable/bin/dcs-source-backend
2573566 1       debiman               go1.25.5 /srv/man/bin/debiman
[…]
root@ax52 ~ % nix run nixpkgs#go -- version -m /srv/man/bin/debiman
/srv/man/bin/debiman: go1.25.5
  path	github.com/Debian/debiman/cmd/debiman
  mod	github.com/Debian/debiman	v0.0.0-20251230101540-ac8f5391b43b+dirty
  build	vcs=git
  build	vcs.revision=ac8f5391b43bc1a9dbdc99f6179e2fb7d7414a04
  build	vcs.time=2025-12-30T10:15:40Z
  build	vcs.modified=true

Go가 기본적으로 올바른 일을 한다는 것은 정말 멋집니다!

100% Go 소프트웨어로 구성된 시스템(제 gokrazy Go 어플라이언스 플랫폼 같은)은 완전히 스탬프가 찍혀 있습니다! 예를 들어 gokrazy 웹 인터페이스를 보면 제 scan2drive 어플라이언스gokrazy/rsync 빌드에 어떤 버전과 의존성이 들어갔는지 정확히 알 수 있습니다.

완전히 스탬프가 찍혀 있음에도 불구하고, gokrazy는 현재 Nix와 같은 간극 때문에 모듈 버전만 보여주고 VCS 빌드 정보는 보여주지 않는다는 점에 유의하세요:

gokrazy scan2drive rsync

Go 버전 보고

롤링 릴리스 모델(버전 번호 없음)을 따르는 gokrazy 패커의 경우, 패커를 Go 모듈에서 설치했든 git 워킹 카피에서 설치했든 git 리비전을 표시하기 위해 결국 몇 줄의 Go 코드를 작성하게 되었습니다.

이 코드는 vcs.revision(쉬운 경우, git에서 빌드된 경우)을 표시하거나 Go 모듈 버전에서 리비전을 추출합니다(메인 모듈의 BuildInfo.Main.Version):

다른 경우들은 무엇일까요? 다음 예시들이 제가 보통 다루는 시나리오를 보여줍니다:

소스 (빌드 대상)빌드 정보 (프로그램에 스탬프된 정보)
디렉터리 (git 없음)모듈 (devel)
Go 모듈모듈 v0.3.1-0.20260105212325-5347ac5f5bcb
디렉터리 (git)모듈 v0.0.0-20260131174001-ccb1d233f2a4+dirty
vcs.revision=ccb1d233f2a43e9118b9146b3c9a5ded1efb7551
vcs.time=2026-01-31T17:40:01Z
vcs.modified=true
go 빌드 정보 스탬핑의 두 가지 경우를 보여주는 다이어그램: git 체크아웃에서 빌드하거나 Go 모듈에서 설치하는 경우
버전을 프로그래밍 방식으로 읽는 Go 코드
package version

import (
	"runtime/debug"
	"strings"
)

func readParts() (revision string, modified, ok bool) {
	info, ok := debug.ReadBuildInfo()
	if !ok {
		return "", false, false
	}
	settings := make(map[string]string)
	for _, s := range info.Settings {
		settings[s.Key] = s.Value
	}
	// When built from a local VCS directory, we can use vcs.revision directly.
	if rev, ok := settings["vcs.revision"]; ok {
		return rev, settings["vcs.modified"] == "true", true
	}
	// When built as a Go module (not from a local VCS directory),
	// info.Main.Version is something like v0.0.0-20230107144322-7a5757f46310.
	v := info.Main.Version // for convenience
	if idx := strings.LastIndexByte(v, '-'); idx > -1 {
		return v[idx+1:], false, true
	}
	return "<BUG>", false, false
}

func Read() string {
	revision, modified, ok := readParts()
	if !ok {
		return "<not okay>"
	}
	modifiedSuffix := ""
	if modified {
		modifiedSuffix = " (modified)"
	}

	return "https://github.com/gokrazy/tools/commit/" + revision + modifiedSuffix
}

실제로는 이렇게 보입니다:

% go install github.com/gokrazy/tools/cmd/gok@latest
% gok --version
https://github.com/gokrazy/tools/commit/8ed49b4fafc7

하지만 git에서 빌드된 버전은 전체 리비전을 사용할 수 있습니다(→ 둘을 구분할 수 있습니다):

% (cd ~gokrazy/../tools && go install ./cmd/...)
% gok --version
https://github.com/gokrazy/tools/commit/ba6a8936f4a88ddcf20a3b8f625e323e65664aa6 (modified)

NixOS에서 VCS 리비전

Nix로 Go 소프트웨어를 패키징할 때 Go VCS 리비전 스탬핑이 쉽게 사라질 수 있습니다:

  1. fetchFromGitHub 같은 Nix fetcher는 GitHub에서 아카이브(.tar.gz) 파일을 가져오는 방식으로 구현됩니다 — 전체 .git 저장소가 전송되지 않으므로 더 효율적입니다.
  2. .git 저장소가 존재하더라도 Nix는 보통 재현성을 위해 의도적으로 이를 제거합니다. .git 디렉터리에는 git gc 실행 등에 따라 변하는 packed object가 포함되어 있어, 같은 소스라도 해시가 달라져 재현 가능한 빌드가 깨질 수 있습니다.

따라서 여기서의 근본적인 긴장은 재현성과 VCS 스탬핑 사이의 충돌입니다.

다행히 두 가지를 모두 만족하는 해결책이 있습니다. 제가 만든 stapelberg/nix/go-vcs-stamping Nix 오버레이 모듈을 가져오면 buildGoModule Nix 표현식에 대해 기본적으로 정상 동작하는 Go VCS 리비전 스탬핑을 얻을 수 있습니다!

go-vcs-stamping 오버레이 해결책 유무에 따른 Git 저장소에서 go 빌드로 가는 과정을 보여주는 다이어그램

Nix Go 빌드 상황 자세히 보기

팁: Nix 사용자가 아니라면 이 섹션은 건너뛰셔도 됩니다. 가장 복잡한 환경에서도 VCS 스탬핑을 동작시키는 완전한 예시를 제공하기 위해 이 글에 포함했습니다.


Nix에서 Go 소프트웨어를 패키징하는 것은 꽤 간단합니다.

예를 들어 Go Protobuf 생성기 플러그인인 protoc-gen-go는 30줄 미만의 Nix 코드로 패키징됩니다: 공식 nixpkgs protoc-gen-go package.nix. buildGoModule을 호출하고, srcfetchFromGitHub의 결과를 제공하고 몇 줄의 메타데이터를 추가하면 됩니다.

하지만 개발 빌드를 완전히 스탬프가 찍히도록 만드는 것은 전혀 간단하지 않습니다!

제 소프트웨어를 패키징할 때는 릴리스된 버전뿐만 아니라 개별 리비전(개발 빌드)도 패키징하고 싶습니다. 저는 동일한 buildGoModule이나, 최신 Go 버전이 필요하면 buildGoLatestModule을 사용합니다. fetchFromGitHub을 사용하는 대신 Flake를 통해 소스를 제공하며, 보통 GitHub나 다른 Git 저장소에서 가져옵니다. 예를 들어 gokrazy/bull을 다음과 같이 패키징합니다:

{
  pkgs,
  pkgs-unstable,
  bullsrc,
  ...
}:

# Use buildGoLatestModule to build with Go 1.26
# even before NixOS 26.05 Yarara is released
# (NixOS 25.11 contains Go 1.25).
pkgs-unstable.buildGoLatestModule {
  pname = "bull";
  version = "unstable";

  src = bullsrc;

  # Needs changing whenever `go mod vendor` changes,
  # i.e. whenever go.mod is updated to use different versions.
  vendorHash = "sha256-sU5j2dji5bX2rp+qwwSFccXNpK2LCpWJq4Omz/jmaXU=";
}

bullsrc는 제 flake.nix에서 옵니다:

전체 flake.nix를 보려면 여기를 클릭하세요
{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
    nixpkgs-unstable.url = "github:nixos/nixpkgs/nixos-unstable";
    disko = {
      url = "github:nix-community/disko";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    stapelbergnix.url = "github:stapelberg/nix";
    zkjnastools.url = "github:stapelberg/zkj-nas-tools";
    configfiles = {
      url = "github:stapelberg/configfiles";
      flake = false;
    };
    bullsrc = {
      url = "github:gokrazy/bull";
      flake = false;
    };
    sops-nix = {
      url = "github:Mic92/sops-nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };
  outputs =
    {
      nixpkgs,
      nixpkgs-unstable,
      disko,
      stapelbergnix,
      zkjnastools,
      bullsrc,
      configfiles,
      sops-nix,
      ...
    }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs {
        inherit system;
        config.allowUnfree = false;
      };
      pkgs-unstable = import nixpkgs-unstable {
        inherit system;
        config.allowUnfree = false;
      };
    in
    {
      nixosConfigurations.keep = nixpkgs.lib.nixosSystem {
        inherit system;
        inherit pkgs;
        specialArgs = { inherit configfiles; };
        modules = [
          disko.nixosModules.disko
          sops-nix.nixosModules.sops
          ./configuration.nix
          {
            nixpkgs.overlays = [
              (final: prev: {
                bull = import ./bull-pkg.nix {
                  pkgs = final;
                  pkgs-unstable = pkgs-unstable;
                  inherit bullsrc;
                };
              })
            ];
          }
        ];
      };
    };
}

Go는 모든 빌드에 스탬프를 찍지만, 여기서는 찍을 것이 많지 않습니다:

  • Go 모듈이 아닌 디렉터리에서 빌드하므로 모듈 버전은 (devel)입니다.
  • 스탬프된 빌드 정보에는 어떤 vcs 정보도 포함되어 있지 않습니다.

gokrazy/bull의 전체 예시는 다음과 같습니다:

% go version -m \
  /nix/store/z3y90ck0fp1wwd4scljffhwxcrxjhb9j-bull-unstable/bin/bull
/nix/store/z3y90ck0fp1wwd4scljffhwxcrxjhb9j-bull-unstable/bin/bull: go1.26.1
        path    github.com/gokrazy/bull/cmd/bull
        mod     github.com/gokrazy/bull (devel) 
        dep     github.com/BurntSushi/toml      v1.4.1-0.20240526193622-a339e1f7089c    
        build   -buildmode=exe
        build   -compiler=gc
        build   -trimpath=true
        build   CGO_ENABLED=0
        build   GOARCH=amd64
        build   GOOS=linux
        build   GOAMD64=v1

VCS 스탬핑을 고치려면 제 goVcsStamping 오버레이를 nixosSystem.modules에 추가하세요:

{
  nixpkgs.overlays = [
    stapelbergnix.overlays.goVcsStamping
  ];
}

(저처럼 nixpkgs-unstable을 사용하고 있다면 두 곳 모두에 오버레이를 적용해야 합니다.)

다시 빌드하면 Go 바이너리에 새로 vcs 빌드 정보가 스탬프되어 있을 것입니다:

% go version -m /nix/store/z8mgsf10pkc6dgvi8pfnbb7cs23pqfkn-bull-unstable/bin/bull
[…]
  build   vcs=git
  build   vcs.revision=c0134ef21d37e4ca8346bdcb7ce492954516aed5
  build   vcs.time=2026-03-22T08:32:55Z
  build   vcs.modified=false

좋네요! 🥳 그런데… 어떻게 동작하는 걸까요? 언제 적용될까요? 설정을 어떻게 고쳐야 하는지 어떻게 알 수 있을까요?

먼저 전체 다이어그램을 보여드리고, 그 다음에 읽는 방법을 설명하겠습니다:

.nix 표현식에서 스탬프가 찍힌 바이너리 또는 VCS 정보가 유실된 바이너리로 가는 모든 경로를 보여주는 큰 다이어그램

.nix 파일에 무엇을 적느냐에 따라 들어가게 되는 Nix 스택의 관련 부분은 3가지입니다:

  1. Fetcher. Flake가 사용하는 것이지만 Flake가 아닌 경우에도 사용됩니다.
  2. 고정 출력 derivation(FOD). pkgs.fetchgit이 구현된 방식이지만, FOD에 내재된 끊임없는 해시 변경(sha256 줄 업데이트)은 번거롭습니다.
  3. Copier. 파일을 Nix 스토어에 그냥 복사할 뿐 git을 인식하지 못합니다.

VCS 리비전 스탬핑을 위해서는 다음을 따라야 합니다:

  • Copier는 피하세요! Flake를 사용한다면:
    • ❌ Flake 입력으로 url = "/home/michael/dcs"를 사용하지 마세요
    • ✅ 대신 git을 인식하도록 url = "git+file:///home/michael/dcs"를 사용하세요
  • 고정 출력 derivation(FOD)도 피합니다.
    • 빌드 시점에 git 저장소를 가져오는 것은 느리고 비효율적입니다.
    • 이 접근 방식에서 VCS 리비전 스탬핑에 필요한 leaveDotGit을 활성화하는 것은 FOD를 재현 가능하게 유지하기 위해 Git 저장소를 결정적으로 새로 구성해야 하므로 더욱 비효율적입니다.

따라서 가장 왼쪽 열인 fetcher에 머무르겠습니다.

안타깝게도 기본적으로 fetcher를 사용하면 Nix attrset(빌드 과정 중 메모리에 존재)에 저장된 VCS 리비전 정보가 Nix 스토어로 전달되지 않으므로, Nix derivation이 평가되고 Go가 소스 코드를 컴파일할 때 Go는 어떤 VCS 리비전도 보지 못합니다.

stapelberg/nix/go-vcs-stamping Nix 오버레이 모듈이 이를 수정하며, 오버레이를 활성화하면 위 다이어그램에서 가장 왼쪽 레인, 즉 Go 바이너리에 스탬프가 찍히는 행복한 경로로 가게 됩니다!

저의 해결 방법: Nix git buildinfo 오버레이

go-vcs-stamping 오버레이는 어떻게 동작할까요? Nix와 Go 사이의 어댑터 역할을 합니다:

  • Nix는 메모리의 .rev attrset에 VCS 리비전을 추적합니다.
  • Go는 .git/HEAD 파일 접근과 git(1) 명령어를 통해 접근되는 .git 저장소에서 VCS 리비전을 찾기를 기대합니다.

그래서 오버레이는 Go가 올바른 정보를 스탬프하도록 3단계를 구현합니다:

  1. .git/HEAD 파일을 합성하여 Go의 vcs.FromDir()이 git 저장소를 감지하도록 합니다.
  2. PATH에 Go가 사용하는 정확히 두 개의 명령어만 구현하고 그 외에는 크게 실패하도록 하는 git 명령어를 주입합니다(Go가 구현을 업데이트할 경우를 대비).
  3. GOFLAGS 환경 변수에 -buildvcs=true를 설정합니다.

전체 소스는 go-vcs-stamping.nix를 참고하세요.

깔끔한 수정

이 간극을 더 깔끔하게 수정하는 방법은 Go 이슈 #77020Go 이슈 #64162를 참고하세요. 패키지 매니저가 올바른 VCS 정보를 주입한 채로 Go 도구를 호출할 수 있도록 하는 것입니다.

이렇게 되면 Nix(또는 gokrazy 역시)도 제 go-vcs-stamping 어댑터 같은 해결 방법 없이 빌드 정보를 깔끔하게 전달할 수 있습니다.

작성 시점에는 이슈 #77020이 크게 주목받지 못하고 아직 열려 있는 상태입니다.

결론: 찍어라! 연결하라! 보고하라!

제 주장은 간단합니다:

VCS 리비전에 스탬프를 찍는 것은 개념적으로는 쉽지만 매우 중요합니다!

예를 들어, 제가 언급한 프로덕션 시스템에서 버전을 보고했다면 몇 시간의 대응 시간을 절약할 수 있었을 것입니다!

안타깝게도 많은 환경에서는 빌드 출력만 식별하고(유용하지만 별개의 문제입니다) VCS 리비전은 연결하지 않으며(훨씬 더 유용합니다!), 적어도 기본적으로는 그렇지 않습니다.

이를 수정하기 위한 실행 계획은 단 3단계뿐입니다:

  1. 찍어라! 소스 VCS 리비전을 프로그램에 포함하세요.
    • 이것은 새로운 아이디어가 아닙니다. i3 빌드에는 2012년부터 git-describe(1) 리비전이 포함되어 있습니다!
  2. 연결하라! 빌드/패키징 시 VCS 리비전이 유실되지 않도록 하세요.
    • 위의 “NixOS에서 VCS 리비전” 사례 연구 섹션에서 VCS 리비전이 왜 유실될 수 있는지, 어떤 경로가 동작할 수 있는지, 그리고 유실된 연결을 어떻게 고치는지 설명했습니다.
  3. 보고하라! 모든 관련 영역에서 소프트웨어가 VCS 리비전을 출력하도록 하세요. 예를 들어:
    • 실행 파일: --version으로 실행할 때 VCS 리비전을 보고하세요
      • Go 프로그램의 경우 언제나 go version -m을 사용할 수 있습니다
    • 서비스와 배치 작업: 시작 로그에 VCS 리비전을 포함하세요.
    • 나가는 HTTP 요청: User-Agent에 VCS 리비전을 포함하세요
    • HTTP 응답: 헤더에 VCS 리비전을 포함하세요(내부적으로)
    • 원격 프로시저 호출(RPC): RPC 메타데이터에 리비전을 포함하세요
    • 사용자 인터페이스: 디버깅을 위해 눈에 잘 띄는 곳에 리비전을 노출하세요.

시스템 전반에 걸쳐 “버전 가시성”을 구현하는 것은 하루 만에 끝낼 수 있는 투자 대비 효과가 매우 높은 프로젝트입니다.

Nix 예시에서 보셨듯이 VCS 리비전은 스택 전반에 걸쳐 사용할 수 있지만 중간에 유실될 수 있습니다. 제 자료가 여러분의 스택도 빠르게 수정하는 데 도움이 되기를 바랍니다:

이제 여러분의 프로그램과 데이터 전송에 스탬프를 찍으세요! 🚀

원문은 Michael Stapelberg님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.