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

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

В конце статьи вы найдете ссылки на GitHub, где код нашего внешнего мониторинга доступности размещен под Open Source лицензией Apache 2.0. Будем рады, если он вам пригодится.

У нас в облаке проекты можно разворачивать в двух регионах - в Москве и в Варшаве. И хотелось иметь возможность автоматически мониторить стабильность работы сетевой инфраструктуры. Для этого, мой коллега, Иван Шихалдин написал на Java+SpringBoot скрипт, и развернул в Amvera Cloud парочку небольших приложений для проведения healthcheck’ов и отправки мониторинговых сообщений в наш мониторинговый телеграм-канал.

Что такое Amvera

Amvera - облако для простого развертывания IT-приложений, предоставляющее бонусом:

  • Автоматизацию деплоя. Делаете push в привязанный git (или перетягиваете файлы в интерфейсе) и сервис сам все настроит и запустит. Это проще настройки VPS;

  • Бесплатный внешний домен с HTTPS;

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

  • Приветственные 111 рублей на баланс для тестов;

Первое приложение поднимает вебсервер и слушает эндпойнт healthcheck’a. Второе приложение в цикле пингует этот эндпойнт и проверяет статус ответа. Если статус ответа изменяется, то оно отправляет предупреждение в группу телеграм, плюс логирует все свои обращения к эндпойнту.

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

Для эндпойнта мы использовали стартеры - spring-boot-starter-web для запуска вебсервера, и spring-boot-starter-security, чтобы прикрыть эндпойнт фильтром на проверку API Key:

// Проверяем, есть ли заголовок "X-API-KEY"
String requestApiKey = request.getHeader("X-API-KEY");


// Если ключ отсутствует или не совпадает с ожидаемым
if (requestApiKey == null || !requestApiKey.equals(apiKey)) {
   // Если запрос идет к /api/v1/healthcheck, и ключ неверный, возвращаем 401 Unauthorized
   // Иначе, если это другой незащищенный эндпоинт, пропускаем дальше (если нужно)
   if (request.getRequestURI().startsWith("/api/v1/")) {
       response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 Unauthorized
       response.getWriter().write("Invalid API Key");
       return;
   }
} else {
   // Если ключ валиден, создаем объект аутентификации и помещаем его в SecurityContext
   // AuthorityUtils.NO_AUTHORITIES означает, что у пользователя нет специфических ролей
   Authentication authentication = new ApiKeyAuthentication(requestApiKey,
           AuthorityUtils.NO_AUTHORITIES);
   SecurityContextHolder.getContext().setAuthentication(authentication);
}


// Передаем запрос дальше по цепочке фильтров
filterChain.doFilter(request, response);

Сам API ключ хранится в переменной окружения в виде строки. Конфигурация секьюрити получилась такая:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
   http
           .csrf(csrf -> csrf.disable()) // Отключаем CSRF для API (не используем сессии)
           .sessionManagement(sm -> sm.sessionCreationPolicy(org.springframework.security.config.http.SessionCreationPolicy.STATELESS)) // Важно для API-ключей/токенов
           .authorizeHttpRequests(authorize -> authorize
                   // Разрешаем /api/v1/healthcheck только с валидным API-ключом
                   .requestMatchers(
                           "/api/v1/healthcheck",
                           "/api/v1/ping").authenticated()
                   // Все остальные запросы разрешаем без аутентификации 
                   .anyRequest().permitAll()
           )
           // Добавляем наш фильтр перед стандартным UsernamePasswordAuthenticationFilter
           .addFilterBefore(apiKeyAuthFilter, UsernamePasswordAuthenticationFilter.class);


   return http.build();
}

Сам рест-контроллер получился очень простым:

@RestController
@RequestMapping("/api/v1/")


public class HealthCheckController {
   @GetMapping("/healthcheck")
   public String index() {
       return "health OK";
   }


   @GetMapping("/ping")
   public String ping() {
       return "pong";
   }
}

Теперь предлагаю заглянуть в содержание тестера. В конфиге gradle практически то же самое, я убрал web-стартер и добавил библиотеку telegrambots в зависимости, чтобы мы могли отправлять уведомления в наш мониторинговый телеграм-чат.

Для этого в TelegramService мы создаем внутренний класс TelegramSender, расширяющий DefaultAbsSender для передачи туда токена бота:

private static class TelegramSender extends DefaultAbsSender {
   protected TelegramSender(DefaultBotOptions options, String botToken) {
       super(options, botToken);
   }
}

Затем мы инжектируем его через конструктор. Токен бота и id чатов, куда будем слать уведомления, задаем через переменные окружения и application.yml:

private final TelegramSender telegramSender;


