Linux, мы не договаривали: glibc, OpenSSL и «просто скопируй бинарник»
Большую часть программистской жизни — а это больше тридцати лет — я программировал под Windows. Всякое бывало: COM, MFC, .NET, нативный C++, Delphi. Привык к определённым вещам. Скомпилировал программу — получил .exe. Скопировал на другую машину — работает. Нужна библиотека? Положи рядом .dll или слинкуй статически. Система предсказуема.
Но вот уже несколько лет к целевым платформам добавились Linux и macOS. И некоторые особенности Linux вызывают у меня искреннее недоумение. Допускаю, что причина этого недоумения в том, что я недостаточно глубоко изучил архитектуру программных вызовов в Linux, и когда всё-таки сделаю это, проникнусь красотой идеи и изменю своё мнение. Но пока этого не произошло — поделюсь своими страданиями.
Страдание первое: скачать файл по HTTPS
В программе мне понадобилось скачать файл с сайта по HTTPS. Казалось бы, чего проще?
import std/httpclient
let client = newHttpClient()
try:
client.downloadFile("https://example.com/file.zip", "local_file.zip")
echo "Загрузка завершена"
finally:
client.close()
Скомпилировал с параметром -d:ssl — программа работает. На моём Debian 12 отрабатывает мгновенно, скачивает файл. Красота.
А потом я скопировал бинарник на машину с Debian 11.
Файл не загружается. Программа требует libssl.so — динамическую библиотеку OpenSSL. Да не любую, а определённой версии. В Debian 12 стоит OpenSSL 3.0, а в Debian 11 — OpenSSL 1.1.1. Мажорная версия другая, .so-файлы называются по-разному, ABI несовместим. Бинарник, прекрасно работающий на одной системе, на соседней превращается в бесполезный набор байтов.
Здесь стоит пояснить, что такое ABI.
API (Application Programming Interface) — это контракт на уровне исходного кода: имена функций, типы параметров, структура заголовочных файлов. Если API совместим, ваш код скомпилируется.
ABI (Application Binary Interface) — контракт на уровне скомпилированного кода: как аргументы передаются через регистры и стек, какого размера структуры, как устроена таблица виртуальных функций, какие версии символов записаны в бинарнике.Если ABI совместим, ваш бинарник запустится.
API может оставаться прежним, а ABI — сломаться: достаточно добавить поле в структуру, изменить тип параметра сintнаlongили пересобрать библиотеку с другими флагами. Именно поэтому OpenSSL 1.1 и OpenSSL 3.0 предоставляют похожий API, но совершенно несовместимый ABI — бинарник, слинкованный с одним, не загрузит другой.
Ладно, я понимаю, что бинарник динамически слинкован и требует конкретную версию libssl.so. Но давайте посмотрим на ситуацию со стороны. Я написал программу, которая делает одну вещь — скачивает файл. Для этого ей нужен TLS. И из-за этой единственной потребности мой бинарник привязан к конкретной версии конкретного дистрибутива.
Как обеспечить независимость? Стандартными средствами языка — никак. Нужна статическая линковка с какой-нибудь альтернативной реализацией SSL: BearSSL, BoringSSL, LibreSSL. Бинарник распухает от кода, который, в общем-то, нужен для одного вызова. Приходится шаманить со сборкой проекта — банальной командой не обойтись. И, естественно, сопровождение такой программы и её перенос на другие машины становятся сложнее.
Страдание второе: glibc
С этой проблемой я столкнулся даже не при написании программы, а при использовании стандартного инструмента — choosenim (менеджер версий для компилятора Nim). Запускаю на macOS — работает. На Windows — работает. На Debian 12 — работает. Запускаю на Debian 11 — вылетает с ошибкой:
/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found
Консольная утилита. Командная строка. Никакой графики, никаких экзотических системных вызовов. Какие зависимости от конкретной версии glibc?
И вот тут начинается самое интересное.
Что такое glibc и почему она везде
glibc — GNU C Library — это реализация стандартной библиотеки языка C для Linux. Её начал писать Ролан Макграт в 1987 году, а с 1990-х разработкой руководил Ульрих Дреппер. glibc предоставляет функции, без которых не работает практически ни одна программа: malloc, printf, open, read, write, pthread_create, обёртки для системных вызовов ядра, реализацию DNS-резолвера, математические функции, обработку локалей.
Почти любая программа на Linux — будь она написана на C, C++, Go, Rust, Nim, Python — так или иначе использует glibc. Даже Go, который гордится статической линковкой, в некоторых случаях линкуется с glibc (например, при использовании net или os/user с CGo).
glibc — не просто библиотека. Это прослойка между вашей программой и ядром Linux. Она оборачивает системные вызовы, предоставляет POSIX-совместимый интерфейс и реализует стандарт языка C. Без неё (или её аналога) программа не может ни выделить память, ни открыть файл, ни создать поток.
Проблема: версионирование символов
glibc использует механизм, называемый symbol versioning — версионирование символов. Каждая функция в glibc помечена версией. Когда вы компилируете программу, линковщик записывает в бинарник не просто memcpy, а memcpy@GLIBC_2.14 — конкретную версию символа.
Это сделано с благими намерениями. Если glibc меняет поведение или сигнатуру функции, старая версия остаётся доступной под старым именем. Новые программы получают новую версию, старые продолжают работать. Обратная совместимость.
Но есть нюанс: прямой совместимости нет. Если вы скомпилировали программу на системе с glibc 2.34 и какой-то символ получил версию GLIBC_2.34, программа не запустится на системе с glibc 2.31. Динамический линковщик видит требуемую версию, не находит её в установленной glibc и отказывается загружать бинарник.
Вот что произошло с choosenim. Программа была скомпилирована на машине с glibc 2.34 (Debian 12). Какая-то из использованных функций получила свежую версионную метку. На Debian 11 стоит glibc 2.31. Три минорных версии разницы — и бинарник мёртв.
Почему нельзя просто обновить glibc?
На Windows вы можете обновить Visual C++ Runtime независимо от ОС — скачав vcredist. На Linux glibc — часть дистрибутива. Она намертво вплетена в систему. Обновление glibc вне пакетного менеджера — это путь к «кирпичу»: если что-то пойдёт не так, не запустится буквально ни одна программа, включая ls, bash и сам пакетный менеджер.
Каждый дистрибутив привязан к конкретной версии glibc:
Разброс — от 2.28 до 2.39. Разница в шесть лет. Бинарник, скомпилированный на Ubuntu 24.04, не запустится ни на одной RHEL 8. И это не баг — это дизайн.
А на Windows так не бывает?
Бывает, но реже и мягче. На Windows стандартная библиотека C (UCRT — Universal C Runtime) поставляется с ОС начиная с Windows 10. Для старых версий она устанавливается через Windows Update. Но главное — Windows активно поощряет статическую линковку CRT. Флаг /MT в MSVC линкует CRT статически, и бинарник не зависит ни от чего, кроме kernel32.dll (который стабилен десятилетиями).
Windows также решает проблему сосуществования разных версий через Side-by-Side Assemblies (SxS) — механизм, позволяющий нескольким версиям одной и той же DLL мирно сосуществовать в системе. Вы можете поставить Visual C++ Redistributable 2015, 2017, 2019 и 2022 одновременно — каждая программа возьмёт нужную версию.
В Linux эквивалента нет. Две версии libc.so.6 в одной системе — это рецепт катастрофы.
OpenSSL: та же история, но хуже
OpenSSL добавляет к проблеме glibc ещё один слой. Если glibc меняется медленно и предсказуемо, то OpenSSL пережил переход с 1.0.x на 1.1.x и затем на 3.x с полной сменой ABI каждый раз. Имена .so-файлов меняются: libssl.so.1.0.0, libssl.so.1.1, libssl.so.3. Программа, слинкованная с одной версией, не найдёт другую.
Это затрагивает не только экзотические языки. Python на Linux по умолчанию использует системный OpenSSL для модуля ssl. Обновление OpenSSL может сломать работающие Python-приложения. Ruby, PHP, Node.js — все зависят от системного OpenSSL.
Казалось бы, TLS — это базовая функция. Скачать файл по HTTPS, подключиться к базе данных, отправить письмо. В 2025 году это потребность уровня «открыть файл». Но в Linux эта потребность привязывает ваш бинарник к конкретному дистрибутиву и конкретной версии.
Решения (каждое со своими компромиссами)
Статическая линковка
Самый прямолинейный подход: включить всё в бинарник. Никаких внешних зависимостей.
Для glibc это официально не поддерживается. glibc использует dlopen() внутри себя (для NSS — Name Service Switch, загрузки модулей DNS, LDAP, NIS). Статически слинкованная glibc может вести себя непредсказуемо при разрешении имён.
musl — альтернативная libc
musl — минималистичная реализация стандартной библиотеки C, спроектированная для статической линковки. Бинарник, слинкованный с musl статически, не зависит ни от glibc, ни от чего-либо в системе. Дистрибутив Alpine Linux целиком построен на musl.
Rust поддерживает сборку под musl через таргет x86_64-unknown-linux-musl. Go может использовать musl через CGO_ENABLED=0 (чистый Go) или линковку с musl.
Компромиссы: musl медленнее glibc на некоторых нагрузках (особенно malloc), имеет тонкие отличия в поведении (обработка локалей, DNS), и не все библиотеки тестируются с musl.
Собрать на самой старой целевой системе
Практический совет от разработчиков: компилируйте на самой старой версии дистрибутива, которую хотите поддерживать. Бинарник, собранный на RHEL 8 с glibc 2.28, будет работать на всём новее. Именно так работают CI/CD пайплайны многих проектов — образы manylinux для Python, например, собираются на CentOS 7.
Но это значит, что ваша среда сборки — музейный экспонат. Вы не можете использовать новые возможности компилятора или системы, потому что привязаны к древнему тулчейну.
Контейнеры
Docker решает проблему радикально: упаковать программу вместе со всей операционной системой. Бинарник + glibc + OpenSSL + всё остальное — в одном образе. Работает одинаково везде, где есть Docker.
Это работает. Но контейнер для утилиты командной строки, которая скачивает файл, — это как привезти мебельный фургон, чтобы перенести табуретку.
AppImage, Flatpak, Snap
Эти форматы пытаются решить проблему переносимости на уровне пользовательских приложений. Каждый формат упаковывает приложение вместе с его зависимостями. Но это решения для десктопных приложений, а не для серверных утилит и библиотек.
Философия: почему Linux «так»
Корень проблемы — философское расхождение между Windows и Linux в вопросе ответственности за зависимости.
Windows: приложение несёт свои зависимости с собой. Инсталлятор ставит нужные библиотеки. Статическая линковка поощряется. Система обеспечивает стабильный ABI ядра (Win32 API не менялся десятилетиями).
Linux: дистрибутив управляет зависимостями. Пакетный менеджер гарантирует, что все установленные библиотеки совместимы. Программа собирается для конкретного дистрибутива и распространяется через его репозиторий. Динамическая линковка — идиоматический подход: обновление одной библиотеки обновляет все программы, её использующие.
Эта модель прекрасно работает внутри одного дистрибутива. apt install curl — и вы получаете curl, слинкованный с правильной версией OpenSSL, которая слинкована с правильной glibc. Пакетный менеджер следит за всем графом зависимостей. Обновили OpenSSL для закрытия уязвимости — все программы, использующие OpenSSL, автоматически защищены. Красота.
Проблема начинается, когда вы выходите за пределы этой модели. Когда вы хотите скомпилировать бинарник и просто скопировать его на другую машину. Linux говорит: «Это не так работает. Используй пакетный менеджер.» Windows же говорит: «Вот тебе .exe, делай что хочешь.»
Ирония
Ирония в том, что Go и Rust — два языка, которые больше всего продвигают идею «один бинарник, никаких зависимостей» — родились в мире Unix/Linux. Go по умолчанию статически линкуется (если не используется CGo). Rust предоставляет таргет musl. Оба языка обходят проблему glibc, потому что их создатели слишком хорошо знакомы с этой болью.
Именно из-за динамической линковки Docker стал настолько популярен. Значительная часть мотивации контейнеризации — не изоляция процессов, не безопасность, не оркестрация. Это «мой бинарник работает в моём окружении, и я упакую это окружение целиком, потому что воспроизвести его на целевой машине невозможно без боли».
Что делать прямо сейчас
Если вы пишете утилиту, которая должна работать на разных Linux-системах:
- Go без CGo (
CGO_ENABLED=0) — полностью статический бинарник, нет зависимостей от glibc. Для DNS используется чистый Go-резолвер. Для TLS —crypto/tlsбез OpenSSL. - Rust с musl (
--target x86_64-unknown-linux-musl) — статический бинарник. Для TLS —rustls(чистый Rust, без OpenSSL). - Zig (
zig build-exe -target x86_64-linux-musl) — Zig из коробки умеет кросс-компиляцию под musl с любой платформы. Никаких дополнительных тулчейнов. - Nim — из коробки компилировать под musl не умеет, но можно организовать такую сборку с помощью сложных заклинаний в .nimble-файле. Заодно и BearSSL статически слинковать.
- Для других языков — собирайте в Docker-контейнере на базе старейшего целевого дистрибутива или используйте musl-based сборку.
Общий принцип: если вам нужен портативный бинарник на Linux — избегайте динамической линковки с glibc и OpenSSL любой ценой. Это не идеологическое утверждение, а практический вывод.
Заключение
Я допускаю, что со временем проникнусь красотой архитектуры Linux. Пойму, почему динамическая линковка с glibc — это правильно, почему версионирование символов — элегантное решение, почему пакетный менеджер — единственный правильный способ распространения программ.
Но пока — каждый раз, когда я компилирую бинарник на одной Linux-машине, копирую его на другую и получаю GLIBC_2.34 not found, я вспоминаю Windows, где calc.exe от Windows XP запускается на Windows 11.
Источники
- The GNU C Library (glibc) — официальный сайт
- musl libc — альтернативная реализация libc
- Symbol Versioning (Ulrich Drepper) — описание механизма
- Rust Platform Support: musl — сборка под musl
- Go: Static Binaries with CGO_ENABLED=0 — статическая линковка в Go
- Zig Cross-Compilation — кросс-компиляция в Zig
- manylinux (PEP 600) — спецификация для портативных Python-пакетов
- Side-by-Side Assemblies (Microsoft) — механизм SxS в Windows