Сколько стоит фреймворк автоматизации #2

Вторая часть будет больше про работу с докером, инфрой, конфигами и подключение к Postgres.

Словарик

  • POJO (Plain Old Java Object) — это простой Java-объект, не зависящий от специфических фреймворков и не расширяющий заранее заданные классы. Обычно он используется для упаковки и переноса данных.
  • АТ - платформа автотестов, которая крутит тесты, что я разрабатываю
  • ОТ - объект тестирования, или тестируемое приложение.
  • JDBC - Java Data Base Connector - тулза для подключения к БД

Задача

Итак, когда была написана большая часть тестов, обнаружилась небольшая проблемка, рантайм в 34 минуты. Пу-пу-пу…

Немного диагностики — и понимаем, что 2/3 данного времени занимает очистка QA-стенда ОТ, а не прогон тестов. Сущность, которая под разными соусами создается 1.5к раз, удаляется с небольшой задержкой, ей нужно от 2 до 5 секунд. Это действие сильно увеличивает runtime.

 читать первым в телеграм    читать первым в макс

Как пофиксить?

  1. Снизить количество тестов, применить разные матрицы тестирования, хитрые сценарии и прочая теоретическая дребедень
  2. Сделать соседний стенд, который можно безбожно засирать и не чистить
  3. Лупить TRUNCATE напрямую в базу

Это все круто, но скучно. Вот бы делать много тестов, не чистить стенд и не думать.

Танцы с бубном

После долгих обсуждений и экспериментов было решено переносить все в Docker, а именно в Testcontainers.

Какие профиты:

  1. Можно развернуть тестируемое приложение, прогнать тесты и все выкинуть. То есть не надо чистить стенд.
  2. Тесты и стенд разворачиваются в CI/CD-раннере, QA-стенд свободен.
  3. Расширение бест-практис внутри компании. Одна платформа автотестов уже работает таким образом.

Что нужно сделать:

  1. Запихать ОТ в контейнер.
  2. Запихать вспомогательное приложение для ОТ в контейнер.
  3. Придумать, что делать с БД, ибо извне (через API) с ОТ можно сделать не все.
Максим Братковский
Максим Братковский
Linux expert
Задать вопрос
Прежде чем описать реализацию, от себя добавлю следующее. Если у вас молодое приложение или уже в докере, я вам завидую. Я попал в самое "интересное". ОТ, которое нужно запихать в докер, 10 лет, а это значит, что оно носитель многих приколов.
  1. Оптимизация под debian. ОТ просто не запустится, если не найдет нужные конфиги в /etc, если у нужных директорий не будет правильного овнера и так далее по списку.
  2. Лицензии… Лицензии часто привязаны к mac-адресу, hostname, ip, а хардкод сетевых параметров — это не то, что любит докер.

Альтернатива

В процессе работы над докером узнал об lxc. Думаю, в кейсе ОТ было бы правильнее развернуть инфру в lxc и управлять ей через SSH-клиент из АТ, но что сделано, то сделано…

Dockerfile

Для начала нужно просто запихнуть ОТ в контейнер. Желательно, чтобы образ не весил тонну. Если у вас full spring-boot-приложение или python-пет с лавандовым рафом, можно использовать spring-boot-maven-builder или другие тулзы, которые за пару строк кода соберут вам контейнер. Если вам надо имитировать что-то debian-подобное, добро пожаловать в мир боли.

Dockerfile, который у меня получился:

# syntax=docker/dockerfile:1
FROM eclipse-temurin:17-jre-jammy  
  
ARG APP_SERVICE=app  
ARG APP_USER=app-user  
ARG APP_GROUP=app-group 
ARG APP_INSTALL_DIR=/opt/${APP_SERVICE}  
  
ENV APP_SERVICE=${APP_SERVICE} \  
    APP_USER=${APP_USER} \  
    APP_GROUP=${APP_GROUP} \  
    APP_INSTALL_DIR=${APP_INSTALL_DIR}  
  
RUN addgroup --system ${APP_GROUP} && \  
    adduser --system \  
            --ingroup ${APP_GROUP} \  
            --no-create-home \  
            --disabled-login \  
            --shell /usr/sbin/nologin \  
            ${APP_USER}
#юзер для sftp
RUN addgroup --system importer && \  
    adduser --system \  
            --ingroup importer \  
            --home /home/importer \  
            --disabled-password \  
            --shell /bin/bash \  
            importer && \  
    echo "importer:importer" | chpasswd && \  
    mkdir -p \  
           /home/importer/import/batch \  
           /home/importer/import/SUCCESS \  
           /home/importer/import/UNSUCCESS  
RUN chown -R importer:importer /home/importer  

