Как лучше организовать структуру БД для последующего анализа?

Для удобных отчетов в будущем (например, построения графиков доступности, расчета Uptime %, поиска «мигающих» сервисов) оптимально разделить базу на две таблицы:

1. runs (История запусков):
Фиксирует сам факт запуска скрипта (id, timestamp). Это избавляет от дублирования метки времени в миллионах строк и дает единую точку отсчета для каждого сканирования.
2. results (Результаты проверок):
Хранит конкретные измерения (run_id, host, type, port, latency_ms).

Также важно создать Индексы по хосту, порту и ID запуска — благодаря им даже через год аналитические SQL-запросы будут выполняться мгновенно.

---

Обновленный код netcheck.py

Скрипт создает файл БД netcheck.db (если его нет), записывает туда данные каждого запуска и по-прежнему дублирует вывод в консоль (если запускается вручную).

#!/usr/bin/env python3
"""
Скрипт параллельной проверки сетевой доступности хостов и TCP-портов.
Результаты сохраняются в базу данных SQLite (с временной меткой запуска)
и дублируются в виде CSV в stdout.
Идеально подходит для регулярного запуска через Cron.
"""

import asyncio
import csv
import sys
import argparse
import time
import sqlite3
from datetime import datetime, timezone
from pathlib import Path

# ==============================================================================
# КОНФИГУРАЦИЯ И НАСТРОЙКИ
# ==============================================================================

# Таймаут ожидания ответа от хоста/порта (в секундах)
TIMEOUT = 3.0

# Значение задержки, если хост/порт недоступен
UNREACHABLE_VAL = 999.0

# Количество повторных попыток перед фиксацией недоступности (999)
MAX_RETRIES = 2

# Задержка между повторными попытками (в секундах)
RETRY_DELAY = 0.5

# Ограничение количества одновременных проверок
MAX_CONCURRENT_TASKS = 50

# Пути к файлам по умолчанию
DEFAULT_HOSTS_FILE = 'hosts.lst'
DEFAULT_DB_FILE = 'netcheck.db'

# ==============================================================================

###semaphore = asyncio.Semaphore(MAX_CONCURRENT_TASKS)
semaphore = None

def init_db(db_path: str):
    """Создает структуру БД и необходимые индексы, если они не существуют."""
    conn = sqlite3.connect(db_path)
    cursor = conn.cursor()
    
    # Таблица запусков
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS runs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            timestamp DATETIME NOT NULL
        )
    ''')
    
    # Таблица результатов
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS results (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            run_id INTEGER NOT NULL,
            host TEXT NOT NULL,
            type TEXT NOT NULL,
            port TEXT NOT NULL,
            latency_ms REAL NOT NULL,
            FOREIGN KEY (run_id) REFERENCES runs (id) ON DELETE CASCADE
        )
    ''')
    
    # Индексы для ускорения последующего анализа
    cursor.execute('CREATE INDEX IF NOT EXISTS idx_results_run_id ON results(run_id)')
    cursor.execute('CREATE INDEX IF NOT EXISTS idx_results_host_port ON results(host, port)')
    
    conn.commit()
    conn.close()


def save_to_db(db_path: str, results_list: list) -> int:
    """Сохраняет результаты текущей проверки в SQLite в единой транзакции."""
    conn = sqlite3.connect(db_path)
    cursor = conn.cursor()
    
    # Регистрируем запуск с текущей датой и временем (UTC)
    now_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
    cursor.execute('INSERT INTO runs (timestamp) VALUES (?)', (now_utc,))
    run_id = cursor.lastrowid
    
    # Подготавливаем данные для пакетной вставки (Bulk Insert)
    rows_to_insert = []
    for item in results_list:
        if item:
            for row in item:
                rows_to_insert.append((
                    run_id,
                    row['Host'],
                    row['Type'],
                    str(row['Port']),
                    row['Latency_ms']
                ))
                
    cursor.executemany('''
        INSERT INTO results (run_id, host, type, port, latency_ms)
        VALUES (?, ?, ?, ?, ?)
    ''', rows_to_insert)
    
    conn.commit()
    conn.close()
    return run_id


