В этой статье я расскажу, на какие подводные камни я споткнулся при разработке своего пет‑проекта — мониторинга сайтов на 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.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 ловит подмену в момент проверки. Один слой без другого не дает должный уровень защиты от подобных атак.
Задача 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 дней — без тяжёлых 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. Поймал это на свежесозданном мониторе, у которого ещё не было ни одной проверки.
Задача 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, специфика хранения временных рядов и вопросы изоляции компонентов.
Если интересно посмотреть, во что это вылилось, могу дать ссылку в комментариях. Буду рад конструктивной критике и аргументированным замечаниям, особенно в области безопасности. Тут нет предела совершенству!
