Kubernetes III: Взаимодействие сервисов Kubernetes

November 9, 2018
cloud kubernetes kubectl service deployment cluster ip nodeport load balancer label selector
Share on:

Подробное описание разработки облачных приложений Cloud Native в целом, и роль в этом Kubernetes, можно узнать в моей новой (бесплатной) книге “Программирование в эпоху облака Cloud”

Первая статья из этой серии - введение в Kubernetes, основные термины, первые развертывания и сервисы.

Предыдущая статья из этой серии - объекты Kubernetes, декларативное управление кластером.

Примеры для этой статьи можно найти на Github в репозитории ivanporty/cloud-docker-k8s

Взаимодействие сервисов Kubernetes

Одним из впечатляющих преимуществ разработки приложения в виде набора микросервисов является возможность работать над ними индивидуально, используя разные библиотеки, языки, платформы, более того, различный процесс и подход к разработке. Далее микросервисы отдельно масштабируются, обновляются и отлаживаются - все это делает жизнь разработчиков более гибкой, а разработку быстрой. Однако возникает вопрос взаимодействия микросервисов - вместо вызова обычных методов и функций каждое взаимодействие становится сетевым вызовом, как правило, следующим формату HTTP и правилам вызова REST.

Вопрос эффективности сетевых взаимодействий между компонентами одной большой системы довольно обширен. Туда входят протоколы данных, оптимизация и сжатие данных, попытка найти золотую середину между громоздкими данными в форматах JSON/Protobuf, слепляющими несколько логических вызовов в один большой пакет, и нагрузкой на сеть, возникающую при чрезмерно частых и мелких сетевых вызовах. Определить нужные параметры и выбрать оптимальный протокол для своих сервисов и систем сможете только вы сами, но взаимодействие сервисов в мире Kubernetes уже работает для вас без дополнительных усилий.

Основной задачей системы управления кластером является обнаружение сервисов (service discovery). Полагаться на прямое использование обычного IP-адреса нельзя - кластер Kubernetes слишком динамичен, при обновлении версий, ошибках, повышении нагрузки и масштабировании отсеки (pod) с контейнерами останавливаются и удаляются, потом запускаются заново, а их сетевые адреса все время меняются. В Интернете системой обнаружения является сервис поиска имен DNS, сервера которой поддерживают базу данных имен и адресов для них, и позволяют пользователям быстро переходить на нужный IP-адрес по простому имени, даже если адреса постоянно меняются.

DNS используется и в кластере Kubernetes. Каждому сервису, созданному с помощью объекта Service, присваивается доменное имя, совпадающее с именем самого сервиса. Это доменное имя доступно только внутри самого кластера, на каждом его узле, для любого запущенного там контейнера. Для взаимодействия сервисы находят необходимые им для работы сервисы-партнеры по заранее известному имени в DNS. Как мы помним, у каждого сервиса Kubernetes должно быть свое уникальное имя.

Давайте проверим.

Обнаружение сервисов через DNS

Для начала мы посмотрим, как именно работает система обнаружения имен DNS внутри нашего кластера Kubernetes. Для этого нам понадобится получить к ней доступ - все внутренние системы Kubernetes, кроме управляющих, например, сервера приема команд API (API server), недоступны извне кластера. Это же относится и к внутренней системе DNS. Можно было бы написать простой скрипт shell, упаковать его в контейнер, запустить на кластере и проверить его журнальные записи (как мы знаем, состоящие из записей стандартного вывода), но это чрезмерно долго и сложно.

Для проверки параметров сети и имен DNS нам понадобится обычный терминал с самыми распространенными командами Linux, и здесь как нельзя кстати пригодится специальный вариант команды kubectl run. Используя ключ --tty, мы просим инструмент kubectl присоединить к запущенному контейнеру эмулятор терминала, а ключ -i (interactive) открывает этот терминал сразу же после запуска контейнера, и в том же сеансе, который используется для самой команды kubectl. Остается выбрать образ Linux, который мы запустим - для этих целей как нельзя лучше пригодится самый минимальный образ busybox, основанный на крайне минималистичной версии Linux, применяемой для встраиваемых устройств - размер этого образа не превышает нескольких мегабайт, а ресурсов требует этот контейнер совсем мало!

$ kubectl run -i --tty busybox --image=busybox --restart=Never -- sh
/ #