async def ping_icmp(host: str) -> float:
    """Проверка доступности через ICMP Ping."""
    async with semaphore:
        for attempt in range(1, MAX_RETRIES + 1):
            start_time = time.perf_counter()
            try:
                proc = await asyncio.create_subprocess_exec(
                    'ping', '-c', '1', '-W', str(int(TIMEOUT)), host,
                    stdout=asyncio.subprocess.DEVNULL,
                    stderr=asyncio.subprocess.DEVNULL
                )
                await asyncio.wait_for(proc.wait(), timeout=TIMEOUT + 0.5)
                elapsed = (time.perf_counter() - start_time) * 1000

                if proc.returncode == 0:
                    return round(elapsed, 2)
            except (asyncio.TimeoutError, Exception):
                pass

            if attempt < MAX_RETRIES:
                await asyncio.sleep(RETRY_DELAY)

        return UNREACHABLE_VAL


async def check_tcp(host: str, port: int) -> float:
    """Проверка доступности TCP-порта."""
    async with semaphore:
        for attempt in range(1, MAX_RETRIES + 1):
            start_time = time.perf_counter()
            try:
                conn = asyncio.open_connection(host, port)
                reader, writer = await asyncio.wait_for(conn, timeout=TIMEOUT)
                elapsed = (time.perf_counter() - start_time) * 1000

                writer.close()
                await writer.wait_closed()
                return round(elapsed, 2)
            except (asyncio.TimeoutError, OSError, Exception):
                pass

            if attempt < MAX_RETRIES:
                await asyncio.sleep(RETRY_DELAY)

        return UNREACHABLE_VAL


async def check_target(target_str: str) -> list:
    """Разбор строки конфигурации и запуск проверок."""
    line = target_str.strip()
    if not line or line.startswith('#'):
        return None

    parts = line.split()
    host = parts[0]
    ports = []

    if len(parts) > 1:
        ports_str = parts[1]
        for p in ports_str.split(','):
            p_clean = p.strip()
            if p_clean.isdigit():
                ports.append(int(p_clean))

    results = []
    if not ports:
        latency = await ping_icmp(host)
        results.append({
            'Host': host,
            'Type': 'ICMP',
            'Port': '-',
            'Latency_ms': latency
        })
    else:
        tasks = [check_tcp(host, port) for port in ports]
        latencies = await asyncio.gather(*tasks)
        for port, latency in zip(ports, latencies):
            results.append({
                'Host': host,
                'Type': 'TCP',
                'Port': port,
                'Latency_ms': latency
            })

    return results


async def main():
    global semaphore
    semaphore = asyncio.Semaphore(MAX_CONCURRENT_TASKS)
    parser = argparse.ArgumentParser(
        description="Скрипт проверки сетевой доступности с сохранением в SQLite."
    )
    parser.add_argument(
        'file',
        nargs='?',
        default=DEFAULT_HOSTS_FILE,
        help=f'Путь к файлу со списком хостов (по умолчанию: {DEFAULT_HOSTS_FILE})'
    )
    parser.add_argument(
        '--db',
        default=DEFAULT_DB_FILE,
        help=f'Путь к файлу SQLite БД (по умолчанию: {DEFAULT_DB_FILE})'
    )
    parser.add_argument(
        '--no-stdout',
        action='store_true',
        help='Отключить вывод CSV в stdout (полезно при запуске из Cron)'
    )
    args = parser.parse_args()

    # Чтение файла хостов
    try:
        with open(args.file, 'r', encoding='utf-8') as f:
            lines = f.readlines()
    except FileNotFoundError:
        sys.stderr.write(f"Ошибка: Файл '{args.file}' не найден.\n")
        sys.exit(1)

    # Инициализация структуры БД
    init_db(args.db)

    # Запуск параллельной проверки
    tasks = [check_target(line) for line in lines]
    raw_results = await asyncio.gather(*tasks)

    # Сохранение в базу данных
    save_to_db(args.db, raw_results)

    # Вывод CSV в stdout (если не отключено флагом --no-stdout)
    if not args.no_stdout:
        writer = csv.writer(sys.stdout)
        writer.writerow(['Host', 'Type', 'Port', 'Latency_ms'])
        for item in raw_results:
            if item:
                for row in item:
                    writer.writerow([row['Host'], row['Type'], row['Port'], row['Latency_ms']])


