Писал мониторинг на Go «за выходные» — застрял на месяцы. Вот на чём
- суббота, 13 июня 2026 г. в 00:00:08
В этой статье я расскажу, на какие подводные камни я споткнулся при разработке своего пет‑проекта — мониторинга сайтов на Golang, аналог UptimeRobot.

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

Примитивная реализация мониторов для проверки работы сайтов выглядит так: есть список мониторов, которые в цикле по очереди вызываются.
Но дьявол кроется в деталях. Проблема заключается в том, что сетевой запрос — ожидание. 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 же это нативно.
Сервис, который по запросу пользователя ходит на произвольный URL, — классическая дыра под названием SSRF(Server‑Side Request Forgery).
Смысл атаки заключается в создании монитора не на внешний сайт, а на внутренний адрес. Например http://169.254.169.254/ — метадата‑сервис облака, откуда можно вытащить ключи доступа. Или же http://localhost:5432, чтобы прощупать порты на моем же сервере. Разумеется, сервис послушно отправится туда от своего имени и вернет все результаты пользователю. Тем самым превратится в инструмент разведки внутренней сети.
Первая мысль для решения этой головной боли была проверка адреса при создании монитора. То есть резолвим домен, смотрим — не приватный ли IP:
var privateRanges []*net.IPNet func 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 rebinding return 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 ловит подмену в момент проверки. Один слой без другого не дает должный уровень защиты от подобных атак.
От мониторинга поступает огромный поток однотипных записей, ведь каждый монитор пишет результат каждые 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 дней — без тяжёлых DELETE SELECT add_retention_policy('checks', INTERVAL '90 days');
Подсчет аптайма:
SELECT COALESCE( COUNT(*) FILTER (WHERE status = 'up')::float / NULLIF(COUNT(*), 0) * 100, 0 ) AS uptime_percent FROM checks WHERE monitor_id = $1 AND created_at BETWEEN $2 AND $3;
Присутствуют два слоя защиты. NULLIF защищает от деления на ноль, если проверок не было. Но без COALESCE снаружи запрос вернёт NULL, а не 0, — и Go не сможет отсканировать его в float64, получите ошибку в рантайме. Поэтому используется снаружи COALESCE(...,0), который превращает NULL обратно в 0. Поймал это на свежесозданном мониторе, у которого ещё не было ни одной проверки.
Сначала все было в одном бинарнике: 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, специфика хранения временных рядов и вопросы изоляции компонентов.
Если интересно посмотреть, во что это вылилось, могу дать ссылку в комментариях. Буду рад конструктивной критике и аргументированным замечаниям, особенно в области безопасности. Тут нет предела совершенству!