Запустив наш контейнер busybox, мы тут же получим доступ к интерактивному терминалу, работающему внутри нашего кластера. Здесь нам доступны все системы и все адреса кластера, и теперь мы можем использовать базовые сетевые команды Linux для проверки наших сервисов в DNS.


/ # nslookup time-service
Server:   10.15.240.10
Address:  10.15.240.10:53

Name: time-service.default.svc.cluster.local
Address: 10.15.251.13

Как мы уже упомянули, каждому сервису, созданному с помощью объекта Service, присваивается доменное имя, совпадающее с именем самого сервиса. Именно это мы видим в выводе команды nslookup, передав ей в качестве параметра имя нашего сервиса time-service (пока он единственный в нашем кластере).

Сервис time-service доступен не только по простому имени сервиса из объекта Service, но мы видим и более сложное составное имя time-service.default.svc.cluster.local. Это общий формат имени сервиса в случае использования дополнительных пространств имен (namespace) в кластере Kubernetes. Мы пока не использовали их и узнаем о них подробнее чуть позже, но по сути это способ разделить кластер на индивидуальные зоны, где возможно использовать одни и те же имена объектов Kubernetes, что-то вроде пакетов в Java или пространств имен в С#. Если вы не указывает пространство имен явно, все сервисы и остальные объекты Kubernetes создаются в пространстве по умолчанию default. Если вы захотите вызвать сервис из другого пространства имен, придется указать именно такой формат или чуть короче, просто указав пространство имен через точку:

[имя сервиса].[пространство имен = default].[сервис = svc].[общий суффикс для кластера = cluster.local]

[имя сервиса].[пространство имен = default]

Суффикс кластера обычно не меняют, для примера в кластере Google Kubernetes Engine это cluster.local, но при желании можно поменять и его - как правило это связано с взаимодействием имен из нескольких кластеров одновременно, и встречается не так часто.

Мы видим, что наш сервис действительно зарегистрирован в системе DNS нашего кластера Kubernetes, но сработает ли сам сервис?

/ # wget time-service:8080/nanotime -O -
Connecting to 10.15.251.13:8080 (10.15.251.13:8080)
{"nanoTime":"1547850153973197546"}

/ # wget time-service.default.svc.cluster.local:8080/nanotime -O -
Connecting to time-service.default.svc.cluster.local:8080 (10.15.251.13:8080)
{"nanoTime":"1547850322608275624"}

/ # wget time-service.default:8080/nanotime -O -
Connecting to time-service.default:8080 (10.97.105.149:8080)
{"nanoTime":"1550678757460527000"}


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

Обнаружение сервисов с помощью переменных окружения

До стабильной поддержки обнаружения сервисов с помощью службы Kubernetes DNS при появлении нового сервиса управляющий узел Kubernetes добавлял в каждый работающий в кластере отсек набор переменных окружения, состоящих из имени сервиса и поддерживаемых им портов. Эти переменные содержали IP-адрес, по которому сервис можно было обнаружить. Мы можем проверить, что это по прежнему работает и для нашего сервиса time-service. Используем запущенный контейнер с Linux busybox и посмотрим, какие переменные, начинающиеся с имени нашего сервиса, доступны в отсеке:

/ # printenv | grep TIME_SERVICE
TIME_SERVICE_PORT=tcp://10.97.105.149:8080
TIME_SERVICE_SERVICE_PORT=8080
TIME_SERVICE_PORT_8080_TCP=tcp://10.97.105.149:8080
TIME_SERVICE_SERVICE_HOST=10.97.105.149
TIME_SERVICE_PORT_8080_TCP_ADDR=10.97.105.149
TIME_SERVICE_PORT_8080_TCP_PORT=8080
TIME_SERVICE_PORT_8080_TCP_PROTO=tcp

Можно использовать эти переменные напрямую из терминала для подключения к сервису:

/ # wget $TIME_SERVICE_SERVICE_HOST:$TIME_SERVICE_SERVICE_PORT/time -O -
Connecting to 10.97.105.149:8080 (10.97.105.149:8080)
{"time":"2019-02-20 16:33:02.865982 +0000 UTC m=+139444.036060438"}

В основном использовались две переменные окружения - [ИМЯ СЕРВИСА]_[SERVICE_HOST] и [ИМЯ СЕРВИСА]_[SERVICE_PORT], определяющие адрес сервиса и его порт, остальные переменные добавляются для совместимости с Docker. По большому счету, использовать переменные окружения не только менее удобно, чем имена DNS, но и не всегда разумно - переменные окружения добавляются во все отсеки немедленно после появления нового объекта Service, даже если он еще не готов. В конце главы мы узнаем, как определяется готовность сервиса в Kubernetes и реализуем такую проверку готовности для своего нового сервиса.

