[Из песочницы] Учимся разворачивать микросервисы. Часть 1. Spring Boot и Docker

1xlr2ld51ukpfae4gtnxoclaogo.jpeg

Привет, Хабр.

В этой статье я хочу рассказать о своем опыте создания учебной среды для экспериментов с микросервисами. При изучении каждого нового инструмента мне всегда хотелось его попробовать не только на локальной машине, но и в более реалистичных условиях. Поэтому я решил создать упрощенное микросервисное приложение, которое впоследствии можно будет «обвешивать» всякими интересными технологиями. Основное требование к проекту — его максимальная функциональная приближенность к реальной системе.

Изначально я разбил создание проекта на несколько шагов:


  1. Создать два сервиса — 'бекенд' (backend) и 'шлюз' (gateway), упаковать их в docker-образы и настроить их совместную работу

    Ключевые слова: Java 11, Spring Boot, Docker, image optimization


  2. Написать Kubernetes конфигурацию и задеплоить систему в Google Kubernetes Engine

    Ключевые слова: Kubernetes, GKE, resource management, autoscaling, secrets


  3. Настройка Jenkins и пайплайна для автоматической доставки кода в кластер

    Ключевые слова: Jenkins configuration, plugins, separate configs repository


  4. Создание чарта с помощью Helm 3 для разворачивания кластера в одну команду

    Ключевые слова: Helm 3, chart deployment


Каждому шагу я планирую посвятить отдельную статью.

Направленность этого цикла статей заключается не в том, как написать микросервисы, а как заставить их работать в единой системе. Хоть все эти вещи обычно лежат за пределами ответственности разработчика, думаю, что все равно полезно быть знакомым с ними хотя бы на 20% (которые, как известно, дают 80% результата). Некоторые безусловно важные темы, такие как обеспечение безопасности, будут оставлены за скобками этого проекта, так как автор в этом мало что понимает система создается исключительно для личного пользования. Я буду рад любым мнениям и конструктивной критике.


Создание микросервисов

Сервисы были написаны на Java 11 с использованием Spring Boot. Протокол межсервисного взаимодействия — REST. Проект будет включать в себя минимальное количество тестов (чтобы потом было, что тестировать в Jenkins). Исходный код сервисов доступен на GitHub: бекенд и шлюз.

Чтобы иметь иметь возможность проверить состояние каждого из сервисов, в их зависимости был добавлен Spring Actuator. Он создаст эндпойнт /actuator/health и будет возвращать 200 статус, если сервис готов принимать траффик, или 504 в случае проблем. В данном случае это довольно фиктивная проверка, так как сервисы очень просты, и при каком-то форсмажоре они скорее станут полностью недоступны, чем сохранят частичную работоспособность. Но в реальных системах Actuator может помочь диагностировать проблему до того, как об нее начнут биться пользователи. Например, при возникновении проблем с доступом к БД, мы сможем автоматически на это среагировать, прекратив обрабатывать запросы сломанным экземпляром сервиса.


Сервис Backend

Сервис бекенда будет просто считать и отдавать количество принятых запросов.

Код контроллера:

@RestController
public class RequestsCounterController {

    private final AtomicLong counter = new AtomicLong();

    @GetMapping("/requests")
    public Long getRequestsCount() {
        return counter.incrementAndGet();
    }
}

Тест на контроллер:

@WebMvcTest(RequestsCounterController.class)
public class RequestsCounterControllerTests {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void firstRequest_one() throws Exception {
        mockMvc.perform(get("/requests"))
            .andExpect(status().isOk())
            .andExpect(MockMvcResultMatchers.content().string("1"));
    }
}


Сервис Gateway

Шлюз будет переадресовывать запрос сервису бекенда, дополняя его следующей информацией:


  • id шлюза. Он нужен, чтобы можно было по ответу сервера отличить один экземпляр шлюза от другого
  • Некий «секрет», который будет играть роль очень важного пароля (№ ключа шифрования важной куки)

Конфигурация в application.properties:

backend.url=http://localhost:8081
instance.id=${random.int}
secret="default-secret"

Адаптер для связи с бекендом:

@Service
public class BackendAdapter {

    private static final String REQUESTS_ENDPOINT = "/requests";

    private final RestTemplate restTemplate;

    @Value("${backend.url}")
    private String backendUrl;

    public BackendAdapter(RestTemplateBuilder builder) {
        restTemplate = builder.build();
    }

    public String getRequests() {
        ResponseEntity response = restTemplate.getForEntity(
backendUrl + REQUESTS_ENDPOINT, String.class);
        return response.getBody();
    }
}

Контроллер:

@RestController
@RequiredArgsConstructor
public class EndpointController {

    private final BackendAdapter backendAdapter;

    @Value("${instance.id}")
    private int instanceId;

    @Value("${secret}")
    private String secret;

    @GetMapping("/")
    public String getRequestsCount() {
        return String.format("Number of requests %s (gateway %d, secret %s)", backendAdapter.getRequests(), instanceId, secret);
    }
}


Запуск:

Запускаем бекенд:

./mvnw package -DskipTests
java -Dserver.port=8081 -jar target/microservices-backend-1.0.0.jar

Запускаем шлюз:

./mvnw package -DskipTests
java -jar target/microservices-backend-1.0.0.jar

Проверяем:

$ curl http://localhost:8080/
Number of requests 1 (gateway 38560358, secret "default-secret")

