Мир сходит с ума, заталкивая калькулятор для 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 выглядит примерно так:


pom.xml
<?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 &ndash;&gt;-->
        <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>

HelloService.java
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);

}

DefaultHelloService.java
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()))
                ));
    }

}

HelloVerticle.java
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 запускал тесты и при падении тестов сборка падала. Сборка выше прошла, и это говорит о том, что либо тесты прошли, либо их нет. Тестов у нас, конечно же, нет, настала пора их написать (тут можно поспорить о методологиях, о том когда и как писать тесты, до или после имплементации, но мы вспомним про важную заметку вначала статьи и пойдём дальше — напишем парочку тестов).
Первый тест-класс является по своей природе юнит-тестом, проверяющим два конкретных метода нашего сервиса:


HelloServiceTest.java
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 запросы ожидаемыми статусами и текстом:


HelloVerticleTest.java
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 вас поймёт


Как и полагается — 3 пода


Посмотрим подробности одного пода


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-сервере.


Что было сделано руками?


  1. Написали код, написали тесты к коду, прошли всё чеки (кодревью и прочее), закоммитили в git-репозиторий
  2. Запуск сборки (mvn), прогон тестов (surefire, allure) — на выходе получаем fat-jar с сервисом
  3. Сборка docker-образа (docker build)
  4. Push docker-образа на докерхаб (или корпоративный приватный docker registry) (docker push)
  5. Деплой сервиса в k8s (kubectl apply)

Что будет делать CI-сервер ?


Да всё то же самое, что и мы ручками делали (кроме написания кода и тестов), только по пути будет уведомлять нас о своих действиях и отчёты деплоить в нужные места. Алгоритм выглядит примерно так:

Опишем пайплайн по шагам:


  1. Пайплайн будет триггерить джобу сборки по коммиту в определенную ветку проекта, пусть это будет ветка master (напрямую в master мы, конечно же, не коммитим, туда коммиты попадают при merge'ах после merge request'ов и тщательного ревью)
  2. Уведомление команды разработчиков о том, что началась сборка сервиса из вышеуказанной dev-ветки (telegram-bot)
  3. Прогон тестов
  4. Проверяем, как поршли тесты
  5. Тесты прошли успешно — деплоим результат прогона тестов в maven repository (конкретно в нашем кейсе используется nexus blob store)
  6. Собираем fat-jar (mvn package, но с маленьким хаком, чтобы не компилить по новой код — мы это уже сделали на этапе прогона тестов)
  7. Собираем docker image из собранного джарника и необходимых конфигов. Тут стоить отметить, что данный шаг делает не только сборку образа, но и пушит его в репозиторий, на который ссылается наш образ как ресурс пайплайна (о ресурсах скоро узнаете). Пуш образа в registry триггерит деплой новой версии сервиса в k8s кластер
  8. Деплой новой версии сервиса в k8s кластер
  9. Уведомление команды сервиса о том, что сборка прошла и новая версия сервиса ушла в требуемый k8s кластер. Уведомление содержит ссылку на джобу с логами сборки и ссылку на результат прогона тестов
  10. Если на 4-м шаге мы понимаем, что тесты не прошли, то деплоим результаты прогона тестов в maven repository
  11. И уведомляем команду о том, что сборка новой версии сервиса упала со всеми необходимыми ссылками в уведомлении

Concourse CI

Вышеописанный пайплайн мы будем писать под CI-сервер Concourse. Особенности Concourse CI:


  • минималистичный UI (всё управление составом пайплайна через yaml-конфиги, которые могут лежать рядом с кодом, и через консольный тул под название fly): это и плюс и минус одновременно — очень удобно и гибко для разработчиков, которые всегда работают с консолью (mvn, docker, fly, kubectl), но неудобно для менеджерского состава, который хочет потыкать в кнопочки (но для них мы будем отчёты писать в tg-группу со ссылками на все необходимые для них ресурсы)
  • каждый степ сборки проходит в docker container'е, что даёт гибкость в настройке окружения для каждого степа (не надо на каждой worker-ноде шаманить с настройками, если что-то environment-зависимое захотели поменять в одном из шагов пайплайна) — собрал образ один раз, степ пайплайна подтянет его в момент старта, и дело в шляпе.

Итак, встречайте, пайплайн сбрки:


pipeline.yaml
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

