А что если… нужен старый софт, но при этом хост 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 обычно без проблем собирает предыдущий мажор, а тот — свой предыдущий. Так что первоначальный цикл у меня был такой:

  1. Беру системный GCC 14 в Debian 13 и собираю им GCC 13. Публикую образ gcc:13.

  2. На всякий случай пересобираю GCC 13 уже им самим и публикую в тот же тег. Теперь в образе компилятор, который точно умеет собрать сам себя.

  3. Повторяю вниз до 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 builder

ARG GCC_MAJOR_VERSION=10
ARG GCC_MINOR_VERSION=5.0
ENV 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 install

ENV PATH="${GCC_INSTALL_DIR}/bin:${PATH}"

# Проверка: компилятор видит multiarch-заголовки Debian
RUN echo '#include <bits/wordsize.h>' | gcc -E - >/dev/null

FROM ghcr.io/epicmorg/debian:trixie-develop
COPY --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.2 u

4.9

9.0.3 / 36.8.0

нет SNI

2.7

1.1.1 w

11

20.3.4 / 44.1.1

3.0, 3.1

1.0.2 u

4.9

—

нет pip, нет SNI

3.2

1.0.2 u

4.9

7.1.2 / 29.0.1

3.3

1.0.2 u

7

10.0.1 / 39.2.0

3.4

1.0.2 u

7

19.1.1 / 43.0.0

3.5

1.1.1 w

7

20.3.4 / 50.3.2

3.6

1.1.1 w

9

21.3.1 / 59.6.0

3.7

1.1.1 w

10

24.0 / 68.0.0

3.8

1.1.1 w

10

25.0.1 / 75.3.2

3.9

1.1.1 w

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‑файла путь поиска может лежать в одном из двух тегов:

DT_RPATH

DT_RUNPATH

Приоритет относительно LD_LIBRARY_PATH

выше

ниже

Действует на зависимости зависимостей

да

нет

-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.1 w уже лежат в базовом 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 builder

ENV PYTHON_VERSION=3.7
ENV PYTHON_FULL_VERSION=${PYTHON_VERSION}.17
ENV PYTHON_DIR=${EMG_LOCAL_BASE_DIR}/python/${PYTHON_VERSION}

# Проверяем, что базовый образ действительно отдал нам OpenSSL и zlib
RUN : "${OPENSSL_111_DIR:?empty}" "${OPENSSL_111_LIB_DIR:?empty}" "${ZLIB_LIB_DIR:?empty}"

# Запрещаем apt тянуть дистрибутивный python
COPY etc/apt/preferences.d /etc/apt/preferences.d

RUN 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.x
RUN 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:trixie

COPY --from=builder ${PYTHON_DIR}      ${PYTHON_DIR}
COPY --from=builder ${OPENSSL_111_DIR} ${OPENSSL_111_DIR}
COPY --from=builder ${ZLIB_DIR}        ${ZLIB_DIR}

# Для тех, кто будет собирать C-расширения против нашего OpenSSL
ENV 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 1
done

# 4. Везде RPATH и нигде RUNPATH
for 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 1
done

# 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-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
$ python3 -V
Python 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 --version
pip 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.1 w, собрано 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 version
OpenSSL 3.5.7 9 Jun 2026 (Library: OpenSSL 3.5.7 9 Jun 2026)
$ openssl version
OpenSSL 1.1.1w  11 Sep 2023

Системный openssl и всё, что ставится через apt, работают с 3.5 из Debian. Мой питон — с 1.1.1 w.

Бонус: музей (Python 2.6, 3.0–3.6)

Совсем старые версии на glibc 2.41 и ncurses 6 требуют точечных правок. Только на этапе сборки, рантайм не трогаю:

  • glibc 2.41 объявляет C23-функцию sinpi(), а в Modules/mathmodule.c есть своя static sinpi. Ошибка 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.2 u

поддержка OpenSSL 1.1 появилась только в PHP 7.1

7.0–8.0

1.1.1 w

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

php:7.4-fpm

15.11.2022

Debian 11.5

1.1.1 n (март 2022)

python:3.7

22.07.2023

Debian 12.1

3.0.9 (май 2023)

epicmorg/php:7.4

25.10.2026

Debian 13

1.1.1 w + патч 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. Пушить ничего не стал, всё собирал локально.

