Команда Go for Devs подготовила перевод статьи о том, как упростить сборку Go-проектов с cgo, используя Docker. Авторы на реальном примере показывают, как избавиться от платформенной боли, сложных зависимостей и ручной настройки окружения, при этом сохранив воспроизводимость продакшен-сборок. Практичный разбор для тех, кто сталкивался с cgo и кроссплатформенной сборкой.
Одна из причин, по которым нам нравится Go, — это возможность собирать бинарники под разные операционные системы и архитектуры вне зависимости от того, на какой машине выполняется сборка. Это означает, что мы можем выпускать бинарники для всех поддерживаемых целевых платформ на одной и той же машине, не поднимая отдельную систему под каждую архитектуру. Такой подход заметно упрощает наш процесс разработки и релизов.
Но. Всё стало значительно сложнее, когда у нас появилась первая зависимость с cgo. Перестав быть чисто Go-проектом, мы довольно быстро добавили ещё пару таких зависимостей. С точки зрения отдельного разработчика ежедневная команда сборки выглядит почти так же, как и раньше, но теперь каждому нужно установить подходящие зависимости для своей платформы (у нас есть инженеры, работающие на Mac и Windows, и при этом все мы используем Linux). Кроме того, каждому приходится настраивать переменные окружения, чтобы подсказать Go, какие cgo-флаги использовать при сборке. Для меня, на macOS, это выглядит примерно так — несколько строк в файле .zshrc.
export LDFLAGS="-L/opt/homebrew/opt/icu4c@77/lib"
export CGO_LDFLAGS="-L/opt/homebrew/opt/icu4c@77/lib"
export CPPFLAGS="-I/opt/homebrew/opt/icu4c@77/include"
export CGO_CPPFLAGS="-I/opt/homebrew/opt/icu4c@77/include"Для тех из нас, кто работает над проектом на постоянной основе, в этом нет ничего страшного. Но Dolt — это open source проект, и мы хотим, чтобы наши пользователи могли собирать его из исходников, не вникая во все тонкости нашей логики сборки.
Раньше максимум, что могли предложить большинство проектов, — это такие инструменты, как autoconf в сочетании со сложным Makefile. В современных проектах от них всё чаще отказываются — и из-за их непрозрачности, и из-за сложности отладки проблем. К счастью, сегодня у нас есть вариант получше, и, что немаловажно, он уже установлен у наших пользователей: Docker.
Написание кроссплатформенного скрипта сборки для запуска в Docker
Мы хотим, чтобы этот скрипт работал для любой заданной платформы и архитектуры. Поэтому начнём с определения набора констант для флагов, необходимых на каждой из платформ.
declare -A platform_cc
platform_cc["linux-arm64"]="aarch64-linux-musl-gcc"
platform_cc["linux-amd64"]="x86_64-linux-musl-gcc"
platform_cc["darwin-arm64"]="clang-19 --target=aarch64-darwin --sysroot=/opt/cross/darwin-sysroot -mmacosx-version-min=12.0"
platform_cc["darwin-amd64"]="clang-19 --target=x86_64-darwin --sysroot=/opt/cross/darwin-sysroot -mmacosx-version-min=12.0"
platform_cc["windows-amd64"]="x86_64-w64-mingw32-gcc"Эти значения соответствуют переменной окружения CC, которую мы передаём команде go build для каждой целевой платформы. Как указано в документации Go по этой теме, данный флаг определяет компилятор, который будет использоваться для сборки необходимого C-кода. Выбор компилятора зависит от целевой платформы и архитектуры.
Когда используется cgo или SWIG,
go buildпередаёт все файлы с расширениями.c,.m,.s,.Sили.sxC-компилятору, а файлы.cc,.cpp,.cxx— C++-компилятору. Переменные окруженияCCиCXXможно задать, чтобы явно указать, какой C- или C++-компилятор использовать.
Переменная CXX делает то же самое, но для C++-кода.
declare -A platform_cxx
platform_cxx["linux-arm64"]="aarch64-linux-musl-g++"
platform_cxx["linux-amd64"]="x86_64-linux-musl-g++"
platform_cxx["darwin-arm64"]="clang++-19 --target=aarch64-darwin --sysroot=/opt/cross/darwin-sysroot -mmacosx-version-min=12.0 --stdlib=libc++"
platform_cxx["darwin-amd64"]="clang++-19 --target=x86_64-darwin --sysroot=/opt/cross/darwin-sysroot -mmacosx-version-min=12.0 --stdlib=libc++"
platform_cxx["windows-amd64"]="x86_64-w64-mingw32-g++"Далее нам нужно задать аналогичную информацию для линковщика — именно это делают следующие две переменные ld.
declare -A platform_go_ldflags
platform_go_ldflags["linux-arm64"]="-linkmode external -s -w"
platform_go_ldflags["linux-amd64"]="-linkmode external -s -w"
platform_go_ldflags["darwin-arm64"]="-s -w -compressdwarf=false -extldflags -Wl,-platform_version,macos,12.0,14.4"
platform_go_ldflags["darwin-amd64"]="-s -w -compressdwarf=false -extldflags -Wl,-platform_version,macos,12.0,14.4"
platform_go_ldflags["windows-amd64"]="-s -w"
declare -A platform_cgo_ldflags
platform_cgo_ldflags["linux-arm64"]="-static -s"
platform_cgo_ldflags["linux-amd64"]="-static -s"
platform_cgo_ldflags["darwin-arm64"]=""
platform_cgo_ldflags["darwin-amd64"]=""
# Stack smash protection lib is built into clang for unix platforms,
# but on Windows we need to pull in the separate ssp library
platform_cgo_ldflags["windows-amd64"]="-static-libgcc -static-libstdc++ -Wl,-Bstatic -lssp"В завершение нам нужна аналогичная информация для ассемблера. Он напрямую не используется процессом сборки Go, но может задействоваться некоторыми компиляторами в определённых ситуациях.
declare -A platform_as
platform_as["linux-arm64"]="aarch64-linux-musl-as"
platform_as["linux-amd64"]="x86_64-linux-musl-as"
platform_as["darwin-arm64"]="clang-19 --target=aarch64-darwin --sysroot=/opt/cross/darwin-sysroot -mmacosx-version-min=12.0"
platform_as["darwin-amd64"]="clang-19 --target=x86_64-darwin --sysroot=/opt/cross/darwin-sysroot -mmacosx-version-min=12.0"
platform_as["windows-amd64"]="x86_64-w64-mingw32-as"Имея всю эту информацию, мы можем написать команду go build следующим образом.
OS_ARCH_TUPLES="windows-amd64 linux-amd64 linux-arm64 darwin-amd64 darwin-arm64"
for tuple in $OS_ARCH_TUPLES; do
os=`echo $tuple | sed 's/-.*//'`
arch=`echo $tuple | sed 's/.*-//'`
o="out/doltgresql-$os-$arch"
mkdir -p "$o/bin"
mkdir -p "$o/licenses"
cp -r ./licenses "$o/licenses"
cp LICENSE "$o/licenses"
echo Building "$o/$bin"
obin="$bin"
if [ "$os" = windows ]; then
obin="$bin.exe"
fi
CGO_ENABLED=1 \
GOOS="$os" \
GOARCH="$arch" \
CC="${platform_cc[${tuple}]}" \
CXX="${platform_cxx[${tuple}]}" \
AS="${platform_as[${tuple}]}" \
CGO_LDFLAGS="${platform_cgo_ldflags[${tuple}]}" \
go build -buildvcs=false -trimpath \
-ldflags="${platform_go_ldflags[${tuple}]}" \
-tags icu_static -o "$o/bin/$obin" \
./cmd/doltgres
if [ "$os" = windows ]; then
(cd out && 7z a "doltgresql-$os-$arch.zip" "doltgresql-$os-$arch" && 7z a "doltgresql-$os-$arch.7z" "doltgresql-$os-$arch")
else
tar cf - -C out "doltgresql-$os-$arch" | pigz -9 > "out/doltgresql-$os-$arch.tar.gz"
fi
doneМы используем ещё несколько дополнительных флагов, которые не имеют отношения к cgo и могут быть уместны не для каждого бинарника:
-buildvcs=false— не включать git-хеш в результат сборки-trimpath— убирать полный путь к исходным файлам из информации о символах, делая его относительным к корню проекта-tags icu_static— используется в нашей библиотеке интеграции ICU и указывает процессу сборки пропускать некоторые файлы. Мы также поддерживаем динамическую линковку библиотеки ICU, но не используем её в продакшен-сборках.
Наконец, остаётся вопрос зависимостей — например, самих компиляторов. Мы устанавливаем их как системные зависимости в начале скрипта. Часть из них берётся из стандартных apt-репозиториев дистрибутива, а часть — это зависимости тулчейнов, которые нам приходится поддерживать самостоятельно для целевых архитектур.
apt-get update && apt-get install -y p7zip-full pigz curl xz-utils mingw-w64 clang-19
cd /
curl -o optcross.tar.xz https://dolthub-tools.s3.us-west-2.amazonaws.com/optcross/"$(uname -m)"-linux_20250327_0.0.3_trixie.tar.xz
tar Jxf optcross.tar.xz
curl -o icustatic.tar.xz https://dolthub-tools.s3.us-west-2.amazonaws.com/icustatic/20250327_0.0.3_trixie.tar.xz
tar Jxf icustatic.tar.xz
export PATH=/opt/cross/bin:"$PATH"Собираем всё вместе
Теперь, когда мы разобрались с отдельными частями, можно посмотреть на весь скрипт целиком — от нач��ла до конца.
#!/bin/bash
# build_binaries.sh
#
# Builds doltgres binaries with os-arch tuples provided as arguments, e.g. windows-amd64 linux-amd64
#
# This script is intended to be run in a docker environment via build.sh or
# build_all_binaries.sh. You can also use it to build locally, but it needs to run apt-get and other
# commands which modify the system.
#
# To build doltgres for the OS / arch of this machine, use build.sh.
set -e
set -o pipefail
apt-get update && apt-get install -y p7zip-full pigz curl xz-utils mingw-w64 clang-19
cd /
curl -o optcross.tar.xz https://dolthub-tools.s3.us-west-2.amazonaws.com/optcross/"$(uname -m)"-linux_20250327_0.0.3_trixie.tar.xz
tar Jxf optcross.tar.xz
curl -o icustatic.tar.xz https://dolthub-tools.s3.us-west-2.amazonaws.com/icustatic/20250327_0.0.3_trixie.tar.xz
tar Jxf icustatic.tar.xz
export PATH=/opt/cross/bin:"$PATH"
cd /src
OS_ARCH_TUPLES="$*"
declare -A platform_cc
platform_cc["linux-arm64"]="aarch64-linux-musl-gcc"
platform_cc["linux-amd64"]="x86_64-linux-musl-gcc"
platform_cc["darwin-arm64"]="clang-19 --target=aarch64-darwin --sysroot=/opt/cross/darwin-sysroot -mmacosx-version-min=12.0"
platform_cc["darwin-amd64"]="clang-19 --target=x86_64-darwin --sysroot=/opt/cross/darwin-sysroot -mmacosx-version-min=12.0"
platform_cc["windows-amd64"]="x86_64-w64-mingw32-gcc"
declare -A platform_cxx
platform_cxx["linux-arm64"]="aarch64-linux-musl-g++"
platform_cxx["linux-amd64"]="x86_64-linux-musl-g++"
platform_cxx["darwin-arm64"]="clang++-19 --target=aarch64-darwin --sysroot=/opt/cross/darwin-sysroot -mmacosx-version-min=12.0 --stdlib=libc++"
platform_cxx["darwin-amd64"]="clang++-19 --target=x86_64-darwin --sysroot=/opt/cross/darwin-sysroot -mmacosx-version-min=12.0 --stdlib=libc++"
platform_cxx["windows-amd64"]="x86_64-w64-mingw32-g++"
declare -A platform_as
platform_as["linux-arm64"]="aarch64-linux-musl-as"
platform_as["linux-amd64"]="x86_64-linux-musl-as"
platform_as["darwin-arm64"]="clang-19 --target=aarch64-darwin --sysroot=/opt/cross/darwin-sysroot -mmacosx-version-min=12.0"
platform_as["darwin-amd64"]="clang-19 --target=x86_64-darwin --sysroot=/opt/cross/darwin-sysroot -mmacosx-version-min=12.0"
platform_as["windows-amd64"]="x86_64-w64-mingw32-a"sgolan
declare -A platform_go_ldflags
platform_go_ldflags["linux-arm64"]="-linkmode external -s -w"
platform_go_ldflags["linux-amd64"]="-linkmode external -s -w"
platform_go_ldflags["darwin-arm64"]="-s -w -compressdwarf=false -extldflags -Wl,-platform_version,macos,12.0,14.4"
platform_go_ldflags["darwin-amd64"]="-s -w -compressdwarf=false -extldflags -Wl,-platform_version,macos,12.0,14.4"
platform_go_ldflags["windows-amd64"]="-s -w"
declare -A platform_cgo_ldflags
platform_cgo_ldflags["linux-arm64"]="-static -s"
platform_cgo_ldflags["linux-amd64"]="-static -s"
platform_cgo_ldflags["darwin-arm64"]=""
platform_cgo_ldflags["darwin-amd64"]=""
# Stack smash protection lib is built into clang for unix platforms,
# but on Windows we need to pull in the separate ssp library
platform_cgo_ldflags["windows-amd64"]="-static-libgcc -static-libstdc++ -Wl,-Bstatic -lssp"
for tuple in $OS_ARCH_TUPLES; do
os=`echo $tuple | sed 's/-.*//'`
arch=`echo $tuple | sed 's/.*-//'`
o="out/doltgresql-$os-$arch"
mkdir -p "$o/bin"
mkdir -p "$o/licenses"
cp -r ./licenses "$o/licenses"
cp LICENSE "$o/licenses"
echo Building "$o/$bin"
obin="$bin"
if [ "$os" = windows ]; then
obin="$bin.exe"
fi
CGO_ENABLED=1 \
GOOS="$os" \
GOARCH="$arch" \
CC="${platform_cc[${tuple}]}" \
CXX="${platform_cxx[${tuple}]}" \
AS="${platform_as[${tuple}]}" \
CGO_LDFLAGS="${platform_cgo_ldflags[${tuple}]}" \
go build -buildvcs=false -trimpath \
-ldflags="${platform_go_ldflags[${tuple}]}" \
-tags icu_static -o "$o/bin/$obin" \
./cmd/doltgres
if [ "$os" = windows ]; then
(cd out && 7z a "doltgresql-$os-$arch.zip" "doltgresql-$os-$arch" && 7z a "doltgresql-$os-$arch.7z" "doltgresql-$os-$arch")
else
tar cf - -C out "doltgresql-$os-$arch" | pigz -9 > "out/doltgresql-$os-$arch.tar.gz"
fi
doneЗапуск в Docker
Этот скрипт сборки рассчитан на запуск через Docker. По нашему опыту, Docker уже установлен почти у всех наших пользователей (к тому же именно так большинство из них разворачивают сервер базы данных). Мы делаем это без Dockerfile — просто напрямую говорим Docker выполнить скрипт.
#!/bin/bash
# build.sh
#
# This script builds doltgres from source for this machine's OS and architecture.
# Requires a locally running docker server.
set -e
set -o pipefail
script_dir=$(dirname "$0")
cd $script_dir/..
go_version=`go version | cut -d" " -f 3 | sed -e 's|go||' | sed -e 's|\.[0-9]$||'`
os=`go version | cut -d" " -f 4 | sed "s|/.*||"`
arch=`go version | cut -d" " -f 4 | sed "s|.*/||"`
echo "os is $os"
echo "arch is $arch"
echo "go version is $go_version"
# Run the build script in docker, using the current working directory as the docker src
# directory. Packaged binaries will be placed in out/
docker run --rm \
-v `pwd`:/src \
golang:"$go_version"-trixie \
/src/scripts/build_binaries.sh "$os-$arch"Эта команда docker запускает официальный контейнер Go и передаёт ему описанный выше скрипт для выполнения. Здесь есть несколько интересных моментов.
Нет Dockerfile. Мы просто используем заранее собранный образ и запускаем в нём bash-скрипт.
--rmговорит docker удалить хранилище контейнера после завершения работы.pwd:/srcмонтирует текущий каталог (корень проекта) в/srcвнутри контейнера.Путь
out/в скрипте сборки тоже считается относительно корня пакета и не удаляется при выходе из контейнера.
Плюсы и минусы этого подхода
Использование Docker для локальных сборок таким образом имеет несколько явных плюсов и минусов.
Плюсы:
У всех и так установлен Docker
Разработчикам «по случаю» не нужно ставить зависимости и настраивать окружение
Гарантированно получается тот же результат, который мы используем для продакшен-сборок
Минусы:
Повторные сборки медленнее, чем в варианте без Docker (хотя это легко исправить, убрав
--rm)Не подходит для старых версий Go, для которых нет образа на Dockerhub (сейчас там поддерживаются только 1.24 и 1.25, и это «движущаяся цель»)
Мы считаем, что это разумный набор компромиссов, чтобы дать пользователям возможность собирать проект из исходников на ещё не выпущенной ветке или в своём форке. Продвинутые пользователи смогут сами разобраться с требованиями к зависимостям и выставить нужные флаги сборки — так же, как это делают наши инженеры. А вот обычные пользователи всё равно с��огут полностью локально воспроизвести тот самый продакшен-процесс сборки, который мы используем для релизов, вообще без какой-либо подготовки.
Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!
