Команда 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 или .sx C-компилятору, а файлы .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. Подписывайтесь, чтобы быть в курсе и ничего не упустить!