Перейти к содержимому

Компиляция и запуск

В предыдущем уроке мы разобрали, из каких деталей состоит Go-программа. Вы уже десяток раз набирали go run main.go и видели результат. Но что стоит за этой командой? Куда девается скомпилированный файл? И как превратить код в бинарник — исполняемый файл с машинными инструкциями, который можно просто скопировать на сервер и запустить — без Go, без зависимостей, без ничего?

В первом уроке мы упоминали “компилируемый язык” и “один бинарник без зависимостей” — пришло время разобраться, что за этими словами стоит на практике. Компиляция — это перевод вашего кода в язык процессора: нули и единицы, которые CPU выполняет напрямую. Результат этого перевода — тот самый бинарник (от слова binary — двоичный). В отличие от Python, где код сначала компилируется в байткод, а затем выполняется виртуальной машиной (CPython VM), Go компилирует сразу в машинные инструкции процессора — без промежуточных слоёв. Отсюда и скорость.

Сегодня снимаем капот.


Для всех примеров в этом уроке будем использовать знакомый main.go:

package main
import "fmt"
func main() {
fmt.Println("Привет, мир!")
}

Откройте терминал в VS Code (Ctrl+`) и убедитесь, что вы в папке с этим файлом.


go run — иллюзия интерпретатора

Если вы пришли из Python или JavaScript, go run ощущается привычно: набрал команду — программа заработала. Никаких промежуточных файлов, никакой возни. Кажется, что Go просто выполняет текст.

Но Go — компилируемый язык. Всегда. Даже когда go run прикидывается интерпретатором, под капотом разворачивается полноценная сборка:

  1. Создаётся временная директория (путь зависит от ОС)
  2. Исходный код компилируется в нативный бинарник
  3. Бинарник запускается как отдельный процесс
  4. После завершения программы временная директория удаляется

Именно поэтому в рабочей папке ничего не появляется — всё живёт и умирает во временном каталоге.

Заглянуть под капот

Хотите увидеть, что происходит? Добавьте флаг -x:

Окно терминала
go run -x main.go

В терминале посыплются десятки строк — каждая команда, которую Go выполняет за кулисами. Там будет и путь к временной директории (WORK=...), и вызовы компилятора, и линковка.

А если хотите сохранить временные файлы для изучения — есть флаг -work:

Окно терминала
go run -work main.go
WORK=/var/folders/.../go-build3712456890 # путь зависит от ОС
Привет, мир!

Директория не удалится после завершения. Можете зайти туда и найти настоящий скомпилированный бинарник.

:::tip Лайфхак go run -work — отличный способ убедиться, что Go действительно создаёт полноценный исполняемый файл, а не “интерпретирует” ваш код. :::

Передача аргументов

В прошлом уроке мы писали программу greeter с os.Args. Запуск выглядел так:

Окно терминала
go run main.go Вася
Привет, Вася!

Всё что после имени файла — аргументы вашей программы. Go сам разберётся, где заканчиваются его флаги и начинаются ваши данные. Флаги Go идут до файла, аргументы программы — после:

Окно терминала
go run -race main.go --port 8080
# ^^^^ ^^^^^^^^^^
# флаг Go аргументы вашей программы

Кстати, вместо go run main.go можно писать go run . — точка означает “весь пакет в текущей директории”. Пока у вас один файл — разницы нет, но это более идиоматичный* подход, и позже вы поймёте почему.

:::caution Точка работает только с Go-модулем Команды с . (go run ., go build .) требуют файла go.mod в директории вашего проекта. Если его нет, Go выдаст ошибку. Создайте модуль одной командой:

Окно терминала
go mod init hello

hello — это имя вашего модуля (можно любое). После этого в директории появится файл go.mod. Подробнее про модули поговорим в отдельном уроке, а пока достаточно знать: перед первым использованием . выполните go mod init. :::

* Идиоматичный — значит “так принято в сообществе”. У каждого языка есть негласные правила: не просто “работает”, а “так пишут опытные разработчики”. В Go-мире go run . — идиоматично, go run main.go — нет. Вы будете часто встречать это слово в Go-документации и статьях.

Ограничения

С версии Go 1.24 у go run появилось кэширование: если код не менялся, повторный запуск берёт готовый бинарник из кэша. Но ограничений всё равно хватает:

  • Медленнее прямого запуска. Даже с кэшем go run проверяет актуальность сборки при каждом вызове. Для крупных проектов накладные расходы могут быть значительными — в некоторых случаях до 8 раз медленнее, чем запуск готового бинарника.
  • Нет контроля над бинарником. Он лежит где-то в кэше, вы не можете его передать или отправить на сервер. os.Executable() вернёт путь во временную директорию — если ваша программа полагается на свой путь, это сломается.
  • Кэш недолговечен. Go агрессивно чистит кэшированные бинарники — примерно через 2 дня неиспользования. Утром go run может снова компилировать с нуля.
  • Только package main. Нельзя запустить библиотечный пакет — только исполняемые программы.
  • Кросс-компиляция бессмысленна. GOOS=linux go run . не имеет смысла — бинарник запускается на вашей машине, а не на целевой платформе.
  • Не для продакшена. Продакшен (production) — это среда, где ваша программа работает “по-настоящему”: обслуживает реальных пользователей, крутится на сервере 24/7. В противоположность — среда разработки (development), где вы пишете и тестируете код на своём компьютере. go run — инструмент разработки, не способ деплоя в продакшен.

Вывод: go run — это “быстро глянуть”. Для всего остального есть go build.


go build — создаём настоящий бинарник

Когда нужен файл, который можно отправить коллеге, загрузить на сервер или положить в Docker-контейнер (Docker — система для упаковки и запуска приложений в изолированных средах, подробнее познакомимся позже) — используйте go build.

Окно терминала
go build -o myapp .
./myapp
Привет, мир!

В директории появился файл myapp (на Windows — myapp.exe) размером около 2 МБ или чуть больше. Это самодостаточный бинарник. На целевой машине не нужен Go, не нужны библиотеки, не нужен рантайм. Просто копируете файл и запускаете.

Имя выходного файла

Без флага -o Go сам выберет имя:

Окно терминала
go build . # Имя из go.mod (или имя директории)
go build -o server . # Явно задаём имя
go build -o bin/app . # Можно указать и путь

На Windows автоматически добавится .exe.

:::tip go build без package main Если запустить go build . в директории с библиотечным пакетом (не main), Go скомпилирует код и проверит его на ошибки, но не создаст выходного файла. Удобный способ валидировать код без засорения директории — особенно в CI. :::

Флаг -race — детектор гонок данных

Представьте: два человека одновременно редактируют один документ, не видя друг друга. Один пишет заголовок, другой его удаляет — результат непредсказуем. В программировании это называется гонка данных (data race) — когда несколько частей программы одновременно читают и изменяют одну и ту же переменную. Результат зависит от того, кто успел первым, и каждый запуск может давать разный результат.

Go умеет такие ситуации находить автоматически. Вот пример — не пытайтесь пока разобрать каждую строку, ключевое слово go мы изучим в уроке про конкурентность (Concurrency). Сейчас важен сам принцип:

package main
import "fmt"
func main() {
count := 0
for i := 0; i < 1000; i++ {
go func() { // запускаем 1000 параллельных задач
count++ // все пишут в одну переменную
}()
}
fmt.Println(count)
}

Запустим без флага — программа молча выдаст непредсказуемый результат:

Окно терминала
go run .
0 # или 127, или 999 — каждый раз по-разному

Теперь с -race:

Окно терминала
go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c00011c028 by goroutine 9:
main.main.func1()
/home/user/main.go:9 +0x2e
Previous write at 0x00c00011c028 by goroutine 8:
main.main.func1()
/home/user/main.go:9 +0x44
Goroutine 9 (running) created at:
main.main()
/home/user/main.go:8 +0x4a
Goroutine 8 (finished) created at:
main.main()
/home/user/main.go:8 +0x4a
==================
... ещё 2 похожих предупреждения ...
651
Found 3 data race(s)
exit status 66

Go нашёл 3 гонки и точно показывает: строка 9, переменная count (адрес 0x00c00011c028), несколько параллельных задач пишут в неё одновременно. Программа завершилась с кодом 66 — специальный код ошибки для гонок. Число 651 вместо ожидаемых 1000 — результат потерянных обновлений, типичное последствие гонки данных. Без -race программа бы тихо выдавала неправильные результаты — баг, который крайне сложно поймать вручную.

Флаг -race работает и с go build, и с go run, и с go test. Бинарник с ним медленнее и потребляет больше памяти, поэтому в продакшен его не ставят. Но в тестах и при разработке — обязательная практика.

Почему Hello World весит 2 мегабайта?

После первого go build новички удивляются: программа из пяти строк — и 2 МБ? На C аналог весит 16 КБ.

Дело в том, что Go-бинарник — это не просто ваш код. Это целая вселенная:

  • Go runtime — среда выполнения
  • Garbage Collector — сборщик мусора
  • Goroutine Scheduler — планировщик горутин (помните из первого урока — многозадачность из коробки?)
  • Таблица символов и отладочная информация — для паник-трейсов и отладки
  • Части стандартной библиотеки, которые вы импортировали

Один только import "fmt" тянет за собой рефлексию, I/O и форматирование строк*. Всё встроено внутрь.

  • * Рефлексия (Reflection) — механизм, позволяющий программе анализировать и изменять собственную структуру во время выполнения: узнавать типы переменных, читать их значения, вызывать функции по имени. Проще говоря — способность программы “смотреть на саму себя”. Вспомните: вы пишете fmt.Println(42) — и получаете 42. Пишете fmt.Println("привет") — получаете привет. Пишете fmt.Println(3.14) — получаете 3.14. Откуда Println знает, как напечатать каждое из этих значений, если число и строка — совершенно разные вещи? Именно через рефлексию: в момент вызова функция спрашивает у рантайма “что мне передали — число? строку? что-то ещё?” — и на основе ответа выбирает, как это отобразить. Без рефлексии пришлось бы писать отдельную функцию для каждого типа данных.
  • * I/O (Input/Output, ввод/вывод) — всё что связано с чтением и записью: вывод в терминал, чтение файлов, передача данных по сети. fmt.Println записывает текст в стандартный поток вывода (stdout) — это I/O.
  • * Форматирование строк — превращение данных в читаемый текст. Когда вы пишете fmt.Println("Ответ:", 42), Go преобразует число 42 в строку "42" и склеивает с "Ответ:".

Именно поэтому в первом уроке мы говорили “один бинарник без зависимостей” — теперь вы понимаете, что это значит. Go кладёт всё необходимое прямо в файл.

Уменьшаем размер

Зачем уменьшать размер? Меньший бинарник — это быстрее скачивание на сервер (особенно если серверов десятки), меньше места в Docker-образе, быстрее запуск контейнеров. Для Hello World разница незаметна, но когда проект вырастет до 20–50 МБ — экономия 25–30% уже ощутима.

Окно терминала
# Стандартная сборка
go build -o app .
# app — ~2 МБ
# Без отладочной информации (-s: символы, -w: DWARF)
go build -ldflags="-s -w" -o app .
# app — ~1.5 МБ

Флаги -s -w убирают таблицу символов и отладочную информацию DWARF. На практике это минус 25–30% от размера. Паник-трейсы при этом продолжают работать — Go хранит свою внутреннюю таблицу для отслеживания ошибок отдельно.

:::tip Рецепт для продакшена

Окно терминала
go build -ldflags="-s -w" -trimpath -o app .

Флаг -trimpath дополнительно вырезает из бинарника абсолютные пути вашей файловой системы. Бонус к безопасности и воспроизводимости сборки. :::

Встраиваем версию

Представьте: вы отправили бинарник на сервер. Через месяц что-то сломалось. Какая версия кода там стоит — вы уже не помните. Пересобирали с тех пор десять раз. Если бинарник сам умеет ответить “я версия 1.0.0” — проблема решается за секунду: запустили с флагом --version, сравнили с актуальной, поняли нужно ли обновлять.

Именно для этого версию вшивают прямо в бинарник на этапе сборки:

package main
import "fmt"
var version = "dev"
func main() {
fmt.Println("Версия:", version)
}

В коде version равна "dev" — значение по умолчанию для разработки. Но при сборке мы подменяем её:

Окно терминала
go build -ldflags="-X main.version=1.0.0" -o app .
./app
Версия: 1.0.0

Флаг -X подменяет значение строковой переменной на этапе компиляции — в самом исходном коде ничего менять не надо. В автоматизированных системах сборки — CI/CD (Continuous Integration / Continuous Delivery, автоматическая сборка и доставка кода) — это стандартная практика: вшивают номер версии, дату сборки, идентификатор изменения из Git. Любой бинарник может “представиться”.

:::tip Начиная с Go 1.24 Go автоматически вшивает информацию из Git (идентификатор изменения, тег версии, пометку +dirty если есть несохранённые правки). Посмотреть можно через go version -m ./app. :::

Build cache — почему второй раз быстрее

Кэш (cache) — место, где хранятся результаты предыдущей работы, чтобы не делать её заново. Браузер кэширует картинки с сайтов, чтобы не скачивать их повторно. Go делает то же самое с компиляцией: сохраняет уже скомпилированные пакеты, а при следующей сборке компилирует только те, что изменились.

Окно терминала
go build -o app . # Первый раз: ~2 секунды
go build -o app . # Второй раз: ~0.3 секунды (линковка)

Где именно Go хранит кэш на вашем компьютере, можно узнать командой:

Окно терминала
go env GOCACHE

Путь зависит от ОС — на Linux, macOS и Windows он будет разным. Если что-то пошло не так и хотите очистить кэш — go clean -cache.

:::tip Для опытных: PGO — оптимизация по профилю Начиная с Go 1.22, Profile-Guided Optimization стабильна. Идея: вы снимаете CPU-профиль работающего приложения (через pprof), кладёте файл default.pgo в корень проекта, и при следующей сборке компилятор использует реальные данные о горячих путях для агрессивного инлайнинга и оптимизации. Прирост производительности — 2–14% без единого изменения в коде. Подробнее — в уроках про тестирование (Testing) и профилирование (Profiling). :::


go install — ставим утилиты глобально

go build создаёт бинарник в текущей папке — там, где вы сейчас стоите в терминале. Это удобно для вашего проекта, но что если вы хотите поставить чужую утилиту — линтер, форматтер, генератор кода — и пользоваться ей из любого места в системе?

Для этого есть go install. Он делает то же, что go build, но кладёт готовый бинарник не рядом с вами, а в специальную папку. В уроке про установку мы видели переменную GOBIN в таблице — вот это она и есть. Узнать, куда именно Go кладёт утилиты на вашей системе, можно командой:

Окно терминала
go env GOPATH

Бинарники попадут в подпапку bin этого пути. Если вы при установке Go добавили эту папку в PATH — утилиты будут доступны из любой директории. Если нет — самое время это сделать (подробнее в уроке про установку).

Пример — установим goimports, утилиту для автоматической расстановки импортов:

Окно терминала
go install golang.org/x/tools/cmd/goimports@latest

Эта команда скачивает исходный код goimports с golang.org/x/tools, компилирует его и кладёт готовый бинарник в ту самую папку bin. Суффикс @latest означает “последняя версия”. Теперь утилита доступна глобально:

Окно терминала
goimports -w main.go

Это работает из любой папки — при условии, что папка bin добавлена в PATH. Именно так устанавливались инструменты в уроке про редактор — gopls, dlv и другие расширения VS Code ставились через go install.

Отличие от go build

go buildgo install
Куда кладёт бинарникВ текущую папкуВ папку bin внутри GOPATH
Для чегоВаш проектЧужие утилиты
Доступен глобальноНетДа (если папка bin в PATH)
Типичное использованиеgo build -o server .go install tool@latest

:::tip Для опытных: директива tool в go.mod (Go 1.24) Раньше, чтобы зафиксировать версию утилиты (линтера, кодогенератора) на весь проект, использовали хак — файл tools.go с blank import-ами. С Go 1.24 появилась директива tool прямо в go.mod:

Окно терминала
go get -tool github.com/golangci/golangci-lint/cmd/golangci-lint@latest

Это добавит tool строку в go.mod, и теперь любой участник команды запускает утилиту через go tool golangci-lint run ./... — гарантированно та же версия, что и у всех. Никаких расхождений между разработчиками и CI. :::

Когда что использовать

go rungo buildgo install
Что делаетКомпилирует → запускает → удаляетКомпилирует → сохраняетКомпилирует → кладёт в GOPATH/bin
Файл остаётся❌ Нет✅ В текущей папке✅ В GOPATH/bin
Когда использоватьРазработка, экспериментыСборка для сервера, DockerУстановка CLI-утилит
КэшированиеС Go 1.24Промежуточные файлыПромежуточные файлы

Типичный рабочий процесс:

  1. go run . — пока пишете и отлаживаете
  2. go build — когда нужен финальный артефакт
  3. go install — для утилит, которыми пользуетесь постоянно

Кросс-компиляция — собираем под любую ОС

Важный момент: каждый бинарник собирается под конкретную ОС и архитектуру. Бинарник, собранный на macOS, не запустится на Linux — получите ошибку Exec format error. И наоборот. Это не как Python-скрипт, который одинаково работает везде, где стоит интерпретатор. Бинарник содержит машинные инструкции для конкретной платформы.

Если вы разрабатываете на macOS, а сервер работает на Linux — нужно собрать бинарник именно для Linux. Это и есть кросс-компиляция: сборка на одной платформе для другой. В первом уроке мы упоминали “собрал под Linux на маке одной командой” — пришло время показать, как.

Помните код из урока про установку?

fmt.Printf("OS: %s\n", runtime.GOOS)
fmt.Printf("Arch: %s\n", runtime.GOARCH)

Тогда runtime.GOOS показывал вашу текущую ОС — и это логично, ведь вы собирали и запускали на одной машине. Но runtime.GOOS и runtime.GOARCH — это не “определение системы в момент запуска”. Это константы, зашитые в бинарник на этапе компиляции. По умолчанию Go ставит туда вашу ОС и архитектуру, поэтому всё совпадает. Но их можно подменить двумя переменными окружения — и тогда бинарник будет собран для другой платформы:

Окно терминала
# Собираем на Mac, запускаем на Linux-сервере
GOOS=linux GOARCH=amd64 go build -o app-linux .
# Для Windows
GOOS=windows GOARCH=amd64 go build -o app.exe .
# Для Mac с Apple Silicon
GOOS=darwin GOARCH=arm64 go build -o app-mac .
# Для Raspberry Pi
GOOS=linux GOARCH=arm GOARM=7 go build -o app-rpi .

Никаких дополнительных компиляторов, тулчейнов, виртуальных машин. Go всё умеет из коробки.

Хотите увидеть все поддерживаемые платформы? Их больше 45:

Окно терминала
go tool dist list
aix/ppc64
android/amd64
darwin/amd64
darwin/arm64
js/wasm
linux/amd64
linux/arm64
wasip1/wasm
windows/amd64
... и ещё 40+

CGO_ENABLED=0 — полностью статический бинарник

Мы говорили, что Go-бинарник самодостаточный — внутри всё необходимое. Но это не совсем так. По умолчанию Go в некоторых случаях использует C-библиотеки операционной системы — например, для работы с DNS (преобразование доменных имён в IP-адреса) и для получения информации о пользователях системы. Это называется CGO (C-Go) — мост между Go и кодом на языке C.

Проблема в том, что C-библиотеки разные на разных системах. Бинарник, собранный с CGO, ожидает определённую C-библиотеку на целевой системе. Если её там нет или стоит другая версия — бинарник упадёт с непонятной ошибкой.

Чтобы бинарник был по-настоящему самодостаточным и не зависел ни от чего на целевой системе, отключаем CGO:

Окно терминала
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app .

CGO_ENABLED=0 говорит Go: “не используй C-библиотеки, сделай всё сам”. Go заменит системные вызовы своими реализациями на чистом Go. Результат — бинарник, которому нужна только операционная система. Никаких библиотек, никаких зависимостей.

:::danger Это важно для Docker Популярный базовый образ Alpine Linux использует musl libc вместо стандартной glibc. Если собрать бинарник без CGO_ENABLED=0 — в Alpine он упадёт с загадочной ошибкой no such file or directory, хотя файл на месте. Система не может найти C-библиотеку, которую бинарник ожидает. Правило простое: для Docker-сборок всегда ставьте CGO_ENABLED=0. :::


Что под капотом: почему Go компилируется за секунды

В первом уроке мы говорили, что Go создали, потому что создатели устали ждать компиляцию C++ по 45 минут. Вот как они решили эту проблему.

Этапы компиляции (на пальцах)

Когда вы набираете go build, происходит следующее:

Пайплайн компиляции Go

  1. Lexer (Scanner) — разбивает текст на токены: ключевые слова, имена, числа, операторы
  2. Parser — собирает токены в дерево (AST — Abstract Syntax Tree), отражающее структуру программы
  3. Semantic Analysis (Type Checker) — проверяет типы: “x — это int, нельзя сложить со строкой”
  4. Intermediate Representation — преобразует AST в промежуточное представление (IR)
  5. SSA (Static Single Assignment) — оптимизирует IR: удаляет мёртвый код, сворачивает константы
  6. Machine Code Generation — превращает SSA в инструкции для конкретного процессора
  7. Linker — склеивает машинный код с runtime → готовый бинарник
  8. Execution — ОС загружает бинарник и запускает

Вы не обязаны понимать каждый шаг. Важно знать, что компиляция — это перевод: человеческий текст → машинные инструкции. Go делает этот перевод очень быстро.

:::tip Для опытных: что происходит на каждом этапе SSA (Static Single Assignment) — ключевое промежуточное представление. Каждой переменной присваивается значение ровно один раз: x = 1; x = x + 2 превращается в x₁ = 1; x₂ = x₁ + 2. Это упрощает оптимизации: удаление мёртвого кода, свёртку констант, устранение лишних проверок границ массивов.

Escape analysis — компилятор решает, где жить переменной: на стеке (быстро, бесплатная очистка) или в куче (дороже, нагружает GC — Garbage Collector, сборщик мусора, который автоматически освобождает неиспользуемую память). Если ссылка на переменную “утекает” за пределы функции — куча. Иначе — стек. Посмотреть решения компилятора: go build -gcflags="-m" .

Inlining — компилятор подставляет тело маленьких функций прямо в место вызова, убирая накладные расходы. Go инлайнит функции до определённой “стоимости” (80 узлов AST). С Go 1.22 инлайнер стал агрессивнее. :::

:::tip Для опытных: что происходит до main() Когда ОС запускает Go-бинарник, до вашего main() далеко:

  1. OS Loader загружает файл в память, находит точку входа
  2. Ассемблерный bootstrap (_rt0_amd64_linux и подобные) сохраняет argc/argv
  3. runtime.rt0_go выделяет стек для системной горутины g0, инициализирует кучу
  4. Запуск подсистем — GC, планировщик горутин, сетевой поллер
  5. init() функции всех импортированных пакетов — снизу вверх по дереву зависимостей
  6. main.main() — наконец-то ваш код

Именно поэтому даже пустая Go-программа “тяжелее” аналога на C — она несёт с собой полноценную среду выполнения. :::

Почему быстрее C++ и Rust

Главная причина — модель зависимостей. Роб Пайк измерял: при компиляции Go-кода компилятор читает в 40 раз меньше исходного текста, чем при компиляции C++. В C++ каждый #include <string> заново “объясняет” компилятору, что такое строки. В Go информация о пакете хранится в скомпилированном виде — компилятор читает только прямые импорты, не проваливаясь в их зависимости.

Другие факторы:

  • 25 ключевых слов — парсер работает мгновенно
  • Запрет циклических импортов — граф зависимостей можно компилировать параллельно
  • Неиспользуемый импорт = ошибка — компилятор не тратит время на мёртвый код
  • Нет шаблонов как в C++ — не нужно раздувать код при инстанциации

Для масштаба: проект Istio (платформа для управления микросервисами, ~350 000 строк Go) компилируется с нуля за 33 секунды на мощной машине. С прогретым кэшем — меньше секунды.

:::tip Для любопытных go build -x покажет каждую команду, которую выполняет тулчейн: компиляцию каждого пакета, генерацию конфигов, линковку. Десятки строк вывода — и всё это за пару секунд. :::


Практические сценарии

Docker multi-stage build

Стандартный способ доставки Go-сервиса на сервер — многоэтапная сборка Docker:

# Этап 1: собираем бинарник
FROM golang:1.25 AS builder
WORKDIR /app
COPY go.mod go.sum ./ # go.sum — файл с контрольными суммами зависимостей
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -trimpath -o server .
# Этап 2: минимальный образ
FROM scratch
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

Образ scratch — абсолютно пустой. Ни bash, ни curl, ни libc. Только ваш бинарник. Финальный образ: 3–5 МБ вместо 700 МБ+ с полным SDK (Software Development Kit — набор инструментов для разработки). Это возможно именно потому, что Go-бинарник самодостаточный.

Makefile

Makefile — это файл с набором команд-рецептов. Вместо того чтобы каждый раз набирать длинную команду сборки с десятком флагов, вы описываете её один раз в Makefile и потом вызываете коротким make build. Утилита make есть на Linux и macOS из коробки, для Windows можно установить отдельно.

Для проектов побольше удобно завернуть команды в Makefile:

APP_NAME = myapp
.PHONY: run build test clean cross
run:
go run .
build:
go build -ldflags="-s -w" -trimpath -o $(APP_NAME) .
test:
go test -race ./...
clean:
rm -f $(APP_NAME) $(APP_NAME)-*
cross:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o $(APP_NAME)-linux .
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o $(APP_NAME)-mac .
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o $(APP_NAME).exe .

Теперь make build вместо длинной команды с флагами. make cross — сборка под три ОС разом.

:::tip Для опытных: GoReleaser Когда проект дорастёт до публичных релизов — кросс-компиляция, архивы, changelog, публикация на GitHub/GitLab, конфиги для Homebrew и Scoop — вручную это ад. GoReleaser автоматизирует весь цикл. Один файл .goreleaser.yaml, одна команда — и релиз под десятки платформ готов. Его используют Kubernetes, Docker, GitHub CLI и тысячи open-source проектов. :::


Типичные грабли новичков

1. “undefined” при go run main.go

Когда проект подрастёт, код естественно разобьётся на несколько файлов — например, main.go и math.go в одном пакете main. И тут есть нюанс.

Когда вы явно указываете файлы — go run main.go — Go считает только перечисленные файлы частью вашего пакета. Остальные .go-файлы в той же директории он не видит, как будто их нет:

Окно терминала
go run main.go
./main.go:10:2: undefined: Add # функция Add из math.go — "не существует"
go run . # ✅ все файлы пакета включены

Важно не путать две вещи:

  • Соседние файлы того же пакета (например math.go с package main в той же папке) — при go run main.go не подтягиваются. Это и есть проблема.
  • Импортированные пакеты (через import) — подтягиваются нормально. Если у вас import "myproject/utils", пакет utils скомпилируется, потому что он разрешается через модульную систему, а не через “соседние файлы в директории”.

go run . и go build . включают все .go-файлы в директории (кроме _test.go). Поэтому привыкайте к go run . — с ней таких проблем не будет.

2. “cannot run non-main package”

Окно терминала
go run .
go run: cannot run non-main package

Забыли написать package main или нет функции main(). Помните из прошлого урока: только package main + func main() создают исполняемую программу.

3. Бинарник не запускается на другой ОС

Собрали на Mac, скопировали на Linux-сервер:

Окно терминала
./myapp
bash: ./myapp: cannot execute binary file: Exec format error

Это бинарник для macOS, а запускаете на Linux. Нужна кросс-компиляция:

Окно терминала
GOOS=linux GOARCH=amd64 go build -o myapp .

4. “permission denied” (Linux/macOS)

Окно терминала
./myapp
bash: ./myapp: Permission denied

Нет прав на выполнение. go build ставит их автоматически, но при копировании через архив или сеть бит выполнения может потеряться. На Windows такой проблемы нет — там работает по-другому.

Окно терминала
chmod +x myapp

5. Загадочная “no such file or directory” в Docker

Окно терминала
exec /server: no such file or directory

Файл на месте, но система не может найти динамический загрузчик glibc. Собрали без CGO_ENABLED=0, а запускаете в Alpine с musl.

Окно терминала
# Правильная сборка для Docker:
CGO_ENABLED=0 go build -o server .

Полный пример: от кода до бинарника

Возьмём программу greeter из прошлого урока и пройдём полный цикл:

main.go
package main
import (
"fmt"
"os"
"strings"
)
var version = "dev"
func main() {
if len(os.Args) > 1 && os.Args[1] == "--version" {
fmt.Println(version)
return
}
name := "Мир"
if len(os.Args) > 1 {
name = strings.Join(os.Args[1:], " ")
}
fmt.Printf("Привет, %s!\n", name)
}
Окно терминала
# 1. Быстрый запуск во время разработки
go run . Вася
Привет, Вася!
# 2. Собираем бинарник с версией
go build -ldflags="-s -w -X main.version=1.0.0" -trimpath -o greeter .
# 3. Проверяем
./greeter --version
1.0.0
./greeter дорогой друг
Привет, дорогой друг!
# 4. Кросс-компиляция для Linux-сервера
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w -X main.version=1.0.0" -trimpath -o greeter-linux .
# 5. Оба файла — около 1.3 МБ каждый

Один исходник — бинарники под любую платформу. Без Docker, без виртуальных машин, без боли.


Итоги

Команда / КонцепцияЧто помнить
go run .Компилирует и запускает. Бинарник временный. Для разработки.
go build -o app .Создаёт постоянный бинарник. Для деплоя и CI/CD.
go installСтавит бинарник в GOPATH/bin. Для CLI-утилит.
-ldflags="-s -w"Убирает отладочную инфу. Минус 25–30% размера.
-trimpathУбирает пути. Безопасность + воспроизводимость.
-X main.var=valВшивает значение переменной при сборке.
GOOS / GOARCHКросс-компиляция. Любая ОС, любая архитектура.
CGO_ENABLED=0Полностью статический бинарник. Обязателен для Docker.
go build -xПоказывает все шаги компиляции. Для любопытных.

Задачи

Задача 1: Заглядываем под капот ⭐

Запустите программу Hello World так, чтобы увидеть путь к временной директории сборки. Временные файлы не должны удаляться после запуска.

Решение
Окно терминала
go run -work main.go
WORK=/var/folders/.../go-build1234567890 # путь зависит от ОС
Привет, мир!

Флаг -work печатает путь и сохраняет временную директорию. Можно зайти в неё и найти скомпилированный бинарник.

Задача 2: Оптимальная сборка ⭐⭐

Соберите бинарник для Linux ARM64 с минимальным размером, без путей файловой системы и без зависимостей от C-библиотек. Имя файла — server.

Решение
Окно терминала
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build -ldflags="-s -w" -trimpath -o server .
  • CGO_ENABLED=0 — статический бинарник без C-зависимостей
  • GOOS=linux GOARCH=arm64 — целевая платформа
  • -ldflags="-s -w" — убираем символы и DWARF
  • -trimpath — убираем абсолютные пути

Задача 3: Версия из командной строки ⭐⭐⭐

Напишите программу, которая при запуске с флагом --version выводит номер версии, а без флага — приветствие. Версия должна вшиваться при сборке через -ldflags, а не хардкодиться.

Окно терминала
go build -ldflags="-X main.version=2.1.0" -o app .
./app --version
app v2.1.0
./app
Привет из app!
Подсказка

Объявите var version = "dev" и используйте os.Args для проверки аргументов.

Решение
package main
import (
"fmt"
"os"
)
var version = "dev"
func main() {
if len(os.Args) > 1 && os.Args[1] == "--version" {
fmt.Println("app v" + version)
return
}
fmt.Println("Привет из app!")
}

Сборка:

Окно терминала
go build -ldflags="-X main.version=2.1.0" -o app .

Задача 4: Мультиплатформенная сборка ⭐⭐⭐

Напишите скрипт (или команды), который соберёт один и тот же проект под три платформы: Linux amd64, macOS arm64, Windows amd64. Все бинарники должны быть статическими, минимального размера и лежать в папке dist/.

Решение
Окно терминала
mkdir -p dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o dist/app-linux .
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o dist/app-mac .
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o dist/app.exe .

Или через Makefile:

.PHONY: dist
dist:
mkdir -p dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o dist/app-linux .
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -trimpath -o dist/app-mac .
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o dist/app.exe .

Что дальше?

Теперь вы умеете превращать код в бинарник, собирать под любую платформу и понимаете, что происходит за кулисами компиляции. В следующем уроке разберём Go Playground — веб-среду для запуска Go-кода прямо в браузере: быстрые эксперименты, обмен сниппетами с коллегами и тестирование идей без локальной установки.


Источники