Мир сходит с ума, заталкивая калькулятор для 2+2 в облака. Чем мы хуже? Давайте Hello World затолкаем в три микросервиса, напишем пару-тройку тестов, обеспечим пользователей документацией, нарисуем красивый пайплайн сборки и обеспечим деплой в условный облачный прод при успешном прохождении тестов. Итак, в данной статье будет показан пример того, как может быть построен процесс разработки продукта от спецификации до деплоя в прод. Инетересно? тогда прошу под кат
С чегоооооо начинается Роооо… ?
Нет не Родина, а продукт. Правильно, продукт начинается c идеи. Итак, идея такова:
- нужен сервис, который отдаёт ‘Hello World’ по REST API
- cлово ‘Hello’ отдаёт один микросервис, проектируемый, создаваемый и тестируемый командой_1
- cлово ‘World’ отдаёт второй, который находится в ведении команды_2
- команда_3 пишет интеграционный сервис для склеивания ‘Hello’ и ‘World’
Toolset
- OS (desktop) — Debian 9 Stretch
- IDE — IntelliJ IDEA 2019.1
- Git Repo — GitHub
- CI — Concourse 5.4.0
- Maven Repo — Nexus
- OpenJDK 11
- Maven 3.6.0
- Kubernetes 1.14 (1 master + 1 worker): calico network, nginx-ingress-controller
Важная заметка: статья не о красивом коде (codestyle, checkstyle, javadocs, SOLID и прочие умные слова) и вылизанных до идеала решениях (холиварить про идеальный Hello World можно бесконечно). Она о том, как собрать воедино код, спецификациии, пайплайн сборки и доставки всего собранного в прод, а вместо HelloWorld в реальности у вас может быть какой-нибудь высоконагруженный продукт с кучей сложных и крутых микросервисов, и описанный процесс можно применить к нему.
Из чего состоит сервис?
Сервис в виде конечного продукта должен содержать в себе:
- спецификацию в виде yaml-документа стандарта OpenAPI и уметь отдавать её по запросу (GET /doc)
- методы API в соответствии со спецификацией из первого пункта
- README.md с примерами запуска и конфигурирования сервиса
Будем разбирать сервисы по порядку. Поехали!
‘Hello’ microservice
Specification
Спеки пишем в Swagger Editor’е и конвертируем им же в OpenAPI спеку. Swagger Editor запускается в докере одной командой, конвертация swagger-доки в openapi-доку делается нажатием одной кнопки в UI эдитора, которая шлёт запрос POST /api/convert на http://converter.swagger.io. Итоговая спецификация hello сервиса:
openapi: 3.0.1 info: title: Hello ;) description: Hello microservice version: 1.0.0 servers: - url: https://demo1.bihero.io/api/hello tags: - name: hello description: Everything about saying 'Hello' paths: /: x-vertx-event-bus: address: service.hello timeout: 1000c get: tags: - hello summary: Get 'Hello' word operationId: getHelloWord responses: 200: description: OK /doc: x-vertx-event-bus: address: service.hello timeout: 1000c get: tags: - hello_doc summary: Get 'Hello' microservice documentation operationId: getDoc responses: 200: description: OK components: {}
Implementation
Сервис с точки зрения кода, который надо писать, состоит из 3-х классов:
- интерфейс с методами сервиса (названия методов указаны в спеке как operationId)
- реализация интерфейса
- vertx verticle для биндинга сервиса со спекой (методы api -> методы интерфейса из первого пункта) и для старта http-сервера
Структура файлов в src выглядит примерно так:

