35. Диагностика сети. ping, traceroute, dig, curl, mtr

Введение: Почему диагностика критична для Go-бэкенда

В золотую эпоху лабораторий Bell Labs был сформулирован бессмертный Unix-принцип: создавайте маленькие, острые программы, каждая из которых решает ровно одну задачу, но решает её безупречно. В мире компьютерных сетей инструментами первостепенной важности стали утилиты ping, traceroute, dig, curl и mtr.

Для бэкенд-разработчика понимание работы сетей — это не просто умение выполнить ping. Это умение интерпретировать метрики, находить узкие места на стыке приложения и инфраструктуры и писать код, который корректно реагирует на сетевые аномалии. Инструменты диагностики (ping, traceroute, dig, curl, mtr) — это пользователи, которые манипулируют сырыми сокетами, настраивают опции IP-стека и отправляют пакеты через syscalls.

В Go эти операции часто скрыты за абстракциями net.Dial или http.Client, но когда производительность падает или соединения рвутся, понимание «под капотом» становится критичным. Мы разберем, как эти инструменты работают на уровне протоколов, какие системные вызовы они вызывают и как перенести их логику в production-ready Go-код.


ping: ICMP и сырые сокеты

ping измеряет задержку (RTT) и потерю пакетов между узлами, используя протокол ICMP (Internet Control Message Protocol). В IPv4 это тип 8 (Echo Request) и тип 0 (Echo Reply).

Словно гидролокатор подводной лодки, посылающий акустический импульс сквозь толщу океана, ping отправляет короткий пакет и засекает время до прихода отраженного эха.

Под капотом

ping работает через сырые сокеты (Raw Sockets). В отличие от SOCK_STREAM (TCP) или SOCK_DGRAM (UDP), сырой сокет позволяет программе самостоятельно формировать заголовки IP и ICMP.

Bash
# Пример создания сырого сокета в Linux
socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)

Для этого процессу требуются права CAP_NET_RAW (root) или соответствующий capability. При отправке пакета ядро автоматически вычисляет IP-хедер, инкрементирует идентификатор пакета и выставляет время жизни (TTL). На стороне ответа ядро перехватывает ICMP-пакет, проверяет валидность и возвращает его в пользовательское пространство.

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph SocketTypes ["Иерархия типов сокетов в Linux"]
        Stream["SOCK_STREAM (TCP): Ядро управляет окном, SYN/ACK, ретрансмиссией"]
        Dgram["SOCK_DGRAM (UDP): Приложение задает payload, ядро ставит UDP-хедер"]
        Raw["SOCK_RAW (ICMP / Raw IP): Приложение само формирует заголовки L3/L4"]
    end
  

Go-имплементация

Пакет net в стандартной библиотеке Go не поддерживает сырые сокеты напрямую (из соображений безопасности и переносимости). Для production-диагностики в Go используется golang.org/x/net/icmp:

Go
package main

import (
    "context"
    "fmt"
    "net"
    "os"
    "time"

    "golang.org/x/net/icmp"
    "golang.org/x/net/ipv4"
)

func pingHost(ctx context.Context, addr string) error {
    conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
    if err != nil {
        return fmt.Errorf("listen icmp: %w", err)
    }
    defer conn.Close()

    // Формируем Echo Request
    msg := &icmp.Message{
        Type: ipv4.ICMPTypeEcho,
        Code: 0,
        Body: &icmp.Echo{
            ID:   os.Getpid() & 0xffff,
            Seq:  1,
            Data: []byte("HELLO"),
        },
    }

    payload, err := msg.Marshal(nil)
    if err != nil {
        return fmt.Errorf("marshal icmp: %w", err)
    }

    // Отправляем
    dst, err := net.ResolveIPAddr("ip4", addr)
    if err != nil {
        return fmt.Errorf("resolve dst: %w", err)
    }

    if _, err := conn.WriteTo(payload, dst); err != nil {
        return fmt.Errorf("write icmp: %w", err)
    }

    // Ждем ответ с таймаутом
    deadline := time.Now().Add(2 * time.Second)
    conn.SetReadDeadline(deadline)
    reply := make([]byte, 1500)
    _, peer, err := conn.ReadFrom(reply)
    if err != nil {
        return fmt.Errorf("read icmp: %w", err)
    }

    fmt.Printf("Reply from %s: RTT OK\n", peer.String())
    return nil
}

traceroute: TTL и маршрутизация

traceroute выявляет путь пакета через сеть, манипулируя полем TTL (Time To Live) в IP-заголовке.

Представьте себе почтовую открытку, на которой написано: «Уничтожить после одной пересылки». Первое же почтовое отделение, принявшее открытку, гасит её и отправляет вам служебное уведомление: «Ваша открытка погибла на станции №1». Вы отправляете следующую открытку с лимитом в две пересылки — и получаете уведомление со станции №2. Шаг за шагом вы картографируете весь магистральный маршрут.