Рассмотрим кратко содержимое пайплайна:


  1. Секция resource_types нужна для объявления кастомных типов ресурсов, с которыми мы хотим работать, собирая наш проект. В нашем кейсе это три типа (имена типов можно задавать любые, сама суть типа закладывается в docker-образе, которым описывается тип): telegram для отправки уведомлений в tg-группу и для триггера джобы по сборке по определённой команде, kubernetes для деплоя новой версии сервиса в k8s-кластер и metadata для обеспечения данных по билду (номер билда, дата сборки и т.д.) в тасках пайплайна
  2. Секция resources нужна для объявления ресурсов, с которыми мы будем работать в процессе билда. Это то самое место в пайплайне, где описываются репозитории с исходниками, docker-registry для деплоя собираемых docker-образов и другие ресурсы, необходимые для выполнения степов сборки проекта. Каждый ресурс может быть использован на каждом степе пайплайна как input-ресурс в соответствующем блоке, описывающем таск пайплайна
  3. Секция jobs описывает набор джоб, которые нужно выполнить для сборки проекта. У нас это одна джоба с набором тасков и put-инструкций для деплоя результатов сборки и уведомлений в tg-группу. Иструкциями — get объявляем входные ресурсы для билда (например, git-репозиторий), — put — выходные ресурсы (docker image) или ресурсы, генерируемые на первых шагах сборки проекта и используемые на последующих (metadata). Каждый task в джобе — команды внутри docker-контейнера на основе docker-image'а, конфигурируемого параметром image_resource таски
  4. Строки вида ((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>

Пишла пора запустить наш первый билд. Сделать мы это можем несколькими способами:


  1. Залогиниться на CI-сервере, выбрать необходимый нам пайплайн и джобу и нажать на кнопку с плюсиком:
  2. Сделать всё то же самое (что и в пункте 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
  3. Отправить сообщение /build_hello_ms в телеграм-группу, на которую указывает telegram-group-to-report-build в файле credentials.yaml
  4. Отправить коммит в master-ветку в гит (помним, что мы не про идеальную разработку сейчас говорим, а про процесс в целом: коммитить в master — это плохо, — но в обучающих целях можно ;) )

В процессе билда (в случае успешного его окончания) мы получим два уведомления в телеграм-группу:


  1. Уведомление о начале работы с джобой:
  2. Уведомление об успешном завершении сборки:

    Давайте посмотрим, как сборка выглядит в UI CI-сервера:

Ура! Сборка прошла, докер-образ собран, задеплоен, приложение в k8s обновлено и отчёты отправлены. Пора проверять задеплоенное:


  1. Образ на docker-hub'е
  2. Смотрим на список подов
  3. И смотрим на версию образа, из которого развёрнут контейнер в одном из подов из 2-го пункта
  4. Делаем запрос и смотрим на ответ:

curl https://demo1.bihero.io/api/hello -v
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


Service specification
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: {}

pom.xml
<?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 &ndash;&gt;-->
        <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>

WorldService.java
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);

}

DefaultWorldService.java
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()))
                ));
    }

}

WorldVerticle.java
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();
    }

}

WorldServiceTest.java
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);
            }
        }));
    }

}

WorldVerticleTest.java
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);
                        }
                    }));
        }));
    }

}

Dockerfile
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"]

run.sh
#!/bin/sh
java ${JVM_OPTS} -Dlogback.configurationFile=./logback-console.xml -jar app.jar -conf config.json

pipeline.yaml
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

k8s app template
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, но обо всё по порядку.


Service specification
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: {}

pom.xml
<?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 &ndash;&gt;-->
        <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>

HelloWorldService.java
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);

}

DefaultHelloWorldService.java
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;
    }

}

HelloWorldVerticle.java
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();
    }

}

HelloWorldServiceTest.java
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


Dockerfile
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, который обеспечит нам запуск докера перед запуском самих тестов:


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

pipeline.yaml
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. Сказано, сделано :


конечная версия темплейта для деплоя интеграционного сервиса в k8s
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)


  1. Много бойлерплейта в помниках — унести всё общее в parent pom и собирать все сервисы продукта на основе него.
  2. Сейчас собранные в пайплайнах docker-образы тегаются и сразу пушатся в docker-hub, включая снэпшотные образы — сделать так, чтобы туда пушились только релизные образы, всё снэпшотное в private registry.
  3. Сделать "нормальное" версионирование (maven-release-plugin? concourse semver-resource ?), возможно хранить версии в отдельном репозитории, и триггерить релизные сборки при изменении в репозитории, отвечающем за хранение версий продукта.
  4. Решить проблему рассинхронизации API между сервисами в условиях НЕ-монорепозитория в гите (когда это три сервиса типа HelloWorld, то проблемы нет, но когда будет несколько десятков сложных сервисов, то наступит АД). Если кто-то занет железобетонные способы, то пишите в комментариях — буду рад узнать и обсудить :)

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


И исходники


https://github.com/bihero-io/hello-microservice
https://github.com/bihero-io/worldmicroservice
https://github.com/bihero-io/helloworldmicroservice


