Stamp It! All Programs Must Report Their Version

Michael Stapelberg

스탬프를 찍어라! 모든 프로그램은 반드시 버전을 보고해야 한다

원문은 Michael Stapelberg님이 에 게재했습니다. 이 블로그 구독하기

최근 프로덕션 장애 대응 중에, 나는 장애의 근본 원인을 한 시간도 안 되어 정확히 추측했고(멋지죠!) 확인 차원에서 수정 패치를 제출했다. 그런데 버전 번호와 배포 현황을 제대로 볼 수 없어 이후 몇 시간을 어둠 속에서 헤매야 했다… 😞

이 경험을 계기로 소프트웨어 버저닝, 더 정확히는 빌드 정보(빌드 버저닝, 버전 스탬핑, 뭐라 부르든)와 버전 리포팅에 대해 다시 생각하게 됐다. 나는 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라는 이름이 너무 짧기 때문에 명시적으로 쓰는 것이 도움이 될 것이라 생각했다. 사용자가 혼잣말로 “아이-3-4-2-4가 뭐지?”라고 할 수도 있지만, “version”이라는 단어를 넣으면 i3가 버전 4.24로 존재하는 어떤 컴퓨터 관련 것(→ 컴퓨터 프로그램)이라는 점이 암시된다.
  2. (2024-11-06)은 릴리스 날짜로, “4.24”가 최신인지 바로 알 수 있게 해준다.
  3. © 2009 Michael Stapelberg는 프로젝트가 언제 시작됐고 누가 핵심 인물인지 알려준다.
  4. and contributors는 도움을 준 많은 사람들에게 공을 돌린다. i3는 한 번도 1인 프로젝트였던 적이 없으며, 언제나 공동의 노력이었다.

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

  1. 질문: “어떤 버전의 i3를 사용하고 계신가요?”
    • i3는 창 안에서 실행되는 일반적인 프로그램(윈도우 매니저/데스크톱 환경)이 아니기 때문에 도움말 → 정보 메뉴가 없다.
    • 대신 우리는 이렇게 묻기 시작했다: i3 --version의 출력은 무엇인가요?
  2. 질문: “새로 생긴 이슈를 보고하는 건가요, 아니면 기존에 있던 이슈인가요? 확인을 위해 이전에 사용하던 i3 버전으로 되돌려 보실 수 있을까요?”. “되돌아가기”에 대한 기술 용어는 다운그레이드, 롤백 또는 리버트다.
    • Linux 배포판에 따라 이 작업은 아주 간단할 수도, 악몽이 될 수도 있다.
    • NixOS에서는 간단하다: 부트로더에서 해당 버전을 선택해 이전 시스템 “세대”로 부팅하면 된다. 혹은 설정이 버전 관리되고 있다면 git에서 리버트하면 된다.
    • Debian Linux나 Arch Linux 같은 명령형 Linux 배포판에서는 파일 시스템 수준의 스냅샷을 찍어두지 않았다면 시스템을 업그레이드한 뒤에 쉽게 그리고 안정적으로 되돌아갈 방법이 없다. 운이 좋다면 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 환경 변수 값(CGO_CFLAGS 같은) 등이 포함된다.

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

참고: 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 packer의 경우, packer를 Go 모듈에서 설치했든 git 워킹 카피에서 설치했든 git 리비전을 표시하기 위해 몇 줄의 Go 코드(아래 참조)로 귀결됐다.

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

다른 경우들은 무엇일까? 다음 예시는 내가 보통 다루는 시나리오를 보여준다:

소스 (빌드 원본)buildinfo (프로그램에 스탬핑된 정보)
디렉터리 (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 스탬핑 사이에 있다.

다행히 둘 다 만족시키는 해결책이 있다: 나는 buildGoModule Nix 표현식에서 기본적으로 동작하는 Go VCS 리비전 스탬핑을 얻을 수 있도록 import할 수 있는 stapelberg/nix/go-vcs-stamping Nix overlay 모듈을 만들었다!

Git 저장소에서 go 빌드로 가는 과정을 보여주는 다이어그램: 내 go-vcs-stamping overlay 우회 방법 없이와 있는 경우

Nix Go 빌드 상황 자세히 보기

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


Nix에서 Go 소프트웨어를 패키징하는 것은 놀랍도록 간단하다.

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

하지만 개발자 빌드를 완전히 스탬핑하는 것은 전혀 간단하지 않다!

내 소프트웨어를 패키징할 때는 릴리스된 버전뿐만 아니라 개별 리비전(개발자 빌드)을 패키징하고 싶다. 나는 동일한 buildGoModule을 사용하고, 최신 Go 버전이 필요하면 buildGoLatestModule을 사용한다. fetchFromGitHub을 사용하는 대신 Flakes를 이용해 소스를 제공하는데, 보통 역시 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)이다.
  • 스탬핑된 buildinfo에는 어떤 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 overlay를 nixosSystem.modules에 추가하라:

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

(나처럼 nixpkgs-unstable을 사용한다면 두 곳 모두에 overlay를 적용해야 한다.)

재빌드 후 Go 바이너리에는 새로 vcs buildinfo가 스탬핑되어야 한다:

% 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. Flakes가 사용하는 것이지만, Flakes가 아닌 경우에도 사용된다.
  2. Fixed-output derivation(FOD). pkgs.fetchgit이 구현된 방식이지만, FOD에 내재된 지속적인 해시 변경(sha256 줄 업데이트)은 성가시다.
  3. Copier. 파일을 Nix 스토어에 그대로 복사할 뿐 git을 인식하지 못한다.

VCS 리비전 스탬핑을 목적으로 한다면 다음과 같이 해야 한다:

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

따라서 우리는 가장 왼쪽 열인 fetcher를 고수할 것이다.

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

stapelberg/nix/go-vcs-stamping Nix overlay 모듈이 이를 수정하며, 이 overlay를 활성화하는 것이 위 다이어그램에서 가장 왼쪽 레인, 즉 Go 바이너리가 이제 스탬핑되는 해피 패스로 가는 방법이다!

내 우회 방법: Nix git buildinfo overlay

go-vcs-stamping overlay는 어떻게 동작할까? 이는 Nix와 Go 사이의 어댑터로 기능한다:

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

따라서 이 overlay는 Go가 올바른 정보를 스탬핑하도록 3단계를 구현한다:

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

전체 소스는 go-vcs-stamping.nix를 보라.

깔끔한 수정 방법

이 공백을 메우는 더 깔끔한 접근 방식에 대해서는 Go 이슈 #77020Go 이슈 #64162를 보라: 패키지 매니저가 올바른 VCS 정보를 주입한 채 Go 도구를 호출할 수 있도록 하는 것이다.

이렇게 되면 Nix(또는 gokrazy)가 go-vcs-stamping 어댑터 같은 우회 방법 없이도 buildinfo를 깔끔하게 전달할 수 있게 된다.

작성 시점에서 이슈 #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 메타데이터에 리비전을 포함하라
    • 사용자 인터페이스: 디버깅을 위해 보이는 곳에 리비전을 노출하라.

시스템 전반에 걸쳐 “버전 가시성”을 구현하는 것은 하루 만에 높은 ROI를 얻는 프로젝트다.

내 Nix 예시에서 봤듯이 VCS 리비전은 스택 전반에 걸쳐 존재하지만 중간에 손실될 수 있다. 내 자료가 여러분의 스택도 빠르게 수정하는 데 도움이 되길 바란다:

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

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

댓글