Практика создания кастомных сборок Spark Kubernetes Executor

213e15c242f7e1d1a17cb2912b3421eb

Поделюсь с коллегами практикой создания 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 правильно работал в Kubernetes

  • USER ${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, который (если вам нужен нестандартный запуск) в этом случае придется вдумчиво патчить, что добавит новые точки отказа.

Короче говоря, вот план действий:

  1. Собираем Java-зависимости для нашего приложения через ivy

  2. Используем указанный артефактный шаблон в ключе -retrieve, чтобы имена файлов новых Java-зависимостей были идентичны уже имеющимся в Spark (если поменяете шаблон то файлы задублируются)

  3. Переносим новые jar-файлы в $SPARK_HOME/jars/ — не дублируя уже имеющиеся, и не перезаписывая оригинальные файлы в целевой папке

  4. Не нужно определять extraClassPath, указывающий на отдельную папку с Java-зависимостями для нового приложения

  5. Избавляемся от «тяжелого» кэша 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, но это уже другая история.

© Habrahabr.ru