Результат:

Цепочка

Итог

Что пришлось поменять, кроме FROM

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

✅

явно добавил libssl-dev и одну строку в LDFLAGS для memcached

Все проверки из пятой части прошли без поблажек: каждая библиотека грузится из своего префикса, в процессе ровно один 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.1 w.

  • Контроль. Нашёл уязвимость — патчу исходники и пересобираю в своём пайплайне, не дожидаясь, пока кто‑то обновит образ на 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.

Комментарии (12)


  1. jorlyf
    30.10.2026 14:19

    Вы пробовали использовать Nix для получения нужной версии GCC/python? По идее, можно просто зафиксировать коммит репозитория пакетов на то время, когда нужная версия была актуальной, и Nix сам скачает или соберет готовую среду.

    И перенос с Debian на Astra Linux прошел бы без изменений.

    Возможно, я что-то не учитываю, но хотел поделиться идеей.


    1. Stam_emg Автор
      30.10.2026 14:19

      Спасибо, хорошая идея, Nix действительно решает похожую задачу.

      Но с Nix я пока плотно не работал, но… вижу несколько нюансов.

      1. Зафиксированный коммит nixpkgs замораживает и зависимости. Если взять nixpkgs того времени, когда Python 3.7 был актуален, вместе с ним приедет OpenSSL, zlib и всё остальное тех же лет со всеми уязвимостями, найденными с тех пор. Это ровно та проблема, с которой я начинал статью про замороженные официальные образы. Чтобы её решить, придётся переопределять OpenSSL через overlay и собирать из исходников. То есть делать то же самое, что у меня, только на языке Nix. Тут Nix ничем не хуже, но и магии нет.

      2. Перенос «без изменений» — потому что closure тащит с собой всё, включая свою glibc. Технически это плюс. Но от Astra тогда остаётся только ядро:

      системные библиотеки и сертифицированный OpenSSL не используются вообще. У меня в эксперименте рантайм работал поверх системной glibc Astra, и именно поэтому всплыли реальные отличия дистрибутива. Одно из них оказалось скрытой ошибкой, которая на Debian пока просто не проявилась.

      3. Эксплуатация. У меня эти образы — база для пачки дочерних образов на обычном Debian, и команде проще читать Dockerfile, чем Nix-выражения. Для Astra в защищённом режиме /nix/store с неподписанными бинарниками — такая же, если не бОльшая, проблема, чем мои бинарники.

      При этом сам подход «один раз описать окружение и воспроизводимо получать его где угодно» мне очень близок, и для разработческих окружений Nix, пожалуй, удобнее Docker.

      Если у вас есть опыт держать на Nix длинный хвост EOL-версий с патчами безопасности, было бы интересно почитать. Спасибо еще раз!


  1. MrBotikkk
    30.10.2026 14:19

    У меня есть собственные неофициальные Docker-образы Astra Linux 1.7 и 1.8.

    Есть официальные в открытом доступе. Просто никто не читает документы. "Образы доступны для скачивания по ссылке в разделе astra."

    Есть специальный проект https://hub.docker.com/r/debian/eol debian/eol:bullseye debian/eol:buster debian/eol:stretch и возможно debian/eol:jessie будут запускаться на современных ядрах. Python 3.7 и ниже не проблема.


    1. Stam_emg Автор
      30.10.2026 14:19

      Спасибо за ссылки , учту.

      Про Astra.

      Свои образы я делал давно, когда официальных в открытом доступе ещё не было, во всяком случае для меня. С тех пор просто поддерживаю их в том же виде, что и остальные свои образы. Сам я не пользователь Astra, и кому нужна именно официальная, тот, конечно, возьмёт официальную. Этой мысли посвящен абзац.

      Про debian/eol.

      Я им пользовался в своих образах, пока на нём не начали копиться CVE. Статья как раз о том, почему я от этого ушёл: debian/eol — это тот же старый зоопарк, только аккуратно упакованный. Старый OpenSSL, старая glibc, обновлений безопасности больше нет. А совсем старые релизы вроде squeeze и wheezy (6 и 7) на свежих ядрах без эмуляции vsyscall не заводятся, о чём я тоже упоминал в начале статьи.


      1. MrBotikkk
        30.10.2026 14:19

        Запускал на Debian 13 docker.io/debian/eol:lenny

        echo 'GRUB_CMDLINE_LINUX_DEFAULT="${GRUB_CMDLINE_LINUX_DEFAULT} vsyscall=emulate"' | sudo tee /etc/default/grub.d/99-vsyscall.cfg
        sudo update-grub
        -----
        sudo rm /etc/default/grub.d/99-vsyscall.cfg
        sudo update-grub


        1. Stam_emg Автор
          30.10.2026 14:19

          это верно. запустить можно. и именно то что вы делаете, написано в моем комментарии выше. эмуляция сисколов и изменение параметров загрузчика.

          в целом, данная ветка немного не про тематику статьи. складывается впечатление.

          доводы которые вы приводите изложены еще в первых абзацах. и достаточно подробно аргументированны. и почему от этого отказался.


          1. MrBotikkk
            30.10.2026 14:19

            Не сочтите за “Грубое общение”, но конкретных решений в статье по vsyscall=emulate нет. Мной такой подход тестировался 3-4 г. назад

            Dockerfile eol:lenny etch
            FROM docker.io/debian/eol:lenny
            
            ENV DEBIAN_FRONTEND=noninteractive
            ENV TERM=xterm
            
            RUN mkdir -p /etc/apt/sources.list.d \
                && printf '%s\n' \
                   'deb [trusted=yes] http://archive.debian.org/debian etch main' \
                   'deb [trusted=yes] http://archive.debian.org/debian-security etch/updates main' \
                   > /etc/apt/sources.list.d/etch.list
            
            RUN mkdir -p /etc/apt/preferences.d \
                && printf '%s\n' \
                   'Package: *' \
                   'Pin: release n=etch' \
                   'Pin-Priority: 100' \
                   > /etc/apt/preferences.d/etch
            
            RUN mkdir -p /var/lib/apt/lists/partial \
                && apt-get update \
                && apt-get install -y --no-install-recommends nano file \
                && rm -rf /var/lib/apt/lists \
                && mkdir -p /var/lib/apt/lists/partial
            
            CMD ["/bin/bash", "--noprofile", "--norc", "-i"]

            Все эти версии Python уже не поддерживаются:

            Python 3.9
            https://peps.python.org/pep-0596/
            Python 3.8
            https://peps.python.org/pep-0569/
            Python 3.7
            https://peps.python.org/pep-0537/
            Python 3.6
            https://peps.python.org/pep-0494/
            Python 2.7
            https://www.python.org/doc/sunset-python-2/

            Отлично собираются в podman/docker Arch Linux image c последними доступными патчами


            1. Stam_emg Автор
              30.10.2026 14:19

              все подробно описано в первом абзаце.

              то что вы делаете - рабочее. и никто не спорит. но в статье от этого пути был осознанный отказ. и статья не про это.

              если честно, мне нечего больше ответить, тк я не понимаю что вы пытаетесь доказать :) точнее, зачем.


    1. MrBotikkk
      30.10.2026 14:19

      Кто обнуляет карму? сегодня в 17:52 -1 Грубое общение. Где проявилось мое грубое общение?


  1. SilverTrouse
    30.10.2026 14:19

    У меня есть немного обратная проблема. Astra 1.7 к нему хочу собрать допустим современный vscodium - в итоге собираем по цепочке binutils ( так-как родной ld чуть багованный и не может собрать gcc 14-15-16). Собираем gcc собираем python собираем nodejs собираем llvm собираем rust. Только уже после билдим vscodium

    Пребинари не подходят так как libc нужен более современный (1.7 - debian 10)


    1. MrBotikkk
      30.10.2026 14:19

      mkdir -p ~/.local/bin
      cd ~/.local/bin
      wget https://github.com/VSCodium/vscodium/releases/download/1.143.17232/VSCodium-1.143.17232-anylinux-x86_64.AppImage
      chmod +x VSCodium-*.AppImage
      ln -sf ~/.local/bin/VSCodium-1.143.17232-anylinux-x86_64.AppImage ~/.local/bin/codium
      echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc && source ~/.bashrc

      или

      cd /tmp
      wget https://github.com/VSCodium/vscodium/releases/download/1.143.17232/VSCodium-1.143.17232-anylinux-x86_64.AppImage
      chmod +x VSCodium-*.AppImage
      sudo mv VSCodium-*.AppImage /usr/local/bin/codium