Сервисы и метки

Мы увидели, что сервисы Kubernetes позволяют вам найти микросервис или приложение по его имени, получить его сетевой адрес внутри кластера и открыть соединение. Как именно система обнаружения находит наш контейнер (работающий внутри отсека pod)? Используются метки (label), которые мы задаем в объекте Service и затем передаем в управляющую систему Kubernetes. Метки задаются в секции metadata и доступны для любого объекта Kubernetes. Вот что мы указали в прошлом разделе для своего микросервиса time-servce:

...
metadata:
  labels:
    app: time-service

Мы использовали всего одну метку (app), и таким образом, все отсеки, имеющие совпадение по этим меткам, будут выбираться для доступа через созданный сервис. Когда мы создавали наше развертывание Deployment, мы указали, что для всех созданных в этом управлении развертываний будет применяться те же самые метки (шаблон для всех создаваемых отсеков с контейнерами - template):

...
  template:
    metadata:
      labels:
        app: time-service

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

$ kubectl get pods --selector=app=time-service
NAME                            READY   STATUS    RESTARTS   AGE
time-service-77d9656579-xrg52   1/1     Running   0          11d

Мы ожидаемо получили единственный экземпляр своего микросервиса (и получим больше, если применим масштабирование) - это единственные отсеки с таким набором меток. Именно этот отсек будет вызываться через систему обнаружения сервисов Kubernetes.

Основные типы сервисов в Kubernetes

Сервис в Kubernetes - это точка доступа к приложению через сетевой порт. Вариантов такого доступа может быть несколько, и особенно важно в динамической, масштабируемой среде запуска Kubernetes обеспечить поддержку работы с приложением, работающим во множестве экземпляров. В этом случае обратиться напрямую к его IP-адресу и порту будет неправильно - мы просто будем игнорировать остальные экземпляры. В простейшем случае должна быть возможность выбрать один из экземпляров приложения случайным образом или циклически. Поддержка динамических экземпляров в Kubernetes использует особую сетевую архитектуру и три основных вида сервисов (поле type в описании объекта Service):

Виртуальные IP-адреса. kube-proxy

Все мы примерно представляем, как работает система доменных имен DNS - на каждое имя тем или иным способом, обычно с помощью записей типа A, возвращается список точных IP-адресов, и по этому адресу находится или сам сервер, или (чаще) точка входа в кластер, обычно это сервер балансировщика нагрузки. В мире Kubernetes сервисы работают внутри отсеков (pods), при создании отсека при масштабировании, обновлениях, новых версиях, всем им присваиваются новые IP-адреса внутри кластера. Это может происходить часто и быстро, и кэш DNS просто не успеет обновить список адресов, по которым надо обратиться. Решение в Kubernetes - виртуальные IP-адреса.

Виртуальные IP-адреса не указывают на конкретный сервер или виртуальный отсек. Вместо этого они служат точкой переадресации на список существующих в данный момент отсеков, соответствующих сервису. Как мы видели, список этих сервисов определяется выбором по меткам. Напрямую обращаться к ним не получится. Для обеспечения их работы в каждом узле (node) кластера Kubernetes работает вспомогательный прокси-компонент kube-proxy. Через этот прокси проходит весь сетевой трафик узла ото всех работающих в нем отсеков (и соответственно, находящихся в них ваших контейнеров с сервисами).

При обращении к виртуальному IP-адресу kube-proxy находит список соответствующих этому адресу отсеков (по меткам сервиса), и выбирает один из них. Выбор одного из множества отсеков, если сервис работает во множестве экземпляров, зависит от настроек самого кластера - как правило это или случайный порядок, или классический циклический выбор, она же “карусель” (round robin).

На схеме это выглядит примерно так:

Теперь картина создания и работы сервиса становится полной - при создании нового объекта Service в кластере создается новый виртуальный IP-адрес, и прокси-компоненты kube-proxy на всех узлах добавляют его в свои списки адресов. После того, как сервис готов к работе, обращение по этому виртуальному адресу переадресуется к одному из отсеков, которые относятся к сервису (поиск по меткам). При перезапуске отсеков, увеличении и уменьшении их количества при масштабировании, их обновлении, состояние kube-proxy постоянно обновляется, позволяя работающим в кластере сервисам прозрачно работать с виртуальным адресом, совершенно не волнуясь о всех происходящих за его кулисами переменах. Это великолепная возможность реализовать взаимодействие большой группы распределенных сервисов в очень динамичном, постоянно меняющемся состоянии.

