Писал мониторинг на Go «за выходные» — застрял на месяцы. Вот на чём

от автора

В этой статье я расскажу, на какие подводные камни я споткнулся при разработке своего пет‑проекта — мониторинга сайтов на Golang, аналог UptimeRobot.

Начнем издалека… Я хотел разработать пет‑проект, но не банальный todolist, а что‑то свежее, интересное в плане архитектуры и реализации. Шерстя по просторам интернета, я наткнулся на UptimeRobot — сервис для мониторинга сайтов. Азарт и любопытство взяли верх и я начал продумывать, как буду разрабатывать «свой» UptimeRobot. Думал — делов на пару недель от силы. Ведь принцип прост: дергать URL по таймеру и проверять код ответа и всё. Но на практике все оказалось намного сложнее, чем я изначально представлял…

Задача 1. Как проверить тысячи сайтов одновременно?

Примитивная реализация мониторов для проверки работы сайтов выглядит так: есть список мониторов, которые в цикле по очереди вызываются.

Но дьявол кроется в деталях. Проблема заключается в том, что сетевой запрос — ожидание. HTTP‑проверка сайта может занимать от 30 мс до нескольких секунд(вплоть до timeout). И если последовательно запускать тысячу мониторов с timeout на 10 секунд, то какой‑либо «висящий» сайт затормозит проверку остальных…

Здесь Go раскрывается по полной благодаря горутинам. Горутина — легковесный поток выполнения. На фоне системного потока ОС, который весит 1–8 МБ, горутина весит около 2 КБ. Если брать 1000 системных потоков по 1МБ, то уже получается ~1 ГБ. В нашем же случае 1000 горутин × ~2 КБ = ~2 МБ.

Пока одна горутина ждет ответа от сети, планировщик Go отдает процессор другим. Каждый монитор живет в своей горутине с собственным тикером:

func (w *Worker) Run(ctx context.Context) {ticker := time.NewTicker(time.Duration(w.monitor.IntervalSec) * time.Second)defer ticker.Stop()w.runCheck(ctx)for {select {case <-ticker.C:w.runCheck(ctx)case <-ctx.Done():w.logger.Info("worker stopped")return}}}

Главная фишка — select с ctx.Done(). Без него при остановке сервиса горутины продолжают работать. Это утечка. Я осознал это, когда забыл поставить return. Получалось, что сервис вроде завершил работу, а горутины‑воркеры продолжают слать запросы, потому что команды или сигнала завершения не было…

Благодаря эффективной работе Go с многопоточностью тысячи таких воркеров на одном недорогом VPS работают стабильно и занимают мало памяти. На PHP пришлось бы городить пул воркеров и очередь, а в Go же это нативно.

Задача 2. Безопасность: ваш мониторинг как вектор атаки

Сервис, который по запросу пользователя ходит на произвольный URL, — классическая дыра под названием SSRF(Server‑Side Request Forgery).

Смысл атаки заключается в создании монитора не на внешний сайт, а на внутренний адрес. Например http://169.254.169.254/  — метадата‑сервис облака, откуда можно вытащить ключи доступа. Или же http://localhost:5432, чтобы прощупать порты на моем же сервере. Разумеется, сервис послушно отправится туда от своего имени и вернет все результаты пользователю. Тем самым превратится в инструмент разведки внутренней сети.

Первая мысль для решения этой головной боли была проверка адреса при создании монитора. То есть резолвим домен, смотрим — не приватный ли IP:

var privateRanges []*net.IPNetfunc init() {for _, cidr := range []string{"10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","127.0.0.0/8","169.254.0.0/16",   // link-local"100.64.0.0/10",    // shared address space (RFC 6598)"0.0.0.0/8",        // current network"192.0.0.0/24",     // IETF protocol assignments"198.18.0.0/15",    // benchmarking"192.0.2.0/24",     // TEST-NET-1"198.51.100.0/24",  // TEST-NET-2"203.0.113.0/24",   // TEST-NET-3"240.0.0.0/4",      // reserved"255.255.255.255/32","::1/128",   // IPv6 loopback"fc00::/7",  // IPv6 unique local"fe80::/10", // IPv6 link-local} {_, network, err := net.ParseCIDR(cidr)if err != nil {panic("ssrf: bad CIDR " + cidr + ": " + err.Error())}privateRanges = append(privateRanges, network)}}func IsPrivateIP(ip net.IP) bool {for _, r := range privateRanges {if r.Contains(ip) {return true}}return false}

Но на одной проверке адреса история не заканчивается. Здесь прячется еще одна коварная атака — DNS rebinding.

Смысл заключается в том, что между этапами проверки домена при создании и этапом реальной проверки проходит некоторое время. Ведь DNS‑запись можно успеть поменять.

Выглядит это все примерно так. Злоумышленник создает монитор на «evil.com». В момент создания домен «evil.com» резолвится в нормальный IP‑шник и проверка успешно проходит. Через некоторое время, к примеру через минуту, злоумышленник меняет DNS‑запись и домен опять начинает резолвиться во внутренний/приватный адрес. Когда воркер уйдет делать проверку, он уйдет уже на внутренний адрес.

Решением этой проблемы является проверка не при создании, а в самый первый момент установки соединения. Для этого в Go есть DialContext:

func SafeDialContext(base *net.Dialer) func(context.Context, string, string) (net.Conn, error) {return func(ctx context.Context, network, addr string) (net.Conn, error) {host, port, err := net.SplitHostPort(addr)if err != nil {return nil, err}// Если передан IP напрямую — проверяем его, DNS не нуженif ip := net.ParseIP(host); ip != nil {if IsPrivateIP(ip) {return nil, fmt.Errorf("SSRF: подключение к приватному адресу %s заблокировано", host)}return base.DialContext(ctx, network, addr)}// Резолвим домен сами и проверяем каждый полученный адресresolved, err := net.DefaultResolver.LookupHost(ctx, host)if err != nil {return nil, err}for _, r := range resolved {if ip := net.ParseIP(r); ip != nil && IsPrivateIP(ip) {return nil, fmt.Errorf("SSRF: %s резолвится в приватный IP %s", host, r)}}if len(resolved) == 0 {return nil, fmt.Errorf("SSRF: не найдено адресов для %s", host)}// Подключаемся по проверенному IP, а не по домену —// исключаем повторный резолв и DNS rebindingreturn base.DialContext(ctx, network, net.JoinHostPort(resolved[0], port))}}

Тонкость в последних строках: подключение к конкретному проверенному IP-адресу, а не к доменному имени. Если передать в dialer домен, Go резолвит его повторно уже внутри — и тогда уже между проверкой и реальным коннектом снова появляется окно для подмены. А так — что проверил, к тому и подключился.

httpClient: &http.Client{    Timeout: time.Duration(monitor.TimeoutSec) * time.Second,    // SSRF-безопасный транспорт: резолвит хост и блокирует приватные IP    // до подключения, защищая от DNS rebinding    Transport: &http.Transport{        DialContext: ssrf.SafeDialContext(&net.Dialer{}),    },    // Не следуем за редиректами автоматически: иначе сайт мог бы    // редиректнуть нас на внутренний адрес в обход проверки    CheckRedirect: func(req *http.Request, via []*http.Request) error {        return http.ErrUseLastResponse    },},

Транспорт с SafeDialContext я ставлю в HTTP‑клиент планировщика — теперь каждый запрос проходит через проверку приватных адресов. Заодно отключаю автоматическое следование за редиректами через http.ErrUseLastResponse: иначе проверяемый сайт мог бы вернуть редирект на внутренний адрес, и клиент пошёл бы туда сам. Получается, защита работает на всех уровнях — при создании монитора, в момент установки соединения и при попытке увести нас редиректом.

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

Задача 3. Хранение: обычный PostgreSQL — не самый лучший выбор

От мониторинга поступает огромный поток однотипных записей, ведь каждый монитор пишет результат каждые 30–60 секунд. Когда мониторов тысячи, выходит около трех миллионов записей в сутки. И почти все запросы к этим данным — по времени.

Если все это добро хранить в обычной таблице PostgreSQL, со временем будет деградация: индексы начнут пухнуть, выборки по диапазонам замедляются, а удаление старых записей выходит дорого и фрагментирует таблицу.

Как аналог я выбрал TimescaleDB — расширение PostgreSQL, заточенное под работу с временными рядами. Снаружи тот же SQL, а под капотом автоматическая нарезка данных на фрагменты(гипертаблица). Это ускоряет выборки по времени по нужным кускам вместо всей таблицы и позволяет удалять старые данные целыми кусками, а не дорогим DELETE.

CREATE TABLE checks (    id            UUID         NOT NULL DEFAULT uuid_generate_v4(),    monitor_id    UUID         NOT NULL,    status        VARCHAR(20)  NOT NULL,    -- up | down | timeout | warn    status_code   INT          NOT NULL DEFAULT 0,    latency_ms    BIGINT       NOT NULL DEFAULT 0,    error         TEXT         NOT NULL DEFAULT '',    ssl_days_left INT          NOT NULL DEFAULT 0,    created_at    TIMESTAMPTZ  NOT NULL DEFAULT NOW(),    -- Композитный PK, чтобы TimescaleDB могла шардить по created_at    PRIMARY KEY (id, created_at));-- Превращаем обычную таблицу в гипертаблицу: данные автоматически-- нарезаются на куски по времени (created_at)SELECT create_hypertable('checks', 'created_at');

Обычная таблица одной командой create_hypertable становится гипертаблицей. Важно учитывать и составной первичный ключ(id, created_at). Поскольку TimescaleDB требует, чтобы колонка, по которой идет нарезка на куски(created_at), входила в первичный ключ. Если просто оставить PRIMARY KEY(id) — create_hypertable упадет с ошибкой. Споткнулся об это во время написания сразу…

-- Индекс под главный паттерн запросов: "проверки монитора за период"CREATE INDEX idx_checks_monitor_id_created_at ON checks(monitor_id, created_at DESC);-- Автоудаление данных старше 90 дней — без тяжёлых DELETESELECT add_retention_policy('checks', INTERVAL '90 days');

Подсчет аптайма:

SELECT COALESCE(    COUNT(*) FILTER (WHERE status = 'up')::float        / NULLIF(COUNT(*), 0) * 100,    0) AS uptime_percentFROM checksWHERE monitor_id = $1 AND created_at BETWEEN $2 AND $3;

Присутствуют два слоя защиты. NULLIF защищает от деления на ноль, если проверок не было. Но без COALESCE снаружи запрос вернёт NULL, а не 0, — и Go не сможет отсканировать его в float64, получите ошибку в рантайме. Поэтому используется снаружи COALESCE(…,0), который превращает NULL обратно в 0. Поймал это на свежесозданном мониторе, у которого ещё не было ни одной проверки.

Задача 4. Изоляция или почему один процесс — плохая идея

Сначала все было в одном бинарнике: API, проверки, алерты. Работает все исправно до того, пока что‑то не затормозит.

Что будет, если даже на пару секунд Telegram API зависнет при отправке алерта? Если же все происходит в одном процессе, блокируются ресурсы, от чего проверки начинают отставать. Внешний сервис, на который я никак не влияю, роняет точность моего мониторинга.

Из‑за чего я пришел к тому, что мониторинг нужно разбить на независимые процессы:

Схема разбиения

Схема разбиения
  • api — обрабатывает HTTP‑запросы от фронтенд‑части

  • scheduler — запускает проверки в горутинах

  • alerter — получает информацию о падениях и шлет алерты

Связь между планировщиком и отправителем выстроена через очередь, а точнее через Redis Streams. Если планировщик засекает падение, он бросает событие в очередь и возвращается к проверкам. Отправитель берет из очереди сообщение и шлет их пользователям. Даже если телеграм тормозит, мой сервис продолжает стабильно работать.

И как приятное дополнение к выше написанному — graceful shutdown с правильным порядком остановки. При получении сигнала на остановку важна последовательность

  1. Останавливаем HTTP‑сервер. Он перестаёт принимать новые запросы, но дожидается тех, что уже в обработке.

  2. Отменяем контекст через cancel() — это сигнал воркерам планировщика завершаться, они слушают ctx.Done().

  3. Ждём завершения воркеров, но не бесконечно: если за отведённый таймаут не уложились — принудительный выход, чтобы не зависнуть из‑за одного застрявшего воркера.

go func() {    sigChan := make(chan os.Signal, 1)    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)    sig := <-sigChan    log.Info("shutdown signal received", "signal", sig.String())    // Контекст с таймаутом на всю процедуру завершения    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)    defer shutdownCancel()    // Шаг 1: останавливаем HTTP-сервер.    // Fiber дожидается активных запросов, новые не принимает.    if err := server.Shutdown(); err != nil {        log.Error("server shutdown error", "error", err)    }    // Шаг 2: отменяем контекст — это сигнал воркерам scheduler'а    // и heartbeat-watcher'у, они слушают ctx.Done()    cancel()    // Шаг 3: ждём завершения всех воркеров, но не дольше таймаута    done := make(chan struct{})    go func() {        sched.Shutdown()        close(done)    }()    select {    case <-done:        log.Info("graceful shutdown complete")    case <-shutdownCtx.Done():        log.Warn("graceful shutdown timed out, forcing exit")    }}()

Если не это, при каждом бы деплое терялись бы результаты тех проверок, которые выполнялись в момент остановки. Почему важен порядок? Если его перепутать — например, сначала завершить работу планировщика, а потом остановить HTTP — запросы, которые в этот момент обрабатывались, упадут с ошибкой. А без timeout в третьем шаге сервис мог бы зависнуть на остановке, если какой‑нибудь воркер не отвечает.

Итого

Изначально казавшаяся легкой в реализации идея таила в себе множество нюансов, с которыми пришлось столкнуться во время разработки. Мониторинг звучит как «дёргай URL по таймеру», но за этим прячутся параллелизм, целый класс атак через SSRF, специфика хранения временных рядов и вопросы изоляции компонентов.

Если интересно посмотреть, во что это вылилось, могу дать ссылку в комментариях. Буду рад конструктивной критике и аргументированным замечаниям, особенно в области безопасности. Тут нет предела совершенству!

ссылка на оригинал статьи https://habr.com/ru/articles/1046696/