Механизм работы

  1. Клиент отправляет UDP-пакет (или ICMP Echo) с TTL=1.
  2. Первый маршрутизатор принимает пакет, уменьшает TTL до 0, отбрасывает его и отправляет обратно клиенту ICMP-сообщение Time Exceeded.
  3. Клиент повторяет процесс с TTL=2, TTL=3 и так далее, пока не достигнет целевого хоста.
  4. Целевой хост отвечает ICMP Port Unreachable (для UDP) или Echo Reply (для ICMP).
flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    Client["Клиент\nTTL=1"]:::entry -->|"Отправка"| R1["Маршрутизатор 1\nTTL -> 0"]
    R1 -->|"ICMP Time Exceeded"| Client
    Client["Клиент\nTTL=2"] -->|"Отправка"| R1
    R1 -->|"TTL -> 1"| R2["Маршрутизатор 2\nTTL -> 0"]
    R2 -->|"ICMP Time Exceeded"| Client
    Client["Клиент\nTTL=3"] -->|"Отправка"| R1
    R1 -->|"TTL -> 2"| R2
    R2 -->|"TTL -> 1"| Host["Целевой хост\nОтвет ICMP/UDP"]
    Host -->|"Reply"| Client
  

Go-имплементация

Для реализации traceroute в Go нужно использовать setsockopt для установки IP_TTL на обычном SOCK_DGRAM или сырой сокет. В чистом Go это делается через golang.org/x/net/ipv4 с передачей RawConn:

Go
// Установка TTL на уровне IP-стека
conn.SetControlMessage(ipv4.FlagTTL, true)
// В golang.org/x/net/icmp есть встроенная поддержка TTL-инкремента
// или использование raw socket через syscall.SetsockoptInt(conn.Fd(), syscall.IPPROTO_IP, syscall.IP_TTL, ttl)

dig: DNS-резолвинг и стратегии Go

dig запрашивает DNS-записи напрямую, минуя системный кеш. В Go DNS-резолвинг управляется пакетом net и конфигурацией net.Resolver.

Под капотом

  1. Клиент отправляет UDP-запрос на порт 53. Если ответ не помещается в UDP-датаграмму (512 байт без EDNS0; у Go с EDNS0 — 1232), сервер усекает ответ и ставит флаг TC, после чего клиент повторяет запрос по TCP. (Флаг DO — это совсем другое: «DNSSEC OK».)
  2. Резолвер проверяет /etc/resolv.conf, nsswitch.conf и системные кэши (systemd-resolved, dnsmasq).
  3. Стратегия go в Go — это чистый Go-резолвер, и на Linux он используется по умолчанию (стратегия cgo делегирует резолвинг libc через getaddrinfo; она нужна, например, для источников nsswitch вроде mdns, но добавляет зависимость от Cgo, а на macOS и Windows cgo-резолвер предпочитается по умолчанию).

Настройка в Go

Для production-систем часто требуется отключить Cgo-резолвер и использовать pure-Go DNS для предсказуемости и безопасности:

Go
package main

import (
    "context"
    "fmt"
    "net"
    "time"
)

func main() {
    // Используем pure-Go resolver, игнорируя resolv.conf
    resolver := net.Resolver{
        PreferGo: true,
        Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
            d := net.Dialer{Timeout: 2 * time.Second}
            return d.DialContext(ctx, "udp", "8.8.8.8:53")
        },
    }

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    ips, err := resolver.LookupIP(ctx, "ip4", "example.com")
    if err != nil {
        fmt.Printf("DNS failed: %v\n", err)
        return
    }
    for _, ip := range ips {
        fmt.Println(ip)
    }
}

curl: HTTP-транспорт и пулинг соединений

curl — это инструмент для отладки HTTP/HTTPS. В Go эквивалентом является http.Client с конфигурацией http.Transport.

Под капотом

curl управляет пулом соединений через keep-alive, устанавливает таймауты на connect, tls handshake и read/write. При превышении лимитов соединения закрываются, а новые открываются.

Production-ready HTTP Client в Go

Go
package main

import (
    "context"
    "fmt"
    "net"
    "net/http"
    "time"
)