Развертывание нескольких сервисов

Итак, мы поняли, что можем находить сервисы просто по их имени, используя встроенную в Kubernetes поддержку DNS. Мы рассматриваем не только возможности Kubernetes, но больше непосредственно разработку новых облачных сервисов и их работу в кластере. Давайте напишем новый сервис, который будет работать на том же самом кластере, где в предыдущих главах мы уже тестировали и запускали простейший микросервис для определения текущего времени. Код этого сервиса будет обращаться к сервису времени, получать текущее время (представим гипотетически, что отдельный сервис времени позволит нам решить задачу точной синхронизации), и определять, выходной ли сегодня день. Как мы прекрасно помним, в мире много часовых поясов (time zone), в некоторых из них дни разные, поэтому такой сервис должен в качестве параметра знать часовой пояс, для которого определяется выходной день. Возможно, такой сервис нам пригодится позже для создания календаря и других услуг.

Создание второго микросервиса, который должен будет использовать библиотеку с поддержкой часовых поясов и инструменты для работы с календарями и датами, как нельзя лучше поможет нам увидеть, как легко соединить совершенно разные технологии и подходы к разработке в одном кластере Kubernetes. Для определения часового пояса и выходных дней используем Java, точнее встроенный в основной набор библиотек SDK пакет java.time, для взаимодействия в стиле HTTP/REST применим простую и компактную библиотеку Spark, которая будет работать со встроенным сервером Jetty, а работу с форматом JSON организуем с помощью библиотеки Google GSON.

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

Итак, напишем сервис выходного дня:

package com.porty.k8s;

import com.google.gson.Gson;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.InputStreamReader;
import java.net.URI;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.ZoneId;
import java.util.EnumSet;

import static spark.Spark.get;
import static spark.Spark.port;

/**
* Простой сервис на основе Spark/Java, запрашивающий время у микросервиса time-service
* и возвращающий результат - выходной ли день в указанной временной зоне,
* и какой именно это выходной.
*/
public class WeekendService {
   private static Logger logger = LoggerFactory.getLogger(WeekendService.class);

   public static void main(String[] args) {
       port(5678);

       Gson gson = new Gson();

       // страна и город для выяснения выходного дня кодируется прямо в пути запроса HTTP
       // пример: /weekend/Europe/Moscow
       get("/weekend/:country/:city", (req, res) -> {
           ZoneId timeZoneId = ZoneId.of(req.params("country") + "/" + req.params("city"));
           logger.info("Запрошен статус выходного дня для зоны {}", timeZoneId);

           // запрашиваем время от вспомогательного сервиса и преобразуем его в объект c данными
           TimeServiceResponse timeServiceResponse = gson.fromJson(
                   new InputStreamReader(
                           URI.create("http://time-service:8080/nanotime").toURL().openStream()),
                   TimeServiceResponse.class);
           // используем пакет java.time для получения данных о текущем дне
           Instant millisTime = Instant.ofEpochMilli(
                   Long.parseLong(timeServiceResponse.getNanoTime()) / 1000000);
           DayOfWeek dayOfWeek = millisTime.atZone(timeZoneId).getDayOfWeek();
           boolean isWeekend = EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY)
                   .contains(dayOfWeek);

           return new TimeZoneReply(isWeekend, dayOfWeek.name());
       }, gson::toJson);
   }

   // стандартный класс с данными для преобразования результата сервиса в JSON
   static class TimeZoneReply {
       private boolean weekend;
       private String day;

       TimeZoneReply(boolean weekend, String day) {
           this.weekend = weekend;
           this.day = day;
       }

       public boolean isWeekend() {
           return weekend;
       }

       public String getDay() {
           return day;
       }
   }

   // стандартный класс с данными для получения данных JSON от сервиса time-service
   static class TimeServiceResponse {
       private String time;
       private String nanoTime;

       public String getTime() {
           return time;
       }

       public void setTime(String time) {
           this.time = time;
       }

       public String getNanoTime() {
           return nanoTime;
       }

       public void setNanoTime(String nanoTime) {
           this.nanoTime = nanoTime;
       }
   }
}