#тулзы для sctp
RUN apt-get update && apt-get install -y \  
    lksctp-tools && \  
    apt-get clean && \  
    rm -rf /var/lib/apt/lists/*  
  
# Копирование исполняемых скриптов
COPY --chown=${APP_USER}:${APP_GROUP} --chmod=755 app/src/main/resources/bin/* ${APP_INSTALL_DIR}/bin/  
  
# Копирование библиотек и основного jar
COPY --chown=${APP_USER}:${APP_GROUP} app/target/lib/* ${APP_INSTALL_DIR}/lib/  
COPY --chown=${APP_USER}:${APP_GROUP} app/target/app*.jar ${APP_INSTALL_DIR}/lib/  
  
# Копирование миграций (сохраняем структуру oracle/postgres)
COPY --chown=${APP_USER}:${APP_GROUP} dao/src/main/resources/db/migrations/oracle/ ${APP_INSTALL_DIR}/config/migrations/oracle/  
COPY --chown=${APP_USER}:${APP_GROUP} dao/src/main/resources/db/migrations/postgres/ ${APP_INSTALL_DIR}/config/migrations/postgres/  
COPY --chown=${APP_USER}:${APP_GROUP} dao/src/main/resources/db/changelog.xml /etc/${APP_SERVICE}/  
  
# Копирование конфигурационных файлов
COPY --chown=${APP_USER}:${APP_GROUP} \  
    app/src/main/resources/app.properties \  
    dao/src/main/resources/dao.properties \  
    app/src/main/resources/config/server.properties \  
    key-proxy-app/src/main/resources/keyproxy.properties \  
    app/src/main/resources/config/log4j2.xml \  
    app/src/main/resources/config/hazelcast.xml \  
    /etc/${APP_SERVICE}/  
  
COPY --chown=${APP_USER}:${APP_GROUP} app/target/classes/etc/app/app.conf /etc/${APP_SERVICE}/  
  
ENTRYPOINT ["/opt/app/bin/run.sh"]

Суть простая: после mvn install копируем в контейнер все jar, баш-скрипты, конфиги, заводим юзера, меняем владельца и прочее прочее. Как итог, получился образ на 253 мегабайта, 90 из них — образ убунты + jre, 163 — сам ОТ. Можно было заморочиться и сделать образ через alpine + jre и сэкономить еще 40 мб таким образом, но я не буду (может потом…).

Далее правим строчку в скрипте запуска с:

if [ "x$A1S_APP_START_LOG" = "x" ]; then  
    nohup $JAVA $APP_OPTS -cp ".:$APP_HOME/lib/*$WARS" com.app.Application >/dev/null 2>&1 &  
else  
    nohup $JAVA $APP_OPTS -cp ".:$APP_HOME/lib/*$WARS" com.app.Application >>$A1S_APP_START_LOG 2>&1 &  
fi

на:

#dockerchek  
if [ -f "/.dockerenv" ]; then  
    IS_DOCKER=true  
else  
    IS_DOCKER=false  
fi

#If app in container we start app without nohup for 1 pid way  
if $IS_DOCKER; then  
    chown -R $APP_USER:$APP_GROUP /etc/${APP_USER}  
    $JAVA $APP_OPTS -cp ".:$A1S_APP_HOME/lib/*$WARS" com.app.Application  
elif [ "x$A1S_APP_START_LOG" = "x" ]; then  
    nohup $JAVA $APP_OPTS -cp ".:$APP_HOME/lib/*$WARS" com.app.Application >/dev/null 2>&1 &  
else  
    nohup $JAVA $APP_OPTS -cp ".:$APP_HOME/lib/*$WARS" com.app.Application >>$A1S_APP_START_LOG 2>&1 &  
fi

И готово, контейнер собран и готов стартовать. Тулзу для тестов пакуем в контейнер таким же образом (да-да, там те же приколы).

Testcontainers

Если кратко, Testcontainers на стадии test разворачивает контейнеры с помощью докер-демона, а как тесты закончатся — все чистит. Удобно, стильно, управление из кода, плюс реализован на многих ЯП, что добавляет актуальности.

Можно собрать инфру чисто в коде, можно развернуть из compose.yml. Думая сейчас, я бы скорее разворачивал из compose, т.к. выше вероятность, что добрый Дядя-Админ поймет его лучше, чем самодельную писанину + compose имеет больше фишек и функционала. Например, вы устанете искать информацию, как забиндить порт хоста и контейнера: в testcontainers это делается через перформанс с бубном, в compose-файле же легко и просто.

Не буду рассказывать пошагово, как и что включать, т.к. есть дока. Опишу наиболее частые или важные моменты.

Например, постгря создается так:

var postgres = new PostgreSQLContainer(DockerImageName.parse("postgres:16"));

Просто тянется из публичного Docker Hub. Можно и из локального registry:

var appContainer = new GenericContainer<>(DockerImageName.parse("you.registry/apps/you_app").withTag(BUILD_NUMBER));

Вот вы и скачали ранее собранный образ.

Важные детали при запуске контейнера в моем кейсе — сетевой хардкод, хоть и Малинин не одобрит. Надо прибить гвоздями хостнейм, ip, mac-адрес, чтобы лицензии не ломались при каждом запуске.

Выглядит примерно так:

private static Network getNetwork() {  
    var ipamConfig = new com.github.dockerjava.api.model.Network.Ipam.Config()  
            .withIpRange("172.19.0.0/24")  
            .withSubnet("172.19.0.0/16")  
            .withGateway("172.19.0.1");  
  
    var ipam = new com.github.dockerjava.api.model.Network.Ipam()  
            .withDriver("default")  
            .withConfig(ipamConfig);  
  
    return Network.builder()  
            .driver("bridge")  
            .createNetworkCmdModifier(createNetworkCmd ->  
                    createNetworkCmd  
                            .withIpam(ipam)  
                            .withName("platform-network"))  
            .build();  
}

//Прикручивается так 
appContainer =  new GenericContainer<>(...)
	.withNetwork(getNetwork())
	.withExposedPorts(port1, port2, port3)
	.withCreateContainerCmdModifier(  
        createContainerCmd -> {  
            createContainerCmd  
                    .withName("app")  
                    .withHostName("host-name")  
                    .withMacAddress("mac")  
                    .withIpv4Address("ip")  
            ;  
        }  
)

Вот мы биндим кучу конфигов, заранее собранных в АТ (да-да, все своими лапками):

appContainer
.withClasspathResourceMapping("/etc/app/app.conf", "/etc/app/app.conf", BindMode.READ_ONLY)  
.withClasspathResourceMapping("/etc/app/hazelcast.xml", "/etc/app/hazelcast.xml", BindMode.READ_ONLY)  
.withClasspathResourceMapping("/etc/app/log4j2.xml", "/etc/app/log4j2.xml", BindMode.READ_ONLY)  
.withClasspathResourceMapping("/etc/app/app.properties", "/etc/app/app.properties", BindMode.READ_ONLY)  
.withClasspathResourceMapping("/etc/app/server.properties", "/etc/app/server.properties", BindMode.READ_ONLY)  
.withClasspathResourceMapping("/etc/app/dao.properties", "/etc/app/dao.properties", BindMode.READ_ONLY)  
.withClasspathResourceMapping("/etc/app/public.key", "/etc/app/public.key", BindMode.READ_ONLY)

Указываем wait:

appContainer.waitingFor(Wait  
        .forLogMessage(".*Started AppApplication in.*", 1)  
        .withStartupTimeout(Duration.ofMinutes(1))  
);

Ну и при запуске не забываем про depends и собираем путь до приложения в контейнере:

appContainer.dependsOn(postgres).
//и
var container_url = "http://" + appContainer.getHost() + ":" + appContainer.getMappedPort(port1) + "/path";

Testcontainers маппит все порты самостоятельно при каждом запуске, поэтому путь важно обновлять, имейте в виду.

БД

С подготовкой базы два варианта:

  1. Тянем дамп базы с QA-стенда, кладем его в контейнер с БД.
  2. Разворачиваем девственный контейнер с БД и заполняем его данными из кода.

По принципу “скучно” первый вариант отметается. Важно оговориться, что ОТ хранит в себе все миграции и прогоняет их при каждом запуске приложения. Это приятный момент, т.к. структура БД уже будет подготовлена.

С загрузкой данных напрямую в БД есть несколько вариантов:

  1. Написать ORM.
  2. В кейсе с Java — JDBI.
  3. В кейсе с Java — JDBC.

Первая попытка была с ORM, но в процессе работы с ним обнаружились недостатки:

  1. Долго.
  2. Дорого.
  3. Не совсем соответствует термину “тестирование”.

DAO, DTO, Batch, Criteria, коннект Hibernate к БД и довольно сильная зависимость от Spring Boot. Это все дорого, долго, громоздко, плюс главная суть всего этого — делать манипуляции с БД ПРАВИЛЬНО, а в тестировании нужно делать не всегда ПРАВИЛЬНО, иногда нужно шатать.

От JDBC меня сразу отговорил Андрюха. Слишком ванильно, слишком много мучений с raw-контекстами. Методом исключения был выбран JDBI. По опыту скажу, тулза великолепная.

  1. Быстрое развертывание.
  2. Отличная дока.
  3. Удобное использование.
  4. Интеграция с Testcontainers.

Приведу несколько примеров, как все собирал.

DTO

Собираем POJO по аналогии с таблицей, над которой хотим колдовать, для удобства использования:

@Data public class DtoExample {  
    @ColumnName("id")  
    private long id;  
    @ColumnName("param")  
    private Object param;
    ...

ЗЫ: Здесь начал экспериментировать с Lombok для генерации всяких геттеров/сеттеров.

Для удобства накидываем в аннотацию название колонки в таблице, чтобы не лазить в psql каждый раз.

DAO

В JDBI есть Declarative API, рекомендую ознакомиться. Во-первых, удобно. Во-вторых, очень красивый код.

public interface DaoExample {  
    @SqlUpdate(
    """  
        INSERT INTO example_table (
        param1,
        param2,
        param3
        ) VALUES (
        :param1,
        :param2,
        :param3
        )
    """
    )  
    @GetGeneratedKeys("id")  
    Long insert(@BindBean DtoExample example);

Что интересно:

  1. Мы получим id сгенерированной сущности в БД — @GetGeneratedKeys(“id”).
  2. Не обязательно указывать все параметры. Если мы укажем только 1 параметр, а остальные будут null, JDBI это срезолвит с помощью @BindBean. Это значит, можно написать один insert на все параметры, а не писать много разных.
  3. JDBI получает данные из бина через геттеры. Важно, чтобы они были.

Подключаем

Далее пилим DB Driver. В нашем кейсе крайне удобно, что данные можно взять из контейнера.

abstract public class PostgresDriver {  
  
    private static final Jdbi JDBI = Jdbi.create(  
                    DB.getJdbcUrl(),  
                    DB.getUsername(),  
                    DB.getPassword()  
            )  
            .installPlugin(new PostgresPlugin())  
            .installPlugin(new SqlObjectPlugin());
            
    public static Jdbi getDriver() {  
        return JDBI;  
    }  
}

Наваливаем DAO в драйвер:

public static final DaoExample daoExample = JDBI.onDemand(DaoExample.class);

И делаем insert после инициализации нашей БД. Примерно так:

dtoExamplePojo.setId(daoExample.insert(dtoExamplePojo));

Ура, данные добавлены в БД, повторить нужное количество раз. Таким же образом можно делать select и прочие sql-команды, пользуйтесь.

Справедливости ради скажу, что большую часть времени при работе занимает не код, а работа с самими таблицами. Отслеживание взаимосвязей, добавление подсущностей, чтобы основная сущность работала корректно, и т.д. и т.п. Команда \d в psql использовалась сотню раз, если не больше.

Заключение

В принципе все, с данным набором наше приложение стартует -> живет -> умирает в докере. Какие бенефиты мы из этого получили:

  1. Рантайм теперь не 34 минуты, а 14. Можно дальше безбожно плодить тесты.
  2. Не занимается мануальный QA-стенд, а главное — не засоряется. Теперь он всегда свободен и чист.
  3. Помимо всего прочего, теперь каждый прогон — это тест на развертывание системы.
  4. Неограниченные возможности допила — сбор дампов, Kibana и прочие ребята.
  5. Бест-практис с другим проектом.
  6. Разработчики могут использовать контейнер в своих целях, хоть клиентам отдавать. Сам контейнер получился довольно гуд.

Затраты

Несмотря на то, что описанное могло показаться простым, этот этап занял тоже полгода, а если верить таск-трекеру — 340 часов :))) Если ваше приложение уже в контейнере или легко в него интегрируется(или хотя бы у него не 120 таблиц в postgres), естественно, этот этап займет куда меньше.

Профит

В этой части сложно выделить профит, т.к. доп. тесты не писались. Здесь, скорее, была подготовлена благодатная почва, чтобы расширить тесты, при этом сделать это максимально удобно. Я ожидаю, что такой сетап переварит около 7к тест-кейсов, что кратно расширит возможности регресса.

Роадмап

Планирую еще прикрутить:

  1. nicolaka/netshoot для дампов.
  2. Контейнер Kibana, чтобы удобно снифать ошибки из логов.
  3. Умный log consumer, который запилил Андрюха для контейнеров.
  4. Накатить многопоточку, когда рантайм опять вылезет за 30 минут.

Может, чуть позже обозрею.

Послесловие

Вот такой интересный кейс получился. Казалось бы, в сумме такой нехитрый фреймворк занял 520 часов разработки, экспериментов, слез, желаний уехать в дурку. Много это или мало — решайте сами.

Хочу выразить респект Андрюхе, которого не раз упоминал на протяжении статьи. Это мой Бадди, наставник, сенсей. @xepyk, большое спасибо за помощь!

Комментарии