[UPD] в TODO'шечки


  1. Запилить степ в пайплайне для сборки helm-чарта, деплоить в k8s с помощью чарта, давать конечным on-prem юзерам возможность деплоиться как с темплейтами, так и из чарта

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


  1. stranger_shaman
    14.12.2019 17:14
    +6

    просто квинтэссенция


  1. shachneff
    14.12.2019 17:50
    +6

    Если кто-то с подобной документацией опишет бэк на Go и фронт на Vue или Flutter Web, включая тесты и сисадминскую часть — под ключ, чтобы только осталось URL юзеру отдать — расцелую в губы или лично переведу на мобильный 500 российских рублей (однократно).

    P.S. В сети очень много учебников и статей. Когда учишься сам и дома, нигде не узнать полной картины, кроме как пойти работать в офис и посмотреть как процесс налажен другими.

    P.P.S. Спасибо!


    1. zhulikovatyi Автор
      14.12.2019 18:38
      +2

      На Go к сожалению (или к счастью :) ) не пишу, но вот про фронт да — забыл добавить описание. Будет время — запилю статью подобную про фронт на React'е и мобилку на Kotlin'е.


      1. fkvf
        14.12.2019 19:28

        Надеюсь посмотреть на сборку и тестирование мобильных приложений в concourse.


  1. algotrader2013
    14.12.2019 22:06
    +2

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

    Даже невзирая на это, читаю статью, и понимаю, что что-то не так современным it. Вроде ж не для этого облака создавались.


    1. zhulikovatyi Автор
      14.12.2019 22:58
      +2

      Пример:
      Высоконагруженный интернет-магазин
      Грубый (очень примерный) список сервисов:

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

      И все сервисы надо скэйлить и обеспечивать high availability (много сервисов в нескольких экземплярах за балансерами и в нескольких дата-центрах). А ещё надо конкуренцию держать и поставлять всё новое и новое на сервера как можно быстрее (десятки, сотни деплоев в день). По мне так для этого облака и придумывались, нет разве?


      1. algotrader2013
        15.12.2019 11:36
        +1

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


        1. gecube
          15.12.2019 12:21

          Все так. Другой вопрос, что если ты приходишь в Ынтерпрайз, то там все эти проблемы решены. И у разработчика остаётся одно — реализовать бизнес-функциональность в существующих рамках платформенного решения. И это здорово, т.к. не болит голова о вещах — как сделать логирование, как писать деплой и пр. А всем этим заведует платформенная команда.
          В стартапе — да, больно, приходится заниматься всем, но часто помогают облачные сервисы типа sentry, splunk, okmeter, newrelic etc., которые при масштабах стартапа могут оказаться легко дешевле, чем з/п "девопса".
          Что ещё сказать. Вероятно, если Вы не хотите писать ямлики, то Вам вообще не кубернетес нужен, а платформа типа докку-хероку....


      1. Kwisatz
        15.12.2019 16:03

        Высоконагруженный
        это сколько? В граммах пожалуйста.

        Сейчас абсолютно каждый мнит себя высоконагруженным сервисом, делая безумное количество микросервисов и прочего быдлокода. А чтоб интересней было еще и без архитектора. Причем на полном серьезе, я уже не раз слышал нечто вроде: у нас очень большой каталог товаров, 10 тысяч (!!!).

        С базами тоже самое: 10 млн строк в таблице, нужны мощные сервера!

        Я правда хочу понять, ну вот правда)


        1. zhulikovatyi Автор
          15.12.2019 18:22

          Высоконагруженный? А фиг знает, сколько это, ибо нагрузки у всех разные и все считают своё высоконагруженным (так или иначе).
          Статьёй я не хотел побуждать всех перезжать в облака и поднимать свой k8s-кластер, а всего лишь хотел показать, как это можно сделать и как построить примерный процесс доставки продукта в клауд.


          Делать это или нет — вопрос вышего выбора :)
          Про микросервисы:


          За свою карьеру много всякого разного монолита видел и микросервисов тоже (не так много и не такого разного как монолита, но видел), и для меня иногда очень остро стоял вопрос скэйлинга того или иного участка бизнес-логики, но я не мог этого сделать легко, если имел дело с монолитом, отжирающим кучу памяти, диска и процессорного времени, а с микросервисом, который делает ровно одну задачу и делает её хорошо, я могу это сделать легко и быстро, условно указав большее количество реплик в деплойменте кубика


          1. algotrader2013
            15.12.2019 21:38
            +2

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

            Все так, не поспоришь. Но есть и обратная сторона, которую много раз видел в своей практике. Факт, что данный способ решения задачи (поднять количество реплик в k8s) доступен для большинства задач скейлинга, говорит о том, что до этого момента было (цифры привожу «свои», в других проектах они другие):
            1) уже проинвестировано/потрачено в 3-10 раз больше времени программистов, чем на создание аналогичного монолита (условно, вместо одного хитровыделанного SQL запроса с джойном на 10 таблиц и десятком CTE в случае монолита, пишется (либо модифицируется код уже существующих) 5 микросервисов тремя командами + юнит тесты на каждый + интеграционные тесты + документация).
            2) 80+ % процессорного времени идет на сериализацию и десериализацию json, запись и чтение из Kafka, логгирование, поддержание работы самой Кафки.


            1. zhulikovatyi Автор
              15.12.2019 21:51
              +1

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


              Ну и не забываем про все практики хорошего тона и кода — кодстайл, чекстайл, код ревью и все прочие пляски с бубном, перед тем как код попадёт в прод, никто не отменяет (это к слову про быдлокодинг)


              1. gecube
                15.12.2019 22:39

                на самом деле все просто. Оптимальная формула — напишите сначала монолит. А потом, когда он докажет свою жизнеспособность — пилите на микросервисы. Т.к иначе, пропустив этот шаг, получаешь не микросервисы, а нечто более худшее — распределенный монолит (1!!!)


                1. zhulikovatyi Автор
                  16.12.2019 07:38
                  +1

                  Да, отличная формула! Уже написанный монолит даёт понять границы отдельных участков бизнес логики, которые можно разнести по микросервисам.


          1. Kwisatz
            16.12.2019 00:04
            +3

            Да я не против таких статей в принципе, я против той истерии что сейчас происходит в отрасли. Все взялись за микросервисы и прочий оверинжиниринг. Я чувствую себя очень не в свое тарелке: на одном из своих первых проектов у меня был монолит обслуживающий 100к уникальных посетителей основного сайта и 500к суммарно по сервисом в день, до 800 млн записей на таблицу и все это крутилось в отсутсвии ssd и на 8 гб оперативки. Сейчас же сотни гигабайт памяти, ssd массивы, микросервисы, не более 100 млн записей на таблицу, а работает все еле-еле.


            1. zhulikovatyi Автор
              16.12.2019 08:05
              +1

              Всё верно, размикросервились тут!


              Но для меня микросервисность и клаудность продукта даёт ещё один плюс — удобство и скорость доставки в прод. Да, это даёт накладные расходы тоже и не малые (в том числе на дизайн и продумывание API), но конечный лояльный клиент для бизнеса, думаю, важнее. О чём это я?


              А, вот пример:


              1. Есть условный Василий, владелец крутого сетевого магазина спортивной экипировки. И у Васи есть интернет магазин монолит.
              2. Есть условный Пётр, владелец крупного интернет-магазина, продающего ВСЁ, спортивную экипировку в том числе. И у него огромная инфраструктура, клауд, k8s и все все все.
              3. Мне нравится магазин Васи, ибо там всегда есть то, что мне нужно и иногда по очень выгодным ценам. НО интернет-магазин Васи откликается на мои действия по 10-15 секунд!!! и местами очень не устраивает UI (конкретно в мобильной версии сайта)
              4. У Пети я могу очень комфортно сёрфить по магазину и выбирать себе спорт-товары, даже те, что есть у Васи и иногда по ценам ниже, чем у Васи (Петя много не потеряет, если продаст последнюю пару лыж чуть дешевле, но клиент останется доволен)
              5. Мы уже дошли до 5-го пункта, и где тут про удобство клауда и про скорость доставки?? Да нету здесь ничего про это. НО когда я пишу Пете, что у него воооот этот вот участок сайта некрасивый для юзера и он чуть тупит, то Петя идёт и решает эту проблему за условных N минут (похачив пару строк во фронтовом компоненте и одной командой задеплоив новую версию микросервиса, отвечающего за этот кусок UI и за этот кусок бизнес логики) — PROFIT. Но когда я Васю прошу о том же, то это может затянуться на несколько дней, недель, месяцев, — монолит не так просто обновлять на боевых серверах.

              Вывод: клауд — недешёвое удовольствие как с точки зрения дизайна API, так и с точки зрения содержания инфраструтуры, НО это огромный плюс в пользу скорости решения пользовательских проблем (много пользователей утром пожаловались на медленную корзину в магазине, во время ланча они уже получили скоростное оформление заказов и маленький аддон в виде красивого виджета в корзине, а команде разработчиков это почти ничего не стоило) и доставки апдейтов в прод


              1. Kwisatz
                16.12.2019 08:43
                +2

                Вот ну никакой логики, вот совсем. Либо у Васи и Пети rest/jsonrpc бек и качественный фронт, либо им обоим ничего не поможет. А судя по отклику 10-15 секунд, уже сразу ничего не поможет, потому что такое можно было сделать только говнокодом в степени n (не, n мало, пусть будет m).

                А если все сделано нормально, то вам без разницы, поправить шаблон в монолите или в микросервисе или на фронте


                1. zhulikovatyi Автор
                  16.12.2019 08:56

                  Логика есть, если поправить и доставить изменение в прод занимает N секунд вместо N минут (а за оставшееся время попробуем проанализировать тупняк и понять, с фига ли мы так тормозим, может чего-нибудь пооптимизируем?).


                  Ну или временно поскейлим кусок логики до 100 инстансов, а пока уходят деньги за использование виртуалок в AWS, и клиенты довольны, мы попробуем понять где мы налажили в коде и попробуем исправить.


                  Да, тормозящий сервис скорее всего тормозит по причине кривых рук разработчиков или админов, но сокорость доставки кусков логики для меня, например, решает много (kubectl apply -f… — ВСЁ)


                  1. Kwisatz
                    16.12.2019 09:04

                    Хм, скорость доставки, скорость доставки… это что?
                    Я вот правда не в курсе, потому что вот тут у нас хук на коммит в мастер.
                    А вот тут автодеплой у IDE (для любителей ftp, внезапно)


                    1. zhulikovatyi Автор
                      16.12.2019 09:21
                      +2

                      Скорость доставки — это про то, насколько быстро изменения в проде появятся или насколько быстро решатся проблемы юзеров без изменения в коде (поскейлим всё в x100).


                      У кого-то это хук в гите, у кото-то watcher на изменения в файловой системе разработчика или директории, куда релизные версии выкладываются и ftp'шкой сразу в прод.


                      Вариантов масса, но мне одноздачно нравится как, например, k8s решает вопрос скейлинга :)
                      Были времена, когда я апдейты уносил в прод файлзиллой, были времена, когда энтерпрайз писали на коболе. Но технологии меняются, развиваются и появляются новые, и надо уметь ими пользоваться и применять на практике (иначе окажешься за бортом, ну или миллионером-коболистом :) ).
                      Ни в коем случае не агитирую на переход в облака и k8s — это ваш выбор и только ваш. Но, если мне это экономит время, то я воспользуюсь этим тулом (а условные облака можно и на своих недорогих железках развернуть, благо некоторые хостеры дают такую возможнсоть)


                      1. Kwisatz
                        16.12.2019 13:03

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

                        Вы еще вот совсем оставляет в стороне контроль целостности. В случае с монолитом у вас есть РСУБД. В случае с микросервисами контроль целостности вы будете выполнять руками, а это крайне нетривиальная задача. Где окажется «скорость доставки», когда у вас, например, сервис оплат станет неконсистентным с сервисом заказов?


                        1. zhulikovatyi Автор
                          16.12.2019 13:31

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

                          Та не вопрос, только, как я выше написал, тулы меняются, эволюционируют и за эволюцией надо успевать. Если ты продолжишь писать на Коболе и доставлять софт на прод дискетами, то найти к себе в команду людей будет сложновато. Это не значит, что надо всё контейнеризовать и обязательно крутить ими в клауде.


                          Вы еще вот совсем оставляет в стороне контроль целостности. В случае с монолитом у вас есть РСУБД. В случае с микросервисами контроль целостности вы будете выполнять руками, а это крайне нетривиальная задача. Где окажется «скорость доставки», когда у вас, например, сервис оплат станет неконсистентным с сервисом заказов?

                          Да, это острый вопрос, и про него я написал в TODO'шечках, и в комментах чуть-чуть уже обсудили, и оставлять в стороне я его не собираюсь и не хочу :)


                          Есть методика написания кода таким образом, чтобы миграция логики сервиса делалась в несколько шагов, когда разные версии разных сервисов становятся совместимыми (пару if'ов в коде).
                          В любом случае, задача комплексная и решать её надо с умом.


                          1. Kwisatz
                            16.12.2019 13:33

                            на прод дискетами,

                            Утрируете. Если хотите аналогию с дискетами то вы с дискетой таскаете еще и дисковод.


                            1. zhulikovatyi Автор
                              16.12.2019 13:45

                              Да, утрирую, прошу прощения.
                              Но аналогия она примерно такая, да.
                              Мне k8s даёт удобный тул деплоя и скейлинга сервисов (а кубик работает с докером), и я его использую, и показал в статье как это можно делать в условиях работы с продуктом.
                              Делать так же в своём продукте вАм или не делать — дело исключительно ваше


                              1. Kwisatz
                                18.12.2019 10:17
                                +1

                                Как раз Я то разберусь. А вот с ВАШИХ последователей придется брать больше денег, поскольку разбираться с микросервисным бардаком очень лениво)


                                1. zhulikovatyi Автор
                                  18.12.2019 10:40
                                  +1

                                  Хм, готовая бизнес модель: клепаем статьи про то как делать по хайпу, направляем таких клиентов к вам, деньги пополам!
                                  Шутка, конечно же)
                                  Пусть каждый сам для себя решает, как делать и не делать.


                  1. gecube
                    16.12.2019 09:04
                    +1

                    Пока мы скейлились в 100 инстансов произошло две вещи:


                    1. Все равно часть клиентов отвалилась, т.к. любой скейлинг работает с запозданием
                    2. И мы разорились, т.к. нам пришел счёт за Амазон на сумму годового оборота магазина

                    Второй коллега тоже разорился, потому что с его монолитом тоже все клиенты разбежались.
                    И стали клиенты покупать товары у Амазона и на Алибаба.
                    Сказке конец.


                    Плюс.
                    Не надо забывать, что скорость поставки — это не только узкое место в виде скорости деплоя. А ещё и скорости разработки. Что толку от мгновенного деплоя, если разработать фичу — две недели. (Я знаю ответ на это замечание, надеюсь, тоже к нему придёте).
                    Ну, и распределенные системы — это всегда дорого. Дорого в эксплуатации. Облака дороже, чем баре метал. Инженеры дорогие. Если что-то ломается — оно может ломаться в неочевидных местах.
                    А ещё я троллю любителей u-сервисов тем, что в них прокси типа nginx едет через прокси. Ну, смотрите сами. WAF, LB, балансировщик кубера, потом Ingress, потом ещё мы захотим service mesh, потом мы ещё запихнем контейнер с nginx+php-fpm в кластер. И путь запроса станет весьма долгим. Да, это более масштабируемая история. Но не всем это надо


                    1. zhulikovatyi Автор
                      16.12.2019 09:27
                      +2

                      Прекрасная показательная история о том, как не надо слепо надеяться на технологии :)
                      "Ребята, тут новый API подвезли — заживём!" Нет, не заживём, а потестим, возьмём то, что реально ускоряет процесс, и ещё раз потестим, а потом может быть в прод.


                      А ещё я троллю любителей u-сервисов тем, что в них прокси типа nginx едет через прокси. Ну, смотрите сами. WAF, LB, балансировщик кубера, потом Ingress, потом ещё мы захотим service mesh, потом мы ещё запихнем контейнер с nginx+php-fpm в кластер. И путь запроса станет весьма долгим. Да, это более масштабируемая история. Но не всем это надо

                      Да, люди забывают, что сервисам в условиях микросервисного окружения надо общаться друг с другом, и чаще всего они это делают через HTTPv1, а он, зараза, медленный.


  1. Andrey_139
    14.12.2019 22:42
    +2

    Классно! такой пустой шаблон всегда очень нужен! Спасибо!


    1. zhulikovatyi Автор
      14.12.2019 22:48
      +1

      Именно с такой целью и писал, чтобы под рукой был шаблон. Рад, что понравилось :)


  1. gecube
    15.12.2019 00:33
    +1

    Отличный пост. Образцовый пайплайн. Все круто. Вышел с https://t.me/architect_says/242
    Чего добавить? Ну, на самом много чего.
    Например, squash не нужен. Во-первых, есть опция в последнем докере --squash Во-вторых, можно сделать сквош для нищих:


    Dockerfile:


    ....
    FROM your_image as base
    .....
    FROM scratch as squashed
    COPY --from=base / / 
    # и где-то здесь еще ENV определить
    ENTRYPOINT ["lalala"]
    CMD ["lalala"]

    По опечаткам — отослал.


    CMD ["./run.sh"]

    не понял — зачем так. Если можно ENTRYPOINT переопределить. И уж если совсем делать красиво, то вызывать jvm напрямую без промежуточных сабшеллов. И, да, я проверял — если все делать правильно, то переменные окружения в конечное приложение прокидываются (там есть пара нюансов с этим).


    Запилить степ в пайплайне для сборки helm-чарта, деплоить в k8s с помощью чарта, давать конечным on-prem юзерам возможность деплоиться как с темплейтами, так и из чарта

    Разумно. А еще круче — добавить в кластер https://github.com/fluxcd/flux-get-started


    Решить проблему рассинхронизации API между сервисами в условиях НЕ-монорепозитория в гите (когда это три сервиса типа HelloWorld, то проблемы нет, но когда будет несколько десятков сложных сервисов, то наступит АД). Если кто-то занет железобетонные способы, то пишите в комментариях — буду рад узнать и обсудить :)

    очевидное решение — сделать "интеграционное" репо, которое будет знать о всех микросервисах и содержать информацию об их взаимосвязах и версиях. И деплоить именно из него. Через механизмы типа гит хука можно в это репо кидать информацию о новых версиях деплойментов из репозиториев каждого из "микросервисов". Еще на самом деле нет проблемы, если у вас в экосистеме будут деплойменты с версией API v1 и версией API v2. Здесь больше вопрос декомпозиции — готовы ли вы сказать, что API v2 — это не следующая версия приложения, а НОВОЕ приложение. Тогда как бы проблема рассасывается сама собой. Либо тащить в какой-то момент времени ОБЕ версии апи и каким-то внешним способом контролировать, что как только потребители v1 исчезли, то можно его выпилить из кодовой базы и деплоить новую версию только с v2, но это тогда не ТЕХНИЧЕСКАЯ, а ОРГАНИЗАЦИОННАЯ проблема


    В общем — у меня все замечания на самом деле минорные.


    1. zhulikovatyi Автор
      15.12.2019 10:45

      Спасибо за замечания!


      Например, squash не нужен. Во-первых, есть опция в последнем докере --squash

      Про --squash в последнем докере почитаю.


      не понял — зачем так. Если можно ENTRYPOINT переопределить

      Почему CMD["./run.sh"]? Это скорее "исторически сложилось": сдедали сервис, запаковали так в докер, работает — не трогай :) будем рассматривать и другие true way способы.


      Разумно. А еще круче — добавить в кластер https://github.com/fluxcd/flux-get-started

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


      очевидное решение — сделать «интеграционное» репо, которое будет знать о всех микросервисах и содержать информацию об их взаимосвязах и версиях. И деплоить именно из него. Через механизмы типа гит хука можно в это репо кидать информацию о новых версиях деплойментов из репозиториев каждого из «микросервисов». Еще на самом деле нет проблемы, если у вас в экосистеме будут деплойменты с версией API v1 и версией API v2. Здесь больше вопрос декомпозиции — готовы ли вы сказать, что API v2 — это не следующая версия приложения, а НОВОЕ приложение. Тогда как бы проблема рассасывается сама собой. Либо тащить в какой-то момент времени ОБЕ версии апи и каким-то внешним способом контролировать, что как только потребители v1 исчезли, то можно его выпилить из кодовой базы и деплоить новую версию только с v2, но это тогда не ТЕХНИЧЕСКАЯ, а ОРГАНИЗАЦИОННАЯ проблема

      Согласен про "организационность" и "не техничность" проблемы рассинхронизации API. Тоже думал про отдельный репозиторий со всеми связями между сервисами и версиями API, но пока не начинаешь это делать, то всё кажется овер сложным и не решаемым. Главное начать. Про версии деплойментов — отлично подмечено, надо попробовать :)


  1. gecube
    15.12.2019 00:43

    Еще идиотский вопрос — какие выгоды от запуска интеграционных тестов в Concourse-CI в dind? Почему их сразу в кубернетес не гонять на отдельном неймспейсе?


    Еще не понял пляску с LE — не проще было кластерный cert-manager сразу настроить? Или это за скоупом статьи? Ну, он просто по инструкции как в статье — можно подкинуть ЛЮБОЙ серт, хоть купленный у DigiCert/thawte/GoDaddy etc.


    1. zhulikovatyi Автор
      15.12.2019 11:04
      +1

      Еще идиотский вопрос — какие выгоды от запуска интеграционных тестов в Concourse-CI в dind? Почему их сразу в кубернетес не гонять на отдельном неймспейсе?

      Потому что тесты пишут разработчики, и у них есть очень классный готовый тул для запуска контейнеров прямо из кода тестов, то есть не надо городить отдельный степ в пайплайне сначала для запуска тестовых контейнеров, а потом для остановки после прогона тестов. А ещё в этом степе надо знать какие именно контейнеры стартовать, а пайплайнами может заниматься отдельный человек, не совсем близкий к коду и ему постоянно надо будет держать руку на пульсе разрабочтиков и знать какие контейнеры когда и как стартовать и когда и как стопать.


      Когда я столкнулся с проблемой запуска контейнеров в контейнере, то в голову сразу пришла идея — а нет ли готовой реализации в tescontainers, которая бы позволяла тестовые контейнеры запускать сразу в кубике? Ответ: готовой реализации нет, но работы, похоже, ведутся


      Еще не понял пляску с LE — не проще было кластерный cert-manager сразу настроить?

      Кластерный cert-manager работает, если ты имеешь дело с регистратором доменов, реализация для работы с которым есть в кубике (проблема не в LE, а в том месте, где ты домен держишь)
      Речь сейчас именно про LE-сертификат, чтобы дёшево можно было сделать сертификат на любой домент.
      Мой домен (bihero.io) хостится в GoDaddy и k8s не смог мне автоматом создавать сертификаты для моих поддоменов. Я пробовал, честное слово, но кубик не смог из коробки это сделать, пришлось ручками ходить (там в DNS записи надо добавлять текст руками, который просит LE).


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


      Ну, он просто по инструкции как в статье — можно подкинуть ЛЮБОЙ серт, хоть купленный у DigiCert/thawte/GoDaddy etc.

      Да, подкинуть можно любой сертификат


      1. gecube
        15.12.2019 12:05

        Касательно godaddy вижу два варианта. Либо взять что-то вроде https://www.npmjs.com/package/acme-dns-01-godaddy и прикрутить его. И челлендж отправлять через эту промежуточную apiху.
        Причины, по которым нет godaddy в ядре cert-manager, описаны тут https://github.com/jetstack/cert-manager/issues/1083


        Либо можно сделать лайфхак. Делегируешь всю зону на cloudflare. Там отключаются все платные штуки с проксированиес трафика. А далее пользуемся штатной интеграцией cert-manager и CF


        Когда я столкнулся с проблемой запуска контейнеров в контейнере, то в голову сразу пришла идея — а нет ли готовой реализации в tescontainers, которая бы позволяла тестовые контейнеры запускать сразу в кубике? Ответ: готовой реализации нет, но работы, похоже, ведутся

        Спасибо, подумаю над этим.


        1. zhulikovatyi Автор
          15.12.2019 18:24

          Спасибо за советы и ссылочки)


      1. karavan_750
        15.12.2019 16:52

        Кластерный cert-manager работает, если ты имеешь дело с регистратором доменов
        Вы, видимо, хотели сказать «с хостером доменов»? В функции регистраторов не входит создание/удаление записей внутри зоны.
        Мой домен (bihero.io) хостится в GoDaddy и k8s не смог мне автоматом создавать сертификаты для моих поддоменов.
        Не знаю как в k8s, но думаю, что как и у всех остальных — есть скрипты (я пользовался certbot, потом перешел на acme.sh), которые в своем составе имеют подключаемые библиотеки по работе с хостерами доменов, у которых есть API. Эти скрипты позволяют писать/подключать свои библиотеки, если для нами используемого хостера оные (библиотеки) отсутствуют.
        Если ковырнуть k8s, может он тоже готов предложить что-то подобное?


        1. zhulikovatyi Автор
          15.12.2019 18:28

          Вы, видимо, хотели сказать «с хостером доменов»? В функции регистраторов не входит создание/удаление записей внутри зоны.

          Да, именно это я имел ввиду :)


          Насчёт интеграции godaddy и кубика — вооон там чуть выше gecube расписал совет, как сделать. А подключаемые библиотеки для certbot — не, не слышал) но тема интересная, надо попробовать. Спасибо!


          1. karavan_750
            15.12.2019 20:48
            +1

            Про библиотеки для certbot я скорее всего лажанул, очень давно от него отказался и к использованию не рекомендую.
            Смотрите сразу в сторону acme.sh.

            Насчёт интеграции godaddy и кубика — вооон там чуть выше gecube расписал совет, как сделать.
            У меня нет кубика и dns-хостингом от godaddy я не пользуюсь, потому мне сложно дать оценку его совету. Но его лайфхак с делегированием зоны на cloudflare поддерживаю полностью.
            Про использование cloudflare скажу так — если б не было cloudflare, то я был бы вынужден писать что-то подобное по части управления зонами и записями под какой-либо сервер имен.


            1. zhulikovatyi Автор
              15.12.2019 20:51
              +1

              Принято. Но всё равно полезно было узнать.


  1. Tzimie
    15.12.2019 18:03

    Каковы требования по диску (общий объем файлов) и по памяти (16GB? 32GB?) для этой реализации?


    1. zhulikovatyi Автор
      15.12.2019 18:38

      Примерные цифры такие (очень примерные):


      • CI: диск 200Gb, RAM 8-10Gb (по сути там три сервиса в докер-контейнерах: CI worker node, CI UI node, postgresql, — для более или менее серьёзных намерений надо ноды разносить на разные виртуалки)
      • k8s master node: диск 100Gb, RAM 8Gb
      • k8s worker node: диск 300Gb, RAM 12Gb
      • nexus: диск 1Tb, память 8Gb

      Объём файлов отдельно не считал.


  1. Crimento
    15.12.2019 20:25
    +3

    И это я ещё над FizzBuzz Enterprise Edition смеялся


    1. zhulikovatyi Автор
      15.12.2019 20:50
      +1

      Я тоже смеялся, чесслово) Но ещё раз скажу про пойнт данной статьи: она не про то как в реальности Привет Мир затолкать в облако (и не надо этого делать), а о том как можно построить процесс разработки и доставки любого вашего продукта до пользователя (а показано на примере Hello World)


  1. InacheNikak
    18.12.2019 12:04

    Спасибо огромное! Очень интересная статья, добаленно в избранное :)