Всем привет! Иногда внутренний мониторинг не даёт полной картины, что все работает как надо. И полезно сделать внешний пинг и посмотреть, действительно ли нужный проект доступен.
Сегодня мы расскажем, как решали эту задачу для себя, и выложим код, который вы сможете применить для простого мониторинга своих проектов. И да, мы знаем про существование специализированных сервисов для решения этой задачи, но всегда веселее написать свой скрипт.
В конце статьи вы найдете ссылки на 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.