<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <properties> <main.verticle>io.bihero.hello.HelloVerticle</main.verticle> <vertx.version>3.8.1</vertx.version> <logback.version>1.2.3</logback.version> <junit-jupiter.version>5.3.1</junit-jupiter.version> <maven-surefire-plugin.version>2.19.1</maven-surefire-plugin.version> <junit-platform-surefire-provider.version>1.1.0</junit-platform-surefire-provider.version> <assertj-core.version>3.8.0</assertj-core.version> <allure.version>2.8.1</allure.version> <allure-maven.version>2.10.0</allure-maven.version> <aspectj.version>1.9.2</aspectj.version> <mockito.version>2.21.0</mockito.version> <rest-assured.version>3.0.0</rest-assured.version> </properties> <groupId>io.bihero</groupId> <artifactId>hello-microservice</artifactId> <version>1.0.0-SNAPSHOT</version> <build> <plugins> <plugin> <artifactId>maven-compiler-plugin</artifactId> <configuration> ```1.8</source> <target>1.8</target> </configuration> <executions> <execution> <id>default-compile</id> <configuration> <annotationProcessors> <annotationProcessor>io.vertx.codegen.CodeGenProcessor</annotationProcessor> </annotationProcessors> <generatedSourcesDirectory>src/main/generated</generatedSourcesDirectory> <compilerArgs> <arg>-Acodegen.output=${project.basedir}/src/main</arg> </compilerArgs> </configuration> </execution> <execution> <id>default-testCompile</id> <configuration> <annotationProcessors> <annotationProcessor>io.vertx.codegen.CodeGenProcessor</annotationProcessor> </annotationProcessors> <generatedTestSourcesDirectory>src/test/generated</generatedTestSourcesDirectory> <compilerArgs> <arg>-Acodegen.output=${project.basedir}/src/test</arg> </compilerArgs> </configuration> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>${maven-surefire-plugin.version}</version> <configuration> <properties> <property> <name>listener</name> <value>io.qameta.allure.junit5.AllureJunit5</value> </property> </properties> <includes> <include>**/*Test*.java</include> </includes> <argLine> -javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar" -Djdk.net.URLClassPath.disableClassPathURLCheck=true </argLine> <systemProperties> <property> <name>allure.results.directory</name> <value>${project.basedir}/target/allure-results</value> </property> <property> <name>junit.jupiter.extensions.autodetection.enabled</name> <value>true</value> </property> </systemProperties> <reportFormat>plain</reportFormat> </configuration> <dependencies> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> <version>${aspectj.version}</version> </dependency> <dependency> <groupId>org.junit.platform</groupId> <artifactId>junit-platform-surefire-provider</artifactId> <version>${junit-platform-surefire-provider.version}</version> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>${junit-jupiter.version}</version> </dependency> </dependencies> </plugin> <plugin> <groupId>io.qameta.allure</groupId> <artifactId>allure-maven</artifactId> <version>${allure-maven.version}</version> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-site-plugin</artifactId> <version>3.7.1</version> <dependencies> <dependency> <groupId>org.apache.maven.wagon</groupId> <artifactId>wagon-webdav-jackrabbit</artifactId> <version>2.8</version> </dependency> </dependencies> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-project-info-reports-plugin</artifactId> <version>3.0.0</version> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>2.3</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <manifestEntries> <Main-Class>io.vertx.core.Launcher</Main-Class> <Main-Verticle>${main.verticle}</Main-Verticle> </manifestEntries> </transformer> <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer"> <resource>META-INF/services/io.vertx.core.spi.VerticleFactory</resource> </transformer> </transformers> <artifactSet> </artifactSet> <outputFile>${project.build.directory}/${project.artifactId}-fat.jar</outputFile> </configuration> </execution> </executions> </plugin> </plugins> <resources> <resource> <directory>src/main/resources</directory> <filtering>true</filtering> <includes> <include>**/version.txt</include> </includes> </resource> <resource> <directory>src/main/resources</directory> <filtering>false</filtering> <excludes> <exclude>**/version.txt</exclude> </excludes> </resource> </resources> </build> <distributionManagement> <site> <id>reports</id> <url>dav:https://nexus.dev.techedge.pro:8443/repository/reports/${project.artifactId}/</url> </site> </distributionManagement> <reporting> <excludeDefaults>true</excludeDefaults> <plugins> <plugin> <groupId>io.qameta.allure</groupId> <artifactId>allure-maven</artifactId> <configuration> <resultsDirectory>${project.build.directory}/allure-results</resultsDirectory> <reportDirectory>${project.reporting.outputDirectory}/${project.version}/allure</reportDirectory> </configuration> </plugin> </plugins> </reporting> <dependencies> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-web-api-service</artifactId> <version>${vertx.version}</version> </dependency> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-codegen</artifactId> <version>${vertx.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>${logback.version}</version> </dependency> <!-- test –>--> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-unit</artifactId> <version>${vertx.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-junit5</artifactId> <version>${vertx.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <version>${junit-jupiter.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>${junit-jupiter.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>${assertj-core.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>${mockito.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>io.qameta.allure</groupId> <artifactId>allure-junit5</artifactId> <version>${allure.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-web-client</artifactId> <version>${vertx.version}</version> <scope>test</scope> </dependency> </dependencies> </project>
package io.bihero.hello; import io.vertx.core.AsyncResult; import io.vertx.core.Handler; import io.vertx.core.Vertx; import io.vertx.ext.web.api.OperationRequest; import io.vertx.ext.web.api.OperationResponse; import io.vertx.ext.web.api.generator.WebApiServiceGen; @WebApiServiceGen public interface HelloService { static HelloService create(Vertx vertx) { return new DefaultHelloService(vertx); } void getHelloWord(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler); void getDoc(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler); }
package io.bihero.hello; import io.vertx.core.AsyncResult; import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; import io.vertx.ext.web.api.OperationRequest; import io.vertx.ext.web.api.OperationResponse; public class DefaultHelloService implements HelloService { private final Vertx vertx; public DefaultHelloService(Vertx vertx) { this.vertx = vertx; } @Override public void getHelloWord(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler) { resultHandler.handle(Future.succeededFuture(OperationResponse.completedWithPlainText(Buffer.buffer("Hello")))); } @Override public void getDoc(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler) { vertx.fileSystem().readFile("doc.yaml", buffResult -> resultHandler.handle(Future.succeededFuture( OperationResponse.completedWithPlainText(buffResult.result())) )); } }
package io.bihero.hello; import io.vertx.core.AbstractVerticle; import io.vertx.core.Promise; import io.vertx.core.eventbus.MessageConsumer; import io.vertx.core.http.HttpServer; import io.vertx.core.http.HttpServerOptions; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.Router; import io.vertx.ext.web.api.contract.openapi3.OpenAPI3RouterFactory; import io.vertx.serviceproxy.ServiceBinder; public class HelloVerticle extends AbstractVerticle { private HttpServer server; private MessageConsumer<JsonObject> consumer; @Override public void start(Promise<Void> promise) { startHelloService(); startHttpServer().future().setHandler(promise); } /** * This method closes the http server and unregister all services loaded to Event Bus */ @Override public void stop(){ this.server.close(); consumer.unregister(); } private void startHelloService() { consumer = new ServiceBinder(vertx).setAddress("service.hello") .register(HelloService.class, HelloService.create(getVertx())); } /** * This method constructs the router factory, mounts services and handlers and starts the http server * with built router * @return */ private Promise<Void> startHttpServer() { Promise<Void> promise = Promise.promise(); OpenAPI3RouterFactory.create(this.vertx, "/doc.yaml", openAPI3RouterFactoryAsyncResult -> { if (openAPI3RouterFactoryAsyncResult.succeeded()) { OpenAPI3RouterFactory routerFactory = openAPI3RouterFactoryAsyncResult.result(); // Mount services on event bus based on extensions routerFactory.mountServicesFromExtensions(); // Generate the router Router router = routerFactory.getRouter(); int port = config().getInteger("serverPort", 8080); String host = config().getString("serverHost", "localhost"); server = vertx.createHttpServer(new HttpServerOptions().setPort(port).setHost(host)); server.requestHandler(router).listen(ar -> { // Error starting the HttpServer if (ar.succeeded()) promise.complete(); else promise.fail(ar.cause()); }); } else { // Something went wrong during router factory initialization promise.fail(openAPI3RouterFactoryAsyncResult.cause()); } }); return promise; } }
В интерфейсе сервиса и его имплементации нет ничего необычного (за исключением аннотации @WebApiServiceGen, но про него можно почитать в документации), а вот код verticle-класса рассмотрим подробнее.
Интересны два метода, которые вызываются на старта вертикла:
- startHelloService создает объект с имплеменатацией нашего сервиса и биндит его на адрес в event bus (вспомним параметр x-vertx-event-bus.address из спецификации выше)
- startHttpServer создаёт router factory на основе спецификации сервиса, создаёт http-сервер и прицепляет созданный router к хэндлеру всех входящих http-запросов (если гурбо, то запрос GET / будет падать в event bus vertex’а с адресом service.hello (а туда мы забиндили реализацию сервиса io.bihero.hello.HelloService) и с именем метода сервиса getHelloWord)
Пора собрать джарник и пробовать запускать:
mvn clean package # собираем джарник java -Dlogback.configurationFile=./src/conf/logback-console.xml -jar target/hello-microservice-fat.jar -conf ./src/conf/config.json # запускаем сервис
В строке запуска интересны два параметра:
- -Dlogback.configurationFile=./src/conf/logback-console.xml — путь до конфиг-файла для logback (в зависимостях проекта должны быть slf4j и logback как имплементация slf4j-api)
- -conf ./src/conf/config.json — конфиг сервиса, там для нас важен порт, на котором будет открыт http REST API:
{ "type": "file", "format": "json", "scanPeriod": 5000, "config": { "path": "/home/slava/JavaProjects/hello-world-to-cloud/hellomicroservice/src/conf/config.json" }, "serverPort": 8081, "serverHost": "0.0.0.0" }
Вывод maven’а нам особо не интересен, а вот как стартанул сервис, можно посмотреть (в настройках логгера для пакета io.netty выставлен level=»INFO»)
2019-10-03 20:52:45,159 [vert.x-worker-thread-0] DEBUG i.s.v.p.OpenAPIV3Parser: Loaded raw data: openapi: 3.0.1 info: title: Hello ;) description: Hello microservice version: 1.0.0 servers: - url: https://demo1.bihero.io/api/hello tags: - name: hello description: Everything about saying 'Hello' paths: /: x-vertx-event-bus: address: service.hello timeout: 1000c get: tags: - hello summary: Get 'Hello' word operationId: getHelloWord responses: 200: description: OK /doc: x-vertx-event-bus: address: service.hello timeout: 1000c get: tags: - hello_doc summary: Get 'Hello' microservice documentation operationId: getDoc responses: 200: description: OK components: {} 2019-10-03 20:52:45,195 [vert.x-worker-thread-0] DEBUG i.s.v.p.OpenAPIV3Parser: Parsed rootNode: {"openapi":"3.0.1","info":{"title":"Hello ;)","description":"Hello microservice","version":"1.0.0"},"servers":[{"url":"https://demo1.bihero.io/api/hello"}],"tags":[{"name":"hello","description":"Everything about saying 'Hello'"}],"paths":{"/":{"x-vertx-event-bus":{"address":"service.hello","timeout":"1000c"},"get":{"tags":["hello"],"summary":"Get 'Hello' word","operationId":"getHelloWord","responses":{"200":{"description":"OK"}}}},"/doc":{"x-vertx-event-bus":{"address":"service.hello","timeout":"1000c"},"get":{"tags":["hello_doc"],"summary":"Get 'Hello' microservice documentation","operationId":"getDoc","responses":{"200":{"description":"OK"}}}}},"components":{}} Oct 03, 2019 8:52:45 PM io.vertx.core.impl.launcher.commands.VertxIsolatedDeployer INFO: Succeeded in deploying verticle
Ура! Сервис заработал, можно проверять:
curl http://127.0.0.1:8081/ Hello curl -v http://127.0.0.1:8081/doc openapi: 3.0.1 info: title: Hello ;) description: Hello microservice version: 1.0.0 servers: - url: https://demo1.bihero.io/api/hello tags: - name: hello description: Everything about saying 'Hello' paths: /: x-vertx-event-bus: address: service.hello timeout: 1000c get: tags: - hello summary: Get 'Hello' word operationId: getHelloWord responses: 200: description: OK /doc: x-vertx-event-bus: address: service.hello timeout: 1000c get: tags: - hello_doc summary: Get 'Hello' microservice documentation operationId: getDoc responses: 200: description: OK components: {}
Сервис отвечает словом Hello на запрос GET /, что соответствует спецификации, и умеет говорить о том, что он умеет делать, отдавая специфкацию по запросу GET /doc. Круто, идём в прод!
Что-то тут не так …
Ранее я писал, что нам не особо важен вывод maven’а при сборке. Я наврал, вывод важен и очень. Нам нужно, чтобы maven запускал тесты и при падении тестов сборка падала. Сборка выше прошла, и это говорит о том, что либо тесты прошли, либо их нет. Тестов у нас, конечно же, нет, настала пора их написать (тут можно поспорить о методологиях, о том когда и как писать тесты, до или после имплементации, но мы вспомним про важную заметку вначала статьи и пойдём дальше — напишем парочку тестов).
Первый тест-класс является по своей природе юнит-тестом, проверяющим два конкретных метода нашего сервиса:
package io.bihero.hello; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.api.OperationRequest; import io.vertx.junit5.VertxExtension; import io.vertx.junit5.VertxTestContext; import org.apache.commons.io.IOUtils; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import java.io.IOException; import static org.assertj.core.api.Assertions.assertThat; @ExtendWith(VertxExtension.class) public class HelloServiceTest { private HelloService helloService = HelloService.create(Vertx.vertx()); @Test @DisplayName("Test 'getHelloWord' method returns 'Hello' word") public void testHelloMethod(VertxTestContext testContext) { helloService.getHelloWord(new OperationRequest(new JsonObject()), testContext.succeeding(it -> { assertThat(it.getStatusCode()).isEqualTo(200); assertThat(it.getPayload().toString()).isEqualTo("Hello"); testContext.completeNow(); })); } @Test @DisplayName("Test 'getDoc' method returns service documentation in OpenAPI format") public void testDocMethod(VertxTestContext testContext) { helloService.getDoc(new OperationRequest(new JsonObject()), testContext.succeeding(it -> { try { assertThat(it.getStatusCode()).isEqualTo(200); assertThat(it.getPayload().toString()).isEqualTo(IOUtils.toString(this.getClass() .getResourceAsStream("../../../doc.yaml"), "UTF-8")); testContext.completeNow(); } catch (IOException e) { testContext.failNow(e); } })); } }
Второй тест — недоинтеграционный тест, проверяющий, что вертикл поднимается и отвечает на соответствующие http запросы ожидаемыми статусами и текстом:
package io.bihero.hello; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.client.WebClient; import io.vertx.ext.web.codec.BodyCodec; import io.vertx.junit5.Checkpoint; import io.vertx.junit5.VertxExtension; import io.vertx.junit5.VertxTestContext; import org.apache.commons.io.IOUtils; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.spy; @ExtendWith(VertxExtension.class) public class HelloVerticleTest { @Test @DisplayName("Test that verticle is up and respond me by 'Hello' word and doc in OpenAPI format") public void testHelloVerticle(Vertx vertx, VertxTestContext testContext) { WebClient webClient = WebClient.create(vertx); Checkpoint deploymentCheckpoint = testContext.checkpoint(); Checkpoint requestCheckpoint = testContext.checkpoint(2); HelloVerticle verticle = spy(new HelloVerticle()); JsonObject config = new JsonObject().put("serverPort", 8081).put("serverHost", "0.0.0.0"); doReturn(config).when(verticle).config(); vertx.deployVerticle(verticle, testContext.succeeding(id -> { deploymentCheckpoint.flag(); // test GET / webClient.get(8081, "localhost", "/") .as(BodyCodec.string()) .send(testContext.succeeding(resp -> { assertThat(resp.body()).isEqualTo("Hello"); assertThat(resp.statusCode()).isEqualTo(200); requestCheckpoint.flag(); })); // test GET /doc webClient.get(8081, "localhost", "/doc") .as(BodyCodec.string()) .send(testContext.succeeding(resp -> { try { assertThat(resp.body()).isEqualTo(IOUtils.toString(this.getClass() .getResourceAsStream("../../../doc.yaml"), "UTF-8")); assertThat(resp.statusCode()).isEqualTo(200); requestCheckpoint.flag(); } catch (Exception e) { requestCheckpoint.flag(); testContext.failNow(e); } })); })); } }
Пора собирать сервис вместе с тестами:
mvn clean package
Нас очень интересует лог плагина surefire, выглядеть он будет примерно так (картинка кликабельна):
Здорово! Сервис собирается, тесты бегут и не падают (чуть позже поговорим о красоте того, как результаты тестов показывать начальству), пора задуматься о том, как мы будем его доставлять до пользователей (то есть до серверов). На дворе конец 2019-го, и, конечно же, бандлить приложение мы будем в виде docker-образа. Поехали!
Docker и все все все
Docker image для нашего первого сервиса будем собирать на основе adoptopenjdk/openjdk11. Добавим в образ наш собранный джарник со всеми необходимыми конфигами и пропишем в докерфайле команду для старта приложения в контейнере. Итоговый Dockerfile будет выглядеть так:
FROM adoptopenjdk/openjdk11:alpine-jre COPY target/hello-microservice-fat.jar app.jar COPY src/conf/config.json . COPY src/conf/logback-console.xml . COPY run.sh . RUN chmod +x run.sh CMD ["./run.sh"]
Скрипт run.sh выглядит так:
#!/bin/sh java ${JVM_OPTS} -Dlogback.configurationFile=./logback-console.xml -jar app.jar -conf config.json
Переменная окружения JVM_OPTS нам на этом этапе пока не особо нужна, но чуть позже мы будем её активно менять и тюнить параметры виртуальной машины и наших сервисов. Пора собрать образ и запустить приложение в контейнере:
docker build -t="hellomicroservice" . docker run -dit --name helloms hellomicroservice # посмотрим в логи контейнера, что он там нам позапускал docker logs -f helloms # вывод docker logs 2019-10-05 14:55:46,059 [vert.x-worker-thread-0] DEBUG i.s.v.p.OpenAPIV3Parser: Loaded raw data: openapi: 3.0.1 info: title: Hello ;) description: Hello microservice version: 1.0.0 servers: - url: https://demo1.bihero.io/api/hello tags: - name: hello description: Everything about saying 'Hello' paths: /: x-vertx-event-bus: address: service.hello timeout: 1000c get: tags: - hello summary: Get 'Hello' word operationId: getHelloWord responses: 200: description: OK /doc: x-vertx-event-bus: address: service.hello timeout: 1000c get: tags: - hello_doc summary: Get 'Hello' microservice documentation operationId: getDoc responses: 200: description: OK components: {} 2019-10-05 14:55:46,098 [vert.x-worker-thread-0] DEBUG i.s.v.p.OpenAPIV3Parser: Parsed rootNode: {"openapi":"3.0.1","info":{"title":"Hello ;)","description":"Hello microservice","version":"1.0.0"},"servers":[{"url":"https://demo1.bihero.io/api/hello"}],"tags":[{"name":"hello","description":"Everything about saying 'Hello'"}],"paths":{"/":{"x-vertx-event-bus":{"address":"service.hello","timeout":"1000c"},"get":{"tags":["hello"],"summary":"Get 'Hello' word","operationId":"getHelloWord","responses":{"200":{"description":"OK"}}}},"/doc":{"x-vertx-event-bus":{"address":"service.hello","timeout":"1000c"},"get":{"tags":["hello_doc"],"summary":"Get 'Hello' microservice documentation","operationId":"getDoc","responses":{"200":{"description":"OK"}}}}},"components":{}} Oct 05, 2019 2:55:46 PM io.vertx.core.impl.launcher.commands.VertxIsolatedDeployer INFO: Succeeded in deploying verticle
Достанем ip-адрес контейнера и проверим работу сервиса внутри контейнера:
docker inspect helloms | grep IPAddress "SecondaryIPAddresses": null, "IPAddress": "172.17.0.2", "IPAddress": "172.17.0.2",
curl http://172.17.0.2:8081/ # тут ожидаем увидеть слово 'Hello' в ответе curl http://172.17.0.2:8081/doc # тут ждём описание сервиса в формате OpenAPI
Итак, сервис запускается в контейнере. Но мы же не будем его руками вот так (docker run) запускать в production-окружении, для этого у нас есть прекрасный kubernetes. Чтобы запустить приложение в kubernetes, нам нужен шаблон, yml-файл, с описанием того, какие ресурсы (deployment, service, ingress, etc) мы будем запускать и на основе какого контейнера. Но, прежде чем мы начнём описывать темплейт для запуска приложения в k8s, пушнем ка собранный ранее образ на докерхаб:
docker tag hello bihero/hello docker push bihero/hello
Пишем темплейт для запуска приложения в kubernetes (в рамках статьи мы не настоящие сварщики и не претендуем на «кошерность» темплейта):
apiVersion: extensions/v1beta1 kind: Deployment metadata: labels: io.bihero.hello.service: bihero-hello name: bihero-hello spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 1 template: metadata: labels: io.bihero.hello.service: bihero-hello spec: containers: - image: bihero/hello:${HELLO_SERVICE_IMAGE_VERSION} name: bihero-hello ports: - containerPort: 8081 imagePullPolicy: Always resources: {} restartPolicy: Always --- apiVersion: v1 kind: Service metadata: labels: io.bihero.hello.service: bihero-hello name: bihero-hello spec: ports: - name: "8081" port: 8081 targetPort: 8081 selector: io.bihero.hello.service: bihero-hello status: loadBalancer: {} --- apiVersion: extensions/v1beta1 kind: Ingress metadata: name: bihero-hello annotations: kubernetes.io/ingress.class: nginx nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/secure-backends: "false" nginx.ingress.kubernetes.io/ssl-passthrough: "false" nginx.ingress.kubernetes.io/rewrite-target: /$2 kubernetes.io/tls-acme: "true" namespace: default spec: tls: - hosts: - ${ID_DOMAIN} secretName: bihero rules: - host: ${ID_DOMAIN} http: paths: - path: /api/hello(/|$)(.*) backend: serviceName: bihero-hello servicePort: 8081
Кратко о том, что мы видим в шаблоне:
- Deployment: тут описываем, из какого образа деплоимся и из какого количества инстансов создаём репликасет для нашего сервиса. Также важно обратить внимание на metadata.labels — по ним будем привязывать Service к Deployment
- Service: привязываем сервис к деплойменту/репликасету. По сути сервис в k8s — это то, к чему уже можно слать http-запроcы внутри кластера (и да — обращаем внимание на selector)
- Ingress: ингресс нужен для того, чтобы сервис выставить наружу, во внешний мир. Все запросы начинающиеся с /api/hello будем заворачивать на наш hello-сервис (https://domain.com/api/hello -> http://bihero-hello.service.internal.domain.local:8081/)
Также в шаблоне фигурируют два переменных окружения:
- ${HELLO_SERVICE_IMAGE_VERSION} — тег docker-образа с сервисом, из которого будем собирать наш первый deployment
- ${ID_DOMAIN} — домен, на котором развернём наши сервисы
Важное про https
В тестовом кластере уже имеется secret с именем bihero, созданный на основе wildcard-сертификата от LetsEncrypt. Если кратко, то команды выглядит такkubectl create secret tls bihero --key keys/privkey.pem --cert keys/fullchain.pemгде privkey.pem и fullchain.pem — файлы, генерируемые letsencrypt’ом
Подробнее про создание secret’а для tls в k8s можно почитать пройдя по ссылке
Настала пора пробовать деплоиться в k8s 🙂 Поехали!
export HELLO_SERVICE_IMAGE_VERSION=latest export ID_DOMAIN=demo1.bihero.io cat k8s.yaml | envsubst | kubectl apply -f -
В stdout должны увидеть вот это:
deployment.extensions/bihero-hello created service/bihero-hello created ingress.extensions/bihero-hello created
Ну что ж, проверим, что там нам kubernetes наворотил:
kubectl get po # да, вместо pod можно писать po, k8s вас поймёт
Посмотрим подробности одного пода
kubectl describe po bihero-hello-5b4759d55b-bf4qc
Как там сервис поживает?
kubectl describe service bihero-hello
А ингресс?
kubectl describe ing bihero-hello
Здорово! Сервис бегает в k8s и так просится, чтобы его проверили парочкой запросов, согласно спеке.
curl https://demo1.bihero.io/api/hello Hello
curl https://demo1.bihero.io/api/hello/doc openapi: 3.0.1 info: title: Hello ;) description: Hello microservice version: 1.0.0 servers: - url: https://demo1.bihero.io/api/hello tags: - name: hello description: Everything about saying 'Hello' paths: /: x-vertx-event-bus: address: service.hello timeout: 1000c get: tags: - hello summary: Get 'Hello' word operationId: getHelloWord responses: 200: description: OK /doc: x-vertx-event-bus: address: service.hello timeout: 1000c get: tags: - hello_doc summary: Get 'Hello' microservice documentation operationId: getDoc responses: 200: description: OK components: {}
А — Автоматизация
Фух… Дошли до самого вкусного и волнительного. Было сделано немало работы и каждый шаг сопровождался ручным запуском каких-то тулов, на каждом этапе своих. Пора задуматься о том, чтобы все шаги запускались автоматически и триггерили своим завершением следующий шаг паплайна, а на финише был ровный и бесшовный апгрейд нашего сервиса в k8s кластере. Сказано, сделано!
Перед тем как начать пилить автоматизацию, давайте разложим всё по полочкам и нарисуем схему того, как будет бежать пайплайн на CI-сервере.
Что было сделано руками?
- Написали код, написали тесты к коду, прошли всё чеки (кодревью и прочее), закоммитили в git-репозиторий
- Запуск сборки (mvn), прогон тестов (surefire, allure) — на выходе получаем fat-jar с сервисом
- Сборка docker-образа (docker build)
- Push docker-образа на докерхаб (или корпоративный приватный docker registry) (docker push)
- Деплой сервиса в k8s (kubectl apply)
Что будет делать CI-сервер ?
Да всё то же самое, что и мы ручками делали (кроме написания кода и тестов), только по пути будет уведомлять нас о своих действиях и отчёты деплоить в нужные места. Алгоритм выглядит примерно так:

Опишем пайплайн по шагам:
- Пайплайн будет триггерить джобу сборки по коммиту в определенную ветку проекта, пусть это будет ветка master (напрямую в master мы, конечно же, не коммитим, туда коммиты попадают при merge’ах после merge request’ов и тщательного ревью)
- Уведомление команды разработчиков о том, что началась сборка сервиса из вышеуказанной dev-ветки (telegram-bot)
- Прогон тестов
- Проверяем, как поршли тесты
- Тесты прошли успешно — деплоим результат прогона тестов в maven repository (конкретно в нашем кейсе используется nexus blob store)
- Собираем fat-jar (mvn package, но с маленьким хаком, чтобы не компилить по новой код — мы это уже сделали на этапе прогона тестов)
- Собираем docker image из собранного джарника и необходимых конфигов. Тут стоить отметить, что данный шаг делает не только сборку образа, но и пушит его в репозиторий, на который ссылается наш образ как ресурс пайплайна (о ресурсах скоро узнаете). Пуш образа в registry триггерит деплой новой версии сервиса в k8s кластер
- Деплой новой версии сервиса в k8s кластер
- Уведомление команды сервиса о том, что сборка прошла и новая версия сервиса ушла в требуемый k8s кластер. Уведомление содержит ссылку на джобу с логами сборки и ссылку на результат прогона тестов
- Если на 4-м шаге мы понимаем, что тесты не прошли, то деплоим результаты прогона тестов в maven repository
- И уведомляем команду о том, что сборка новой версии сервиса упала со всеми необходимыми ссылками в уведомлении
Concourse CI
Вышеописанный пайплайн мы будем писать под CI-сервер Concourse. Особенности Concourse CI:
- минималистичный UI (всё управление составом пайплайна через yaml-конфиги, которые могут лежать рядом с кодом, и через консольный тул под название fly): это и плюс и минус одновременно — очень удобно и гибко для разработчиков, которые всегда работают с консолью (mvn, docker, fly, kubectl), но неудобно для менеджерского состава, который хочет потыкать в кнопочки (но для них мы будем отчёты писать в tg-группу со ссылками на все необходимые для них ресурсы)
- каждый степ сборки проходит в docker container’е, что даёт гибкость в настройке окружения для каждого степа (не надо на каждой worker-ноде шаманить с настройками, если что-то environment-зависимое захотели поменять в одном из шагов пайплайна) — собрал образ один раз, степ пайплайна подтянет его в момент старта, и дело в шляпе.
Итак, встречайте, пайплайн сбрки:
resource_types: - name: telegram type: docker-image source: repository: vtutrinov/concourse-telegram-resource tag: latest - name: kubernetes type: docker-image source: repository: zlabjp/kubernetes-resource tag: 1.16 - name: metadata type: docker-image source: repository: olhtbr/metadata-resource tag: 2.0.1 resources: - name: metadata type: metadata - name: sources type: git source: branch: master uri: git@github.com:bihero-io/hello-microservice.git private_key: ((deployer-private-key)) - name: docker-image type: docker-image source: repository: bihero/hello username: ((docker-registry-user)) password: ((docker-registry-password)) - name: telegram type: telegram source: bot_token: ((telegram-ci-bot-token)) chat_id: ((telegram-group-to-report-build)) ci_url: ((ci_url)) command: "/build_hello_ms" - name: kubernetes-demo type: kubernetes source: server: https://178.63.194.241:6443 namespace: default kubeconfig: ((kubeconfig-demo)) jobs: - name: build-hello-microservice serial: true public: true plan: - in_parallel: - get: sources trigger: true - get: telegram trigger: true - put: metadata - put: telegram params: status: Build In Progress - task: unit-tests config: platform: linux image_resource: type: docker-image source: repository: ((docker-registry-uri))/bih/maven tag: 3-jdk-11 username: ((docker-private-registry-user)) password: ((docker-private-registry-password)) inputs: - name: sources outputs: - name: tested-workspace run: path: /bin/sh args: - -c - | output_dir=tested-workspace cp -R ./sources/* "${output_dir}/" mvn -f "${output_dir}/pom.xml" clean test caches: - path: ~/.m2/ on_failure: do: - task: tests-report config: platform: linux image_resource: type: docker-image source: repository: ((docker-registry-uri))/bih/maven tag: 3-jdk-11 username: ((docker-private-registry-user)) password: ((docker-private-registry-password)) inputs: - name: tested-workspace outputs: - name: message run: path: /bin/sh args: - -c - | output_dir=tested-workspace mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -f "${output_dir}/pom.xml" site-deploy version=$(cat $output_dir/target/classes/version.txt) cat >message/msg <<EOL <a href="https://nexus.dev.techedge.pro:8443/repository/reports/hello-microservice/${version}/allure/">Allure report</a> EOL caches: - path: ~/.m2/ - put: telegram params: status: Build Failed (unit-tests) message_file: message/msg - task: tests-report config: platform: linux image_resource: type: docker-image source: repository: ((docker-registry-uri))/bih/maven tag: 3-jdk-11 username: ((docker-private-registry-user)) password: ((docker-private-registry-password)) inputs: - name: tested-workspace outputs: - name: message - name: tested-workspace run: path: /bin/sh args: - -c - | work_dir=tested-workspace mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -f "${work_dir}/pom.xml" site-deploy version=$(cat $work_dir/target/classes/version.txt) cat >message/msg <<EOL <a href="https://nexus.dev.techedge.pro:8443/repository/reports/hello-microservice/${version}/allure/">Allure report</a> EOL caches: - path: ~/.m2/ - task: package config: platform: linux image_resource: type: docker-image source: repository: ((docker-registry-uri))/bih/maven tag: 3-jdk-11 username: ((docker-private-registry-user)) password: ((docker-private-registry-password)) inputs: - name: tested-workspace - name: metadata outputs: - name: app-packaged-workspace - name: metadata run: path: /bin/sh args: - -c - | output_dir=app-packaged-workspace cp -R ./tested-workspace/* "${output_dir}/" mvn -f "${output_dir}/pom.xml" package -Dmaven.main.skip -DskipTests env tag="-"$(cat metadata/build_name) echo $tag >> ${output_dir}/target/classes/version.txt cat ${output_dir}/target/classes/version.txt > metadata/version caches: - path: ~/.m2/ on_failure: do: - put: telegram params: status: Build Failed (package) - put: docker-image params: build: app-packaged-workspace tag_file: app-packaged-workspace/target/classes/version.txt tag_as_latest: true get_params: skip_download: true - task: make-k8s-app-template config: platform: linux image_resource: type: docker-image source: repository: bhgedigital/envsubst inputs: - name: sources - name: metadata outputs: - name: k8s run: path: /bin/sh args: - -c - | export DOMAIN=demo1.bihero.io export HELLO_SERVICE_IMAGE_VERSION=$(cat metadata/version) cat sources/k8s.yaml | envsubst > k8s/hello_app_template.yaml cat k8s/hello_app_template.yaml - put: kubernetes-demo params: kubectl: apply -f k8s/hello_app_template.yaml - put: telegram params: status: Build Success message_file: message/msg
Рассмотрим кратко содержимое пайплайна:
- Секция resource_types нужна для объявления кастомных типов ресурсов, с которыми мы хотим работать, собирая наш проект. В нашем кейсе это три типа (имена типов можно задавать любые, сама суть типа закладывается в docker-образе, которым описывается тип): telegram для отправки уведомлений в tg-группу и для триггера джобы по сборке по определённой команде, kubernetes для деплоя новой версии сервиса в k8s-кластер и metadata для обеспечения данных по билду (номер билда, дата сборки и т.д.) в тасках пайплайна
- Секция resources нужна для объявления ресурсов, с которыми мы будем работать в процессе билда. Это то самое место в пайплайне, где описываются репозитории с исходниками, docker-registry для деплоя собираемых docker-образов и другие ресурсы, необходимые для выполнения степов сборки проекта. Каждый ресурс может быть использован на каждом степе пайплайна как input-ресурс в соответствующем блоке, описывающем таск пайплайна
- Секция jobs описывает набор джоб, которые нужно выполнить для сборки проекта. У нас это одна джоба с набором тасков и put-инструкций для деплоя результатов сборки и уведомлений в tg-группу. Иструкциями — get объявляем входные ресурсы для билда (например, git-репозиторий), — put — выходные ресурсы (docker image) или ресурсы, генерируемые на первых шагах сборки проекта и используемые на последующих (metadata). Каждый task в джобе — команды внутри docker-контейнера на основе docker-image’а, конфигурируемого параметром image_resource таски
- Строки вида ((parameter-name)) — ссылки на параметры в отдельном файле, обычно в этом файле лежат секреты, явки пароли к ресурсам и прочие параметры, универсальные для всех имеющихся пайплайнов (например ссылка до docker-registry).
Деплой пайплайна с файлом параметров выглядит так:
fly -t bih sp -p hello-microservice -c pipeline.yaml -l credentials.yaml # -t - target name # sp - alias to set-pipeline # -p - pipeline name # -c - pipeline config file # -l - file with parameters and credentials
Файл credentials.yaml может выглядеть так:
docker-registry-user: <dockerhub-user> docker-registry-password: <dockerhub-password> docker-registry-uri: <private-docker-registry-url> docker-private-registry-user: <private-docker-registry-user> docker-private-registry-password: <private-docker-registry-passwordl> telegram-ci-bot-token: <telegram-bot-token> telegram-group-to-report-build: <telegram-group-id> ci_url: <ci-server-url> deployer-private-key: | -----BEGIN OPENSSH PRIVATE KEY----- github-deploy-key -----END OPENSSH PRIVATE KEY----- kubeconfig-demo: | apiVersion: v1 clusters: - cluster: certificate-authority-data: <kube-cert-data> server: <kube-api-server-url> name: kubernetes contexts: - context: cluster: kubernetes user: kubernetes-admin name: kubernetes-admin@kubernetes current-context: kubernetes-admin@kubernetes kind: Config preferences: {} users: - name: kubernetes-admin user: client-certificate-data: <kube-client-cert-data> client-key-data: <kube-client-key-data>
Пишла пора запустить наш первый билд. Сделать мы это можем несколькими способами:
- Залогиниться на CI-сервере, выбрать необходимый нам пайплайн и джобу и нажать на кнопку с плюсиком:
- Сделать всё то же самое (что и в пункте 1), но только используя конcольную утилиту fly, которую можно скачать с того же CI-сервера:
fly -t bih tj -j hello-microservice/build-hello-microservice -w # tj - alias for 'trigger-job' # -j - job (<piprlinr-name>/<job-name-in-pipeline>) # -w - watch - Отправить сообщение /build_hello_ms в телеграм-группу, на которую указывает telegram-group-to-report-build в файле credentials.yaml
- Отправить коммит в master-ветку в гит (помним, что мы не про идеальную разработку сейчас говорим, а про процесс в целом: коммитить в master — это плохо, — но в обучающих целях можно 😉 )
В процессе билда (в случае успешного его окончания) мы получим два уведомления в телеграм-группу:
- Уведомление о начале работы с джобой:

- Уведомление об успешном завершении сборки:

Давайте посмотрим, как сборка выглядит в UI CI-сервера:
Ура! Сборка прошла, докер-образ собран, задеплоен, приложение в k8s обновлено и отчёты отправлены. Пора проверять задеплоенное:
- Образ на docker-hub’е

- Смотрим на список подов

- И смотрим на версию образа, из которого развёрнут контейнер в одном из подов из 2-го пункта

- Делаем запрос и смотрим на ответ:
curl https://demo1.bihero.io/api/hello -v 5350 14:59:04 * Trying 178.63.194.243... * TCP_NODELAY set * Connected to demo1.bihero.io (178.63.194.243) port 443 (#0) * ALPN, offering h2 * ALPN, offering http/1.1 * Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH * successfully set certificate verify locations: * CAfile: /etc/ssl/certs/ca-certificates.crt CApath: /etc/ssl/certs * TLSv1.2 (OUT), TLS header, Certificate Status (22): * TLSv1.2 (OUT), TLS handshake, Client hello (1): * TLSv1.2 (IN), TLS handshake, Server hello (2): * TLSv1.2 (IN), TLS handshake, Certificate (11): * TLSv1.2 (IN), TLS handshake, Server key exchange (12): * TLSv1.2 (IN), TLS handshake, Server finished (14): * TLSv1.2 (OUT), TLS handshake, Client key exchange (16): * TLSv1.2 (OUT), TLS change cipher, Client hello (1): * TLSv1.2 (OUT), TLS handshake, Finished (20): * TLSv1.2 (IN), TLS change cipher, Client hello (1): * TLSv1.2 (IN), TLS handshake, Finished (20): * SSL connection using TLSv1.2 / ECDHE-RSA-AES256-GCM-SHA384 * ALPN, server accepted to use h2 * Server certificate: * subject: CN=*.bihero.io * start date: Nov 7 13:59:46 2019 GMT * expire date: Feb 5 13:59:46 2020 GMT * subjectAltName: host "demo1.bihero.io" matched cert's "*.bihero.io" * issuer: C=US; O=Let's Encrypt; CN=Let's Encrypt Authority X3 * SSL certificate verify ok. * Using HTTP2, server supports multi-use * Connection state changed (HTTP/2 confirmed) * Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0 * Using Stream ID: 1 (easy handle 0x55778f779520) > GET /api/hello HTTP/1.1 > Host: demo1.bihero.io > User-Agent: curl/7.52.1 > Accept: */* > * Connection state changed (MAX_CONCURRENT_STREAMS updated)! < HTTP/2 200 < server: nginx/1.15.8 < date: Sun, 01 Dec 2019 11:59:06 GMT < content-type: text/plain < content-length: 5 < strict-transport-security: max-age=15724800; includeSubDomains < * Curl_http_done: called premature == 0 * Connection #0 to host demo1.bihero.io left intact Hello
Много всего было сделано, но давайте на забывать, для чего мы тут собрались. Продукт же пилим, и ещё целых два микросервиса не написаны. Дальше мы не будем подробно разжёвывать содержимое каждого оставшего сервиса, только лишь исходники и пайплайн сборки в спойлерах (разве что только для интеграционного сервиса замутим интеграционных тестов с testcontainers). А в конце будут выводы и внушительный TODO-лист (куда же без бэклога). Поехали!
‘World’ microservice
openapi: 3.0.1 info: title: World ;) description: "'World' word microservice" version: 1.0.0 servers: - url: https://demo1.bihero.io/api/world tags: - name: world description: Everything about 'World' word paths: /: x-vertx-event-bus: address: service.world timeout: 1000 get: tags: - world summary: Get 'World' word operationId: getWorldWord responses: 200: description: OK content: {} /doc: x-vertx-event-bus: address: service.world timeout: 1000c get: tags: - world summary: Get 'World' microservice documentation operationId: getDoc responses: 200: description: OK components: {}
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <properties> <main.verticle>io.bihero.world.WorldVerticle</main.verticle> <vertx.version>3.8.1</vertx.version> <logback.version>1.2.3</logback.version> <junit-jupiter.version>5.3.1</junit-jupiter.version> <maven-surefire-plugin.version>2.19.1</maven-surefire-plugin.version> <junit-platform-surefire-provider.version>1.1.0</junit-platform-surefire-provider.version> <assertj-core.version>3.8.0</assertj-core.version> <allure.version>2.8.1</allure.version> <allure-maven.version>2.10.0</allure-maven.version> <aspectj.version>1.9.2</aspectj.version> <mockito.version>2.21.0</mockito.version> <rest-assured.version>3.0.0</rest-assured.version> </properties> <groupId>io.bihero</groupId> <artifactId>world-microservice</artifactId> <version>1.0.0-SNAPSHOT</version> <build> <plugins> <plugin> <artifactId>maven-compiler-plugin</artifactId> <configuration> ```11</source> <target>11</target> </configuration> <executions> <execution> <id>default-compile</id> <configuration> <annotationProcessors> <annotationProcessor>io.vertx.codegen.CodeGenProcessor</annotationProcessor> </annotationProcessors> <generatedSourcesDirectory>src/main/generated</generatedSourcesDirectory> <compilerArgs> <arg>-Acodegen.output=${project.basedir}/src/main</arg> </compilerArgs> </configuration> </execution> <execution> <id>default-testCompile</id> <configuration> <annotationProcessors> <annotationProcessor>io.vertx.codegen.CodeGenProcessor</annotationProcessor> </annotationProcessors> <generatedTestSourcesDirectory>src/test/generated</generatedTestSourcesDirectory> <compilerArgs> <arg>-Acodegen.output=${project.basedir}/src/test</arg> </compilerArgs> </configuration> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>${maven-surefire-plugin.version}</version> <configuration> <properties> <property> <name>listener</name> <value>io.qameta.allure.junit5.AllureJunit5</value> </property> </properties> <includes> <include>**/*Test*.java</include> </includes> <argLine> -javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar" -Djdk.net.URLClassPath.disableClassPathURLCheck=true </argLine> <systemProperties> <property> <name>allure.results.directory</name> <value>${project.basedir}/target/allure-results</value> </property> <property> <name>junit.jupiter.extensions.autodetection.enabled</name> <value>true</value> </property> </systemProperties> <reportFormat>plain</reportFormat> </configuration> <dependencies> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> <version>${aspectj.version}</version> </dependency> <dependency> <groupId>org.junit.platform</groupId> <artifactId>junit-platform-surefire-provider</artifactId> <version>${junit-platform-surefire-provider.version}</version> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>${junit-jupiter.version}</version> </dependency> </dependencies> </plugin> <plugin> <groupId>io.qameta.allure</groupId> <artifactId>allure-maven</artifactId> <version>${allure-maven.version}</version> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-site-plugin</artifactId> <version>3.7.1</version> <dependencies> <dependency> <groupId>org.apache.maven.wagon</groupId> <artifactId>wagon-webdav-jackrabbit</artifactId> <version>2.8</version> </dependency> </dependencies> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-project-info-reports-plugin</artifactId> <version>3.0.0</version> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>2.3</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <manifestEntries> <Main-Class>io.vertx.core.Launcher</Main-Class> <Main-Verticle>${main.verticle}</Main-Verticle> </manifestEntries> </transformer> <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer"> <resource>META-INF/services/io.vertx.core.spi.VerticleFactory</resource> </transformer> </transformers> <artifactSet> </artifactSet> <outputFile>${project.build.directory}/${project.artifactId}-fat.jar</outputFile> </configuration> </execution> </executions> </plugin> </plugins> <resources> <resource> <directory>src/main/resources</directory> <filtering>true</filtering> <includes> <include>**/version.txt</include> </includes> </resource> <resource> <directory>src/main/resources</directory> <filtering>false</filtering> <excludes> <exclude>**/version.txt</exclude> </excludes> </resource> </resources> </build> <distributionManagement> <site> <id>reports</id> <url>dav:https://nexus.dev.techedge.pro:8443/repository/reports/${project.artifactId}/</url> </site> </distributionManagement> <reporting> <excludeDefaults>true</excludeDefaults> <plugins> <plugin> <groupId>io.qameta.allure</groupId> <artifactId>allure-maven</artifactId> <configuration> <resultsDirectory>${project.build.directory}/allure-results</resultsDirectory> <reportDirectory>${project.reporting.outputDirectory}/${project.version}/allure</reportDirectory> </configuration> </plugin> </plugins> </reporting> <dependencies> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-web-api-service</artifactId> <version>${vertx.version}</version> </dependency> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-codegen</artifactId> <version>${vertx.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>${logback.version}</version> </dependency> <!-- test –>--> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-unit</artifactId> <version>${vertx.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-junit5</artifactId> <version>${vertx.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <version>${junit-jupiter.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>${junit-jupiter.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>${assertj-core.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>${mockito.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>io.qameta.allure</groupId> <artifactId>allure-junit5</artifactId> <version>${allure.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-web-client</artifactId> <version>${vertx.version}</version> <scope>test</scope> </dependency> </dependencies> </project>
package io.bihero.world; import io.vertx.core.AsyncResult; import io.vertx.core.Handler; import io.vertx.core.Vertx; import io.vertx.ext.web.api.OperationRequest; import io.vertx.ext.web.api.OperationResponse; import io.vertx.ext.web.api.generator.WebApiServiceGen; @WebApiServiceGen public interface WorldService { static WorldService create(Vertx vertx) { return new DefaultWorldService(vertx); } void getWorldWord(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler); void getDoc(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler); }
package io.bihero.world; import io.vertx.core.AsyncResult; import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.Vertx; import io.vertx.core.buffer.Buffer; import io.vertx.ext.web.api.OperationRequest; import io.vertx.ext.web.api.OperationResponse; public class DefaultWorldService implements WorldService { private final Vertx vertx; public DefaultWorldService(Vertx vertx) { this.vertx = vertx; } public void getWorldWord(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler) { resultHandler.handle(Future.succeededFuture(OperationResponse.completedWithPlainText(Buffer.buffer("World")))); } @Override public void getDoc(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler) { vertx.fileSystem().readFile("doc.yaml", buffResult -> resultHandler.handle(Future.succeededFuture( OperationResponse.completedWithPlainText(buffResult.result())) )); } }
package io.bihero.world; import io.vertx.core.AbstractVerticle; import io.vertx.core.Promise; import io.vertx.core.eventbus.MessageConsumer; import io.vertx.core.http.HttpServer; import io.vertx.core.http.HttpServerOptions; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.Router; import io.vertx.ext.web.api.contract.openapi3.OpenAPI3RouterFactory; import io.vertx.serviceproxy.ServiceBinder; public class WorldVerticle extends AbstractVerticle { HttpServer server; MessageConsumer<JsonObject> consumer; public void startWorldService() { consumer = new ServiceBinder(vertx).setAddress("service.world") .register(WorldService.class, WorldService.create(getVertx())); } /** * This method constructs the router factory, mounts services and handlers and starts the http server * with built router * @return */ private Promise<Void> startHttpServer() { Promise<Void> promise = Promise.promise(); OpenAPI3RouterFactory.create(this.vertx, "/doc.yaml", openAPI3RouterFactoryAsyncResult -> { if (openAPI3RouterFactoryAsyncResult.succeeded()) { OpenAPI3RouterFactory routerFactory = openAPI3RouterFactoryAsyncResult.result(); // Mount services on event bus based on extensions routerFactory.mountServicesFromExtensions(); // Generate the router Router router = routerFactory.getRouter(); int port = config().getInteger("serverPort", 8080); String host = config().getString("serverHost", "localhost"); server = vertx.createHttpServer(new HttpServerOptions().setPort(port).setHost(host)); server.requestHandler(router).listen(ar -> { // Error starting the HttpServer if (ar.succeeded()) promise.complete(); else promise.fail(ar.cause()); }); } else { // Something went wrong during router factory initialization promise.fail(openAPI3RouterFactoryAsyncResult.cause()); } }); return promise; } @Override public void start(Promise<Void> promise) { startWorldService(); startHttpServer().future().setHandler(promise); } /** * This method closes the http server and unregister all services loaded to Event Bus */ @Override public void stop(){ this.server.close(); consumer.unregister(); } }
package io.bihero.world; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.api.OperationRequest; import io.vertx.junit5.VertxExtension; import io.vertx.junit5.VertxTestContext; import org.apache.commons.io.IOUtils; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import java.io.IOException; import static org.assertj.core.api.Assertions.assertThat; @ExtendWith(VertxExtension.class) public class WorldServiceTest { private WorldService worldService = WorldService.create(Vertx.vertx()); @Test @DisplayName("Test 'getWorldWord' method returns 'World' word") public void testHelloMethod(VertxTestContext testContext) { worldService.getWorldWord(new OperationRequest(new JsonObject()), testContext.succeeding(it -> { assertThat(it.getStatusCode()).isEqualTo(200); assertThat(it.getPayload().toString()).isEqualTo("World"); testContext.completeNow(); })); } @Test @DisplayName("Test 'getDoc' method returns service documentation in OpenAPI format") public void testDocMethod(VertxTestContext testContext) { worldService.getDoc(new OperationRequest(new JsonObject()), testContext.succeeding(it -> { try { assertThat(it.getStatusCode()).isEqualTo(200); assertThat(it.getPayload().toString()).isEqualTo(IOUtils.toString(this.getClass() .getResourceAsStream("../../../doc.yaml"), "UTF-8")); testContext.completeNow(); } catch (IOException e) { testContext.failNow(e); } })); } }
package io.bihero.world; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.client.WebClient; import io.vertx.ext.web.codec.BodyCodec; import io.vertx.junit5.Checkpoint; import io.vertx.junit5.VertxExtension; import io.vertx.junit5.VertxTestContext; import org.apache.commons.io.IOUtils; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.spy; @ExtendWith(VertxExtension.class) public class WorldVerticleTest { @Test @DisplayName("Test that verticle is up and respond me by 'World' word and doc in OpenAPI format") public void testHelloVerticle(Vertx vertx, VertxTestContext testContext) { WebClient webClient = WebClient.create(vertx); Checkpoint deploymentCheckpoint = testContext.checkpoint(); Checkpoint requestCheckpoint = testContext.checkpoint(2); WorldVerticle verticle = spy(new WorldVerticle()); JsonObject config = new JsonObject().put("serverPort", 8082).put("serverHost", "0.0.0.0"); doReturn(config).when(verticle).config(); vertx.deployVerticle(verticle, testContext.succeeding(id -> { deploymentCheckpoint.flag(); // test GET / webClient.get(8082, "localhost", "/") .as(BodyCodec.string()) .send(testContext.succeeding(resp -> { assertThat(resp.body()).isEqualTo("World"); assertThat(resp.statusCode()).isEqualTo(200); requestCheckpoint.flag(); })); // test GET /doc webClient.get(8082, "localhost", "/doc") .as(BodyCodec.string()) .send(testContext.succeeding(resp -> { try { assertThat(resp.body()).isEqualTo(IOUtils.toString(this.getClass() .getResourceAsStream("../../../doc.yaml"), "UTF-8")); assertThat(resp.statusCode()).isEqualTo(200); requestCheckpoint.flag(); } catch (Exception e) { requestCheckpoint.flag(); testContext.failNow(e); } })); })); } }
FROM adoptopenjdk/openjdk11:alpine-jre COPY target/world-microservice-fat.jar app.jar COPY src/conf/config.json . COPY src/conf/logback-console.xml . COPY run.sh . RUN chmod +x run.sh CMD ["./run.sh"]
#!/bin/sh java ${JVM_OPTS} -Dlogback.configurationFile=./logback-console.xml -jar app.jar -conf config.json
resource_types: - name: telegram type: docker-image source: repository: vtutrinov/concourse-telegram-resource tag: latest - name: kubernetes type: docker-image source: repository: zlabjp/kubernetes-resource tag: 1.16 - name: metadata type: docker-image source: repository: olhtbr/metadata-resource tag: 2.0.1 resources: - name: metadata type: metadata - name: sources type: git source: branch: master uri: git@github.com:bihero-io/worldmicroservice.git private_key: ((deployer-private-key)) - name: docker-image type: docker-image source: repository: bihero/world username: ((docker-registry-user)) password: ((docker-registry-password)) - name: telegram type: telegram source: bot_token: ((telegram-ci-bot-token)) chat_id: ((telegram-group-to-report-build)) ci_url: ((ci_url)) command: "/build_world_ms" - name: kubernetes-demo type: kubernetes source: server: ((k8s-api-server)) namespace: default kubeconfig: ((kubeconfig-demo)) jobs: - name: build-world-microservice serial: true public: true plan: - in_parallel: - get: sources trigger: true - get: telegram trigger: true - put: metadata - put: telegram params: status: Build In Progress - task: unit-tests config: platform: linux image_resource: type: docker-image source: repository: ((docker-registry-uri))/bih/maven-dind tag: 3-jdk-11 username: ((docker-private-registry-user)) password: ((docker-private-registry-password)) inputs: - name: sources outputs: - name: tested-workspace run: path: /bin/sh args: - -c - | output_dir=tested-workspace cp -R ./sources/* "${output_dir}/" mvn -f "${output_dir}/pom.xml" clean test caches: - path: ~/.m2/ on_failure: do: - task: tests-report config: platform: linux image_resource: type: docker-image source: repository: ((docker-registry-uri))/bih/maven-dind tag: 3-jdk-11 username: ((docker-private-registry-user)) password: ((docker-private-registry-password)) inputs: - name: tested-workspace outputs: - name: message run: path: /bin/sh args: - -c - | output_dir=tested-workspace mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -f "${output_dir}/pom.xml" site-deploy version=$(cat $output_dir/target/classes/version.txt) cat >message/msg <<EOL <a href="https://nexus.dev.techedge.pro:8443/repository/reports/hello-microservice/${version}/allure/">Allure report</a> EOL caches: - path: ~/.m2/ - put: telegram params: status: Build Failed (unit-tests) message_file: message/msg - task: tests-report config: platform: linux image_resource: type: docker-image source: repository: ((docker-registry-uri))/bih/maven-dind tag: 3-jdk-11 username: ((docker-private-registry-user)) password: ((docker-private-registry-password)) inputs: - name: tested-workspace outputs: - name: message - name: tested-workspace run: path: /bin/sh args: - -c - | work_dir=tested-workspace mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -f "${work_dir}/pom.xml" site-deploy version=$(cat $work_dir/target/classes/version.txt) cat >message/msg <<EOL <a href="https://nexus.dev.techedge.pro:8443/repository/reports/world-microservice/${version}/allure/">Allure report</a> EOL caches: - path: ~/.m2/ - task: package config: platform: linux image_resource: type: docker-image source: repository: ((docker-registry-uri))/bih/maven-dind tag: 3-jdk-11 username: ((docker-private-registry-user)) password: ((docker-private-registry-password)) inputs: - name: tested-workspace - name: metadata outputs: - name: app-packaged-workspace - name: metadata run: path: /bin/sh args: - -c - | output_dir=app-packaged-workspace cp -R ./tested-workspace/* "${output_dir}/" mvn -f "${output_dir}/pom.xml" package -Dmaven.main.skip -DskipTests tag="-"$(cat metadata/build_name) echo $tag >> ${output_dir}/target/classes/version.txt cat ${output_dir}/target/classes/version.txt > metadata/version caches: - path: ~/.m2/ on_failure: do: - put: telegram params: status: Build Failed (package) - put: docker-image params: build: app-packaged-workspace tag_file: app-packaged-workspace/target/classes/version.txt tag_as_latest: true get_params: skip_download: true - task: make-k8s-app-template config: platform: linux image_resource: type: docker-image source: repository: bhgedigital/envsubst inputs: - name: sources - name: metadata outputs: - name: k8s run: path: /bin/sh args: - -c - | export DOMAIN=demo1.bihero.io export WORLD_SERVICE_IMAGE_VERSION=$(cat metadata/version) cat sources/k8s.yaml | envsubst > k8s/world_app_template.yaml cat k8s/world_app_template.yaml - put: kubernetes-demo params: kubectl: apply -f k8s/world_app_template.yaml - put: telegram params: status: Build Success message_file: message/msg
apiVersion: extensions/v1beta1 kind: Deployment metadata: labels: io.bihero.hello.service: bihero-world name: bihero-world spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 1 template: metadata: labels: io.bihero.hello.service: bihero-world spec: containers: - image: bihero/world:${WORLD_SERVICE_IMAGE_VERSION} name: bihero-world ports: - containerPort: 8082 imagePullPolicy: Always resources: {} restartPolicy: Always --- apiVersion: v1 kind: Service metadata: labels: io.bihero.hello.service: bihero-world name: bihero-world spec: ports: - name: "8082" port: 8082 targetPort: 8082 selector: io.bihero.hello.service: bihero-world status: loadBalancer: {} --- apiVersion: extensions/v1beta1 kind: Ingress metadata: name: bihero-world annotations: kubernetes.io/ingress.class: nginx nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/secure-backends: "false" nginx.ingress.kubernetes.io/ssl-passthrough: "false" nginx.ingress.kubernetes.io/rewrite-target: /$2 kubernetes.io/tls-acme: "true" namespace: default spec: tls: - hosts: - ${DOMAIN} secretName: bihero rules: - host: ${DOMAIN} http: paths: - path: /api/world(/|$)(.*) backend: serviceName: bihero-world servicePort: 8082
‘HelloWorld’ microservice
Этот орешек оказался крепче, чем казалось изначально. Ну, да ладно, мы и его раскололи. Основные сложности возникли при запуске интеграционных тестов с testcontainers, но обо всё по порядку.
openapi: 3.0.1 info: title: Hello World ;) description: "Hello World microservice. Aggregate 'Hello World' by hellomicroservice and worldmicroservice" version: 1.0.0 servers: - url: https://demo1.bihero.io/api/helloworld tags: - name: helloworld description: Everything about 'Hello World' paths: /: x-vertx-event-bus: address: service.helloworld timeout: 1000 get: tags: - helloworld summary: Aggregate 'Hello World' operationId: getHelloWorld responses: 200: description: OK content: {} /doc: x-vertx-event-bus: address: service.helloworld timeout: 1000c get: tags: - world summary: Get 'Hello World' microservice documentation operationId: getDoc responses: 200: description: OK components: {}
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <properties> <main.verticle>io.bihero.helloworld.HelloWorldVerticle</main.verticle> <vertx.version>3.8.1</vertx.version> <logback.version>1.2.3</logback.version> <junit-jupiter.version>5.3.1</junit-jupiter.version> <maven-surefire-plugin.version>2.19.1</maven-surefire-plugin.version> <junit-platform-surefire-provider.version>1.1.0</junit-platform-surefire-provider.version> <assertj-core.version>3.8.0</assertj-core.version> <allure.version>2.8.1</allure.version> <allure-maven.version>2.10.0</allure-maven.version> <aspectj.version>1.9.2</aspectj.version> <mockito.version>2.21.0</mockito.version> <rest-assured.version>3.0.0</rest-assured.version> <testcontainers.version>1.12.3</testcontainers.version> </properties> <groupId>io.bihero</groupId> <artifactId>hello-world-microservice</artifactId> <version>1.0.0-SNAPSHOT</version> <build> <plugins> <plugin> <artifactId>maven-compiler-plugin</artifactId> <configuration> ```11</source> <target>11</target> </configuration> <executions> <execution> <id>default-compile</id> <configuration> <annotationProcessors> <annotationProcessor>io.vertx.codegen.CodeGenProcessor</annotationProcessor> </annotationProcessors> <generatedSourcesDirectory>src/main/generated</generatedSourcesDirectory> <compilerArgs> <arg>-Acodegen.output=${project.basedir}/src/main</arg> </compilerArgs> </configuration> </execution> <execution> <id>default-testCompile</id> <configuration> <annotationProcessors> <annotationProcessor>io.vertx.codegen.CodeGenProcessor</annotationProcessor> </annotationProcessors> <generatedTestSourcesDirectory>src/test/generated</generatedTestSourcesDirectory> <compilerArgs> <arg>-Acodegen.output=${project.basedir}/src/test</arg> </compilerArgs> </configuration> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>${maven-surefire-plugin.version}</version> <configuration> <properties> <property> <name>listener</name> <value>io.qameta.allure.junit5.AllureJunit5</value> </property> </properties> <includes> <include>**/*Test.java</include> </includes> <argLine> -javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar" -Djdk.net.URLClassPath.disableClassPathURLCheck=true </argLine> <systemProperties> <property> <name>allure.results.directory</name> <value>${project.basedir}/target/allure-results</value> </property> <property> <name>junit.jupiter.extensions.autodetection.enabled</name> <value>true</value> </property> </systemProperties> <reportFormat>plain</reportFormat> </configuration> <dependencies> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> <version>${aspectj.version}</version> </dependency> <dependency> <groupId>org.junit.platform</groupId> <artifactId>junit-platform-surefire-provider</artifactId> <version>${junit-platform-surefire-provider.version}</version> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>${junit-jupiter.version}</version> </dependency> </dependencies> </plugin> <plugin> <groupId>io.qameta.allure</groupId> <artifactId>allure-maven</artifactId> <version>${allure-maven.version}</version> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-site-plugin</artifactId> <version>3.7.1</version> <dependencies> <dependency> <groupId>org.apache.maven.wagon</groupId> <artifactId>wagon-webdav-jackrabbit</artifactId> <version>2.8</version> </dependency> </dependencies> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-project-info-reports-plugin</artifactId> <version>3.0.0</version> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>2.3</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <manifestEntries> <Main-Class>io.vertx.core.Launcher</Main-Class> <Main-Verticle>${main.verticle}</Main-Verticle> </manifestEntries> </transformer> <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer"> <resource>META-INF/services/io.vertx.core.spi.VerticleFactory</resource> </transformer> </transformers> <artifactSet> </artifactSet> <outputFile>${project.build.directory}/${project.artifactId}-fat.jar</outputFile> </configuration> </execution> </executions> </plugin> </plugins> <resources> <resource> <directory>src/main/resources</directory> <filtering>true</filtering> <includes> <include>**/version.txt</include> </includes> </resource> <resource> <directory>src/main/resources</directory> <filtering>false</filtering> <excludes> <exclude>**/version.txt</exclude> </excludes> </resource> </resources> </build> <distributionManagement> <site> <id>reports</id> <url>dav:https://nexus.dev.techedge.pro:8443/repository/reports/${project.artifactId}/</url> </site> </distributionManagement> <reporting> <excludeDefaults>true</excludeDefaults> <plugins> <plugin> <groupId>io.qameta.allure</groupId> <artifactId>allure-maven</artifactId> <configuration> <resultsDirectory>${project.build.directory}/allure-results</resultsDirectory> <reportDirectory>${project.reporting.outputDirectory}/${project.version}/allure</reportDirectory> </configuration> </plugin> </plugins> </reporting> <dependencies> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-web-api-service</artifactId> <version>${vertx.version}</version> </dependency> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-web-client</artifactId> <version>${vertx.version}</version> </dependency> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-codegen</artifactId> <version>${vertx.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>${logback.version}</version> </dependency> <!-- test –>--> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-unit</artifactId> <version>${vertx.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>io.vertx</groupId> <artifactId>vertx-junit5</artifactId> <version>${vertx.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <version>${junit-jupiter.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>${junit-jupiter.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>${assertj-core.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>${mockito.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>io.qameta.allure</groupId> <artifactId>allure-junit5</artifactId> <version>${allure.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <version>${testcontainers.version}</version> </dependency> </dependencies> </project>
package io.bihero.helloworld; import io.vertx.core.AsyncResult; import io.vertx.core.Handler; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.api.OperationRequest; import io.vertx.ext.web.api.OperationResponse; import io.vertx.ext.web.api.generator.WebApiServiceGen; @WebApiServiceGen public interface HelloWorldService { static HelloWorldService create(Vertx vertx, JsonObject config) { return new DefaultHelloWorldService(vertx, config); } void getHelloWorld(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler); void getDoc(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler); }
package io.bihero.helloworld; import io.vertx.core.*; import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.api.OperationRequest; import io.vertx.ext.web.api.OperationResponse; import io.vertx.ext.web.client.WebClient; public class DefaultHelloWorldService implements HelloWorldService { private final Vertx vertx; private final JsonObject config; private final WebClient webClient; public DefaultHelloWorldService(Vertx vertx, JsonObject config) { this.vertx = vertx; this.config = config; this.webClient = WebClient.create(this.vertx); } @Override public void getHelloWorld(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler) { getHelloWord().compose(this::getHelloWorld).setHandler(v -> resultHandler.handle( Future.succeededFuture(OperationResponse.completedWithPlainText(Buffer.buffer(v.result()))) )); } @Override public void getDoc(OperationRequest context, Handler<AsyncResult<OperationResponse>> resultHandler) { vertx.fileSystem().readFile("doc.yaml", buffResult -> resultHandler.handle(Future.succeededFuture( OperationResponse.completedWithPlainText(buffResult.result())) )); } private Future<String> getHelloWord() { Future<String> future = Future.future(); webClient.get(config.getInteger("hello-service-port"), config.getString("hello-service-host"), "/").send(ar -> future.handle(Future.succeededFuture(ar.result().bodyAsString()))); return future; } private Future<String> getHelloWorld(String helloWord) { Future<String> future = Future.future(); webClient.get(config.getInteger("world-service-port"), config.getString("world-service-host"), "/").send(ar -> future.handle(Future.succeededFuture(helloWord + " " + ar.result().bodyAsString()))); return future; } }
package io.bihero.helloworld; import io.vertx.core.AbstractVerticle; import io.vertx.core.Promise; import io.vertx.core.eventbus.MessageConsumer; import io.vertx.core.http.HttpServer; import io.vertx.core.http.HttpServerOptions; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.Router; import io.vertx.ext.web.api.contract.openapi3.OpenAPI3RouterFactory; import io.vertx.serviceproxy.ServiceBinder; public class HelloWorldVerticle extends AbstractVerticle { HttpServer server; MessageConsumer<JsonObject> consumer; public void startWorldService() { consumer = new ServiceBinder(vertx).setAddress("service.helloworld") .register(HelloWorldService.class, HelloWorldService.create(vertx, config())); } /** * This method constructs the router factory, mounts services and handlers and starts the http server * with built router * @return */ private Promise<Void> startHttpServer() { Promise<Void> promise = Promise.promise(); OpenAPI3RouterFactory.create(this.vertx, "/doc.yaml", openAPI3RouterFactoryAsyncResult -> { if (openAPI3RouterFactoryAsyncResult.succeeded()) { OpenAPI3RouterFactory routerFactory = openAPI3RouterFactoryAsyncResult.result(); // Mount services on event bus based on extensions routerFactory.mountServicesFromExtensions(); // Generate the router Router router = routerFactory.getRouter(); int port = config().getInteger("serverPort", 8080); String host = config().getString("serverHost", "localhost"); server = vertx.createHttpServer(new HttpServerOptions().setPort(port).setHost(host)); server.requestHandler(router).listen(ar -> { // Error starting the HttpServer if (ar.succeeded()) promise.complete(); else promise.fail(ar.cause()); }); } else { // Something went wrong during router factory initialization promise.fail(openAPI3RouterFactoryAsyncResult.cause()); } }); return promise; } @Override public void start(Promise<Void> promise) { startWorldService(); startHttpServer().future().setHandler(promise); } /** * This method closes the http server and unregister all services loaded to Event Bus */ @Override public void stop(){ this.server.close(); consumer.unregister(); } }
package io.bihero.helloworld; import io.vertx.core.DeploymentOptions; import io.vertx.core.Vertx; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.client.WebClient; import io.vertx.ext.web.codec.BodyCodec; import io.vertx.junit5.Checkpoint; import io.vertx.junit5.VertxExtension; import io.vertx.junit5.VertxTestContext; import org.apache.commons.io.IOUtils; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.testcontainers.containers.GenericContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.spy; @Testcontainers @ExtendWith(VertxExtension.class) public class HelloWorldServiceTest { @Container private static final GenericContainer helloServiceContainer = new GenericContainer("bihero/hello") .withExposedPorts(8081); @Container private static final GenericContainer worldServiceContainer = new GenericContainer("bihero/world") .withExposedPorts(8082); @Test @DisplayName("Test 'helloworld' microservice respond by 'Hello World' string and doc in OpenAPI format") public void testHelloWorld(Vertx vertx, VertxTestContext testContext) { WebClient webClient = WebClient.create(vertx); Checkpoint deploymentCheckpoint = testContext.checkpoint(); Checkpoint requestCheckpoint = testContext.checkpoint(2); HelloWorldVerticle verticle = spy(new HelloWorldVerticle()); JsonObject config = new JsonObject().put("serverPort", 8083) .put("serverHost", "0.0.0.0") .put("hello-service-host", helloServiceContainer.getContainerIpAddress()) .put("world-service-host", worldServiceContainer.getContainerIpAddress()) .put("hello-service-port", helloServiceContainer.getMappedPort(8081)) .put("world-service-port", worldServiceContainer.getMappedPort(8082)); DeploymentOptions deploymentOptions = new DeploymentOptions().setConfig(config); vertx.deployVerticle(verticle, deploymentOptions, testContext.succeeding(id -> { deploymentCheckpoint.flag(); // test GET / webClient.get(8083, "localhost", "/") .as(BodyCodec.string()) .send(testContext.succeeding(resp -> { assertThat(resp.body()).isEqualTo("Hello World"); assertThat(resp.statusCode()).isEqualTo(200); requestCheckpoint.flag(); })); // test GET /doc webClient.get(8083, "localhost", "/doc") .as(BodyCodec.string()) .send(testContext.succeeding(resp -> { try { assertThat(resp.body()).isEqualTo(IOUtils.toString(this.getClass() .getResourceAsStream("../../../doc.yaml"), "UTF-8")); assertThat(resp.statusCode()).isEqualTo(200); requestCheckpoint.flag(); } catch (Exception e) { requestCheckpoint.flag(); testContext.failNow(e); } })); })); } }
Dockerfile для интеграционного сервиса немножечко отличается от двух сервисов выше — конфиг для сервиса мы кладём не в / как обычно, а в /usr/local, чтобы иметь возможность переопределять его ConfigMap’ом при запуске сервиса в k8s
FROM adoptopenjdk/openjdk11:alpine-jre COPY target/hello-world-microservice-fat.jar app.jar COPY src/conf/config.json /usr/local/config.json COPY src/conf/logback-console.xml . COPY run.sh . RUN chmod +x run.sh CMD ["./run.sh"]
Итак, подошли к пайпалйну сборки и тут стоит пояснить, как вообще CI крутится и как там таски запускаются. Concourse в той конфигурации, на основе которой писалась эта статья, имеет несколько worker-нод и всё они запущены docker-compose’ом (рядом ещё крутятся ui-нода и postgresql). Таски в джобах — это тоже отдельно стартующие docker-контейнеры, то есть мы уже имеем docker в docker’е. А ещё мы очень хотим интеграционные тесты запускать с помощью testcontainers (в нашем кейсе сервисы hello и world запускаем при помощи этого крутого тула). Чувствуете чем пахнет? Правилько: докер в докере в докере! И для этого нам нужен модный образ с docker’ом, maven’ом и 11-ой джавой на борту. Встречаем, Dockerfile:
FROM alpine:3.7 ENV DOCKER_CHANNEL=stable \ DOCKER_VERSION=17.12.1-ce \ DOCKER_COMPOSE_VERSION=1.19.0 \ DOCKER_SQUASH=0.2.0 # Install Docker, Docker Compose, Docker Squash RUN apk --update --no-cache add \ bash \ curl \ device-mapper \ py-pip \ iptables \ util-linux \ ca-certificates \ maven \ openjdk11 --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community \ && \ apk upgrade && \ curl -fL "https://download.docker.com/linux/static/${DOCKER_CHANNEL}/x86_64/docker-${DOCKER_VERSION}.tgz" | tar zx && \ mv /docker/* /bin/ && chmod +x /bin/docker* && \ pip install docker-compose==${DOCKER_COMPOSE_VERSION} && \ curl -fL "https://github.com/jwilder/docker-squash/releases/download/v${DOCKER_SQUASH}/docker-squash-linux-amd64-v${DOCKER_SQUASH}.tar.gz" | tar zx && \ mv /docker-squash* /bin/ && chmod +x /bin/docker-squash* && \ rm -rf /var/cache/apk/* && \ rm -rf /root/.cache COPY repository /root/.m2/repository # тут мы кладём в образ вендор-зависимости, чтобы не тянуть и при каждом билде с централа COPY settings.xml /root/.m2/settings.xml # конфиг для maven'а с кредами к приватному репозиторию COPY entrypoint.sh /bin/entrypoint.sh # волшебный баш-скрипт, который даёт нам возможность стартовать докер в таске ENV JAVA_HOME=/usr/lib/jvm/java-11-openjdk/ ENTRYPOINT ["entrypoint.sh"]
В пайплайне сборки на шаг запуска тестов будем заходить через entrypoint.sh, который обеспечит нам запуск докера перед запуском самих тестов:
#!/usr/bin/env bash # Inspired by concourse/docker-image-resource: # https://github.com/concourse/docker-image-resource/blob/master/assets/common.sh set -o errexit -o pipefail -o nounset # Waits DOCKERD_TIMEOUT seconds for startup (default: 60) DOCKERD_TIMEOUT="${DOCKERD_TIMEOUT:-60}" # Accepts optional DOCKER_OPTS (default: --data-root /scratch/docker) DOCKER_OPTS="${DOCKER_OPTS:-}" # Constants DOCKERD_PID_FILE="/tmp/docker.pid" DOCKERD_LOG_FILE="/tmp/docker.log" sanitize_cgroups() { local cgroup="/sys/fs/cgroup" mkdir -p "${cgroup}" if ! mountpoint -q "${cgroup}"; then if ! mount -t tmpfs -o uid=0,gid=0,mode=0755 cgroup "${cgroup}"; then echo >&2 "Could not make a tmpfs mount. Did you use --privileged?" exit 1 fi fi mount -o remount,rw "${cgroup}" # Skip AppArmor # See: https://github.com/moby/moby/commit/de191e86321f7d3136ff42ff75826b8107399497 export container=docker # Mount /sys/kernel/security if [[ -d /sys/kernel/security ]] && ! mountpoint -q /sys/kernel/security; then if ! mount -t securityfs none /sys/kernel/security; then echo >&2 "Could not mount /sys/kernel/security." echo >&2 "AppArmor detection and --privileged mode might break." fi fi sed -e 1d /proc/cgroups | while read sys hierarchy num enabled; do if [[ "${enabled}" != "1" ]]; then # subsystem disabled; skip continue fi grouping="$(cat /proc/self/cgroup | cut -d: -f2 | grep "\\<${sys}\\>")" if [[ -z "${grouping}" ]]; then # subsystem not mounted anywhere; mount it on its own grouping="${sys}" fi mountpoint="${cgroup}/${grouping}" mkdir -p "${mountpoint}" # clear out existing mount to make sure new one is read-write if mountpoint -q "${mountpoint}"; then umount "${mountpoint}" fi mount -n -t cgroup -o "${grouping}" cgroup "${mountpoint}" if [[ "${grouping}" != "${sys}" ]]; then if [[ -L "${cgroup}/${sys}" ]]; then rm "${cgroup}/${sys}" fi ln -s "${mountpoint}" "${cgroup}/${sys}" fi done # Initialize systemd cgroup if host isn't using systemd. # Workaround for https://github.com/docker/for-linux/issues/219 if ! [[ -d /sys/fs/cgroup/systemd ]]; then mkdir "${cgroup}/systemd" mount -t cgroup -o none,name=systemd cgroup "${cgroup}/systemd" fi } # Setup container environment and start docker daemon in the background. start_docker() { echo >&2 "Setting up Docker environment..." mkdir -p /var/log mkdir -p /var/run sanitize_cgroups # check for /proc/sys being mounted readonly, as systemd does if grep '/proc/sys\s\+\w\+\s\+ro,' /proc/mounts >/dev/null; then mount -o remount,rw /proc/sys fi local docker_opts="${DOCKER_OPTS:-}" # Pass through `--garden-mtu` from gardian container if [[ "${docker_opts}" != *'--mtu'* ]]; then local mtu="$(cat /sys/class/net/$(ip route get 8.8.8.8|awk '{ print $5 }')/mtu)" docker_opts+=" --mtu ${mtu}" fi # Use Concourse's scratch volume to bypass the graph filesystem by default if [[ "${docker_opts}" != *'--data-root'* ]] && [[ "${docker_opts}" != *'--graph'* ]]; then docker_opts+=' --data-root /scratch/docker' fi rm -f "${DOCKERD_PID_FILE}" touch "${DOCKERD_LOG_FILE}" echo >&2 "Starting Docker..." dockerd ${docker_opts} &>"${DOCKERD_LOG_FILE}" & echo "$!" > "${DOCKERD_PID_FILE}" } # Wait for docker daemon to be healthy # Timeout after DOCKERD_TIMEOUT seconds await_docker() { local timeout="${DOCKERD_TIMEOUT}" echo >&2 "Waiting ${timeout} seconds for Docker to be available..." local start=${SECONDS} timeout=$(( timeout + start )) until docker info &>/dev/null; do if (( SECONDS >= timeout )); then echo >&2 'Timed out trying to connect to docker daemon.' if [[ -f "${DOCKERD_LOG_FILE}" ]]; then echo >&2 '---DOCKERD LOGS---' cat >&2 "${DOCKERD_LOG_FILE}" fi exit 1 fi if [[ -f "${DOCKERD_PID_FILE}" ]] && ! kill -0 $(cat "${DOCKERD_PID_FILE}"); then echo >&2 'Docker daemon failed to start.' if [[ -f "${DOCKERD_LOG_FILE}" ]]; then echo >&2 '---DOCKERD LOGS---' cat >&2 "${DOCKERD_LOG_FILE}" fi exit 1 fi sleep 1 done local duration=$(( SECONDS - start )) echo >&2 "Docker available after ${duration} seconds." } # Gracefully stop Docker daemon. stop_docker() { if ! [[ -f "${DOCKERD_PID_FILE}" ]]; then return 0 fi local docker_pid="$(cat ${DOCKERD_PID_FILE})" if [[ -z "${docker_pid}" ]]; then return 0 fi echo >&2 "Terminating Docker daemon." kill -TERM ${docker_pid} local start=${SECONDS} echo >&2 "Waiting for Docker daemon to exit..." wait ${docker_pid} local duration=$(( SECONDS - start )) echo >&2 "Docker exited after ${duration} seconds." } start_docker trap stop_docker EXIT await_docker # do not exec, because exec disables traps if [[ "$#" != "0" ]]; then "$@" else bash --login fi
resource_types: - name: telegram type: docker-image source: repository: vtutrinov/concourse-telegram-resource tag: latest - name: kubernetes type: docker-image source: repository: zlabjp/kubernetes-resource tag: 1.16 - name: metadata type: docker-image source: repository: olhtbr/metadata-resource tag: 2.0.1 resources: - name: metadata type: metadata - name: sources type: git source: branch: master uri: git@github.com:bihero-io/helloworldmicroservice.git private_key: ((deployer-private-key)) - name: docker-image type: docker-image source: repository: bihero/helloworld username: ((docker-registry-user)) password: ((docker-registry-password)) - name: telegram type: telegram source: bot_token: ((telegram-ci-bot-token)) chat_id: ((telegram-group-to-report-build)) ci_url: ((ci_url)) command: "/build_helloworld_ms" - name: kubernetes-demo type: kubernetes source: server: ((k8s-api-server)) namespace: default kubeconfig: ((kubeconfig-demo)) jobs: - name: build-helloworld-microservice serial: true public: true plan: - in_parallel: - get: sources trigger: true - get: telegram trigger: true - put: metadata - put: telegram params: status: Build In Progress - task: tests privileged: true config: platform: linux image_resource: type: docker-image source: repository: ((docker-registry-uri))/bih/dind # тут мы указываем образ, собранный на основе докерфайла, где мы ставим докер, maven и 11-ю джаву tag: latest username: ((docker-private-registry-user)) password: ((docker-private-registry-password)) inputs: - name: sources outputs: - name: tested-workspace run: path: entrypoint.sh args: - bash - -ceux - | # вот тут мы уже имеем запущенный докер внутри таски и можем запускать тесты с testcontainers output_dir=tested-workspace cp -R ./sources/* "${output_dir}/" mvn -f "${output_dir}/pom.xml" clean test caches: - path: ~/.m2/ on_failure: do: - task: tests-report config: platform: linux image_resource: type: docker-image source: repository: ((docker-registry-uri))/bih/maven-dind tag: 3-jdk-11 username: ((docker-private-registry-user)) password: ((docker-private-registry-password)) inputs: - name: tested-workspace outputs: - name: message run: path: /bin/sh args: - -c - | output_dir=tested-workspace mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -f "${output_dir}/pom.xml" site-deploy version=$(cat $output_dir/target/classes/version.txt) cat >message/msg <<EOL <a href="https://nexus.dev.techedge.pro:8443/repository/reports/hello-world-microservice/${version}/allure/">Allure report</a> EOL caches: - path: ~/.m2/ - put: telegram params: status: Build Failed (unit-tests) message_file: message/msg - task: tests-report config: platform: linux image_resource: type: docker-image source: repository: ((docker-registry-uri))/bih/maven-dind tag: 3-jdk-11 username: ((docker-private-registry-user)) password: ((docker-private-registry-password)) inputs: - name: tested-workspace outputs: - name: message - name: tested-workspace run: path: /bin/sh args: - -c - | work_dir=tested-workspace mvn -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -f "${work_dir}/pom.xml" site-deploy version=$(cat $work_dir/target/classes/version.txt) cat >message/msg <<EOL <a href="https://nexus.dev.techedge.pro:8443/repository/reports/hello-world-microservice/${version}/allure/">Allure report</a> EOL caches: - path: ~/.m2/ - task: package config: platform: linux image_resource: type: docker-image source: repository: ((docker-registry-uri))/bih/maven-dind tag: 3-jdk-11 username: ((docker-private-registry-user)) password: ((docker-private-registry-password)) inputs: - name: tested-workspace - name: metadata outputs: - name: app-packaged-workspace - name: metadata run: path: /bin/sh args: - -c - | output_dir=app-packaged-workspace cp -R ./tested-workspace/* "${output_dir}/" mvn -f "${output_dir}/pom.xml" package -Dmaven.main.skip -DskipTests tag="-"$(cat metadata/build_name) echo $tag >> ${output_dir}/target/classes/version.txt cat ${output_dir}/target/classes/version.txt > metadata/version caches: - path: ~/.m2/ on_failure: do: - put: telegram params: status: Build Failed (package) - put: docker-image params: build: app-packaged-workspace tag_file: app-packaged-workspace/target/classes/version.txt tag_as_latest: true get_params: skip_download: true - task: make-k8s-app-template config: platform: linux image_resource: type: docker-image source: repository: bhgedigital/envsubst inputs: - name: sources - name: metadata outputs: - name: k8s run: path: /bin/sh args: - -c - | export DOMAIN=demo1.bihero.io export HELLO_WORLD_SERVICE_IMAGE_VERSION=$(cat metadata/version) cat sources/k8s.yaml | envsubst > k8s/helloworld_app_template.yaml cat k8s/helloworld_app_template.yaml - put: kubernetes-demo params: kubectl: apply -f k8s/helloworld_app_template.yaml - put: telegram params: status: Build Success message_file: message/msg
Подошли к деплою интеграционного сервиса в k8s. И тут возникает необходимость знать адреса сервисов hello и world внутри k8s-кластера. По дефолту все сервисы внутри k8s имет адреса типа <service-name>..default.svc.cluster.local, вот ими и воспользуемся, не будем же мы ходить до сервисов, которые крутятся рядом через внешний API. Сказано, сделано :
apiVersion: v1 kind: ConfigMap metadata: name: hello-world-config data: config.json: | { "type": "file", "format": "json", "scanPeriod": 5000, "config": { "path": "/config.json" }, "serverPort": 8083, "serverHost": "0.0.0.0", "hello-service-host": "bihero-hello.default.svc.cluster.local", "hello-service-port": 8081, "world-service-host": "bihero-world.default.svc.cluster.local", "world-service-port": 8082 } --- apiVersion: extensions/v1beta1 kind: Deployment metadata: labels: io.bihero.hello.service: bihero-helloworld name: bihero-helloworld spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 1 template: metadata: labels: io.bihero.hello.service: bihero-helloworld spec: containers: - image: bihero/helloworld:${HELLO_WORLD_SERVICE_IMAGE_VERSION} name: bihero-helloworld ports: - containerPort: 8083 imagePullPolicy: Always resources: {} volumeMounts: # в /usr/local заменяем дефолтный конфиг на занчение из ConfigMap'а сверху - mountPath: /usr/local/ name: hello-world-config restartPolicy: Always volumes: - name: hello-world-config configMap: name: hello-world-config --- apiVersion: v1 kind: Service metadata: labels: io.bihero.hello.service: bihero-helloworld name: bihero-helloworld spec: ports: - name: "8083" port: 8083 targetPort: 8083 selector: io.bihero.hello.service: bihero-helloworld status: loadBalancer: {} --- apiVersion: extensions/v1beta1 kind: Ingress metadata: name: bihero-helloworld annotations: kubernetes.io/ingress.class: nginx nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/secure-backends: "false" nginx.ingress.kubernetes.io/ssl-passthrough: "false" nginx.ingress.kubernetes.io/rewrite-target: /$2 kubernetes.io/tls-acme: "true" namespace: default spec: tls: - hosts: - ${DOMAIN} secretName: bihero rules: - host: ${DOMAIN} http: paths: - path: /api/helloworld(/|$)(.*) backend: serviceName: bihero-helloworld servicePort: 8083
Ну, и как обычно — коммитимся, пушимся, билдимся, деплоимся, тестируемся:
curl https://demo1.bihero.io/api/helloworld Hello World
curl https://demo1.bihero.io/api/helloworld/doc openapi: 3.0.1 info: title: Hello World ;) description: "Hello World microservice. Aggregate 'Hello World' by hellomicroservice and worldmicroservice" version: 1.0.0 servers: - url: https://demo1.bihero.io/api/helloworld tags: - name: helloworld description: Everything about 'Hello World' paths: /: x-vertx-event-bus: address: service.helloworld timeout: 1000 get: tags: - helloworld summary: Aggregate 'Hello World' operationId: getHelloWorld responses: 200: description: OK content: {} /doc: x-vertx-event-bus: address: service.helloworld timeout: 1000c get: tags: - world summary: Get 'Hello World' microservice documentation operationId: getDoc responses: 200: description: OK components: {}
Ура! Работает! Можем релизиться и в прод, но для полноты картины…
TODO’шечки (backlog)
- Много бойлерплейта в помниках — унести всё общее в parent pom и собирать все сервисы продукта на основе него.
- Сейчас собранные в пайплайнах docker-образы тегаются и сразу пушатся в docker-hub, включая снэпшотные образы — сделать так, чтобы туда пушились только релизные образы, всё снэпшотное в private registry.
- Сделать «нормальное» версионирование (maven-release-plugin? concourse semver-resource ?), возможно хранить версии в отдельном репозитории, и триггерить релизные сборки при изменении в репозитории, отвечающем за хранение версий продукта.
- Решить проблему рассинхронизации API между сервисами в условиях НЕ-монорепозитория в гите (когда это три сервиса типа HelloWorld, то проблемы нет, но когда будет несколько десятков сложных сервисов, то наступит АД). Если кто-то занет железобетонные способы, то пишите в комментариях — буду рад узнать и обсудить 🙂
Список в голове был большой, но забылся по пути, если вспомнится, то дополню, ну или дополняйте в комментариях 🙂
И исходники
https://github.com/bihero-io/hello-microservice
https://github.com/bihero-io/worldmicroservice
https://github.com/bihero-io/helloworldmicroservice
[UPD] в TODO’шечки
- Запилить степ в пайплайне для сборки helm-чарта, деплоить в k8s с помощью чарта, давать конечным on-prem юзерам возможность деплоиться как с темплейтами, так и из чарта
ссылка на оригинал статьи https://habr.com/ru/post/465149/





Добавить комментарий