В этой статье я расскажу, на какие подводные камни я споткнулся при разработке своего пет‑проекта — мониторинга сайтов на 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 с правильным порядком остановки. При получении сигнала на остановку важна последовательность
-
Останавливаем HTTP‑сервер. Он перестаёт принимать новые запросы, но дожидается тех, что уже в обработке.
-
Отменяем контекст через cancel() — это сигнал воркерам планировщика завершаться, они слушают ctx.Done().
-
Ждём завершения воркеров, но не бесконечно: если за отведённый таймаут не уложились — принудительный выход, чтобы не зависнуть из‑за одного застрявшего воркера.
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/