'Hello World' вам в облако

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


pom.xml


    4.0.0

    
        io.bihero.hello.HelloVerticle
        3.8.1
        1.2.3
        5.3.1
        2.19.1
        1.1.0
        3.8.0
        2.8.1
        2.10.0
        1.9.2
        2.21.0
        3.0.0
    

    io.bihero
    hello-microservice
    1.0.0-SNAPSHOT
    
        
            
                maven-compiler-plugin
                
```1.8
                    1.8
                
                
                    
                        default-compile
                        
                            
                                io.vertx.codegen.CodeGenProcessor
                            
                            src/main/generated
                            
                                -Acodegen.output=${project.basedir}/src/main
                            
                        
                    
                    
                        default-testCompile
                        
                            
                                io.vertx.codegen.CodeGenProcessor
                            
                            src/test/generated
                            
                                -Acodegen.output=${project.basedir}/src/test
                            
                        
                    
                
            
            
                org.apache.maven.plugins
                maven-surefire-plugin
                ${maven-surefire-plugin.version}
                
                    
                        
                            listener
                            io.qameta.allure.junit5.AllureJunit5
                        
                    
                    
                        **/*Test*.java
                    
                    
                        -javaagent:"${settings.localRepository}/org/aspectj/aspectjweaver/${aspectj.version}/aspectjweaver-${aspectj.version}.jar" -Djdk.net.URLClassPath.disableClassPathURLCheck=true
                    
                    
                        
                            allure.results.directory
                            ${project.basedir}/target/allure-results
                        
                        
                            junit.jupiter.extensions.autodetection.enabled
                            true
                        
                    
                    plain
                
                
                    
                        org.aspectj
                        aspectjweaver
                        ${aspectj.version}
                    
                    
                        org.junit.platform
                        junit-platform-surefire-provider
                        ${junit-platform-surefire-provider.version}
                    
                    
                        org.junit.jupiter
                        junit-jupiter-engine
                        ${junit-jupiter.version}
                    
                
            
            
                io.qameta.allure
                allure-maven
                ${allure-maven.version}
            
            
                org.apache.maven.plugins
                maven-site-plugin
                3.7.1
                
                    
                        org.apache.maven.wagon
                        wagon-webdav-jackrabbit
                        2.8
                    
                
            

            
                org.apache.maven.plugins
                maven-project-info-reports-plugin
                3.0.0
            
            
                org.apache.maven.plugins
                maven-shade-plugin
                2.3
                
                    
                        package
                        
                            shade
                        
                        
                            
                                
                                    
                                        io.vertx.core.Launcher
                                        ${main.verticle}
                                    
                                
                                
                                    META-INF/services/io.vertx.core.spi.VerticleFactory
                                
                            
                            
                            
                            ${project.build.directory}/${project.artifactId}-fat.jar
                        
                    
                
            
        
        
            
                src/main/resources
                true
                
                    **/version.txt
                
            
            
                src/main/resources
                false
                
                    **/version.txt
                
            
        
    
    
        
            reports
            dav:https://nexus.dev.techedge.pro:8443/repository/reports/${project.artifactId}/
        
    
    
        true
        
            
                io.qameta.allure
                allure-maven
                
                    ${project.build.directory}/allure-results
                    ${project.reporting.outputDirectory}/${project.version}/allure
                
            
        
    

    
        
            io.vertx
            vertx-web-api-service
            ${vertx.version}
        
        
            io.vertx
            vertx-codegen
            ${vertx.version}
            provided
        
        
            ch.qos.logback
            logback-classic
            ${logback.version}
        

        
        
            io.vertx
            vertx-unit
            ${vertx.version}
            test
        
        
            io.vertx
            vertx-junit5
            ${vertx.version}
            test
        
        
            org.junit.jupiter
            junit-jupiter-api
            ${junit-jupiter.version}
            test
        
        
            org.junit.jupiter
            junit-jupiter-engine
            ${junit-jupiter.version}
            test
        
        
            org.assertj
            assertj-core
            ${assertj-core.version}
            test
        
        
            org.mockito
            mockito-core
            ${mockito.version}
            test
        
        
            io.qameta.allure
            allure-junit5
            ${allure.version}
            test
        
        
            io.vertx
            vertx-web-client
            ${vertx.version}
            test
        
    


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> resultHandler);

    void getDoc(OperationRequest context, Handler> 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> resultHandler) {
        resultHandler.handle(Future.succeededFuture(OperationResponse.completedWithPlainText(Buffer.buffer("Hello"))));
    }

    @Override
    public void getDoc(OperationRequest context, Handler> 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 consumer;

    @Override
    public void start(Promise 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 startHttpServer() {
        Promise 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, выглядеть он будет примерно так (картинка кликабельна):

3ls3dkohbjzzlfj1rruhtvtxjzs.png

Здорово! Сервис собирается, тесты бегут и не падают (чуть позже поговорим о красоте того, как результаты тестов показывать начальству), пора задуматься о том, как мы будем его доставлять до пользователей (то есть до серверов). На дворе конец 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 вас поймёт

upuumhgqijpejz-jgu9n8zc_ds4.png
Как и полагается — 3 пода

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

kubectl describe po bihero-hello-5b4759d55b-bf4qc

dv318n2kmrvhua0bbv63togmivs.png

Как там сервис поживает?

kubectl describe service bihero-hello

to1rfysgzpxwi3zp6jlaffswra4.png

А ингресс?

kubectl describe ing bihero-hello

o2npm4hdhucyueujgknmuqitfny.png

Здорово! Сервис бегает в 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-сервер ?

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


  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 <Allure report
                      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 <Allure report
                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)) — ссылки на пар

    © Habrahabr.ru