Добрый день, меня зовут Станислав, я Java-разработчик в компании Rocket Science. Мы занимаемся заказной разработкой и я хочу поделиться с вами некоторыми мыслями и результатами, к которым я пришёл спустя N-ное количество рабочих и пет-проектов. Тема для обсуждения — сборка Java-приложений в CI.

Фактически у всех заказчиков, над проектами которых мне довелось поработать последние несколько лет, имеются собственный внутренний инстанс GitLab-а. Я сам где-то с 2015 стараюсь периодически следить за блогом разработчиков GitLab и быть в курсе возможностей, которые он предоставляет. И, раз уж я про них знаю, некоторые из них я пытаюсь использовать на практике.

Когда приходится настроить автоматическую сборку в GitLab CI, многие люди действуют максимально логично — идут и ищут в интернете туториалы. Я прямо сейчас погуглил и вот какие результаты нашёл:

  • Одна из последних статей на Хабре по этой теме Настройка GitLab CI CD для Java приложения. Отдельная джоба для mvn compile, отдельная джоба для mvn test, отдельная джоба для mvn package -DskipTests=true. Сборка docker-образа в статье не упоминается. Видно, что этот подход взят из примеров, которые имеются в документации самого GitLab CI. Так же советуют делать ещё в некоторых нагугливаемых статьях. Мне кажется, что изначально автор самой первой из них хотел показать именно возможность разделения сборки на разные этапы, а получился популярный пример как это нужно делать, и все стали его копировать.

  • Туториалов именно по сборке Java (или конкретно Spring Boot) приложений среди примеров в документации GitLab CI я не нашёл, поэтому расширил кругозор и вот нашёл интересный пример: Automate Spring Boot App Deployment With GitLab CI and Docker. Здесь делают отдельной джобой сборку (mvn install), и отдельной джобой собирают докер-образ. Во время сборки нам потребуется скачать 3 разных образа: maven:3.6.3-jdk-11-slim (примерно 230 Мб), docker:stable (примерно 60 Мб), базовый образ для нашего приложения openjdk:11-slim (примерно 220 Мб, но я думаю большая часть слоёв должна шариться с maven:3.6.3-jdk-11-slim).

Давайте посмотрим на репозиторий Maven на Docker Hub. Везде фигурирует последняя версия (3.8.1), но образы на основе Alpine есть только ibmjava-8, нет ни 11, ни текущих релизов. Образ 11-slim занимает 230 Мб (compressed size). В своих проектах в качестве базового образа для приложений я использую BellSoft Liberica (актуальный lts / release) Alpine Musl. Мне известно, что у некоторых команд иногда возникают проблемы с Alpine и Musl при решении специфических задач, но лично меня это никогда не касалось (меня касалось только ограничение на размер пространства в предоставленном Container Registry, и это был плюс за выбор Alpine). Образ с 11 Java занимает уже всего 75 Мб (compressed size) — всего 30% от размера 11-slim. Но в нём нет Maven.

В распакованном виде разница в размере ещё увеличивается
В распакованном виде разница в размере ещё увеличивается

Все разрабатываемые приложения используют Spring и в них регулярно встречается Testcontainers. Часто это означает, что в контейнер системными администраторами уже проброшен сокет docker daemon. Если бы в образе была утилита командной строки docker, я мог бы сразу собрать и запушить образ, а не передавать его через артефакты GitLab на следующий этап, ожидать скачивания docker:stable, и тем самым избежать разных накладных расходов. Сборка образа сразу после получения .jar-ника была бы полезна для приложений с небольшим временем сборки, но если у вас много времени занимают юнит-тесты и иногда падает Container Registry (мне встречалось) — так лучше не делать.

Постепенно я пришёл к тому, что максимально удобно было бы иметь свой образ со всеми необходимыми инструментами для сборки, как то: Maven и/или Gradle, инструмент командной строки docker, плюс всякие curl, jq, envsubst. В общем-то, вот он:

Dockerfile тулинга
ARG JAVA_VERSION="16"
FROM bellsoft/liberica-openjdk-alpine-musl:${JAVA_VERSION}

# We have to keep this up to date.
ARG MAVEN3_VERSION="3.8.1"
ARG GRADLE_VERSION="7.1.1"
ARG DOCKER_VERSION="20.10.8"

# Links to download binary files.
ARG MAVEN3_CLI_URL="http://mirror.linux-ia64.org/apache/maven/maven-3/$MAVEN3_VERSION/binaries/apache-maven-$MAVEN3_VERSION-bin.zip"
ARG GRADLE_CLI_URL="https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip"
ARG DOCKER_CLI_URL="https://download.docker.com/linux/static/stable/x86_64/docker-$DOCKER_VERSION.tgz"