Код краток и достаточно информативен сам по себе - мы импортируем статические методы библиотеки Spark, и задаем редко используемый номер порта методом port, чтобы избежать конфликтов и путаницы с широко распространенными номерами портов при тестировании. Основной метод нашего нового сервиса - обработка HTTP-запроса GET по маршруту /weekend. Для простоты обращения к сервису необходимые нам параметры для указания часового пояса задаются прямо в маршруте HTTP, а не дополнительными параметрами, так что для того, чтобы выяснить выходной ли день в Москве, нужно будет вызвать адрес /weekend/Europe/Moscow.

Для получения времени используется уже созданный и развернутый нами в кластере Kubernetes сервис time-service, и заранее известный нам номер порта, по которому к нему можно обратиться. Мы используем класс URL и открываем с его помощью поток данных для чтения InputStream, ну а тот, в свою очередь, преобразуется в объект с данными Java TimeServiceResponse с помощью GSON. Для преобразования необходимо совпадения полей и их типов между данными Json и классом Java.

Далее мы преобразуем время с точностью до наносекунд, которое нам предоставляет сервис time-service, в миллисекунды, и используя элементарные преобразования библиотеки Java Time, получаем день недели в указанном часовом поясе, легко выводя, выходной ли это день. Далее данные упаковываются в объект TimeZoneReply и преобразуются в Json с помощью GSON. Все детали используемых методов и библиотек легко можно найти в их документации в Интернете.

Наконец, чтобы иметь представление, что происходит в уже работающем сервисе, мы применяем самую распространенную библиотеку для записи журналов (logs) в Java - SLF4J. Без дополнительной настройки журналы будут использовать формат по умолчанию (как правило с указанием времени, имени потока thread, и названия объекта, создавшего запись), что обычно вполне достаточно, и выводить записи в стандартный вывод. Как мы знаем из введения в Kubernetes, это основной способ работы с журналами в контейнерах. Более того, инфраструктура Spark также использует SLF4J для записи журналов.

Сборка сервиса, образ контейнера и быстрые тесты

Концепция облачного приложения или его части в виде отдельного микросервиса диктует упаковку в контейнер, что мы и сделаем с помощью уже хорошо нам знакомого инструмента Docker и его декларации для описания образа контейнера Dockerfile. Для начала используем стандартный для Java способ сборки проекта и описания его зависимостей и библиотек с помощью инструмента Gradle (можно использовать и Maven, но для таких небольших проектов описание сборки проекта с помощью Gradle чуть более кратко и выразительно, чем используемый в Maven XML):

plugins {
   id 'java'
}

group 'com.porty.k8s'
version '0.1.0-SNAPSHOT'

sourceCompatibility = 1.8

repositories {
   mavenCentral()
}

dependencies {
   compile "com.sparkjava:spark-core:2.7.2"
   compile "org.slf4j:slf4j-simple:1.7+"
   compile "com.google.code.gson:gson:2.8+"
}

// особый способ упаковки сервиса java в контейнер - все библиотеки складываем в "толстый архив" (fat JAR)
jar {
   manifest { attributes "Main-Class": "com.porty.k8s.WeekendService" }
   from {
       configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
   }
}

Файл сборки объявляет название и версию проекта (для первой версии, согласно популярному способу семантического версионирования semver.org, обычно используют 0.1.0), зависимости от библиотек Spark, GSON и журналирования SLF4J, и самое интересное, собирает все необходимые зависимости в “толстый” архив JAR, и объявляет его класс с методом main(). Это распространенная практика для упрощения создания контейнера для программ Java, чтобы уменьшить копирование обычно немаленького количества требуемых для работы дополнительных библиотек JAR. Собрать наш сервис в “толстый” архив теперь элементарно:

$ ./gradlew build

В результате мы получим архив со всеми зависимостями в директории проекта build\libs.

Создадим образ контейнера:

FROM openjdk:8-jre-alpine

EXPOSE 5678

ADD build/libs/weekend-service-0.1.0-SNAPSHOT.jar .

CMD ["java", "-jar", "weekend-service-0.1.0-SNAPSHOT.jar"]

Образ контейнера весьма прост - мы копируем созданный заранее “толстый” архив со всем необходимым, используем в качестве базового образа оптимизированный образ OpenJDK Java версии 8 на основе Linux Alpine, которая, как правило, является самой экономичной версией Linux, и требует наименьшего количества оперативной и дисковой памяти. При запуске контейнера мы просто вызовем свой метод основной класс и его метод main(), записанный в манифесте архива JAR. Здесь мы несколько упростили создание образа - чтобы удостовериться, что образ собран максимально верно, лучше собрать его из исходного текста - однако в Java это означает скачивание всех зависимостей из репозитория Maven Central, и будет занимать немалое время. На сайте книги вы найдете решения, ускоряющие этот процесс, например, инструмент Jib, созданный в компании Google.