Все работает. Внимательный читатель отметит, что нам ничего не мешает обратиться к бекенду напрямую в обход шлюза (http://localhost:8081/requests). Чтоб это исправить, сервисы должны быть объединены в одну сеть, а наружу «торчать» должен только шлюз.
Также оба сервиса делят одну файловую систему, плодят потоки и в один момент могут начать мешать друг другу. Было бы неплохо изолировать наши микросервисы. Этого можно достичь с помощью разнесения приложений по разным машинам (много денег, сложно), использования виртуальных машин (ресурсоемко, долгий запуск) или же с помощью контейнеризации. Ожидаемо выбираем третий вариант и Docker как инструмент для контейнеризации.


Docker

Если вкратце, то докер создает изолированные контейнеры, по одному на приложение. Чтобы использовать докер, требуется написать Dockerfile — инструкцию по сборке и запуску приложения. Далее можно будет собрать образ, загрузить его в реестр образов (№ DockerHub) и в одну команду развернуть свой микросервис в любой докеризированной среде.


Dockerfile

Одна из важнейшей характеристик образа — это его размер. Компактный образ быстрее скачается с удаленного репозитория, займет меньше места, и ваш сервис быстрее стартует. Любой образ строится на основании базового образа, и рекомендуется выбирать наиболее минималистичный вариант. Хорошим вариантом является Alpine — полноценный дистрибутив Linux с минимумом пакетов.

Для начала попробуем написать Dockerfile «в лоб»:

FROM adoptopenjdk/openjdk11:jdk-11.0.5_10-alpine
ADD . /src
WORKDIR /src
RUN ./mvnw package -DskipTests
EXPOSE 8080
ENTRYPOINT ["java","-jar","target/microservices-gateway-1.0.0.jar"]

Здесь мы используем базовый образ на основе Alpine с уже установленным JDK для сборки нашего проекта. Командой ADD мы добавляем в образ текущую директорию src, отмечаем ее рабочей (WORKDIR) и запускаем сборку. Команда EXPOSE 8080 сигнализирует докеру, что приложение в контейнере будет использовать его порт 8080 (это не сделает приложение доступным извне, но позволит обратиться к приложению, например, из другого контейнера в той же сети докера).

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

docker image build . -t msvc-backend:1.0.0

В результате получаем образ размером в 456 Мбайт (из них базовый образ JDK 340 занял Мбайт). И все притом, что классов в нашем проекте по пальцем пересчитать. Чтобы уменьшить размер нашего образа:


  • Используем многошаговую сборку. На первом шаге соберем проект, на втором установим JRE, а третим шагом скопируем все это в новый чистый Alpine образ. Итого в финальном образе окажутся только необходимые компоненты.
  • Воспользуемся модуляризацией java. Начиная с Java 9, можно с помощью инструмента jlink создать JRE только из нужных модулей

Для любознательных, вот хорошая статья про подходы уменьшения размеров образа https://habr.com/ru/company/ruvds/blog/485650/.

Итоговый Dockerfile:

FROM adoptopenjdk/openjdk11:jdk-11.0.5_10-alpine as builder
ADD . /src
WORKDIR /src
RUN ./mvnw package -DskipTests

FROM alpine:3.10.3 as packager
RUN apk --no-cache add openjdk11-jdk openjdk11-jmods
ENV JAVA_MINIMAL="/opt/java-minimal"
RUN /usr/lib/jvm/java-11-openjdk/bin/jlink \
    --verbose \
    --add-modules \
        java.base,java.sql,java.naming,java.desktop,java.management,java.security.jgss,java.instrument \
    --compress 2 --strip-debug --no-header-files --no-man-pages \
    --release-info="add:IMPLEMENTOR=radistao:IMPLEMENTOR_VERSION=radistao_JRE" \
    --output "$JAVA_MINIMAL"

FROM alpine:3.10.3
LABEL maintainer="Anton Shelenkov anshelen@yandex.ru"
ENV JAVA_HOME=/opt/java-minimal
ENV PATH="$PATH:$JAVA_HOME/bin"
COPY --from=packager "$JAVA_HOME" "$JAVA_HOME"
COPY --from=builder /src/target/microservices-backend-*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","/app.jar"]

Пересоздаем образ, и он в итоге похудел в 6 раз, составив 77 МБайт. Неплохо. После, готовые образы можно загрузить в реестр образов, чтобы ваши образы были доступны для скачивания из интернета.


Совместный запуск сервисов в Docker

Для начала наши сервисы должны быть в одной сети. В докере существует несколько типов сетей, и мы используем самый примитивный из них — bridge, позволяющий объединять в сеть контейнеры, запущенные на одном хосте. Создадим сеть следующей командой:

docker network create msvc-network

Далее запустим контейнер бекенда с именем 'backend' с образом microservices-backend:1.0.0:

docker run -dit --name backend --network msvc-net microservices-backend:1.0.0

Стоит отметить, что bridge-сеть предоставляет из коробки service discovery для контейнеров по их именам. То есть сервис бекенда будет доступен внутри сети докера по адресу http://backend:8080.

Запускаем шлюз:

docker run -dit -p 80:8080 --env secret=my-real-secret --env BACKEND_URL=http://backend:8080/ --name gateway --network msvc-net microservices-gateway:1.0.0

В этой команде мы указываем, что мы пробрасываем 80 порт нашего хоста на 8080 порт контейнера. Опции env мы используем для установки переменных среды, которые автоматически будут вычитаны спрингом и переопределят свойства из application.properties.

После запуска вызываем http://localhost/ и убеждаемся, что все работает, как и в прошлом случае.


Заключение

В итоге мы создали два простеньких микросервиса, упаковали их в докер-контейнеры и совместно запустили на одной машине. У полученной системы, однако, есть ряд недостатков:


  • Плохая отказоустойчивость — у нас все работает на одном сервере
  • Плохая масштабируемость — при увеличении нагрузки было бы неплохо автоматически разворачивать дополнительные экземпляры сервисов и балансировать нагрузку между ними
  • Сложность запуска — нам понадобилось ввести как минимум 3 команды, причем с определенными параметрами (это только для 2 сервисов)

Для устранения вышеперечисленных проблем существует ряд решений, таких как Docker Swarm, Kubernetes или OpenShift. Если вся система будет написана на Java можно посмотреть в сторону Spring Cloud (хорошая статья).

В следующей части я расскажу про то, как я настраивал Kubernetes и деплоил проект в Google Kubernetes Engine.

© Habrahabr.ru