HTTP Health Check на Go
Как с помощью Go проверять состояние микросервисов
Введение
В этой заметке разберёмся, как на Go реализовать проверку состояния микросервисов, которые используют паттерн Health Check API для отдачи информации о своём состоянии.
Это может пригодиться, например, при создании дашборда, который будет выводить состояние имеющихся микросервисов или для алертинга.
Всё, что будет рассмотрено и сделано далее, можно использовать в качестве образовательных целей или при написании каких-либо внутренних инструментов. Для наблюдения за сервисами на продакшене существуют специальные продукты вроде telegraf, Prometheus, Grafana.
С чем будем работать
Предположим, что у нас есть несколько сервисов, у которых реализован служебный хэндлер (энд-поинт/ручка) /health
, при вызове последнего, можно проверить жив ли сервис, всё ли у него хорошо с подключениями к внешним ресурсам, например, к БД, и какая у сервиса текущая версия:
{"name": "service a", "status": "ok", "version": "1.231.1"}
Будем считать сервис “живым”, если он отвечает на /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.
Конфигурацию будем хранить в таком виде:
var config = map[string]string{
"service A": "https://httpbin.org/get",
"service B": "https://httpbin.org/status/500",
}
Полный вариант будет выглядеть следующим образом:
Запускаем программу при помощи go run .
или через IDE и видим результат:
2021/04/04 11:38:32 service A: status OK
2021/04/04 11:38:32 service B: status check ERROR: 500 != 200
Третья версия. Таймауты
Попробуем добавить в настройки ещё один сервис с адресом: https://httpbin.org/delay/10.
При вызове такого адреса httpbin ответ отдаёт не сразу, а с задержкой 10 секунд.
// конфигурация урлов для хэлсчека
var config = map[string]string{
"service A": "https://httpbin.org/get",
"service B": "https://httpbin.org/status/500",
"service C": "https://httpbin.org/delay/10",
}
Запустим программу и посмотрим на её вывод (вывод может незначительно отличаться):
2021/04/04 11:53:56 service B: status check ERROR: 500 != 200
2021/04/04 11:54:06 service C: status OK
2021/04/04 11:54:06 service A: status OK
Здесь мы видим несколько интересных моментов.
Во-первых, видно, что опрос сервисов осуществляется не в том порядке, в котором они указаны в словаре. Это происходит потому, что в 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 запроса.
var client = &http.Client{
Timeout: 2 * time.Second,
}
Затем заменим метод http.Get()
на client.Get()
.
А также добавим в словарь ещё один сервис, который отвечает с задержкой менее 2 секунд.
Полная версия будет выглядеть следующим образом:
Запустим и посмотрим на вывод:
2021/04/04 13:01:23 service D: status OK
2021/04/04 13:01:23 service A: status OK
2021/04/04 13:01:23 service B: status check ERROR: 500 != 200
2021/04/04 13:01:25 service C: check ERROR: Get "https://httpbin.org/delay/10?status=ok&version=1.231.1&name=service.C": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Четвёртая версия. Обработка ответа
Добавим обработку содержимого ответа от хэндера проверки статуса микросервиса.
Чтобы в ответе возвращались полезные данные, которые будем извлекать в нашем чекере, воспользуемся способностью httpbin отображать переданные в URL параметры.
Если перейдём по ссылке с параметрами в URL, то получим ответ примерно такого содержания:
{
"args": {
"name": "service.A",
"status": "ok",
"version": "1.231.1"
},
"headers": {
"Accept": "*/*",
"Host": "httpbin.org",
"User-Agent": "curl/7.64.1",
"X-Amzn-Trace-Id": "Root=1-60698d13-0a001d332689d93254aa5ca6"
}
}
Нас интересует содержимое args
, его мы и будем извлекать.
Заменяем в настройках адреса на параметризованные:
var config = map[string]string{ "service A": "https://httpbin.org/get?status=ok&version=1.231.1&name=service.A",
"service B": "https://httpbin.org/status/500",
"service C": "https://httpbin.org/delay/10?status=ok&version=2.100.0&name=service.C",
"service D": "https://httpbin.org/delay/1?status=ok&version=5.555.5&name=service.D",
}
После проверки http статуса считываем ответ при помощи io.ReadAll()
bytes, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("%s: status check ERROR: can't read body: %s\n", service, err)
continue
}
Обратите внимание, что начиная с Go 1.16 функция
ReadAll()
переехала из библиотекиioutils
вio, а сама ioutils задепрекейчена.
Содержимое ответа будем преобразовывать в структуру с помощью json.Unmarshal()
:
type PingResponse struct {
Args struct {
Name string
Status string
Version string
}
}
Полная версия:
Обратите внимание, что в этой версии мы не закрываем resp.Body, что может привести к утечке ресурсов. Позднее мы это исправим, переместив код опроса одного сервиса в отдельную функцию.
Содержимое вывода в консоль:
2021/04/04 13:29:59 service D: status OK: answer: {Args:{Name:service.D Status:ok Version:5.555.5}}
2021/04/04 13:29:59 service A: status OK: answer: {Args:{Name:service.A Status:ok Version:1.231.1}}
2021/04/04 13:29:59 service B: status check ERROR: 500 != 200
2021/04/04 13:30:01 service C: check ERROR: Get "https://httpbin.org/delay/10?status=ok&version=2.100.0&name=service.C": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Пятая часть. Рефакторинг
Мы уже можем проверять http статус ответа, его содержимое и поддерживаем таймаут обращения к одному сервису.
Пока наш чекер не поддерживает периодический опрос сервисов, а для того, чтобы сделать такой опрос нам потребуется отрефакторить текущий код.
Суть рефакторинга:
- Убрать код из
main
- Разбить код опроса на несколько функций:
pingAll()
,pingOne()
,getPingAnswer()
- Результаты опроса обрабатывать в
pingAll()
В результате у нас должна получиться примерно такая структура программы:
func main() {
pingAll()
}
func pingAll() {
// цикл по сервисам
for service, url := range config {
answer, err := pingOne(url)
// обработка ошибок
}
}
func pingOne(url string) (PingResponse, error) {
// опрос сервиса
...
getPingAnswer(...)
}func getPingAnswer(resp *http.Response) (PingResponse, error) {
}
Итак, начнём в обратном порядке и перенесём код, предназначенный для чтения ответа сервиса в отдельную функцию, попутно заменим логирование проблем на возврат ошибки.
func getPingAnswer(resp *http.Response) (PingResponse, error) {
bytes, err := io.ReadAll(resp.Body)
if err != nil {
return PingResponse{}, fmt.Errorf("can't read body: %w", err)
}
var respJSON PingResponse
err = json.Unmarshal(bytes, &respJSON)
if err != nil {
return PingResponse{}, fmt.Errorf("can't parse body as JSON: %w", err)
}
return respJSON, nil
}
Теперь напишем функцию, выполняющую опрос одного сервиса. Она будет принимать его адрес и возвращать результат и возможную ошибку.
func pingOne(url string) (PingResponse, error) {
resp, err := client.Get(url)
if err != nil {
return PingResponse{}, err
}
// при выходе из функции закрываем Body
defer resp.Body.Close()
if resp.StatusCode != 200 {
return PingResponse{}, fmt.Errorf("status %d != 200", resp.StatusCode)
}
return getPingAnswer(resp)
}
Функция, которая будет запускать опрос всех сервисов будет выглядеть следующим образом:
func pingAll() {
// цикл по сервисам
for service, url := range config {
answer, err := pingOne(url)
if err != nil {
log.Printf("%s: status check ERROR: %s\n", service, err)
} else if answer.Args.Status != "ok" {
log.Printf("%s: status check ERROR: answer.Args.Status != ok: %s\n", service, answer.Args.Status)
} else {
log.Printf("%s: status OK: answer: %+v", service, answer)
}
}
}
Код после рефакторинга:
// импорты, структуры и настройки остались теми же и поэтому пропущеныfunc main() {
pingAll()
}
func pingAll() {
// цикл по сервисам
for service, url := range config {
answer, err := pingOne(url)
if err != nil {
log.Printf("%s: status check ERROR: %s\n", service, err)
} else if answer.Args.Status != "ok" {
log.Printf("%s: status check ERROR: answer.Args.Status != ok: %s\n", service, answer.Args.Status)
} else {
log.Printf("%s: status OK: answer: %+v", service, answer)
}
}
}
func pingOne(url string) (PingResponse, error) {
resp, err := client.Get(url)
if err != nil {
return PingResponse{}, err
}
// при выходе из функции закрываем Body
defer resp.Body.Close()
if resp.StatusCode != 200 {
return PingResponse{}, fmt.Errorf("status %d != 200", resp.StatusCode)
}
return getPingAnswer(resp)
}
func getPingAnswer(resp *http.Response) (PingResponse, error) {
bytes, err := io.ReadAll(resp.Body)
if err != nil {
return PingResponse{}, fmt.Errorf("can't read body: %w", err)
}
var respJSON PingResponse
err = json.Unmarshal(bytes, &respJSON)
if err != nil {
return PingResponse{}, fmt.Errorf("can't parse body as JSON: %w", err)
}
return respJSON, nil
}
Вывод программы не должен был измениться:
2021/04/04 14:07:19 service A: status OK: answer: {Args:{Name:service.A Status:ok Version:1.231.1}}
2021/04/04 14:07:19 service B: status check ERROR: status 500 != 200
2021/04/04 14:07:21 service C: status check ERROR: Get "https://httpbin.org/delay/10?status=ok&version=2.100.0&name=service.C": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
2021/04/04 14:07:22 service D: status OK: answer: {Args:{Name:service.D Status:ok Version:5.555.5}}
Попробуйте добавить в настройки сервис, который вернёт не
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()
Итак, реализация первого способа будет выглядеть так:
func main() {
pingAllLoop()
}func pingAllLoop() {
for {
pingAll()
time.Sleep(5*time.Second)
}
}
Второй вариант использует возможности работы с каналами, выглядит немного сложнее, но делает код более гибким (позднее мы сможем добавить в него обработку контекста для grace shutdown-а)
func main() {
pingAllLoop()
}
func pingAllLoop() {
pingAll()
for {
select {
case <- time.After(5*time.Second):
pingAll()
}
}
}
Здесь в цикле каждые 5 секунд будут прилетать данные в канал, который создаёт функция time.After()
, после чего будет запускаться функция pingAll()
. Поскольку первые данные в канал прилетят только через 5 секунд, мы однократно делаем вызов pingAll()
перед запуском цикла.
Полная версия будет выглядеть следующим образом:
Итак, теперь мы умеем опрашивать сервисы, интерпретировать их ответы и можем делать это периодически.
Седьмая часть. Graceful shutdown
Добавим корректный выход из бесконечного цикла.
- Создадим контекст с возможностью отмены
context.WithCancel()
и передадим егоpingAllLoop()
. - Подпишемся на события прерывания в отдельной горутине с помощью
signal.Notify()
и будем вызыватьcancel()
при получении сигналаSIGINT
.
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
termCh := make(chan os.Signal, 1)
signal.Notify(termCh, os.Interrupt, syscall.SIGINT)
<-termCh
log.Println("Shutdown...")
cancel()
}()
pingAllLoop(ctx)
}func pingAllLoop(ctx context.Context) {
pingAll()
for {
select {
case <-time.After(5 * time.Second):
pingAll()
case <-ctx.Done():
return
}
}
}
Запускаем и проверяем, что выход из цикла происходит корректно при нажатии сочетания CTRL-C
в консоли.
2021/04/04 18:07:03 START ping 5 services
2021/04/04 18:07:05 service E: status check ERROR: answer.Args.Status != ok: error
2021/04/04 18:07:05 service A: status OK: answer: {Args:{Name:service.A Status:ok Version:1.231.1}}
2021/04/04 18:07:05 service B: status check ERROR: status 500 != 200
2021/04/04 18:07:07 service C: status check ERROR: Get "https://httpbin.org/delay/10?status=ok&version=2.100.0&name=service.C": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
2021/04/04 18:07:08 service D: status OK: answer: {Args:{Name:service.D Status:ok Version:5.555.5}}
2021/04/04 18:07:08 END ping 5 services
^C2021/04/04 18:07:13 Shutdown...
Восьмая часть. Используем resty
Перепишем наш чекер, чтобы он использовал сторонний http клиент go-resty
.
Устанавливаем пакет:
go get github.com/go-resty/resty/v2
Чтобы делать запросы при помощи resty
надо для начала создать клиент:
var client = resty.New().SetTimeout(2 * time.Second)
Затем для выполнения запроса надо создать request при помощи функции R()
:
resp, err := client.R().
SetResult(PingResponse{}).
Get(url)
Здесь мы создаём запрос и настраиваем его, в частности, просим интерпретировать ответ сервера как PingResponse
. Нам больше не нужен код ручного преобразования ответа в структуру, то есть можем удалить метод getPingAnswer()
Полный код функции pingOne()
:
...var client = resty.New().SetTimeout(2 * time.Second)...func pingOne(url string) (PingResponse, error) {
resp, err := client.R().
SetResult(PingResponse{}).
ForceContentType("application/json").
Get(url)
if err != nil {
return PingResponse{}, err
}
if resp.StatusCode() != 200 {
return PingResponse{}, fmt.Errorf("status %d != 200", resp.StatusCode())
}
responseJSON, ok := resp.Result().(*PingResponse)
if !ok {
return PingResponse{}, fmt.Errorf("can't parse response as JSON")
}
return *responseJSON, nil
}
Девятая часть. Параллельный опрос
Сейчас опрос сервисов выполняется последовательно. Сделаем его параллельным. Для этого обернём в горутину тело цикла в функции pingAll()
, а также добавим ожидание завершения всех горутин через sync.WaitGroup{}
func pingAll() {
log.Printf("START ping %d services\n", len(config))
wg := sync.WaitGroup{}
// цикл по сервисам
for service, url := range config {
wg.Add(1)
go func(service string, url string) {
defer wg.Done()
answer, err := pingOne(url)
if err != nil {
log.Printf("%s: status check ERROR: %s\n", service, err)
} else if answer.Args.Status != "ok" {
log.Printf("%s: status check ERROR: answer.Args.Status != ok: %s\n", service, answer.Args.Status)
} else {
log.Printf("%s: status OK: answer: %+v", service, answer)
}
}(service, url)
}
// дожидаемся завершения всех горутин
wg.Wait()
log.Printf("END ping %d services\n", len(config))
}
Полная версия будет выглядеть следующим образом:
Лог работы:
2021/04/04 20:07:24 START ping 5 services
2021/04/04 20:07:25 service A: status OK: answer: {Args:{Name:service.A Status:ok Version:1.231.1}}
2021/04/04 20:07:25 service B: status check ERROR: status 500 != 200
2021/04/04 20:07:26 service E: status check ERROR: answer.Args.Status != ok: error
2021/04/04 20:07:26 service D: status OK: answer: {Args:{Name:service.D Status:ok Version:5.555.5}}
2021/04/04 20:07:27 service C: status check ERROR: Get "https://httpbin.org/delay/10?status=ok&version=2.100.0&name=service.C": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
2021/04/04 20:07:27 END ping 5 services
2021/04/04 20:07:32 START ping 5 services
2021/04/04 20:07:32 service B: status check ERROR: status 500 != 200
2021/04/04 20:07:33 service A: status OK: answer: {Args:{Name:service.A Status:ok Version:1.231.1}}
2021/04/04 20:07:33 service E: status check ERROR: answer.Args.Status != ok: error
2021/04/04 20:07:33 service D: status OK: answer: {Args:{Name:service.D Status:ok Version:5.555.5}}
2021/04/04 20:07:35 service C: status check ERROR: Get "https://httpbin.org/delay/10?status=ok&version=2.100.0&name=service.C": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
2021/04/04 20:07:35 END ping 5 services
^C2021/04/04 20:07:36 Shutdown...
Заключение
Мы попробовали написать черновик приложения, проверяющего состояние микро-сервисов.
Что ещё можно было бы сделать:
- Добавить загрузку конфигурации сервисов из внешнего источника (файл или другой сервис).
- Считывать настройки периода опроса сервисов и таймаут для клиента из командной строки, например, с помощью стандартного пакета flags или стороннего urfave/cli.
- Сохранять результаты опроса каждого сервиса в отдельный словарь, чтобы потом отдавать результат по REST/gRPC.
- Выводить результаты через веб-интерфейс, например, так: