Статические бинарники и кросс-компиляция: CGO_ENABLED=0, загадка пропавшего ld-linux и дисциплина флагов сборки
CGO_ENABLED=0 убирает libc: нет интерпретатора ld-linux, чистый Go-резолвер DNS, бинарник безопасен для scratch. GOOS/GOARCH кросс-компилирует без C-тулчейна, пока нет cgo. -trimpath даёт воспроизводимость, -X внедряет версию, go version -m вскрывает происхождение бинарника.
Деплой на новый scratch-образ ушёл в шесть вечера, и под умер мгновенно: exec /server: no such file or directory. Бинарник был на месте — debug-контейнер его показывал, 9,1 МБ, бит исполнения стоит. Команда час гонялась за фантомными багами COPY, пока кто-то не запустил file: dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2. Ошибка вообще была не про бинарник. execve нашёл файл, прочитал ELF-заголовок и пошёл искать интерпретатор, который заголовок называет, — а в scratch нет ничего, включая ld-linux. Зависимость породил дефолт, который никто не ставил под вопрос: обычный go build на glibc-раннере CI шёл с CGO_ENABLED=1, и одного импорта net хватило, чтобы прилинковать libc ради DNS. CGO_ENABLED=0 уехал в прод той же ночью. Через две недели — второй акт: имя хоста, которое всегда резолвилось, резолвиться перестало — старый образ через libc-резолвер уважал nsswitch.conf с LDAP-бэкендом для hosts; чистый Go-резолвер читает /etc/hosts и resolv.conf, и точка. Статическая линковка — не деталь упаковки. Она меняет ваш DNS-резолвер.
Развилка в каждой сборке: CGO_ENABLED
$ CGO_ENABLED=1 go build -o server-cgo . # дефолт при нативной сборке с C-тулчейном
$ CGO_ENABLED=0 go build -o server-static . # чистый Go, никакого C
$ file server-cgo
server-cgo: ELF 64-bit LSB executable, dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2 ...
$ file server-static
server-static: ELF 64-bit LSB executable, statically linked ...
$ ldd server-cgo
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6Зачем языку с собственным рантаймом вообще линковать libc? Два пакета stdlib уходят в C, когда cgo доступен: net (резолв имён через getaddrinfo) и os/user (поиск аккаунтов). При CGO_ENABLED=1 — дефолте всякой нативной сборки при наличии C-компилятора — эти пути компилируются внутрь, и линковщик записывает в ELF-заголовок (ELF, Executable and Linkable Format — стандартный формат исполняемых файлов в Linux) динамический интерпретатор. Это и есть вся анатомия загадки из крючка: execve открывает бинарник, читает PT_INTERP (секцию ELF-заголовка с путём к динамическому загрузчику) и пытается загрузить /lib64/ld-linux-x86-64.so.2 — файл, которого в scratch нет. Возвращённый ENOENT называет бинарник, который вы запускали, а не интерпретатор, которого не нашлось, — поэтому ошибка читается как ложь. Вариант с Alpine — тот же баг в другой краске: glibc-собранный бинарник просит glibc-загрузчик, а musl кладёт /lib/ld-musl-x86_64.so.1.
Подмена резолвера — более тонкая половина. С вкомпилированным cgo Go решает на каждый лукап, звать getaddrinfo или собственный DNS-клиент, — по содержимому /etc/nsswitch.conf и /etc/resolv.conf плюс пара переменных окружения; всё, что Go не умеет эмулировать (модули mdns или nis, экзотические опции resolv), уходит через libc. CGO_ENABLED=0 убирает сам выбор: всегда чистый резолвер, который читает /etc/hosts и /etc/resolv.conf и сам говорит по DNS. В scratch нет nsswitch.conf вовсе, и Go-резолвер принимает стандартный порядок «files, потом dns» — всё хорошо, пока инфраструктура молча не зависела от плагина nsswitch: hosts из LDAP, mDNS, кэш nscd исчезают без единого сообщения об ошибке. Когда подозревается само решение, GODEBUG=netdns=go+1 форсирует чистый резолвер и логирует выбор; netdns=cgo+1 форсирует другую ветку.
Go-сервис копируют в scratch-образ. На старте контейнер умирает с exec /server: no such file or directory — хотя файл на месте и исполняемый. Что на самом деле сломалось?
Когда cgo не выкинуть — и матрица кросс-компиляции
Честный список исключений короток. mattn/go-sqlite3 оборачивает C-библиотеку — modernc.org/sqlite даёт чистый Go-выход, но с реальной (мерьте сами, часто 1,5-2x) ценой по скорости. Детектор гонок построен на C++-рантайме ThreadSanitizer, так что сборки с -race требуют cgo — и это нормально: это артефакт CI и тестов, никогда не релизный бинарник. Некоторым корпоративным DNS-окружениям действительно нужны модули nsswitch — то есть cgo-резолвер. И пакет plugin. Рабочий паттерн: cgo там, где реально живёт C-зависимость, CGO_ENABLED=0 — для артефакта деплоя.
Кросс-компиляция — место, где Go собирает дивиденды за всё это:
$ GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o server-arm64 ./cmd/server
$ go tool dist list | wc -l # поддерживаемая матрица GOOS/GOARCH
44Две переменные окружения с любой машины разработчика — ноутбук на Mac выпускает Linux-ARM-бинарники без единой дополнительной установки, потому что тулчейн Go несёт кодогенерацию под каждую цель, а чистый рантайм делает сисколы сам, без целевого libc, который надо где-то взять. Сравните с миром C: кросс-тулчейн, sysroot и система сборки, уважающая и то и другое, — на каждую цель. Как только входит cgo, вы наследуете ровно этот зоопарк: кросс-сборка cgo-кода требует настоящего C-кросс-компилятора (CC=aarch64-linux-gnu-gcc или современный трюк с zig cc) и каждой транзитивно линкуемой C-библиотеки, собранной под цель.
▸Почему это работает
Почему Go кросс-компилирует так буднично, а C — нет? Потому что сложной частью кросс-компиляции никогда не была кодогенерация — компиляторы испокон веков умеют чужие наборы инструкций. Сложная часть — целевой userland: заголовки, libc, окружение времени линковки. Чистый Go несёт свой рантайм с собой, сам делает сисколы и линкуется статически — воспроизводить целевой userland просто не нужно. CGO_ENABLED=1 возвращает C-userland, а с ним и всю проблему, которую Go удалил.
Дисциплина флагов и происхождение
Когда вы выпускаете бинарник, вы неявно делаете три заявления: этот код пришёл из этого коммита, это версия, которую прочитает дежурный в алерте, и этот образ идентичен тому, что проверял CI. Ни одно из них не истинно по умолчанию — для каждого нужен конкретный флаг или команда аудита.
$ go build -trimpath \
-ldflags="-s -w -X main.version=v1.42.0" \
-o server ./cmd/server
$ go version -m server
server: go1.25.4
path example.com/api/cmd/server
mod example.com/api v1.42.0
dep github.com/jackc/pgx/v5 v5.7.1 h1:...
build -trimpath=true
build CGO_ENABLED=0
build vcs.revision=9f3c2e1a...
build vcs.modified=false-trimpath вычищает из бинарника пути сборочной машины, и одинаковые исходники с одинаковым тулчейном дают бит-в-бит идентичный артефакт на любой машине — предусловие воспроизводимых сборок и доверия к тому, что бинарник соответствует коммиту. -ldflags="-s -w" выбрасывает ELF-таблицу символов и DWARF, обычно минус 25-30% размера (сервис на 9 МБ приземляется около 6,5 МБ). Честная бухгалтерия: стектрейсы паник по-прежнему символизируются — рантайм держит собственный pclntab (таблица соответствия адресов программного счётчика именам функций и номерам строк); теряете вы DWARF, то есть delve на прод-бинарнике, gdb и внешние инструменты символизации. Сеньорский ход — перестать считать это трейдоффом: собирайте на деплой со стрипом и архивируйте нестрипнутого близнеца (или заливайте символы в трекер ошибок) — получаете и маленький образ, и отлаживаемый артефакт. -X importpath.var=value внедряет человеческую версию в строковую переменную на этапе линковки; с Go 1.18 тулчейн сам встраивает vcs.revision, vcs.time и vcs.modified, так что коммит записан, даже если -X никто не передавал.
Суперсила аудита — последняя строка: go version -m работает на любом Go-бинарнике, включая скопированный из прод-контейнера, и печатает модуль, полный список зависимостей с хэшами и настройки сборки. Вопрос «что именно сейчас крутится» в три часа ночи перестаёт быть археологией и становится одной командой.
Вы выпускаете релизные бинарники с -ldflags=-s -w ради размера. Прилетает паника из прода. Как выглядит стектрейс?
- 01Разбери отказ exec /server: no such file or directory в scratch — что на самом деле искал execve и какой дефолт создал зависимость?
- 02В чём дисциплина релизных флагов сборки Go-сервиса и что честно стоит каждый флаг?
Каждая Go-сборка ветвится на CGO_ENABLED. С включённым cgo — нативный дефолт — net и os/user линкуют libc, ELF-заголовок записывает динамический интерпретатор, и образ обязан принести ld-linux и glibc — иначе смерть на execve с ENOENT, называющим не тот файл. С CGO_ENABLED=0 бинарник по-настоящему статический: один файл, безопасный для scratch, а DNS-резолвер — чистый Go, читающий /etc/hosts и resolv.conf вместо похода в nsswitch.conf через getaddrinfo. Это перемена поведения, а не только линковки, — и причина, по которой DNS может отличаться между старым debian-образом и новым scratch. Вопросы «какой резолвер сработал» решает GODEBUG=netdns. Cgo остаётся там, где реально живёт C: sqlite через mattn (или чистый Go от modernc с налогом на скорость), сборки с детектором гонок в CI, окружения, зависящие от nsswitch. Кросс-компиляция — дивиденд: GOOS и GOARCH выбирают из примерно сорока целей без дополнительного тулчейна, потому что чистый Go везёт рантайм и сисколы с собой, — ровно до момента, когда cgo заново импортирует C-userland с его зоопарком кросс-тулчейнов. Релизные флаги — дисциплина, а не фольклор: -trimpath для воспроизводимых сборок уровня провенанса; -X для семвера, который читают люди; -s -w для минус четверти-трети размера ценой DWARF — паники символизируются через pclntab, но держите нестрипнутого близнеца на случай отладчика. А go version -m читает модуль, зависимости, хэши и встроенную VCS-ревизию с любого бинарника, скопированного из контейнера, — инструмент аудита на самый плохой день. Теперь, когда в три ночи перед вами окажется бинарник неизвестного происхождения, вы знаете ровно одну команду, которая ответит на вопросы «что», «когда» и «из какого коммита».
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.