Linux, мы не договаривали: glibc, OpenSSL и «просто скопируй бинарник»

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:

Дистрибутивglibc
Debian 11 (Bullseye)2.31
Debian 12 (Bookworm)2.36
Ubuntu 22.042.35
Ubuntu 24.042.39
RHEL 82.28
RHEL 92.34

Разброс — от 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-системах:

  1. Go без CGo (CGO_ENABLED=0) — полностью статический бинарник, нет зависимостей от glibc. Для DNS используется чистый Go-резолвер. Для TLS — crypto/tls без OpenSSL.

  2. Rust с musl (--target x86_64-unknown-linux-musl) — статический бинарник. Для TLS — rustls (чистый Rust, без OpenSSL).

  3. Zig (zig build-exe -target x86_64-linux-musl) — Zig из коробки умеет кросс-компиляцию под musl с любой платформы. Никаких дополнительных тулчейнов.

  4. Nim — из коробки компилировать под musl не умеет, но можно организовать такую сборку с помощью сложных заклинаний в .nimble-файле. Заодно и BearSSL статически слинковать.

  5. Для других языков — собирайте в Docker-контейнере на базе старейшего целевого дистрибутива или используйте musl-based сборку.

Общий принцип: если вам нужен портативный бинарник на Linux — избегайте динамической линковки с glibc и OpenSSL любой ценой. Это не идеологическое утверждение, а практический вывод.

Заключение

Я допускаю, что со временем проникнусь красотой архитектуры Linux. Пойму, почему динамическая линковка с glibc — это правильно, почему версионирование символов — элегантное решение, почему пакетный менеджер — единственный правильный способ распространения программ.

Но пока — каждый раз, когда я компилирую бинарник на одной Linux-машине, копирую его на другую и получаю GLIBC_2.34 not found, я вспоминаю Windows, где calc.exe от Windows XP запускается на Windows 11.

И немножко скучаю.

Источники

Предыдущий
Наверх