А что если… нужен старый софт, но при этом хост Linux свежий?
Как вариант, можно взять, да хоть на примере питона или php, нужный официальный образ в докере. Да и пользоваться.
Казалось бы, проблема решена. Но есть нюанс (с). Добро пожаловать под кат.
Зачем это всё
Однажды мне понадобился Python 3.7. Потом 2.7. Потом — внезапно — 3.4 и даже 2.6 под легаси, которое никто не собирается переписывать. И каждый раз вставали одни и те же вопросы:
-
Что делать, если не устраивают готовые образы старых версий?
-
Что, если старую версию надо запустить на хосте без контейнеризации?
-
Что, если бинарей нет, или они ссылаются на старую glibc, и собирать придётся из исходников?
Чем мне не нравятся готовые образы со старыми версиями:
-
раскатаны они, как правило, на старых Debian/Ubuntu, которые давно сами в EOL;
-
старый дистрибутив — это старые библиотеки, старые зависимости и старые уязвимости;
-
я уже говорил про glibc?
-
а совсем древние дистрибутивы ещё и на свежем ядре заводятся не всегда (vsyscall, загрузчик и прочие радости).
Объективно, тут надо выбрать компромисс:
-
Либо идём на сделку с дьяволом: изолируем рантайм, экономим время и пользуемся готовыми образами со старыми дистрами.
-
Либо идём на авантюру и собираем старый софт на современном Linux.
Я выбрал второй вариант. Дух авантюризма во мне пока не пропал.
Всё, о чём пойдёт речь, живёт в открытом репозитории моего pet-проекта: там сейчас GCC с 4-й по 16-ю версию и CPython от 2.6 до 3.15 и многое другое, и почти все собраны поверх Debian 13 trixie. Всё это я собирал сам, вечерами. Ниже — выжимка с граблями, на которые я наступил по дороге.
Часть первая. Компилятор
Скорее всего, вы скажете: есть же Makefile и INSTALL — вперёд и с песней!
Добро пожаловать в dependency hell, отвечу я вам.
Начнём с компилятора. В Debian 13 из коробки идёт GCC 14. Как и сами языки C/C++, компиляторы со временем становятся строже: то, что было warning-ом, становится error-ом, меняются стандарты по умолчанию. Возьмите Python 3.4 и соберите его GCC 14 — словите пачку ошибок.
«Но можно же написать лапшу из флагов!» — скажете вы. Можно, но работать это будет частично или номинально.
Поэтому я собрал старый GCC.
Лесенка версий
Новый GCC обычно без проблем собирает предыдущий мажор, а тот — свой предыдущий. Так что первоначальный цикл у меня был такой:
-
Беру системный GCC 14 в Debian 13 и собираю им GCC 13. Публикую образ
gcc:13. -
На всякий случай пересобираю GCC 13 уже им самим и публикую в тот же тег. Теперь в образе компилятор, который точно умеет собрать сам себя.
-
Повторяю вниз до GCC 4. Глубже копать не стал.
Это разовая история. Когда лесенка пройдена, каждый образ gcc:N пересобирается из самого себя: FROM gcc:10 AS builder собирает очередной минорный релиз 10.x. Поэтому в Dockerfile ниже стоит --disable-bootstrap: трёхстадийная самопроверка GCC уже не нужна, её роль сыграла лесенка.
Сокращённый Dockerfile для GCC 10 (полный — apps/gcc/10):
FROM ghcr.io/epicmorg/gcc:10 AS builderARG GCC_MAJOR_VERSION=10ARG GCC_MINOR_VERSION=5.0ENV GCC_VERSION=${GCC_MAJOR_VERSION}.${GCC_MINOR_VERSION}ENV GCC_INSTALL_DIR=${EMG_LOCAL_BASE_DIR}/gcc/${GCC_MAJOR_VERSION}RUN set -eux; \ aria2c -d /tmp "https://gcc.gnu.org/pub/gcc/releases/gcc-${GCC_VERSION}/gcc-${GCC\_VERSION}.tar.xz" && \ tar -xf /tmp/gcc-${GCC_VERSION}.tar.xz -C /tmp && \ cd /tmp/gcc-${GCC_VERSION} && ./contrib/download_prerequisites && \ mkdir -p /tmp/gcc-build && cd /tmp/gcc-build && \ /tmp/gcc-${GCC_VERSION}/configure \ --prefix=${GCC_INSTALL_DIR} \ --enable-languages=c,c++ \ --disable-multilib \ --disable-bootstrap \ --enable-checking=release \ --enable-multiarch \ --with-multiarch-defaults=x86_64-linux-gnu \ --enable-default-pie \ --enable-shared \ --enable-threads=posix \ --with-system-zlib && \ make -j$(nproc) && make installENV PATH="${GCC_INSTALL_DIR}/bin:${PATH}"# Проверка: компилятор видит multiarch-заголовки DebianRUN echo '#include <bits/wordsize.h>' | gcc -E - >/dev/nullFROM ghcr.io/epicmorg/debian:trixie-developCOPY --from=builder ${GCC_INSTALL_DIR} ${GCC_INSTALL_DIR}ENV PATH="${GCC_INSTALL_DIR}/bin:${PATH}"
Обратите внимание: у меня компилятор живёт в своём префиксе /usr/local/share/epicmorg/gcc/10, а не в /usr. Системный GCC 14 никуда не делся.
Грабли компилятора
-
Multiarch. Debian кладёт архитектурно-зависимые заголовки в
/usr/include/x86_64-linux-gnu. GCC, собранный без--enable-multiarch --with-multiarch-defaults=x86_64-linux-gnu, туда не заглядывает, и дальше вы получаетеgmp.h: No such file or directoryи прочие чудеса. -
Триплеты. GCC 6 и старше ломаются от явных
--build/--host/--target(This configuration is not supported…). Для них я триплеты не передаю, пусть configure определит сам. -
gcc≠cc. В образеgcc— это мой компилятор, аcc— системный Debian. Autoconf разных проектов ищет их в разном порядке: Python первым находитgcc, PHP —cc. Это работает, но не стоит гадать, кто реально собирал — проверяйтеsys.version. И не надо насильно ставитьCC=gcc11«для единообразия»: старый компилятор не умеет линковаться с системными библиотеками trixie, и у PHP 7.3 после этого падает вообще каждый тест configure. -
libstdc++ разных версий. Если библиотеку собрал GCC 14, а линкуете вы её GCC 11 — получите
undefined reference to …@GLIBCXX_3.4.30. Правило простое: кто собирал зависимость, тот собирает и всё, что с ней линкуется.
С компилятором разобрались. Переходим к практике.
Часть вторая. Старый софт и ловушка зависимостей
Итак, у меня есть GCC 10, который переварит исходники Python 3.7. Казалось бы, делай ./configure && make и открывай шампанское.
Но тут меня ждал финальный босс — системные библиотеки. А самый коварный из них — OpenSSL.
В Debian 13 идёт OpenSSL 3.x. API/ABI у него обновились, и Python 3.7 с ним не дружит. Если просто запустить сборку, питон радостно скомпилируется, а в конце скажет, что модуль ssl не собран. Питон без SSL в современных реалиях бесполезен: ни pip install, ни HTTPS-запроса.
Значит, OpenSSL тоже собираю сам. Под каждую версию Python своя связка — вот матрица из репозитория:
|
Python |
OpenSSL |
GCC |
pip / setuptools |
Заметки |
|---|---|---|---|---|
|
2.6 |
1.0.2u |
4.9 |
9.0.3 / 36.8.0 |
нет SNI |
|
2.7 |
1.1.1w |
11 |
20.3.4 / 44.1.1 |
|
|
3.0, 3.1 |
1.0.2u |
4.9 |
— |
нет pip, нет SNI |
|
3.2 |
1.0.2u |
4.9 |
7.1.2 / 29.0.1 |
|
|
3.3 |
1.0.2u |
7 |
10.0.1 / 39.2.0 |
|
|
3.4 |
1.0.2u |
7 |
19.1.1 / 43.0.0 |
|
|
3.5 |
1.1.1w |
7 |
20.3.4 / 50.3.2 |
|
|
3.6 |
1.1.1w |
9 |
21.3.1 / 59.6.0 |
|
|
3.7 |
1.1.1w |
10 |
24.0 / 68.0.0 |
|
|
3.8 |
1.1.1w |
10 |
25.0.1 / 75.3.2 |
|
|
3.9 |
1.1.1w |
10 |
26.0.1 / 80.9.0 |
|
|
3.10 – 3.12 |
3.5.x (LTS) |
13 |
актуальные |
|
|
3.13 – 3.14 |
3.5.x (LTS) |
14 |
актуальные |
|
|
3.15 |
3.5.x (LTS) |
15 |
актуальные |
pre-release |
OpenSSL 1.0.2 в EOL с конца 2019 года, 1.1.1 — с сентября 2023-го. Для новых питонов я беру 3.5 именно потому, что это LTS (до 2030 года). 3.6 и 4.0 сознательно пропускаю.
Куда класть старые библиотеки?
Вот тут главная архитектурная развилка.
Вариант 1: /usr/lib, /usr/local/lib или /etc/ld.so.conf.d. Идеальный способ выстрелить себе в ногу. Рано или поздно системная утилита подтянет вашу libssl вместо своей — и привет.
Вариант 2: LD_LIBRARY_PATH. Костыль, который мы все так любим. Он действует на процесс и на всех его потомков: питон вызвал curl или git — и они тоже пошли за вашим старым OpenSSL.
Вариант 3: статическая линковка. Обновить одну зависимость после очередной CVE — значит пересобрать всё, что с ней слинковано. Да и не всякая библиотека на это соглашается.
Я пошёл четвёртым путём. Каждая зависимость ставится в свой префикс /usr/local/share/epicmorg/<имя>/<версия>, а пути к ним вшиваются прямо в ELF через RPATH:
-Wl,-rpath,/usr/local/share/epicmorg/openssl/1.1.1/lib
Загрузчик (ld.so) прочитает путь из заголовка бинарника и найдёт библиотеку без всяких переменных окружения. Система остаётся чистой, а мой старый софт ходит со своими костылями.
Часть третья. RPATH, который оказался RUNPATH
Вот тут было больно, поэтому подробно.
В секции .dynamic ELF-файла путь поиска может лежать в одном из двух тегов:
|
|
|
|
|---|---|---|
|
Приоритет относительно |
выше |
ниже |
|
Действует на зависимости зависимостей |
да |
нет |
-Wl,-rpath — это просто «записать путь». А в какой тег его запишет линкер, определяет флаг --enable-new-dtags / --disable-new-dtags. И линкер в Debian по умолчанию пишет RUNPATH, хотя вы ничего такого не просили.
Чем это кончается? Классика: libicuuc нашла себя по RUNPATH, а libicudata, которую загружает уже сама libicuuc, — нет. RUNPATH не распространяется на транзитивные зависимости, и на этапе make вы получаете
libicudata.so.73: cannot open shared object file: No such file or directory
хотя -rpath указывает куда надо.
Лечение — явно рядом с каждым -rpath добавлять -Wl,--disable-new-dtags и проверять результат:
readelf -d python3.7 | grep -E 'RPATH|RUNPATH|NEEDED'
Отдельный сюрприз от Python. В distutils (а через него собираются модули вроде sqlite3) функция runtimelibrary_dir_option() сама подставляет -Wl,--enable-new-dtags,-R<dir>. То есть вы всё сделали правильно, а часть модулей всё равно получила RUNPATH. Приходится патчить:
RUN f=Lib/distutils/unixccompiler.py; \ if grep -q -- '--enable-new-dtags' "$f"; then \ sed -i 's/--enable-new-dtags/--disable-new-dtags/g' "$f"; \ fi
Часть четвёртая. Сборка Python 3.7
Пайплайн — multi-stage Dockerfile. Zlib и OpenSSL 1.1.1w уже лежат в базовом build-образе (debian:trixie-develop, от которого наследуется gcc:10), каждый в своём префиксе и с собственным RPATH. Сам OpenSSL собирается вот так:
CFLAGS="-Wno-error -fPIC" ./config \ zlib --with-zlib-include=${ZLIB_DIR}/include --with-zlib-lib=${ZLIB_DIR}/lib \ enable-ec_nistp_64_gcc_128 enable-ssl-trace threads shared \ -Wl,-rpath,${OPENSSL_111_LIB_DIR} \ -Wl,-rpath,${ZLIB_LIB_DIR} \ -Wl,--disable-new-dtags \ --release --prefix=${OPENSSL_111_DIR}
Обратите внимание: RPATH на zlib прописан и в самом libssl. Иначе «мой» OpenSSL внутри питона загрузит системный libz.
Сокращённый builder-слой Python (полный — apps/python/3.7):
FROM ghcr.io/epicmorg/gcc:10 AS builderENV PYTHON_VERSION=3.7ENV PYTHON_FULL_VERSION=${PYTHON_VERSION}.17ENV PYTHON_DIR=${EMG_LOCAL_BASE_DIR}/python/${PYTHON_VERSION}# Проверяем, что базовый образ действительно отдал нам OpenSSL и zlibRUN : "${OPENSSL_111_DIR:?empty}" "${OPENSSL_111_LIB_DIR:?empty}" "${ZLIB_LIB_DIR:?empty}"# Запрещаем apt тянуть дистрибутивный pythonCOPY etc/apt/preferences.d /etc/apt/preferences.dRUN apt-get update && apt-get install -y \ build-essential pkg-config libffi-dev libncurses-dev libreadline-dev \ libsqlite3-dev libgdbm-dev libbz2-dev libexpat1-dev liblzma-dev uuid-dev tk-dev# Убираем с дороги системные заголовки OpenSSL 3.xRUN mv /usr/include/openssl /usr/include/openssl.disabled# ... скачивание исходников и патч distutils из предыдущей части ...RUN set -eux; \ export LDFLAGS="-L${OPENSSL_111_LIB_DIR} -L${ZLIB_LIB_DIR} \ -Wl,-rpath,${OPENSSL_111_LIB_DIR} \ -Wl,-rpath,${PYTHON_DIR}/lib \ -Wl,-rpath,${ZLIB_LIB_DIR} \ -Wl,--disable-new-dtags" && \ export CPPFLAGS="-I${OPENSSL_111_INC_DIR} -I${ZLIB_INC_DIR}" && \ ./configure \ --prefix=${PYTHON_DIR} \ --enable-shared \ --enable-optimizations \ --with-lto \ --enable-option-checking=fatal \ --enable-loadable-sqlite-extensions \ --with-ensurepip=install \ --with-computed-gotos \ --with-openssl=${OPENSSL_111_DIR} && \ make -j$(nproc) && \ make altinstall && \ ${PYTHON_DIR}/bin/python${PYTHON_VERSION} -m pip install --upgrade pip setuptools
Пара моментов:
-
mv /usr/include/openssl …— страховка от самой подлой ошибки: собрались с заголовками системного OpenSSL 3.x, а слинковались с моим 1.1.1. Такое собирается, запускается и падает в проде. -
--enable-option-checking=fatal— если вы выдумали флаг configure (или он исчез в этой версии), сборка упадёт, а не проигнорирует его молча. Флаги вообще стоит сверять с./configure --helpконкретной версии: они появляются и исчезают в обе стороны.
Финальный слой — чистый debian:trixie, в который копируются префиксы Python, OpenSSL и zlib:
FROM ghcr.io/epicmorg/debian:trixieCOPY --from=builder ${PYTHON_DIR} ${PYTHON_DIR}COPY --from=builder ${OPENSSL_111_DIR} ${OPENSSL_111_DIR}COPY --from=builder ${ZLIB_DIR} ${ZLIB_DIR}# Для тех, кто будет собирать C-расширения против нашего OpenSSLENV CPPFLAGS="-I${OPENSSL_111_DIR}/include"ENV LDFLAGS="-L${OPENSSL_111_LIB_DIR} -Wl,-rpath,${OPENSSL_111_LIB_DIR} -Wl,--disable-new-dtags"RUN update-alternatives --install /usr/bin/python3 python3 ${PYTHON_DIR}/bin/python${PYTHON_VERSION} 1 && \ update-alternatives --install /usr/bin/pip3 pip3 ${PYTHON_DIR}/bin/pip${PYTHON_VERSION} 1
«А почему в финальном образе есть build-essential и -dev пакеты?» — спросит внимательный читатель. Потому что у части пакетов (psycopg2, pycurl, lxml, python-ldap…) под старые питоны нет колёс, и pip install будет компилировать их на месте. Сам Python этим компилятором не собирается — это делает builder.
Часть пятая. Доверяй, но проверяй
Представьте, ABI-дрейф не шумит. ваш образ, слинкованный не с той библиотекой, отлично собирается, проходит smoke-тест и падает уже на проде. Неприятно. Поэтому у меня сборку валит каждая из этих проверок — прямо в Dockerfile, и в builder-слое, и в финальном:
py=${PYTHON_DIR}/bin/python3.7# 1. С какими заголовками компилировали (0x1010… = OpenSSL 1.1.x)$py -c 'import _ssl; print(hex(_ssl.OPENSSL_VERSION_NUMBER))'# 2. Какая библиотека реально загрузилась$py -c 'import ssl; print(ssl.OPENSSL_VERSION)'# Если 1 и 2 расходятся — собрали с чужими заголовками# 3. _ssl и _hashlib линкуются из нашего префикса и без "not found"for m in _ssl _hashlib; do so=$($py -c "import $m; print($m.__file__)") ldd "$so" | grep -q 'not found' && exit 1 ldd "$so" | grep -q "${OPENSSL_111_LIB_DIR}/lib" || exit 1done# 4. Везде RPATH и нигде RUNPATHfor f in $py ${PYTHON_DIR}/lib/libpython*.so.* ${PYTHON_DIR}/lib/python3.7/lib-dynload/*.so; do readelf -d "$f" | grep -q '(RUNPATH)' && exit 1 readelf -d "$f" | grep -q '(RPATH)' || exit 1done# 5. В процессе ровно один libz, и он наш$py -c 'import ssl, zlib; print(open("/proc/self/maps").read())' | awk '/libz\.so/ {print $NF}' | sort -u
Обратите внимание: смотреть надо ldd модуля _ssl, а не самого python3. Бинарник интерпретатора с libssl не линкуется вообще, SSL приезжает с подгружаемым модулем.
И главное правило: две версии одной библиотеки в одном процессе — это всегда баг. Если ldd показывает и libssl.so.1.1, и libssl.so.3, ищите, кто слинковался не с тем префиксом.
Результат — docker run --rm -it ghcr.io/epicmorg/python:3.7:
$ head -1 /etc/os-releasePRETTY_NAME="Debian GNU/Linux 13 (trixie)"$ python3 -VPython 3.7.17$ python3 -c 'import sys; print(sys.version)'3.7.17 (default, Oct 25 2026, 09:09:48)[GCC 10.5.0]$ pip --versionpip 24.0 from /usr/local/share/epicmorg/python/3.7/lib/python3.7/site-packages/pip (python 3.7)$ python3 -c 'import ssl; print(ssl.OPENSSL_VERSION)'OpenSSL 1.1.1w 11 Sep 2023$ python3 -c 'import _ssl; print(hex(_ssl.OPENSSL_VERSION_NUMBER))'0x1010117f
Заголовки 1.1.1 (0x1010117f), библиотека 1.1.1w, собрано GCC 10.5. Теперь линковка модуля _ssl:
$ ldd $(python3 -c 'import _ssl; print(_ssl.__file__)')linux-vdso.so.1 (0x00007fce02884000)libssl.so.1.1 => /usr/local/share/epicmorg/openssl/1.1.1/lib/libssl.so.1.1 (0x00007fce0279e000)libcrypto.so.1.1 => /usr/local/share/epicmorg/openssl/1.1.1/lib/libcrypto.so.1.1 (0x00007fce02444000)libpython3.7m.so.1.0 => /usr/local/share/epicmorg/python/3.7/lib/libpython3.7m.so.1.0 (0x00007fce02114000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fce01f14000)libz.so.1 => /usr/local/share/epicmorg/zlib/1.3.1/lib/libz.so.1 (0x00007fce01ef5000)libcrypt.so.1 => /lib/x86_64-linux-gnu/libcrypt.so.1 (0x00007fce01eb9000)libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007fce01dc9000)/lib64/ld-linux-x86-64.so.2 (0x00007fce02886000)$ readelf -d $(python3 -c 'import _ssl; print(_ssl.__file__)') | grep -E 'RPATH|RUNPATH' 0x000000000000000f (RPATH) Library rpath: [/usr/local/share/epicmorg/openssl/1.1.1/lib:/usr/local/share/epicmorg/python/3.7/lib:/usr/local/share/epicmorg/zlib/1.3.1/lib]
libssl, libcrypto и даже libz — из моих префиксов, системные только libc, libm и libcrypt. В заголовке — именно RPATH, не RUNPATH. А вот сам интерпретатор, как и обещал, с OpenSSL не линкуется вовсе:
$ ldd $(command -v python3.7)linux-vdso.so.1 (0x00007f270d4c8000)libpython3.7m.so.1.0 => /usr/local/share/epicmorg/python/3.7/lib/libpython3.7m.so.1.0 (0x00007f270d18d000)libcrypt.so.1 => /lib/x86_64-linux-gnu/libcrypt.so.1 (0x00007f270d147000)libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f270d057000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f270ce63000)/lib64/ld-linux-x86-64.so.2 (0x00007f270d4ca000)
И вишенка — два OpenSSL в одном контейнере, которые друг другу не мешают:
$ /usr/bin/openssl versionOpenSSL 3.5.7 9 Jun 2026 (Library: OpenSSL 3.5.7 9 Jun 2026)$ openssl versionOpenSSL 1.1.1w 11 Sep 2023
Системный openssl и всё, что ставится через apt, работают с 3.5 из Debian. Мой питон — с 1.1.1w.
Бонус: музей (Python 2.6, 3.0–3.6)
Совсем старые версии на glibc 2.41 и ncurses 6 требуют точечных правок. Только на этапе сборки, рантайм не трогаю:
-
glibc 2.41 объявляет C23-функцию
sinpi(), а вModules/mathmodule.cесть своя staticsinpi. Ошибкаstatic declaration of 'sinpi' follows non-static declaration, модульmathмолча не собирается, а за ним ломаютсяrandom,tempfileиensurepip.
Лечится переименованием, как сделали в upstream начиная с 3.7:sh sed -i 's/\bsinpi\b/m_sinpi/g' Modules/mathmodule.c -
ncurses 6 делает
WINDOWнепрозрачным (NCURSES_OPAQUE=1), а старыйpy_curses.hлезет вwin->_flags. Бэкпортировал защиту из 3.6. -
Нет SNI в
sslу 2.6, 3.0 и 3.1. На PyPI по HTTPS без SNI не попасть, так чтоpip installиз интернета там не работает — только локальные wheel/sdist. У 3.0 и 3.1 pip вообще нет. -
Кое-что так и не собирается:
nis(в glibc больше нетrpc/rpc.h),dbmна 3.1–3.6,tkinterна 2.6/3.0,lzmaдо 3.3.
Не только Python: PHP и патч, которого не было
Всё описанное выше работает не только для питона. На том же конвейере я собираю PHP с 5.3 по 8.5. Базовый образ тот же epicmorg/gcc, и та же лесенка OpenSSL:
|
PHP |
OpenSSL |
Почему |
|---|---|---|
|
5.3–5.6 |
1.0.2u |
поддержка OpenSSL 1.1 появилась только в PHP 7.1 |
|
7.0–8.0 |
1.1.1w |
OpenSSL 3.x надёжно работает только с 8.1 |
|
8.1–8.5 |
3.5.x |
LTS-ветка OpenSSL, поддержка до 2030 года |
Те же приёмы: прячу системные заголовки OpenSSL от ./configure, собираю свои ICU, libcurl и libpq, если системные тянут OpenSSL 3, и прописываю RPATH. Подробности в README образа PHP.
А теперь история, ради которой всё это стоило затевать.
В октябре 2026 года вышли сразу две уязвимости в ядре PHP: CVE-2026-109618 и CVE-2026-109619, в народе PHP2Shell. Мир, мягко говоря, напрягся. 21 октября PHP выпустил исправленные 8.2–8.5. С EOL-ветками сложнее. Новых релизов 7.4, 8.0 и 8.1 не будет, их поддержка закончилась. Но разработчики PHP выложили для них минимальный фикс «Avoid state corruption via bailout» в отдельные ветки php-src. Тарбола не было, только коммит.
Дальше всё зависит от того, откуда у вас PHP.
Если от вендора, вы ждёте. На Debian и Ubuntu де-факто стандарт для PHP — репозиторий Ondřej Surý (sury.org). С июля 2024 года его автор геоблокирует Россию на packages.sury.org (issue #2155), поэтому из РФ патчи приходилось ждать вдвойне. А для 7.4–8.1 этот фикс в репозитории так и не появился. Кто сидит на этих версиях, тот остался с дырой.
Если сборка своя, для поддерживаемых веток достаточно поднять номер версии и контрольную сумму:
ENV PHP_FULL_VERSION=${PHP_VERSION}.27# Upstream sha256 for php-8.4.27.tar.xz - bump together with PHP_FULL_VERSION.ARG PHP_SHA256=dcf93923b0e38e0d5252b25b796520b5a47761a08b3488b6d0b5d1115764d3f0
Для EOL-веток я кладу коммит из php-src рядом с Dockerfile и накатываю его на исходники последнего релиза. Любой конфликт патча валит сборку:
# PHP_PATCHES: applied in name order, any reject is fatal.COPY tmp/patches/ /tmp/php-patches/RUN set -eux; \ for p in /tmp/php-patches/*.patch; do \ patch -d ${PHP_SRC_DIR} -p1 --forward --no-backup-if-mismatch < "$p"; \ done; \ if find ${PHP_SRC_DIR} -name "*.rej" | grep -q .; then echo "FATAL: patch rejects" >&2; exit 1; fi; \ rm -rf /tmp/php-patches
Теги остаются прежними (7.4.33 остается7.4.33), а что именно наложено, видно в переменной окружения и в метке образа:
ENV PHP_PATCHES="CVE-2026-109618 CVE-2026-109619"LABEL org.epicmorg.php.patches="CVE-2026-109618 CVE-2026-109619"
Весь фикс — один коммит в тот же день, когда вышло объявление PHP: семь веток, от 7.4 до 8.5. Дальше CI пересобрал образы, а на проде оставалось только перекатить пару-тройку контейнеров. Никакого ожидания вендора.
Для сравнения — что внутри официальных образов тех же версий на Docker Hub (замер на 30.10.2026):
|
Образ |
Последняя сборка |
База |
OpenSSL |
|---|---|---|---|
|
|
15.11.2022 |
Debian 11.5 |
1.1.1n (март 2022) |
|
|
22.07.2023 |
Debian 12.1 |
3.0.9 (май 2023) |
|
|
25.10.2026 |
Debian 13 |
1.1.1w + патч PHP2Shell |
Официальные образы EOL-версий просто заморожены в день окончания поддержки: вместе с базой, системными пакетами и всеми уязвимостями, найденными с тех пор.
Проверка тезиса: переезд на Astra Linux
Через всю статью проходит одна мысль: если рантайм сам носит свой компилятор, OpenSSL и прочие зависимости, ему почти всё равно, на каком дистрибутиве жить. Лишь бы glibc была не старее нужной. Звучит красиво, поэтому я решил проверить это на практике.
У меня есть собственные неофициальные Docker-образы Astra Linux 1.7 и 1.8. Я взял develop-образ Astra 1.8.6 (glibc 2.36, база Debian 12) и перенёс на него те же цепочки, что описаны выше: GCC 10 и 14, Python 3.7 и 3.14, PHP 7.4 (с патчем PHP2Shell) и PHP 8.5. Пушить ничего не стал, всё собирал локально.
Результат:
|
Цепочка |
Итог |
Что пришлось поменять, кроме |
|---|---|---|
|
GCC 10.5 и 14.4 |
✅ |
ничего: собрались прямо системным GCC 12 из Astra, лесенка не понадобилась |
|
Python 3.7 и 3.14 |
✅ |
ничего |
|
PHP 7.4 |
✅ |
системная libxml2 в Astra собрана с ICU 72, поэтому intl тоже пришлось перевести на ICU 72 |
|
PHP 8.5 |
✅ |
явно добавил |
Все проверки из пятой части прошли без поблажек: каждая библиотека грузится из своего префикса, в процессе ровно один OpenSSL, HTTPS работает.
Самое ценное вылезло с PHP 8.5. Системная libhashkit (её тянет расширение memcached) зависит от libcrypto и при этом несёт RUNPATH — помните третью часть? RPATH нашего модуля на неё не действует, и она подтягивала системный OpenSSL. На Debian это маскировалось случайностью: libsasl2 успевала загрузить наш libcrypto раньше. В Astra libsasl2 собрана без libcrypto, и проблема проявилась сразу. Исправление — одна строка, и оно нужно в том числе для Debian. Так что Astra заодно нашла мне баг, который на «родной» базе просто ещё не выстрелил.
Гипотеза подтвердилась: четыре рантайма переехали на другой дистрибутив с двумя точечными правками, и обе касались системных библиотек, а не самого подхода.
Честные оговорки:
-
Это develop-образ в Docker, а не настоящий сертифицированный хост. Как поведёт себя замкнутая программная среда (проверка цифровых подписей бинарников), я не проверял. А мои бинарники ничем не подписаны.
-
Свой OpenSSL рядом с системным не входит в сертифицированную конфигурацию Astra. Если вам важен именно сертификат, этот подход вам вряд ли подойдёт.
-
Astra 1.7 (glibc 2.28) я пока не трогал. Там, скорее всего, понадобится та самая лесенка компиляторов.
Выводы
Стоит ли оно того? Если нужно быстро поднять проект «на отвали» — берите готовый образ и не мучайтесь.
Но если вы строите инфраструктуру, которая должна жить долго и быть предсказуемой (насколько это возможно со старым софтом), изоляция зависимостей и сборка из исходников — ваш путь.
Что я получил:
-
Современная база. Свежее ядро, Debian 13, glibc 2.41. Никакой эмуляции старых syscall-ов.
-
Чистая система. В
/usr/libиld.so.confя ничего не подкладываю. Системный софт работает с системным OpenSSL 3.x, мой Python 3.7 — со своим 1.1.1w. -
Контроль. Нашёл уязвимость — патчу исходники и пересобираю в своём пайплайне, не дожидаясь, пока кто-то обновит образ на Docker Hub (спойлер: для EOL-софта никто не обновит). История с PHP2Shell это наглядно показала: свой конвейер закрыл дыру за вечер, а пользователи EOL-веток из внешних репозиториев остались без патча.
-
Независимость. Внешний репозиторий может отстать, забросить старую ветку или просто закрыть доступ для вашей страны. Исходники и контрольные суммы, которые лежат у вас, никуда не денутся.
-
Один парк вместо зоопарка. Python с Docker Hub, PHP из sury, Node от ещё одного вендора — это разные базовые образы (Alpine, Bullseye, Bookworm, Ubuntu), разные libc и разные версии OpenSSL. Каждый тащит свои слои и свой набор уязвимостей. У меня одна база,
epicmorg/debian:trixie, под всеми рантаймами. Обновил базу — обновились все. -
Переносимость. Тот же рецепт почти без правок переехал на Astra Linux. Рантайм зависит от моего конвейера, а не от конкретного дистрибутива.
-
Проверяемость. Если линковка поехала, образ просто не соберётся.
Сразу оговорюсь: я не продаю свои образы как решение, а использую как наглядный пример. Они у меня пока не такие лёгкие, как вендорские: в них стоит чуть больше софта и зависимостей для более широкого круга дочерних образов. Но это частности. В статье я рассказываю способ. Подойдут мои образы — берите. Не подойдут — соберите у себя по тем же рецептам, все Dockerfile открыты.
Да, на старте придётся потратить время на матрицу совместимости и на грабли. Это пара вечеров на каждый рантайм. Зато потом каждое обновление и каждый срочный патч — это поменять номер версии и дождаться CI. Один раз выстроив этот конвейер, вы получаете железобетонный и переносимый рантайм и экономите себе на перспективу необъятное количество времени.
Не бойтесь заглядывать под капот линкера. Иногда старый добрый RPATH (именно RPATH, а не RUNPATH!) спасает нервы лучше любых контейнеров.
Все Dockerfile из статьи — в EpicMorg/docker, образы на Docker Hub, quay.io и ghcr.io.
ссылка на оригинал статьи https://habr.com/ru/articles/1101800/