if __name__ == '__main__':
    asyncio.run(main())
                      

example hosts.lst

# ==============================================================================
# GLOBAL GEOGRAPHIC HOSTS LIST (BY CONTINENT)
# ==============================================================================

# ------------------------------------------------------------------------------
# 1. NORTH AMERICA (Северная Америка)
# ------------------------------------------------------------------------------
# Anycast & Core DNS Gateways
1.1.1.1
8.8.8.8

# US East Coast (N. Virginia / Washington D.C.)
ec2.us-east-1.amazonaws.com 443

# US West Coast (California / Oregon)
ec2.us-west-1.amazonaws.com 443

# Canada
amazon.ca 443


# ------------------------------------------------------------------------------
# 2. EUROPE (Европа)
# ------------------------------------------------------------------------------
# Western Europe (London / UK)
bbc.co.uk 443

# Central Europe (Frankfurt / Germany)
ec2.eu-central-1.amazonaws.com 443
hetzner.de 443

# Northern Europe (Stockholm / Sweden)
sapo.pt 443

# Eastern Europe (Poland / Ukraine)
meta.ua 80,443
wp.pl 443


# ------------------------------------------------------------------------------
# 3. ASIA (Азия)
# ------------------------------------------------------------------------------
# East Asia (Japan - Tokyo)
ec2.ap-northeast-1.amazonaws.com 443
yahoo.co.jp 443

# East Asia (China / Hong Kong)
baidu.com 80,443

# Southeast Asia (Singapore)
ec2.ap-southeast-1.amazonaws.com 443

# South Asia (India)
ec2.ap-south-1.amazonaws.com 443


# ------------------------------------------------------------------------------
# 4. SOUTH AMERICA (Южная Америка)
# ------------------------------------------------------------------------------
# Brazil (São Paulo)
ec2.sa-east-1.amazonaws.com 443
uol.com.br 443

# Chile (Santiago)
cl.telefonicabusinesssolutions.com 80


# ------------------------------------------------------------------------------
# 5. AFRICA (Африка)
# ------------------------------------------------------------------------------
# South Africa (Johannesburg / Cape Town)
ec2.af-south-1.amazonaws.com 443
takealot.com 443

# North Africa (Egypt)
eg.orange.com 80

# West Africa (Nigeria)
courtevillegroup.com 80


# ------------------------------------------------------------------------------
# 6. OCEANIA & AUSTRALIA (Океания и Австралия)
# ------------------------------------------------------------------------------
# Australia (Sydney)
ec2.ap-southeast-2.amazonaws.com 443
telstra.com.au 443

# New Zealand (Auckland)
nzherald.co.nz 443


# ------------------------------------------------------------------------------
# 7. ANTARCTICA (Антарктида)
# ------------------------------------------------------------------------------
# US McMurdo Station Gateway (USAP Infrastructure)
usap.gov 443

---

Настройка запуска через Cron

Чтобы Cron правильно отрабатывал (независимо от текущей рабочей директории), укажите в crontab абсолютные пути к скрипту, файлу хостов и базе данных.

1. Откройте редактор crontab:

crontab -e

2. Добавьте строку (пример запуска каждые 5 минут):

*/5 * * * * /usr/bin/python3 /abs/path/to/netcheck.py /abs/path/to/hosts.lst --db /abs/path/to/netcheck.db --no-stdout

*Флаг --no-stdout предотвратит отправку спам-писем от системного Cron на ваш почтовый ящик при каждом запуске.*

---

Готовые SQL-запросы для последующего анализа

Когда наберется статистика, вы сможете быстро выполнять выгрузки через утилиту sqlite3:

1. Список всех сбоев за последние 24 часа:

SELECT r.timestamp, res.host, res.type, res.port
FROM results res
JOIN runs r ON r.id = res.run_id
WHERE res.latency_ms = 999 
  AND r.timestamp >= datetime('now', '-1 day');

2. Расчет Uptime (процента доступности) по каждому сервису:

