Практика создания кастомных сборок Spark Kubernetes Executor
Поделюсь с коллегами практикой создания Docker-сборок на базе Spark разных версий, которые могут запускаться как Spark Kubernetes Executors для параллельного выполнения Spark-задач в кластере.
В нашем конкретном случае сборки включают Pyspark и Cassandra Connector, однако вы можете использовать этот материал как набор практических примеров, чтобы сконструировать собственные Docker-сборки для Spark на другом стеке или с другими приложениями.
В рамках поставленной задачи мне понадобилось подготовить, как минимум, пару Docker-сборок: одну для поддержки легаси кода и другую вполне современную для последующей миграции. Ниже я расскажу об этапах создания таких Docker-сборок, погружаясь в детали по ходу дела.
Планирование
Первым делом необходимо определиться с версиями Java и Spark, а также с версиями всех приложений или пакетов, которые попадут в финальную сборку. Звучит очевидно, однако, если пропустить этот этап, то добавление впоследствии другого компонента (не предусмотренного вначале), потянет за собой цепочку изменений зависимостей версий всего остального. Из-за чего образ придется пересобирать заново.
За основу возьмем официальную сборку Spark нужной нам версии из Apache Archive Distribution Directory (здесь Spark v3.3.2 для примера). В свою очередь, сам Spark использует базовый образ с OpenJDK, поэтому будет вполне логичным именно его и взять за основу.
И тем не менее, для одной из сборок в качестве базового образа неожиданно использовался python:2.7.18-slim-buster
— ниже расскажу почему.
Итак, после обстоятельного изучения официальной документации с таблицами совместимости версий у нас получилась пара вот таких конфигураций. Первую условно назовем «современной»:
# Debian GNU/Linux 11 (bullseye)
# OpenJDK Runtime Environment 18.9 (build 11.0.16+8)
# Python 3.9.2 + dependencies (requirements.txt)
# Scala 2.12.15 + Spark/Pyspark 3.3.2
# Cassandra Connector 3.3.0 + dependencies (ivy.xml)
А вторую, опять же условно, назовем «нужной» (что правда):
# Debian GNU/Linux 10 (buster) slim with Python 2.7.18
# OpenJDK Runtime Environment (Temurin)(build 1.8.0_422-b05)
# Python 2.7.18 + dependencies (requirements.txt)
# Scala 2.11 (2.11.12) + Spark/PySpark 2.4.5
# Cassandra Connector 2.5.2 + dependencies (ivy.xml)
Spark как основа
Внутри архива с оф. сборкой Spark вы найдете /kubernetes/dockerfiles/spark/Dockerfile
— это именно тот каркас, вокруг которого будет собираться вся остальная кастомная сборка. Скопируйте себе этот Dockerfile (из нужной вам версии Spark) «как есть». Выглядит он примерно так с некоторыми отличиями от версии к версии:
ARG java_image_tag=11-jre-slim
FROM openjdk:${java_image_tag}
ARG spark_uid=185
RUN set -ex && \
sed -i 's/http:\/\/deb.\(.*\)/https:\/\/deb.\1/g' /etc/apt/sources.list && \
apt-get update && \
ln -s /lib /lib64 && \
apt install -y bash tini libc6 libpam-modules krb5-user libnss3 procps && \
mkdir -p /opt/spark && \
mkdir -p /opt/spark/examples && \
mkdir -p /opt/spark/work-dir && \
touch /opt/spark/RELEASE && \
rm /bin/sh && \
ln -sv /bin/bash /bin/sh && \
echo "auth required pam_wheel.so use_uid" >> /etc/pam.d/su && \
chgrp root /etc/passwd && chmod ug+rw /etc/passwd && \
rm -rf /var/cache/apt/*
# ЗДЕСЬ БУДЕТ НАША ВСТАВКА, ГДЕ СКАЧИВАЕТСЯ ДИСТРИБУТИВ SPARK
COPY jars /opt/spark/jars
COPY bin /opt/spark/bin
COPY sbin /opt/spark/sbin
COPY kubernetes/dockerfiles/spark/entrypoint.sh /opt/
COPY kubernetes/dockerfiles/spark/decom.sh /opt/
COPY examples /opt/spark/examples
COPY kubernetes/tests /opt/spark/tests
COPY data /opt/spark/data
ENV SPARK_HOME /opt/spark
# ЗДЕСЬ БУДЕТ НАША ВСТАВКА, ГДЕ УСТАНАВЛИВАЕТСЯ CASSANDRA CONNECTOR
WORKDIR /opt/spark/work-dir
RUN chmod g+w /opt/spark/work-dir
RUN chmod a+x /opt/decom.sh
ENTRYPOINT [ "/opt/entrypoint.sh" ]
USER ${spark_uid}
В общих чертах здесь происходит следующее:
Устанавливаются несколько нужных пакетов
Подготавливается фиксированная структура папок для Spark
По этим папкам раскидываются файлы из одноименных папок дистрибутива
Выставляются нужные права
Скрипт
/opt/entrypoint.sh
отвечает за запуск Spark Driver/Executor и является ключевым, чтобы в дальнейшем Spark правильно работал в KubernetesUSER ${spark_uid}
— запуск контейнера из-под пользователяspark
(вместо root)
Что здесь можно оптимизировать без ущерба для функционала?
Пакеты
libpam-modules
иkrb5-user
с большой вероятностью не понадобятся, если только вы не планируете аутентификацию пользователей внутри пода/контейнераПачку команд COPY вполне можно заменить на одну
RUN mv … && mv … && mv …
, тем самым оптимизировав число слоёвСодержимое папок
examples, tests, data
пригодится для отладки и тестирования, впоследствии их вполне можно выкинуть
Теперь давайте сделаем первую вставку в обозначенном месте:
# ВСТАВКА: Скачиваем и распаковываем архив с дистрибутивом Spark нужной версии
RUN mkdir -p /tmp && cd $_ && \
wget -nv -O spark.tgz https://archive.apache.org/.../spark-3.3.2.tgz && \
tar -xf spark.tgz --strip-components=1 && \
chown -R spark:spark .
Возможно у вас будет не так, но на моем dialup дистрибутив Spark скачивается ну о-очень медленно. Поэтому, при желании, вы можете скачать его один раз, положить в репозиторий сборки, после чего сделать COPY spark.tgz .
, одновременно удалив ставший ненужным wget
.
В целом, наш базовый образ Spark Kubernetes Executor готов, и сейчас можно обдумать, что в него добавить дальше.
Планируем стадии сборки
Вначале набросаем скелет будущего Dockerfile, используя лучшие практики multistage build. Вот вариант для «современной» сборки (если помните, я говорил, что у нас их будет две):
### СТАДИЯ 1: Сборка Python
FROM openjdk:11-jre-slim AS python3_builder
# Установка Python, pip, venv и зависимостей из requirements.txt
...
### СТАДИЯ 2: Cassandara Connector и зависимости
FROM openjdk:11-jre-slim AS cassandra_connector_builder
...
### Финальная стадия сборки
FROM openjdk:11-jre-slim
COPY --from=python3_builder ...
# Весь остальной Dockerfile со Spark, подготовленный выше
...
И вариант для старой версии Python, где в качестве базового образа используется python:2.7.18-slim-buster
, а JDK устанавливается непосредственно на финальной стадии сборки:
### СТАДИЯ 1: Сборка Python
FROM python:2.7.18-slim-buster AS python2_builder
# Установка venv и зависимостей из requirements.txt
...
### СТАДИЯ 2: Cassandara Connector и зависимости
FROM openjdk:11-jre-slim AS cassandra_connector_builder
...
### Финальная стадия сборки
FROM python:2.7.18-slim-buster
COPY --from=python2_builder /opt/venv /opt/venv
# Установка Java в финальный образ
RUN apt-get install -y temurin-8-jdk
ENV JAVA_HOME=/usr/lib/jvm/temurin-8-jdk-amd64
ENV PATH="$JAVA_HOME/bin:$PATH"
# Весь остальной Dockerfile со Spark, подготовленный выше
...
Возможно, глядя на второй Dockerfile, вы скажете, что установку JDK вполне можно было поместить в отдельный stage и будете правы. Скажу больше — вначале так и было сделано и даже все работало. Но были некоторые едва уловимые отличия, которые (возможно) могут привести к непредсказуемому поведению Java-приложений. Поэтому от греха подальше в целях стабильности сборки было решено пожертвовать (в этой части) преимуществами multistage build и выполнить установку JDK непосредственно в финальный образ.
Установка Python
Вот вариант для «современной» сборки:
### СТАДИЯ 1: Сборка Python
FROM openjdk:11-jre-slim AS python3_builder
RUN apt-get update && \
apt install -y wget python3.9 python3.9-distutils libpython3.9 && \
update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.9 1 && \
update-alternatives --install /usr/bin/python python /usr/bin/python3.9 1 && \
wget https://bootstrap.pypa.io/get-pip.py && \
python3.9 get-pip.py
COPY requirements.txt .
RUN pip install --upgrade pip && \
pip install virtualenv && \
virtualenv /opt/venv && \
. /opt/venv/bin/activate && \
pip install -r requirements.txt
Не буду заострять внимание на очевидных моментах, которые легко считываются из приведенных инструкций, и обращу ваше внимание на некоторые детали:
Установка pip через
get-pip.py
происходит быстрее и скачивает намного меньше пакетов, чемapt install python3-pip
Если используете slim-образ, то установка библиотек
python3.9-distutils, libpython3.9
необходима в большинстве случаевЕсли пихаете все в одну кучу собираете образ без multistage build, то не забудьте подчистить кэш установки пакетов и pip (
--no-cache-dir
для pip)Будет хорошей идеей создать виртуальную среду
venv
со всеми зависимостями, а в финальной стадии сборки сделать:
COPY --from=python3_builder /opt/venv /opt/venv
SHELL ["/bin/bash", "-c"]
RUN . /opt/venv/bin/activate
Что касается второй Docker-сборки со старой версией Python, то конкретно Python v2.7.18 является музейной редкостью отсутствует в репозитории Debian, поэтому можно либо взять за основу готовый образ python:2.7.18-slim-buster
(как и было сделано во 2-м примере Dockerfile) либо скомпилировать Python «с нуля».
Отмечу, что сборка Python из исходников весьма времязатратна. И несмотря на то что кэширование слоев в Docker работает, все же количество перекомпиляций в процессе отладки достаточное, чтобы потерять терпение. А его и так не хватает, поскольку это далеко не единственное «бутылочное горлышко» во всей истории.
Что касается содержимого requirements.txt, то вам, вероятно, потребуется что-то вроде этого в качестве основы для Pyspark (если вы собираетесь его запускать):
pyspark==3.3.2
py4j>=0.10.9.5
grpcio<1.57
grpcio-status<1.57
googleapis-common-protos==1.56.4
...
Установка Cassandara Connector
Этот этап вы можете рассматривать как пример для интеграции любых Java-приложений или зависимостей в Docker-образы со Spark. Так что Cassandara Connector здесь просто частный случай. И этот этап оказался самым выматывающим интересным квестом, который пришлось пройти.
Я не Java-разработчик и, возможно, что-то упускаю. Тем не менее, после серии экспериментов со сборкой Java-зависимостей, удалось найти вполне стабильное и не самое сложное «production ready» решение, с которым ошибки Java в логе выполнения отсутствуют.
Чтобы не плодить сущности, для сборки Java-зависимостей был взят пакет ivy
, уже включенный в дистрибутив Spark. Поскольку соответствующий stage с Cassandara Connector в Dockerfile находится выше, чем установка Spark, то можно просто скачать ту же самую версию ivy
. В результате получится примерно такой stage (для разных версий Spark отличаются только версии ivy
, входящие в дистрибутив):
### СТАДИЯ 2: Cassandara Connector и зависимости
FROM openjdk:11-jre-slim AS cassandra_connector_builder
RUN apt update && \
apt install -y wget && \
wget -O ivy-2.5.1.jar https://repo1.maven.org/maven2/org/apache/ivy/ivy/2.5.1/ivy-2.5.1.jar
COPY java/ivy.xml .
COPY java/ivysettings.xml .
# Собираем Java-зависимости для Cassandra Connector
RUN java -jar ivy-2.5.1.jar \
-settings ivysettings.xml \
-ivy ivy.xml \
-retrieve "/opt/spark/cassandra/[artifact]-[revision](-[classifier]).[ext]"
Идея состоит в том, чтобы все собранные Java-зависимости переместить в $SPARK_HOME/jars/
(ClassPath
по умолчанию) и, тем самым, избежать дублирования файлов пакетов внутри сборки и обойтись без кэша ivy
в финальном образе, который избыточен и занимает много места.
Это также избавляет от необходимости передавать extraClassPath
в параметры Spark-приложения, а ivy
тогда перестаёт выкачивать зависимости и принудительно создавать кэш при каждом запуске контейнера. В том числе при каждом запуске Spark App в Kubernetes, когда ivy
начинает скачивать зависимости, адски тормозя стартующий под.
Наконец, когда все пакеты зависимостей помещены в дефолтный Spark ClassPath
, тогда, во-первых, больше НЕ нужно при инициализации Spark-сессии делать что-то вроде .set("spark.jars.packages", "repo:package")
на Python. А, во-вторых, НЕ нужно придумывать сложные варианты запуска через spark-submit --packages repo:package
или spark-submit --jars repo:package
.
И хотя при отладке контейнера запустить spark-submit
с параметрами не составляет труда, вспомните, что в кластере Spark будет нативно запускаться из /opt/entrypoint.sh
, который (если вам нужен нестандартный запуск) в этом случае придется вдумчиво патчить, что добавит новые точки отказа.
Короче говоря, вот план действий:
Собираем Java-зависимости для нашего приложения через
ivy
Используем указанный артефактный шаблон в ключе
-retrieve
, чтобы имена файлов новых Java-зависимостей были идентичны уже имеющимся в Spark (если поменяете шаблон то файлы задублируются)Переносим новые jar-файлы в
$SPARK_HOME/jars/
— не дублируя уже имеющиеся, и не перезаписывая оригинальные файлы в целевой папкеНе нужно определять
extraClassPath
, указывающий на отдельную папку с Java-зависимостями для нового приложенияИзбавляемся от «тяжелого» кэша
ivy
— как на этапе сборки, существенно уменьшая ее размер, так и на этапе запуска в Kubernetes
Теперь давайте разберемся с парой важных файлов ivy.xml
и ivysettings.xml
, необходимых для сборщика пакетов.
Список зависимостей для Java-приложения, как правило, легко обнаруживается в оф. репозиториях. В случае с Cassandara Connector в этой папке репозитория лежит pom-файл для сборщика проектов Apache Maven. Осталось заглянуть в pom-файл и подготовить аналогичные ivy.xml
и ivysettings.xml
с тем же списком пакетов, воспользовавшись любым подходящим конвертором (или просто скормив исходный pom своей любимой GPT). Тестовые зависимости при этом можно выбросить.
NB: Конкретно для Cassandara Connector добавьте еще пакет jnr-posix
в необходимые зависимости.
Собирать и тестировать этот stage можно поручить джуну независимо от всей остальной сборки и даже вообще в отдельном образе/контейнере. Для удобства отладки можно пробросить папку java/
в контейнер, чтобы удобнее править xml-конфиги. И приготовьтесь к тому, что сборка очень долгая.
Возможно, вы еще не забыли, что в самом первом скелете Dockerfile (см. «Spark как основа») было предусмотрено место для второй вставки. Пришло время ее добавить:
# ВСТАВКА: Установка Cassandara Connector
COPY --from=cassandra_connector_builder /opt/spark/cassandra /opt/spark/cassandra
RUN set -ex && \
# Переносим только новые уникальные зависимости для Cassandra Connector
mv -n $SPARK_HOME/cassandra/*.jar $SPARK_HOME/jars/ && \
chown -R spark:spark $SPARK_HOME/jars/ && \
# В исходной папке остались только дубли, они больше не нужны
rm -rf $SPARK_HOME/cassandra
Если вы не используете multistage build, то не забудьте удалить кэш ivy:
RUN rm -rf /root/.ivy2
Финализация сборки
Что можно и нужно сделать в заключение?
Проверьте, что вы не забыли выполнить
chown -R spark:spark
для всех файлов, которые переносили/копировали в$SPARK_HOME/jars/
. И тоже самое для любых новых папок и файлов внутри$SPARK_HOME/
, если таковые были.Используйте
/opt/spark/work-dir
в качестве основной рабочей директории для всего, включая ваши скрипты. Если ну очень хочется создать другую папку для этой цели, сделайте ей и файлам внутриchown -R spark:spark
и права 775 на папку.Проверьте, что ваш Dockerfile заканчивается командами:
ENTRYPOINT [ "/opt/entrypoint.sh" ]
USER ${spark_uid}
В манифесте Spark Application желательно указать
securityContext
(где 185 это spark_uid):
spec:
driver:
securityContext:
runAsUser: 185
runAsGroup: 185
fsGroup: 185
executor:
securityContext:
runAsUser: 185
runAsGroup: 185
fsGroup: 185
С разрешения читателя опущу окончательный Dockerfile по нескольким причинам: Во-первых, ваше кастомное решение наверняка будет отличаться в целом или в деталях. Во-вторых, хотелось показать набор приемов для работы с образами Spark, плюс подсветить некоторые полезные фишки, что, я надеюсь, получилось. В-третьих, я не смог выбрать из двух Dockerfile, поскольку они оба красивые.
А потом эти сборки вполне успешно запустились из Airflow и Spark Operator в Kubernetes, но это уже другая история.