func NewTransport() *http.Transport {
    return &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,

        // Таймауты критичны для предотвращения slowloris и утечки горутин
        TLSHandshakeTimeout:   10 * time.Second,
        ResponseHeaderTimeout: 20 * time.Second,
        ExpectContinueTimeout: 1 * time.Second,

        // DialContext для контроля сетевого стека
        DialContext: (&net.Dialer{
            Timeout:   5 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,

        // Force attempt HTTP/2
        ForceAttemptHTTP2: true,
    }
}

func main() {
    client := &http.Client{
        Transport: NewTransport(),
        Timeout:   30 * time.Second,
    }

    ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/health", nil)
    if err != nil {
        panic(err)
    }

    resp, err := client.Do(req)
    if err != nil {
        fmt.Printf("Request failed: %v\n", err)
        return
    }
    defer resp.Body.Close()
    fmt.Printf("Status: %s\n", resp.Status)
}

mtr: непрерывный мониторинг в продакшене

mtr (My Traceroute) объединяет ping и traceroute, непрерывно отправляя пакеты с инкрементируемым TTL и собирая статистику (min/avg/max/loss) для каждого узла в реальном времени.

Применение в Go

Для мониторинга сети в продакшене не стоит запускать внешние процессы. Вместо этого реализуем легкий probe:

Go
package main

import (
    "context"
    "fmt"
    "log"
    "net"
    "sync"
    "time"

    "golang.org/x/net/icmp"
    "golang.org/x/net/ipv4"
)

func runContinuousProbe(ctx context.Context, dest string, interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    var wg sync.WaitGroup
    defer wg.Wait()
    for {
        select {
        case <-ctx.Done():
            return // остановка по контексту; defer дождётся запущенных проб
        case <-ticker.C:
            wg.Add(1)
            go func() {
                defer wg.Done()
                if err := pingOnce(ctx, dest); err != nil {
                    log.Printf("probe failed: %v", err)
                }
            }()
        }
    }
}

func pingOnce(ctx context.Context, addr string) error {
    conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
    if err != nil {
        return err
    }
    defer conn.Close()

    dst, _ := net.ResolveIPAddr("ip4", addr)
    msg := &icmp.Message{Type: ipv4.ICMPTypeEcho, Code: 0, Body: &icmp.Echo{ID: 0, Seq: 1}}
    payload, _ := msg.Marshal(nil)

    start := time.Now()
    if _, err := conn.WriteTo(payload, dst); err != nil {
        return err
    }
    conn.SetReadDeadline(time.Now().Add(2 * time.Second))
    reply := make([]byte, 1500)
    _, _, err = conn.ReadFrom(reply)
    if err != nil {
        return fmt.Errorf("timeout after %v", time.Since(start))
    }
    fmt.Printf("RTT: %v for %s\n", time.Since(start), addr)
    return nil
}

Под капотом: Syscalls, сырые сокеты и планировщик Go

Инструменты диагностики наглядно демонстрируют взаимодействие пользовательского пространства с ядром Linux:

  1. Сырые сокеты vs Dgram: ping и traceroute (UDP mode) требуют манипуляции с IP-хедером. В Linux это делается через setsockopt(SOL_IP, IP_TTL, val) или IP_HDRINCL. Пакет net сам не выставляет IP_HDRINCL (причина не в «безопасности»: управлять опцией можно через SyscallConn или golang.org/x/net/ipv4.RawConn), поэтому для ICMP обычно используют golang.org/x/net/icmp.
  2. Планировщик и блокировки: Сокеты net.ListenIP/icmp.ListenPacket в Go неблокирующие: если горутина вызывает conn.ReadFrom() и пакет не приходит, она паркуется в netpoller, а поток M продолжает выполнять другие горутины. Блокирует поток только прямой вызов syscall.Read над блокирующим дескриптором, который вы создали сами.
  3. eBPF и современный стек: В современных ядрах (4.9+) сырые сокеты для мониторинга заменяются на eBPF (см. статью 36. eBPF и XDP для анализа и ускорения сетевого трафика). eBPF позволяет перехватывать пакеты в ядре без блокировки пользовательских тредов и без CAP_NET_RAW.

Итог

  1. ping использует ICMP и сырые сокеты (SOCK_RAW). В Go требует golang.org/x/net/icmp и понимания рисков блокировки M-thread.
  2. traceroute манипулирует полем IP_TTL через setsockopt. Для обхода фаерволов используется TCP SYN.
  3. dig демонстрирует важность net.Resolver и отключения Cgo-резолвера в изолированных средах. Всегда используйте context.WithTimeout.
  4. curl отражает архитектуру http.Transport. Таймауты (TLSHandshakeTimeout, ResponseHeaderTimeout) и пулинг соединений — обязательный минимум для production.
  5. mtr показывает паттерны потерь в реальном времени. В Go реализуется через тикер + асинхронные probe-горутины.
  6. Системный уровень: Сырые сокеты требуют привилегий (root или CAP_NET_RAW); при использовании net/x/net/icmp они работают через netpoller и треды не блокируют. Дополнительная альтернатива — eBPF.

Мы завершили обзор классических инструментов диагностики. В следующей статье мы перейдем к современному методу перехвата и анализа трафика без блокировки тредов: 36. eBPF и XDP для анализа и ускорения сетевого трафика.