HTTP Health Check на Go

Как с помощью Go проверять состояние микросервисов

zaz600
Golang Notes
10 min readApr 5, 2021

--

Введение

В этой заметке разберёмся, как на Go реализовать проверку состояния микросервисов, которые используют паттерн Health Check API для отдачи информации о своём состоянии.

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

Всё, что будет рассмотрено и сделано далее, можно использовать в качестве образовательных целей или при написании каких-либо внутренних инструментов. Для наблюдения за сервисами на продакшене существуют специальные продукты вроде telegraf, Prometheus, Grafana.

С чем будем работать

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

Будем считать сервис “живым”, если он отвечает на /health и отдаёт "status": "ok" .

В качестве подопытных веб-сервисов будем использовать http://httpbin.org/ и возможности, которые он предоставляет для кастомизации ответов.

В заметке мы сначала реализуем проверку только http статуса, а затем сделаем проверку содержимого ответа от эндпоинта /health, будем работать со стандартной библиотекой net/http, а также поработаем со сторонней библиотекой go-resty.

Подготовка

  • Скачиваем и устанавливаем Go
  • Создаём папку для проекта: mkdir ~/src/gohttpchecker
  • Переходим в папку: cd ~/src/gohttpchecker/
  • Инициализируем модуль:
    go mod init github.com/zaz600/gohttpchecker
  • Открываем проект в любимом редакторе кода.
  • Создаём файл main.go

Первая версия. Начало

Чтобы выполнить проверку сервиса, воспользуемся стандартной библиотекой net/http.

Итак, самая первая и простая версия, которая проверяет один адрес при помощи функции http.Get() и анализирует http статус ответа:

Запускаем программу при помощи go run . или через IDE и видим результат:

2021/04/04 11:17:45 service A: status OK

Вторая версия. Несколько сервисов

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

Имитировать ответ отличный от 200 будем при помощи такого хэндлера https://httpbin.org/status/500.

Конфигурацию будем хранить в таком виде:

Полный вариант будет выглядеть следующим образом:

Запускаем программу при помощи go run . или через IDE и видим результат:

Третья версия. Таймауты

Попробуем добавить в настройки ещё один сервис с адресом: https://httpbin.org/delay/10.

При вызове такого адреса httpbin ответ отдаёт не сразу, а с задержкой 10 секунд.

Запустим программу и посмотрим на её вывод (вывод может незначительно отличаться):

Здесь мы видим несколько интересных моментов.

Во-первых, видно, что опрос сервисов осуществляется не в том порядке, в котором они указаны в словаре. Это происходит потому, что в go (как и во многих других языках) не гарантируется одинаковая последовательность извлечения элементов из словаря при итерировании по нему. Цитата из документации:

The iteration order over maps is not specified and is not guaranteed to be the same from one iteration to the next.

Во-вторых, мы видим, что запрос к service C занял 10 секунд. То есть клиент, который выполнял http запрос, ждал ответа 10 секунд.

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

В примере выше просто демонстрируется отсутствие таймаута у http.Get(). В реальности, если доступ к сервису ограничен, например, брандмауэром, то ответа мы можем не дождаться ни за 10 ни за 30 секунд и цикл опроса может никогда не завершиться.

Настроить таймауты у метода http.Get() невозможно — он выполняет запросы с настройками по умолчанию, которые включают бесконечное ожидание ответа.

Подробнее прочитать о том, какие ещё бывают таймауты можно в этой статье.

Чтобы настроить таймауты, создадим и настроим свой http клиент. В примере ниже мы устанавливаем общий таймаут 2 секунды на все этапы http запроса.

Затем заменим метод http.Get() на client.Get().

А также добавим в словарь ещё один сервис, который отвечает с задержкой менее 2 секунд.

Полная версия будет выглядеть следующим образом:

Запустим и посмотрим на вывод:

Четвёртая версия. Обработка ответа

Добавим обработку содержимого ответа от хэндера проверки статуса микросервиса.

Чтобы в ответе возвращались полезные данные, которые будем извлекать в нашем чекере, воспользуемся способностью httpbin отображать переданные в URL параметры.

Если перейдём по ссылке с параметрами в URL, то получим ответ примерно такого содержания:

Нас интересует содержимое args, его мы и будем извлекать.

Заменяем в настройках адреса на параметризованные:

После проверки http статуса считываем ответ при помощи io.ReadAll()

Обратите внимание, что начиная с Go 1.16 функция ReadAll() переехала из библиотеки ioutils в io, а сама ioutils задепрекейчена.

Содержимое ответа будем преобразовывать в структуру с помощью json.Unmarshal():

Полная версия:

Обратите внимание, что в этой версии мы не закрываем resp.Body, что может привести к утечке ресурсов. Позднее мы это исправим, переместив код опроса одного сервиса в отдельную функцию.

Содержимое вывода в консоль:

Пятая часть. Рефакторинг

Мы уже можем проверять http статус ответа, его содержимое и поддерживаем таймаут обращения к одному сервису.

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

Суть рефакторинга:

  • Убрать код из main
  • Разбить код опроса на несколько функций: pingAll(), pingOne(), getPingAnswer()
  • Результаты опроса обрабатывать в pingAll()

В результате у нас должна получиться примерно такая структура программы:

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

Теперь напишем функцию, выполняющую опрос одного сервиса. Она будет принимать его адрес и возвращать результат и возможную ошибку.

Функция, которая будет запускать опрос всех сервисов будет выглядеть следующим образом:

Код после рефакторинга:

Вывод программы не должен был измениться:

Попробуйте добавить в настройки сервис, который вернёт не ok в поле Status и проверьте работу чекера.
Результат работы должен быть примерно таким:
2021/04/04 14:26:49 service E: status check ERROR: answer.Args.Status != ok: error

Шестая часть. Периодический опрос

Периодический опрос сервисов можно сделать несколькими способами. Разберём подробнее два способа:

  • Способ 1. Бесконечный цикл с time.Sleep()
  • Способ 2. Бесконечный цикл + select + time.After()

Итак, реализация первого способа будет выглядеть так:

Второй вариант использует возможности работы с каналами, выглядит немного сложнее, но делает код более гибким (позднее мы сможем добавить в него обработку контекста для grace shutdown-а)

Здесь в цикле каждые 5 секунд будут прилетать данные в канал, который создаёт функция time.After(), после чего будет запускаться функция pingAll(). Поскольку первые данные в канал прилетят только через 5 секунд, мы однократно делаем вызов pingAll() перед запуском цикла.

Полная версия будет выглядеть следующим образом:

Итак, теперь мы умеем опрашивать сервисы, интерпретировать их ответы и можем делать это периодически.

Седьмая часть. Graceful shutdown

Добавим корректный выход из бесконечного цикла.

  • Создадим контекст с возможностью отмены context.WithCancel() и передадим его pingAllLoop().
  • Подпишемся на события прерывания в отдельной горутине с помощью signal.Notify() и будем вызывать cancel() при получении сигнала SIGINT.

Запускаем и проверяем, что выход из цикла происходит корректно при нажатии сочетания CTRL-C в консоли.

Восьмая часть. Используем resty

Перепишем наш чекер, чтобы он использовал сторонний http клиент go-resty.

Устанавливаем пакет:

Чтобы делать запросы при помощи resty надо для начала создать клиент:

Затем для выполнения запроса надо создать request при помощи функции R() :

Здесь мы создаём запрос и настраиваем его, в частности, просим интерпретировать ответ сервера как PingResponse. Нам больше не нужен код ручного преобразования ответа в структуру, то есть можем удалить метод getPingAnswer()

Полный код функции pingOne():

Девятая часть. Параллельный опрос

Сейчас опрос сервисов выполняется последовательно. Сделаем его параллельным. Для этого обернём в горутину тело цикла в функции pingAll(), а также добавим ожидание завершения всех горутин через sync.WaitGroup{}

Полная версия будет выглядеть следующим образом:

Лог работы:

Заключение

Мы попробовали написать черновик приложения, проверяющего состояние микро-сервисов.

Что ещё можно было бы сделать:

  • Добавить загрузку конфигурации сервисов из внешнего источника (файл или другой сервис).
  • Считывать настройки периода опроса сервисов и таймаут для клиента из командной строки, например, с помощью стандартного пакета flags или стороннего urfave/cli.
  • Сохранять результаты опроса каждого сервиса в отдельный словарь, чтобы потом отдавать результат по REST/gRPC.
  • Выводить результаты через веб-интерфейс, например, так:

--

--