В этой статье я расскажу, на какие подводные камни я споткнулся при разработке своего пет‑проекта — мониторинга сайтов на 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 с правильным порядком остановки. При получении сигнала на остановку важна последовательность

  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, специфика хранения временных рядов и вопросы изоляции компонентов.

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