В своей работе я не раз сталкивался с тем, что, собрав Go/Rust-программу на своей рабочей машине и скопировав её на другую, с более старой версией дистрибутива, есть большой шанс того, что она при запуске упадёт с ошибкой вида /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.38' not found. Обычно разбираться всегда было некогда, и я просто пересобирал свою программу на нужной версии дистрибутива, но вот тут стало интересно – и я пошёл посмотреть, как скомпилить свою Rust’овую программу статически с glibc, чтобы не иметь таких проблем. В результате узнал для себя что-то новое и делюсь своими находками.

glibc

Первая мысль была довольно предсказуемой и понятной: “пойду-ка посмотрю, как собрать статически Rust’овый бинарь с glibc” – и, на самом деле, особых проблем с этим нет. Надо всего лишь сделать вот так:

RUSTFLAGS='-C target-feature=+crt-static' cargo build --release --target x86_64-unknown-linux-gnu

– и оно работает, программа запускается без проблем!

Но, если покопать эту тему чуть более подробно, то выясняется, что решение это на самом деле – так себе. Всё дело в том, что glibc так устроена, что она в принципе не особо предназначена для статической линковки: из-за NSS, iconv(3) и пр. она полагается на dlopen(3), и определённые функции стандартной библиотеки могут стриггерить, скажем, загрузку NSS-модуля, который является динамически разделяемой библиотекой, к тому же динамически слинкованной с glibc, что в свою очередь может привести к ситуации, когда у нас в адресном пространстве приложения будут загружены две glibc: статическая и динамически подгруженная через зависимость NSS-модуля, что в итоге может привести к разным интересным последствиям (к примеру, как они будут делить буферы stdout?). Внутри неё на самом деле есть различные подпорки, чтобы статическая сборка всё-таки нормально работала в большинстве случаев, но вот только гарантий, что она будет работать во всех возможных сценариях – нет.

В итоге, большинство людей сходятся в том, что статическую сборку с glibc лучше не использовать, т. к. мы тут идём против её дизайна и рискуем получить неожиданные последствия от таких действий. И самый оптимальный вариант тут – использовать для сборки Docker-контейнер с каким-нибудь заведомо не самым свежим LTS-дистрибутивом – и линковаться против glibc его версии.

Но также есть и другие варианты.

musl

Напомню, что Linux – это только ядро, а не операционная система. Интерфейсом к ядру являются системные вызовы, и поэтому ничто не мешает нам вместо glibc использовать что-то другое. Наиболее распространённом вариантом в данном случае является musl.

Честно говоря, я musl до этого момента ни разу не пользовался, т. к. мне всегда казалось довольно странным использовать что-то нестандартное вместо glibc – обязательно ведь где-нибудь что-нибудь будет работать по-другому, и можно нарваться на какие-нибудь неприятные сюрпризы в самый неподходящий на то момент. Поэтому всегда считал, что её использование имеет смысл разве что в embedded, либо где-нибудь вроде Alpine, где нам по какой-то причине хочется получить максимально компактный образ.

Но если смотреть с позиции статической линковки, использование musl вполне имеет смысл, т. к. для неё, в отличие от glibc, статическая линковка является абсолютно стандартным вариантом использования.

Поэтому (в случае Rust) выполняем:

rustup target add x86_64-unknown-linux-musl
cargo build --target x86_64-unknown-linux-musl

– и получаем то, что нам нужно. Правда, свои “но” тут тоже есть…

musl ставит своей целью быть компактной и простой в реализации, и, как это часто бывает, когда кто-то ставит себе такую цель, то порой оказывается, что некоторые вещи в принципе не должны быть простыми, и слишком простая их реализация приводит к проблемам.

В результате чего нередки ситуации, когда наша программа, которая без проблем работала с glibc, вдруг перестаёт работать из-за того, что в musl DNS resolver не поддерживает ответы с большим количеством записей (на самом деле уже исправлено), либо работает в десятки (!) раз медленнее – раз, два, три (как правило, в случае интенсивной аллокации памяти из нескольких потоков одновременно, но также это может быть связано и с тем, что “раздутая” glibc использует AVX и прочие инструкции для оптимизации своих функций, а “простая и компактная” musl – нет), либо у вас какая-то специфическая конфигурация, в которой проявляются отличия в реализации glibc и musl. Ну и, понятное дело, musl не подойдет, если вам нужен NSS (к примеру, в случае с LDAP).

В итоге, я бы сказал, что если у вас в качестве приложения довольно простая command line-утилита, то скорее всего проблем не будет – и для простоты можно статически линковаться с musl, но если у вас какой-то навороченный/высоконагруженный сервис, то либо стоит быть готовым к сюрпризам, либо использовать более сложную конфигурацию вроде musl + mimalloc/jemalloc в качестве аллокатора.

Eyra

Если говорить о Rust, то на самом деле есть ещё один интересный вариант – Eyra. Данный проект ставит своей целью реализовать все функции стандартной библиотеки на Rust и линковаться в процессе сборки с ними.

Этот вариант я, честно говоря, даже не пробовал (уж больно молодой проект), но идея интересная и многообещающая.

Интересный факт

Я этого не знал, но, оказывается, ABI системных вызовов стабильный только у Linux. В Windows и MacOS он может меняться абсолютно непредсказуемым образом (даже в минорных версиях), и стабильным является только API стандартной библиотеки (ntdll.dll в Windows и libSystem.dylib в MacOS). И там такой опции у нас вовсе нет.

Go даже поначалу пытался идти тут против течения, но в итоге сдался.