Построим образ, используя все ту же версию 0.1.0 в качестве метки:

docker build . -t {ваша_учетная_запись_Docker}/weekend-service:0.1.0

Прежде чем погрузиться в создание развертывания Deployment и сервиса Service для кластера Kubernetes, всегда будет отличной мыслью проверить полученный образ контейнера на работоспособность, запустив его на своей локальной машине с помощью того же инструмента Docker и команды run:

$ docker run -p 5678:5678 {ваша_учетная_запись_Docker}/weekend-service:0.1.0

[Thread-0] INFO org.eclipse.jetty.util.log - Logging initialized @233ms to org.eclipse.jetty.util.log.Slf4jLog
[Thread-0] INFO spark.embeddedserver.jetty.EmbeddedJettyServer - == Spark has ignited ...
[Thread-0] INFO spark.embeddedserver.jetty.EmbeddedJettyServer - >> Listening on 0.0.0.0:5678
[Thread-0] INFO org.eclipse.jetty.server.Server - jetty-9.4.z-SNAPSHOT, build timestamp: 2017-11-21T21:27:37Z, git hash: 82b8fb23f757335bb3329d540ce37a2a2615f0a8
[Thread-0] INFO org.eclipse.jetty.server.session - DefaultSessionIdManager workerName=node0
[Thread-0] INFO org.eclipse.jetty.server.session - No SessionScavenger set, using defaults
[Thread-0] INFO org.eclipse.jetty.server.session - Scavenging every 660000ms
[Thread-0] INFO org.eclipse.jetty.server.AbstractConnector - Started ServerConnector@1f813481{HTTP/1.1,[http/1.1]}{0.0.0.0:5678}
[Thread-0] INFO org.eclipse.jetty.server.Server - Started @347ms

Запустив контейнер с помощью команды docker run, необходимо не забыть перенаправить порт контейнера, который обслуживает веб-сервер, на порт нашей локальной машины, после чего мы увидим процесс запуска веб-сервера библиотеки Spark с помощью журнальных записей в стандартном выводе на консоль. Там же мы увидим, по какому адресу можно найти этот сервер Started ServerConnector@1f813481 {HTTP/1.1,[http/1.1]} {0.0.0.0:5678}. Попробуем получить ответ сервера:

$ curl 0.0.0.0:5678
<html><body><h2>404 Not found</h2></body></html>

Ответ ожидаемый, и приятно видеть что используемая нами инфраструктура Spark позаботилась о форматировании и правильном коде ответа HTTP - в нашем сервисе запрос к корневому адресу / не обслуживается. Подтверждение запроса мы увидим в журнале сервиса:

[qtp1759478938-18] INFO spark.http.matching.MatcherFilter - The requested route [/] has not been mapped in Spark for Accept: [*/*]

Попробуем теперь получить день недели, и узнать, выходной ли это, для часового пояса Москвы, в стандартном описании часового пояса:

$ curl 0.0.0.0:5678/weekend/Europe/Moscow
<html><body><h2>500 Internal Server Error</h2></body></html>

На этот раз ответ не столь приятен - внутренняя ошибка сервера. В идеальном сервисе мы бы позаботились о детальном ответе в формате JSON, но для прототипа и просто примера этого вполне достаточно. Вновь посмотрев на стандартный вывод нашего контейнера с сервисом, мы ожидаемо увидим причину ошибки - требуемый для работы сервис time-service не доступен через адрес DNS и свой порт (UnknownHostException):

[qtp1759478938-19] INFO com.porty.k8s.WeekendService - Запрошен статус выходного дня для зоны Europe/Moscow
[qtp1759478938-19] ERROR spark.http.matching.GeneralError - 
java.net.UnknownHostException: time-service
  at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:184)
  at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392)
  at java.net.Socket.connect(Socket.java:589)
  at java.net.Socket.connect(Socket.java:538)
  at sun.net.NetworkClient.doConnect(NetworkClient.java:180)
  at sun.net.www.http.HttpClient.openServer(HttpClient.java:463)
           ...

Чтобы получить доступ к дополнительным микросервисам через DNS, нам уже не обойтись простым запуском второго контейнера time-service. Чуть позже мы обсудим, какие подходы существуют в тестировании микросервисов на локальной машине без запуска новых сервисов, развертываний, и кластеров. Сейчас давайте запустим свой сервис на кластере Kubernetes.

Создание объектов Kubernetes и развертывание сервиса

Вновь применяя уже знакомый нам по предыдущей главе подход kubectl --dry-run для получения шаблонов YAML для объектов Kubernetes, создадим заготовки с описанием объекта Deployment для развертывания Kubernetes, и поменяем имя образа и его версию:

apiVersion: apps/v1
kind: Deployment
metadata:
 labels:
   app: weekend-service
 name: weekend-service
spec:
 selector:
   matchLabels:
     app: weekend-service
 template:
   metadata:
     labels:
       app: weekend-service
   spec:
     containers:
       - image: ivanporty/weekend-service:0.1.0
         name: weekend-service

Мы применили практически идентичный с нашим первым микросервисом для получения времени подход, используя метку (label) app с именем сервиса, и указав образ контейнера и его версию в секции template, которая и будет определять, какие контейнеры будут запускаться при создании новых отсеков развертывания.

Практически аналогичным будет и объект сервиса Service:

apiVersion: v1
kind: Service
metadata:
 labels:
   app: weekend-service
 name: weekend-service
spec:
 ports:
   - port: 5678
 selector:
   app: weekend-service
 type: NodePort

Нам остается удостовериться, что сервис выбирает правильные отсеки по меткам label, указать открытый порт, и сделать этот порт доступным на всех отсеках кластера, используя тип сервиса NodePort.

Файлы с описанием объектов развертывания и сервиса мы разместили в папке k8s, и теперь вновь можем применить практически магическую команду apply, чтобы привести состояние своего кластера в соответствие с состоянием объекта в наших описания YAML:

$ kubectl apply -f k8s/
deployment.apps/weekend-service created
service/weekend-service created

При первом запуске развертывание и сервис будут созданы, а при последующим запусках уже модифицированы, и все детали и разницу в состояниях возьмет на себя управляющий узел Kubernetes. Мы уже должны иметь работающий сервис получения времени на своем кластере из предыдущих тестов (если нет, разверните микросервис time-service снова), и проверив состояние кластера, мы увидим, что в нем развернуты и доступны два сервиса:

$ kubectl get deploy
NAME  ...
time-service         ...
weekend-service    …

$ kubectl get service
NAME       ...
time-service      NodePort    ...       8080:31250/TCP   
weekend-service   NodePort  ...        5678:31056/TCP

Нам остается удостовериться, что сервис и развертывание работают, и на этот раз вспомогательный микросервис доступен с помощью встроенной в Kubernetes системы DNS. Напрямую получить доступ к сервису, если это не локальный кластер Docker и Minikube, у нас не получится, но как мы знаем из первых опытов с Kubernetes, мы всегда сможем использовать услугу переадресации порта port-forward инструмента kubectl, запустив и оставив ее работать в отдельном терминале. Дальше мы сможем запросить свой новый сервис и узнать о дне в запрошенном часовом поясе:

$ kubectl port-forward deploy/weekend-service 5678
Forwarding from 127.0.0.1:5678 -> 5678
Forwarding from [::1]:5678 -> 5678
…

$ curl 0.0.0.0:5678/weekend/Europe/Moscow
{"weekend":false,"day":"FRIDAY"}
$ curl 0.0.0.0:5678/weekend/Asia/Tokyo
{"weekend":true,"day":"SATURDAY"}

Как мы видим, теперь наш новый сервис работает, и запрашивает вспомогательный микросервис для получения текущего времени, а затем преобразует его во время переданного ему часового пояса, возвращая ответ в формате JSON. К сожалению, в момент теста выходной день в Москве еще не настал, но это была пятница - тоже неплохо. Зато к этому моменту уже настала суббота в Токио - наш сервис подтверждает, что дни календаря относительны.

Запросив журнал (log) для развертывания, мы увидим записи успешно работающего веб-сервера и обработку запросов:

$ kubectl logs deploy/weekend-service
…
[Thread-0] INFO org.eclipse.jetty.server.AbstractConnector - Started ServerConnector@2a2cb2dc{HTTP/1.1,[http/1.1]}{0.0.0.0:5678}
[Thread-0] INFO org.eclipse.jetty.server.Server - Started @756ms
…
[qtp1759478938-19] INFO com.porty.k8s.WeekendService - Запрошен статус выходного дня для зоны Europe/Moscow
[qtp1759478938-19] INFO com.porty.k8s.WeekendService - Запрошен статус выходного дня для зоны Asia/Tokyo

Локальное тестирование взаимодействующих сервисов

Проверка готовности сервиса к работе

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

В подобных распределенных системах хорошей проверенной практикой считается поддержка каждым сервисом сигнала его готовности к работе (readiness check). Как правило, готовность сервиса к работе может значительно отличаться от момента непосредственного запуска его процесса. В примере с нашим микросервисом выходного дня это особенно легко будет увидеть, случись нам использовать его под нагрузкой и в процессе непрерывного обновления - это приложение Java, которое, как хорошо известно, требует некоторого времени для выделения памяти, загрузки классов, и “прогрева” виртуальной машины. Все эти мгновения процесс уже будет запущен, однако сервис будет недоступен. Это может привести к значительному замедлению распределенной системы из множества сервисов, и к возможному сбою многоступенчатой операции.

Если же мы реализуем хороший сигнал готовности к работе, наш контейнер (и его отсек) никогда не будут вызываться через сервисы Kubernetes и их виртуальные IP-адреса, до тех пор пока сигнал не станет положительным. После этого экземпляр сервиса становится доступен.

Kubernetes поддерживает несколько стандартных сигналов готовности сервиса - самый распространенный и как правило удобный - обращение по определенному маршруту по протоколу HTTP. Описание проверки готовности к работе добавляется к объекту развертывания Deployment, в под-объект template/spec/containers - в этом есть смысл, ведь именно описание шаблона для всех будущих контейнеров (template) определяет, как они будут создаваться, а объект Deployment управляет набором отсеков с контейнерами приложения или его модуля/сервиса.

Давайте добавим проверку готовности в развертывание сервиса, только что нами созданного:

apiVersion: apps/v1
kind: Deployment
metadata:
 labels:
   app: weekend-service
 name: weekend-service
spec:
 selector:
   matchLabels:
     app: weekend-service
 template:
   metadata:
     labels:
       app: weekend-service
   spec:
     containers:
       name: weekend-service
       - readinessProbe:
            httpGet:
              path: /ready
              port: 5678
            periodSeconds: 2
            initialDelaySeconds: 5
            failureThreshold: 1
            successThreshold: 1
       - image: ivanporty/weekend-service:0.1.0

Итак, проверка готовности описана в разделе readinessProbe. Сразу же идет тип проверки - у нас это будет запрос по адресу и порту HTTP (httpGet). Можно отрегулировать проверку параметрами:

Нам остается включить поддержку нового маршрута (/ready) в свой сервис и реализовать логику, которая определит точный момент готовности сервиса к обработке запросов. На наше счастье, в библиотеке Spark есть для этого простой метод. Вот что у нас получится:

...
private static AtomicBoolean ready = new AtomicBoolean(false);

public static void main(String[] args) {
   port(5678);

  …

   // поддержка сигнала о готовности сервиса к работе. Используем встроенный в библиотеку Spark
   // метод для ожидания готовности сервера к работе, ожидая его в отдельном потоке Thread.
   get("/ready", (req, res) -> {
       if (ready.get()) {
           return "Готов!";
       } else {
           throw new IllegalStateException("Не готов!");
       }
   });
   // ожидаем готовности в параллельном потоке.
   new Thread(() -> {
       awaitInitialization();
       ready.set(true);
   }).start();
}

Мы добавили новый маршрут (/ready) и сделали его зависимым от значения флага ready, который будет выставлен в отдельном потоке, как только синхронный метод awaitInitialization() вернет потоку управление, что и будет означать готовность встроенного сервера Spark к приему запросов. В противном случае мы выбросим исключение, что автоматически приведет к ответу с кодом HTTP 500 (Internal Error). Система проверки готовности Kubernetes в этом случае выведет наш контейнер из списка готовых к приему запросов.

Чем больше будет в вашей распределенной системе модулей и микросервисом, чем чаще они будут обновляться и масштабироваться, тем более жизненно необходимой будет качественная проверка готовности сервиса к работе. Еще одна проверка в Kubernetes - проверка жизнеспособности (liveness) контейнера, отличается тем, что проверяется возможность контейнера продолжать работу в принципе или необходимость его перезапустить. Мы узнаем о ней в подробном описании развертываний.

Резюме

comments powered by Disqus