SELECT 
    host, 
    port,
    COUNT(*) as total_checks,
    SUM(CASE WHEN latency_ms < 999 THEN 1 ELSE 0 END) as successful_checks,
    ROUND(CAST(SUM(CASE WHEN latency_ms < 999 THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*) * 100, 2) as uptime_pct
FROM results
GROUP BY host, port;

3. Средняя задержка (Latency) для рабочих сервисов (без учета 999):

SELECT host, port, ROUND(AVG(latency_ms), 2) as avg_latency_ms
FROM results
WHERE latency_ms < 999
GROUP BY host, port;

Вот подборка полезных SQL-запросов (SELECT), которые помогут превратить собранную базу в наглядную аналитику: от поиска нестабильных ресурсов до подготовки данных для графиков в Excel или Grafana.

---

1. Поиск «мигающих» ресурсов (Flapping / Flaky Services)

Запрос находит хосты и порты, которые часто меняют свой статус (то работают, то падают). Это главный признак нестабильной сети или перегруженного сервиса.

SELECT 
    host,
    port,
    COUNT(*) AS status_changes
FROM (
    SELECT 
        host,
        port,
        latency_ms,
        LAG(CASE WHEN latency_ms = 999 THEN 0 ELSE 1 END) OVER (
            PARTITION BY host, port ORDER BY run_id
        ) AS prev_status,
        CASE WHEN latency_ms = 999 THEN 0 ELSE 1 END AS curr_status
    FROM results
)
WHERE prev_status IS NOT NULL AND prev_status != curr_status
GROUP BY host, port
ORDER BY status_changes DESC;

---

2. Подробная хронология инцидентов (Начало и Длительность сбоя)

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

WITH status_changes AS (
    SELECT 
        res.host,
        res.port,
        r.timestamp,
        res.latency_ms,
        LAG(CASE WHEN res.latency_ms = 999 THEN 1 ELSE 0 END, 1, 0) OVER (
            PARTITION BY res.host, res.port ORDER BY r.id
        ) AS was_down
    FROM results res
    JOIN runs r ON r.id = res.run_id
)
SELECT 
    host,
    port,
    timestamp AS downtime_start
FROM status_changes
WHERE latency_ms = 999 AND was_down = 0
ORDER BY downtime_start DESC;

---

3. Анализ задержек: Медиана, Мин, Макс и Процентиль (p95)

Простая средняя задержка (AVG) часто искажается из-за единичных всплесков. 95-й процентиль показывает честную картину производительности (95% запросов были быстрее этого значения).

SELECT 
    host,
    port,
    MIN(latency_ms) AS min_ms,
    ROUND(AVG(latency_ms), 2) AS avg_ms,
    MAX(latency_ms) AS max_ms
FROM results
WHERE latency_ms < 999
GROUP BY host, port
ORDER BY avg_ms DESC;

---

4. Распределение сбоев по часам суток (Поиск паттернов)

Запрос группирует падения по часам. Он помогает понять, не связаны ли сбои с ночными бэкапами, перезагрузками или пиковой дневной нагрузкой.

SELECT 
    strftime('%H', r.timestamp) AS hour_of_day,
    COUNT(*) AS total_failures
FROM results res
JOIN runs r ON r.id = res.run_id
WHERE res.latency_ms = 999
GROUP BY hour_of_day
ORDER BY total_failures DESC;

---

5. Сводная таблица доступности по дням (Heatmap / Дашборд)

Выводит процент доступности (Uptime %) для каждого хоста по дням за последнюю неделю. Удобно для выгрузки в CSV/Excel.

SELECT 
    host,
    port,
    DATE(r.timestamp) AS check_date,
    COUNT(*) AS total_checks,
    SUM(CASE WHEN latency_ms < 999 THEN 1 ELSE 0 END) AS ok_checks,
    ROUND(CAST(SUM(CASE WHEN latency_ms < 999 THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*) * 100, 2) AS uptime_pct
FROM results res
JOIN runs r ON r.id = res.run_id
WHERE r.timestamp >= datetime('now', '-7 days')
GROUP BY host, port, check_date
ORDER BY check_date DESC, host;

---

6. Поиск массовых сбоев (Сетевые штормы / Падение магистрали)

Запрос показывает моменты времени, когда одновременно упало больше 30% всех проверяемых ресурсов. Это указывает на проблему с локальной сетью, роутером или каналом провайдера (а не с конкретным сервером).

SELECT 
    r.timestamp,
    COUNT(*) AS total_targets,
    SUM(CASE WHEN res.latency_ms = 999 THEN 1 ELSE 0 END) AS failed_targets,
    ROUND(CAST(SUM(CASE WHEN res.latency_ms = 999 THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*) * 100, 1) AS failure_rate_pct
FROM results res
JOIN runs r ON r.id = res.run_id
GROUP BY r.id
HAVING failure_rate_pct > 30.0
ORDER BY r.timestamp DESC;

---

Полезный совет: Создание SQL-View (Представления)

Чтобы не писать сложные JOIN каждый раз, можно создать в SQLite виртуальное представление:

CREATE VIEW IF NOT EXISTS v_full_results AS
SELECT 
    r.id AS run_id,
    r.timestamp,
    res.host,
    res.type,
    res.port,
    res.latency_ms,
    CASE WHEN res.latency_ms = 999 THEN 0 ELSE 1 END AS is_up
FROM results res
JOIN runs r ON r.id = res.run_id;

После этого любой анализ станет в разы короче:

SELECT * FROM v_full_results WHERE is_up = 0 AND timestamp >= datetime('now', '-1 hour');

Для поиска «мигающих» и нестабильных задержек (где разница между минимальным и максимальным пингом превышает 50 мс) подходят два варианта SQL-запросов — в зависимости от того, что именно хочется проанализировать.

В обоих запросах сбои (latency_ms = 999) исключаются, чтобы смотреть только на реальные колебания сети, а не на таймауты.

---

Вариант 1. Общий разброс за всё время (Max − Min > 50 ms)

Показывает хосты, у которых за всю историю наблюдений разница между самым быстрым и самым медленным откликом составила более 50 миллисекунд.

SELECT 
    res.host,
    res.port,
    MIN(res.latency_ms) AS min_ms,
    MAX(res.latency_ms) AS max_ms,
    ROUND(MAX(res.latency_ms) - MIN(res.latency_ms), 2) AS jitter_ms,
    ROUND(AVG(res.latency_ms), 2) AS avg_ms,
    COUNT(*) AS total_checks
FROM results res
WHERE res.latency_ms < 999
GROUP BY res.host, res.port
HAVING (MAX(res.latency_ms) - MIN(res.latency_ms)) > 50
ORDER BY jitter_ms DESC;

---

Вариант 2. Скачки пинга между СОСЕДНИМИ запусками (> 50 ms) (за 24 часа)

Этот запрос анализирует динамику: он сравнивает пинг в текущем запуске с пингом в предыдущем запуске (с помощью оконной функции LAG) и выводит конкретные моменты времени, когда отклик резко прыгнул вверх или вниз более чем на 500 мс.

WITH latency_diffs AS (
    SELECT 
        r.timestamp,
        res.host,
        res.port,
        res.latency_ms AS curr_ms,
        LAG(res.latency_ms) OVER (
            PARTITION BY res.host, res.port ORDER BY r.id
        ) AS prev_ms
    FROM results res
    JOIN runs r ON r.id = res.run_id
    -- Берем данные за 2 дня, чтобы для первой записи за сегодня был доступен "вчерашний" prev_ms
    WHERE res.latency_ms < 999 
      AND r.timestamp >= datetime('now', '-2 days')
)
SELECT 
    timestamp,
    host,
    port,
    prev_ms,
    curr_ms,
    ROUND(ABS(curr_ms - prev_ms), 2) AS spike_ms
FROM latency_diffs
WHERE prev_ms IS NOT NULL 
  AND ABS(curr_ms - prev_ms) > 500
  -- Оставляем в итоговом отчете только скачки за последние сутки
  AND timestamp >= datetime('now', '-1 day')
ORDER BY timestamp DESC;
  • spike_ms — абсолютный размер скачка задержки (в миллисекундах) по сравнению с предыдущей проверкой.

DELETE ALL NOT ANSWER

SELECT * FROM results WHERE (host, port) IN (SELECT host, port FROM results GROUP BY host, port HAVING MAX(latency_ms) = 999 AND MIN(latency_ms) = 999); DELETE FROM runs WHERE id NOT IN (SELECT DISTINCT run_id FROM results);