public TelegramService(@Value("${app.telegram.token}") String telegramBotToken,
                      @Value("${app.telegram.chat}") String telegramBotChat,
                      @Value("${app.amvera.alerts.chat}") String amveraAlertsChat){
   this.telegramBotToken = telegramBotToken;
   this.telegramBotChat = telegramBotChat;
   this.amveraAlertsChat = amveraAlertsChat;
   this.telegramSender = new TelegramSender(new DefaultBotOptions(), telegramBotToken);
   log.info("Bot is initialized");
}

Сам этот сервис содержит всего один метод - для отправки уведомления в телеграм чат:

public void sendMessage(String messageText){
   var message = new SendMessage();
   message.setChatId(telegramBotChat);
   message.setText(messageText);
   try{
       telegramSender.execute(message);
       log.info("Message sended: {}", messageText);
   }catch (TelegramApiException e){
       log.error(e.getMessage());
   }


   if(amveraAlertsChat!=null&&amveraAlertsChat.isEmpty()==false){
       //отправим в чат алертов уведомление
       message.setChatId(amveraAlertsChat);
       message.setText(messageText);
       try{
           telegramSender.execute(message);
           log.info("Alert sended: {}", messageText);
       }catch (TelegramApiException e){
           log.error("Chat id {} not found",amveraAlertsChat);
           log.error(e.getMessage());
       }
   }
}

Также наш тестер содержит еще один сервис HealthCheckService, в котором мы по расписанию опрашиваем наш эндпойнт и анализируем его ответы. Используем аннотацию @Scheduled(fixedRate = 1*60000L, initialDelay = 5000L), чтобы наш тестирующий метод запускался раз в минуту (60000 миллисекунд), с задержкой 5 секунд (5000мс) перед стартом, чтобы программа успевала загрузиться:

   @Scheduled(fixedRate = 1*60000L, initialDelay = 5000L)
   public void ping() { //error messages every 5 minutes
//        log.info("Starting ping...");
       HttpClient client = HttpClient.newBuilder().build();


       // Запрос к /api/v1/ping
       var response = sendHealthCheckRequest(client, pingURL);


       if(response == null){
           log.error("PING NULL response");
           if(status != 0) {
               telegramService.sendMessage("%s - не удается установить соединение".formatted(goal));
           }
           status = 0;
       } else {
           if (response.statusCode()==200) {
               log.info("PING {}",response.statusCode());
           } else{
               log.warn("PING {}",response.statusCode());
           }
           if(status != response.statusCode()){
               if (response.statusCode()==200) {
                   telegramService.sendMessage("%s OK".formatted(goal));
               } else{
                   telegramService.sendMessage("%s сбоит : %s"
                           .formatted(goal,response.statusCode()));
               }
           }


           status = response.statusCode();
       }
   }

В данном случае GOAL это направление тестирования - Москва или Варшава, по сути, просто строка с текстом из переменных окружения, которая информационно будет передаваться в телеграм-алерт. Тут мы рассматриваем три состояния - эндпойнт вернул статус-код 200, отправляем статус “Все ОК”, или же вернул отличный от 200 статус - отправляем статус с ошибкой, или же мы просто не смогли установить соединение (например, сетевые проблемы). Тогда так и пишем в телеграм - не смогли установить соединение.

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

   private HttpResponse<String> sendHealthCheckRequest(HttpClient client, String url) {
       HttpResponse<String> response;
       try {
           HttpRequest request = HttpRequest.newBuilder()
                   .uri(URI.create(url)) // Указываем URL
                   .header("X-API-KEY", API_KEY) // Добавляем HTTP-заголовок с API-ключом
                   .GET() // Указываем HTTP-метод GET
                   .build();


           response = client.send(request, HttpResponse.BodyHandlers.ofString());


           log.debug("ANSWER {} {} {}", response.statusCode(), response.body(), url);
       } catch (IOException e) {
//            log.error("IOException when HTTP Request to {} [{}] {}", url, e.getMessage(),e);
           log.error("IOException when HTTP Request to {} [{}]", url, e.getMessage());
           response = null;
       } catch (InterruptedException e) {
           log.error("InterruptedException  when HTTP Request to {} [{}] {}", url, e.getMessage(),e);
           response = null;
       }
       return response;
   }

Здесь мы дергаем ручку эндпойнта (URL тоже берется из переменных окружения), в заголовке передаем X-API-KEY и вызываем метод GET нашего HTTP запроса.

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

Код нашего решения для пинга доступен по ссылкам на GitHub 1 и 2 под Open Source лицензией Apache 2.0.

Надеюсь, код поможет осущесвлять мониторинг ваших проектов. А лучшая благодарность, это развертывание пингатора в нашем облаке)  Для этого в репозитории уже есть все конфигурационные файлы. Достаточно заменить эндпоинты и сделать git push amvera master.

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