# Add installation targets to PATH.
ENV PATH="/opt/apache-maven-$MAVEN3_VERSION/bin:/opt/gradle-$GRADLE_VERSION/bin:${PATH}"

# Ultimately update OS packages.
# hadolint ignore=DL3017, DL3018, DL3019
RUN  echo "http://dl-3.alpinelinux.org/alpine/latest-stable/main"      >  /etc/apk/repositories \
  && echo "http://dl-3.alpinelinux.org/alpine/latest-stable/community" >> /etc/apk/repositories \
  && apk update \
  && apk upgrade --available \
  && apk add git \
             curl \
             wget \
             tzdata \
             gettext \
             busybox-extras \
  && rm -rf /var/cache/apk/* \
  # ===== ===== ===== Install Apache Maven ===== ===== =====
  && curl -s -L -o     /opt/apache-maven-$MAVEN3_VERSION-bin.zip  $MAVEN3_CLI_URL \
  && unzip -q -d /opt  /opt/apache-maven-$MAVEN3_VERSION-bin.zip \
  && rm -f             /opt/apache-maven-$MAVEN3_VERSION-bin.zip \
  && mvn --version \
  && echo "​ Maven ${MAVEN3_VERSION} installed." \
  # ===== ===== ===== Install Gradle ===== ===== =====
  && curl -s -L -o     /opt/gradle-$GRADLE_VERSION-bin.zip  $GRADLE_CLI_URL \
  && unzip -q -d /opt  /opt/gradle-$GRADLE_VERSION-bin.zip \
  && rm -f             /opt/gradle-$GRADLE_VERSION-bin.zip \
  && gradle --version \
  && echo "​ Gradle ${GRADLE_VERSION} installed." \
  # ===== ===== ===== Install Docker CLI ===== ===== =====
  && curl -s -L -o /tmp/docker-$DOCKER_VERSION.tgz  $DOCKER_CLI_URL \
  && tar -x -z -C  /tmp -f /tmp/docker-$DOCKER_VERSION.tgz \
  && mv            /tmp/docker/docker  /usr/local/bin/ \
  && rm -f         /tmp/docker-$DOCKER_VERSION.tgz \
  && rm -rf        /tmp/docker \
  && docker --version \
  && echo "​ Docker CLI ${DOCKER_VERSION} installed."

Хитрое обновление пакетов пришло после требований от безопасников на одном из проектов, где в Container Registry встроили сканирование образов на уязвимости. Я заметил, что базовый образ Liberica JDK построен на основе Alpine 3.12 (на момент написания статьи), а актуальный релиз — 3.14. Поэтому таким вот образом обновляю всю ОС до последней версии. Если вы считаете, что это плохо — готов обсудить в комментариях. Перед сборкой все докерфайлы прогоняются через hadolint, и вы видите, что здесь я намеренно нарушаю несколько его правил. Также я поступаю и в Dockerfile-е самого приложения, в конце статьи приведу его бонусом.

Так как почти все проекты закрытые, приходится тащить этот Dockerfile в репозиторий каждого проекта и добавлять отдельную джобу для его сборки:

Где-то в начале .gitlab-ci.yml
variables:
  TOOLING_IMAGE: $CI_REGISTRY_IMAGE/tooling/java:16

tooling:
  stage: tooling
  image: docker:stable
  script:
    - cd tooling
    - docker run   --rm -i    hadolint/hadolint   < Dockerfile
    - docker build --rm --tag $TOOLING_IMAGE .
    - docker login --username $CI_REGISTRY_USER --password $CI_REGISTRY_PASSWORD  $CI_REGISTRY
    - docker push             $TOOLING_IMAGE
  only:
    changes:
      - tooling/*
  allow_failure: true

Дальнейшая сборка приложения уже осуществляется с использованием $TOOLING_IMAGE в качестве image джобы.

На самом деле недавно в моих тулинг-образах появился ещё один жирный компонент. GitLab умеет отображать в Merge Request-е очень многое, например — показывать список прогнанных тестов и даже выводить лог каждого отдельного. Для этого необходимо просто указать в описании джобы откуда GitLab-у следует забрать JUnit-отчёты в формате XML. Вот как это выглядит:

В merge request-е был добавлен тест и он сломался.
В merge request-е был добавлен тест и он сломался.

Эта фича почти ничего не стоит. Гораздо интереснее добавить в свой проект поддержку Code Coverage, чтобы прямо в diff-е видеть, какой код покрыт тестами, а какой нет:

Зелёные полоски — строки, покрытые юнит-тестами.
Зелёные полоски — строки, покрытые юнит-тестами.

Для этого, во-первых, нужно во время сборки воспользоваться JaCoCo, чтобы получить отчёт в его формате. У меня для этого в pom.xml добавлен jacoco-maven-plugin:

pom.xml / project / build / plugins
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.7</version>
    <configuration>
        <destFile>jacoco-unit.exec</destFile>
        <dataFile>jacoco-unit.exec</dataFile>
    </configuration>
    <executions>
        <execution>
            <id>jacoco-prepare-agent</id>
            <phase>test-compile</phase>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>jacoco-report</id>
            <phase>prepare-package</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
        <execution>
            <id>jacoco-check</id>
            <phase>package</phase>
            <goals>
                <goal>check</goal>
            </goals>
            <configuration>
                <rules>
                    <rule>
                        <element>BUNDLE</element>
                        <limits>
                            <limit>
                                <counter>COMPLEXITY</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.40</minimum>
                            </limit>
                        </limits>
                    </rule>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

Во-вторых, GitLab умеет показывать Code Coverage только в формате Cobertura. По ссылкам из документации можно найти скрипты на Python, которые конвертируют первое во второе. И да, для этого вам нужен Python. Я ничего на нём не писал, поэтому в данном случае доверился предложенной инструкции, и вот что у меня получилось. В образ с тулингом добавляем ещё одну команду на установку всего необходимого:

Внизу Dockerfile-а тулинга
# hadolint ignore=DL3013, DL3018
RUN apk --no-cache add python3 \
                       python3-dev \
                       py3-pip \
                       build-base gcc \
                       libxml2-dev \
                       libxslt-dev \
  && pip3 install --no-cache-dir --upgrade pip wheel \
  && pip3 install --no-cache-dir lxml \
  && curl -s -L -o /opt/cover2cover.py      https://gitlab.com/haynes/jacoco2cobertura/-/raw/main/cover2cover.py \
  && curl -s -L -o /opt/source2filename.py  https://gitlab.com/haynes/jacoco2cobertura/-/raw/main/source2filename.py \
  && echo "​ Python scripts for code coverage installed."

Буду очень рад, если кто-то знающий подскажет, как в этом случае после сборки я могу "подтереть лишнее".

Далее мы можем собрать всё воедино в одной джобе в GitLab-е. Постараюсь сразу пояснить строки комментариями.

Где-то в недрах .gitlab-ci.yml
# Джоба, которой собираем образ приложения из исходников.
my-app:
  image: $TOOLING_IMAGE
  variables:
    # Необходимо для кеширования зависимостей между запусками.
    MAVEN_OPTS: "-Dmaven.repo.local=${CI_PROJECT_DIR}/.m2/repository"
    MAVEN_CLI_OPTS: "--batch-mode"
    # GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
    # Для удобства вызова команд преобразования формата Code Coverage.
    JACOCO_REPORT: target/site/jacoco/jacoco.xml
    COBERTURA_XML: target/site/cobertura.xml
  script:
    # Компиляция проекта, прогон тестов и получение итогового .jar.
    - mvn $MAVEN_CLI_OPTS verify
    # Переносим готовый файл в каталог выше, а target добавлен в .dockerignore. 
    - mv -f target/*.jar application.jar
    # Валидируем Dockerfile, собираем приложение в образ и пушим его куда нужно.
    - docker run   --rm -i hadolint/hadolint < Dockerfile
    - docker build --tag my-app .
    - docker push my-app
  after_script:
    # Этот комментарий остался тут из документации GitLab CI.
    # Report is generated by jacoco-maven-plugin in your pom.xml.
    # These lines convert it to cobertura format.
    - python3 /opt/cover2cover.py ${JACOCO_REPORT} src/main/java > ${COBERTURA_XML}
    - python3 /opt/source2filename.py ${COBERTURA_XML}
  cache:
    key: m2-cache
    paths:
      - .m2/repository
      # - .gradle/caches/
      # - .gradle/wrapper/
      # - .gradle/build-cache/
  artifacts:
    reports:
      # Сохраняем отчёт JUnit по пройденным юнит-тестам.
      junit:
        - '*/target/surefire-reports/TEST-*.xml'
        - '*/target/failsafe-reports/TEST-*.xml'
      # Сохраняем отчёт JaCoCO о покрытии кода.
      cobertura:
        - '*/target/site/cobertura.xml'

Таким образом и добиваемся поставленных целей. Выше я обещал привести вариант своего Dockerfile для приложения. Вот он:

Dockerfile приложения
# Временный образ для распаковки .jar приложения.
FROM bellsoft/liberica-openjdk-alpine-musl:16 AS builder

# Распаковываем .jar-файл.
WORKDIR                             /extracted
COPY application.jar                /extracted/application.jar
RUN  java -Djarmode=layertools -jar /extracted/application.jar extract

# Начинаем ещё раз с чистого листа. Как вариант, тут можно заменить openjdk
# на openjre для уменьшения итогового образа, но я решил шарить одинаковые слои.
FROM bellsoft/liberica-openjdk-alpine-musl:16

# Приложение будет работать во временной зоне Europe/Moscow.
ENV TZ="Europe/Moscow"

# Установка обновлений пакетов и дополнительных инструментов.
# hadolint ignore=DL3017, DL3018
RUN  echo "http://dl-3.alpinelinux.org/alpine/latest-stable/main"      >  /etc/apk/repositories \
  && echo "http://dl-3.alpinelinux.org/alpine/latest-stable/community" >> /etc/apk/repositories \
  && apk update \
  && apk upgrade --available \
  && rm -rf /var/cache/apk/* \
  && apk --no-cache add jq \
                        zip \
                        curl \
                        htop \
                        tzdata \
                        busybox-extras \
  # Создаём пользователя для запуска контейнера не под root-ом.
  && mkdir -p         /my-demo-app \
  && chmod 666        /my-demo-app \
  && adduser -S -D -h /my-demo-app -u 3456 my-demo-app

# Приложение будет запускаться от имени созданного выше пользователя.
# Это требование системных администраторов, отвечающих за Kubernetes.
USER my-demo-app

WORKDIR                                                /my-demo-app
COPY --from=builder /extracted/dependencies/           /my-demo-app/
COPY --from=builder /extracted/snapshot-dependencies/  /my-demo-app/
COPY --from=builder /extracted/spring-boot-loader/     /my-demo-app/
COPY --from=builder /extracted/application/            /my-demo-app/

# REST API.
EXPOSE 8090
# Actuator.
EXPOSE 8081

ENTRYPOINT ["java", \
            # Настройки JVM.
            "-XX:MaxDirectMemorySize=50M", \
            "-XX:MaxMetaspaceSize=200M", \
            "-XX:ReservedCodeCacheSize=120M", \
            "-Xss512K", \
            "-Xms480M", "-Xmx480M", \
            # Look at my horse, my horse is amazing!
            "-XX:+UseShenandoahGC", \
            # Информативные NPE.
            "-XX:+ShowCodeDetailsInExceptionMessages", \
            # Разрешаем использовать Preview-фичи.
            "-XX:+UnlockExperimentalVMOptions", \
            "--enable-preview", \
            # Запуск распакованного Fat Jar.
            "org.springframework.boot.loader.JarLauncher" \
]
CMD []

Да, я знаю, что есть Cloud Native Buildpacks, которые ещё и встроят Java Memory Calculator в приложение, и посчитают адекватные параметры для запуска, но мне нравится контролировать это самому, подглядывая временами на метрики приложения в Grafana.


Всё было хорошо, пока мне не пришлось в очередной раз копировать Dockerfile тулинга и джобу по его сборке в очередной внутренний проект — следи потом за его обновлением! Я плюнул и завёл публичный репозиторий на Docker Hub:

https://hub.docker.com/r/rcktsci/java-tooling

Настроена еженедельная автоматическая сборка и публикация тулинга для LTS-релизов Java, а также текущего и предыдущего не-LTS релизов. На сегодня это 8, 11, 15 и 16. После выхода 17 перестанут обновляться образы для 15. Имеются версии с установленными python и скриптами — у них суффикс "-code-coverage".

Приблизились к размеру 11-slim
Приблизились к размеру 11-slim

​Всё вышеописанное может оказаться дичайшим over-engineering-ом.

В моём опыте всего лишь один проект на Gradle, который мы собираем в GitLab CI, и тот в заморозке, поэтому, возможно, вам придётся что-то допилить в предоставленном материале для получения отчёта о юнит-тестах и code coverage. Можете делиться этим в комментариях.

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


  1. kabkasik
    16.08.2021 10:23

    Спасибо!


  1. ealebed
    17.08.2021 23:26

    А ещё можно использовать для сборки Java-образов плагин jib

    Посмотрите, по свободе, вам понравится )


    1. SimSonic Автор
      18.08.2021 05:48

      Благодарю! Когда-то очень давно слышал про него, а потом напрочь вылетело из головы.

      Насколько я понял из беглого прочтения README, если запускать через Maven-плагин или через CLI, наверное ему всё равно нужен доступный docker daemon. Да, с моим подходом можно заменить docker cli на jib cli, например, в первом образе.

      Возможно, в течении пары недель попробую собрать им и посмотреть на разницу в итоговом образе со сборкой моим Dockerfile-ом.