Привет.
В этой статье я хочу рассказать о своем опыте создания учебной среды для экспериментов с микросервисами. При изучении каждого нового инструмента мне всегда хотелось его попробовать не только на локальной машине, но и в более реалистичных условиях. Поэтому я решил создать упрощенное микросервисное приложение, которое впоследствии можно будет "обвешивать" всякими интересными технологиями. Основное требование к проекту — его максимальная функциональная приближенность к реальной системе.
Изначально я разбил создание проекта на несколько шагов:
-
Создать два сервиса — 'бекенд' (backend) и 'шлюз' (gateway), упаковать их в docker-образы и настроить их совместную работу
Ключевые слова: Java 11, Spring Boot, Docker, image optimization
-
Написать Kubernetes конфигурацию и задеплоить систему в Google Kubernetes Engine
Ключевые слова: Kubernetes, GKE, resource management, autoscaling, secrets
-
Настройка Jenkins и пайплайна для автоматической доставки кода в кластер
Ключевые слова: Jenkins configuration, plugins, separate configs repository
-
Создание чарта с помощью 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<String> 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.
Автор: Anshelen