Bash для сетевого инженера: от автоматизации конфигов до систем мониторинга

Курс ориентирован на углубленное изучение Bash-скриптинга в контексте сетевого администрирования и DevOps. Вы научитесь автоматизировать настройку оборудования, парсить сложные выводы утилит и создавать отказоустойчивые системы мониторинга сетевой инфраструктуры.

Специфика Bash для сетевого инженера

Специфика Bash для сетевого инженера

Представьте: три часа ночи, падает магистральный маршрутизатор. Вы подключаетесь к транзитному Linux-серверу по SSH. На сервере нет ни Python с библиотекой Netmiko, ни Ansible с готовыми плейбуками. У вас есть только голая консоль, стандартные сетевые утилиты и Bash. Способность быстро связать вывод ip route, ping и grep в одну логическую цепочку — это то, что отличает инженера от простого оператора консоли.

Bash в 2026 году жив не потому, что это лучший язык программирования. Он жив потому, что это идеальный клей. Понимание того, как работает этот клей, определяет, будете ли вы писать хрупкие скрипты, которые ломаются от лишнего пробела, или надежные системы мониторинга.

Философия «оркестратора»

Главная ошибка начинающих — пытаться использовать Bash как полноценный язык программирования, реализуя в нем сложную математику или обработку структур данных.

Специфика Bash заключается в том, что он сам почти ничего не делает. Его задача — запускать другие программы (утилиты), передавать данные между ними и реагировать на их завершение.

Вместо того чтобы писать цикл для резолва ста доменных имен средствами самого языка, в Bash вы просто вызываете утилиту dig или host, передавая ей список. Ваша программа на Bash — это дирижер, а утилиты (ping, curl, nc, ip) — музыканты.

Конвейер (Pipeline): патч-корды для данных

В сетях мы соединяем порт коммутатора с портом маршрутизатора патч-кордом. В Bash мы соединяем стандартный вывод (stdout) одной программы со стандартным вводом (stdin) другой с помощью конвейера — символа | (pipe).

Для сетевого инженера конвейер — это главный инструмент фильтрации шума. Сетевое оборудование и утилиты обычно вываливают на экран «простыни» текста.

Рассмотрим классическую задачу: найти MAC-адрес конкретного активного соседа. Вместо того чтобы читать всю ARP-таблицу глазами, мы строим конвейер:

ip neigh | grep REACHABLE | awk '{print $1, $5}'

  1. ip neigh отдает сырой список всех соседей.
  2. grep REACHABLE отсеивает только тех, кто доступен прямо сейчас.
  3. awk '{print $1, $5}' вырезает из оставшихся строк только IP-адрес (первая колонка) и MAC-адрес (пятая колонка).

Вам не нужно сохранять промежуточные результаты в переменные или временные файлы. Данные текут сквозь утилиты непрерывным потоком, как трафик через цепочку firewall-правил.

Коды возврата: скрытый язык успеха

Как скрипту понять, что удаленный сервер доступен?

Инстинкт подсказывает: нужно запустить ping, прочитать его вывод и поискать там фразу вроде "0% packet loss". Это плохой путь. Вывод может измениться в другой версии утилиты, язык системы может оказаться русским (и тогда там будет "0% потерь"), а скрипт сломается.

Специфика Bash в том, что он опирается на коды возврата (exit status). Любая программа в Linux после своего завершения оставляет системе невидимое число от 0 до 255.

  • 0 — программа отработала успешно.
  • Любое другое число (1-255) — произошла ошибка.

Bash автоматически сохраняет код возврата последней выполненной команды в специальную переменную $?.

Если нам нужно просто узнать, жив ли хост, мы отправляем один пакет, а весь текстовый вывод выбрасываем в «черную дыру» (/dev/null), чтобы он не мусорил на экране:

ping -c 1 192.168.1.1 > /dev/null

Сразу после этого мы проверяем код возврата. Если $? равен нулю — хост ответил. Если единице — недоступен. Нам вообще не нужно парсить текст!

Этот принцип лежит в основе всей автоматизации мониторинга. Утилита nc (netcat) вернет 0, если TCP-порт открыт. Утилита curl вернет 0, если HTTP-запрос прошел успешно. Ваша задача как инженера — проверять эти нули, а не читать логи.

Текст против Объектов

Последняя, но самая важная особенность Bash, которую нужно принять: всё есть текст.

Если вы работали с Python (библиотеки отдают словари) или PowerShell (отдает .NET объекты), вы привыкли, что у интерфейса есть свойство interface.mtu или interface.status.

В Bash вы получаете монолитный кусок текста. У текста нет свойств. Чтобы достать значение MTU, вам придется использовать инструменты потоковой обработки текста (регулярные выражения, grep, sed, awk), чтобы буквально «вырезать» нужные цифры из строки.

Инструмент Парадигма данных Идеально для...
Bash Неструктурированный текст (строки) Быстрых проверок, склеивания CLI-утилит, легковесных демонов мониторинга.
Python Объекты, словари, списки Сложной логики, работы с REST API (JSON), долгосрочных проектов.
Ansible Декларативные YAML-модели Массовой конфигурации сотен однотипных устройств.

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

Переменные, массивы и структуры данных в сетевых сценариях

Переменные, массивы и структуры данных в сетевых сценариях

В конвейерах, которые мы строили ранее, данные живут ровно до тех пор, пока команда не завершит работу. Мы научились тихо проверять доступность узла и получать код возврата, но в реальных системах мониторинга этого мало. Если задержка ответа t>100t > 100 мс, нам нужно сгенерировать алерт, прикрепив к нему IP-адрес узла, время отклика и имя интерфейса. Чтобы оперировать этими сущностями, их нужно извлечь из потока текста и сохранить в памяти скрипта.

Bash не имеет строгой типизации и сложных объектов, как Python. Вся память скрипта строится на строках и массивах строк.

Переменные и подстановка команд

В Bash переменная создается в момент присваивания ей значения. Главное и самое неочевидное правило для новичков: вокруг знака равенства не должно быть пробелов.

# Правильно
TARGET_IP="10.15.0.1"
MAX_LATENCY=100

# Ошибка: Bash попытается выполнить команду 'TARGET_IP' с аргументами '=' и '10.15.0.1'
TARGET_IP = "10.15.0.1"

Для обращения к значению переменной используется символ доллара: echo $TARGET_IP.

Но жестко задавать данные в коде скрипта (хардкодить) приходится редко. Гораздо чаще сетевому инженеру нужно динамически получить значение от системы и сохранить его. Для этого используется подстановка команд (command substitution) — конструкция $().

Она работает так: Bash выполняет команду внутри скобок, берет её стандартный вывод (то, что обычно печатается на экран) и подставляет это как текст прямо в то место, где был вызван $().

Например, нам нужно сохранить IP-адрес шлюза по умолчанию, чтобы использовать его в дальнейших проверках:

# Выполняем конвейер и сохраняем результат в переменную
DEFAULT_GW=$(ip route | grep default | awk '{print $3}')

echo "Шлюз по умолчанию: $DEFAULT_GW"

Здесь конвейер внутри $() отфильтровал таблицу маршрутизации, извлек нужный IP-адрес, и этот адрес стал значением переменной DEFAULT_GW.

Индексные массивы: списки узлов

Обычная переменная хранит одно значение. Но что делать, если нужно опросить десяток DNS-серверов или проверить статус нескольких BGP-пиров? Хранить их в одной строке через пробел неудобно. Для списков используются индексные массивы.

В индексном массиве элементы пронумерованы по порядку, начиная с нуля. Массив создается путем перечисления элементов в круглых скобках:

DNS_SERVERS=("8.8.8.8" "1.1.1.1" "9.9.9.9")

Чтобы извлечь конкретный элемент, используются фигурные скобки и индекс:

  • ${DNS_SERVERS[0]} вернет 8.8.8.8.
  • ${DNS_SERVERS[1]} вернет 1.1.1.1.

Особый синтаксис ${DNS_SERVERS[@]} позволяет обратиться сразу ко всем элементам массива. Это идеальная подготовка для циклов, которые мы будем использовать для массового обхода хостов.

Если в процессе работы скрипта нужно добавить новый узел в уже существующий список, используется оператор +=:

DNS_SERVERS+=("208.67.222.222")

Ассоциативные массивы: словари для сетевых метрик

Индексные массивы отлично подходят для простых очередей задач. Но в сетевой инженерии данные часто имеют парную структуру: «интерфейс — MAC-адрес», «IP-адрес — статус», «VLAN — название».

Искать данные по числовому индексу (например, «какой статус у интерфейса под номером 3?») неудобно. Гораздо логичнее спросить: «какой статус у интерфейса eth0?». Для этого в Bash (начиная с версии 4.0) существуют ассоциативные массивы (в других языках их называют словарями или хэш-таблицами).

В отличие от индексных массивов, ассоциативные массивы обязательно нужно предварительно объявить с помощью команды declare -A (заглавная A).

# Явное объявление ассоциативного массива
declare -A PORT_SPEED

# Заполнение данными (ключ указывается в квадратных скобках)
PORT_SPEED["eth0"]="1000BaseT"
PORT_SPEED["eth1"]="10Gbase-SR"
PORT_SPEED["enp3s0"]="100BaseT"

# Получение значения по ключу
echo "Скорость на eth1: ${PORT_SPEED["eth1"]}"

Разница между двумя типами массивов принципиальна для проектирования скриптов.

Характеристика Индексный массив Ассоциативный массив
Ключ (индекс) Только целое число (0,1,2...0, 1, 2...) Любая строка (IP, имя интерфейса)
Объявление Неявно (создается при присваивании) Строго через declare -A
Сценарий в сети Очередь IP-адресов для пинга Хранение состояния конкретного порта

Синтез: сбор метрик в структуру

Свяжем подстановку команд и ассоциативные массивы воедино. Представим, что скрипт мониторинга должен зафиксировать текущие MAC-адреса для двух критичных интерфейсов, чтобы позже проверить, не изменились ли они (защита от ARP-spoofing).

declare -A MAC_TABLE

# Динамически получаем MAC-адреса и сохраняем их по ключу-имени интерфейса
MAC_TABLE["eth0"]=$(ip link show eth0 | awk '/ether/ {print $2}')
MAC_TABLE["eth1"]=$(ip link show eth1 | awk '/ether/ {print $2}')

# Теперь у нас есть структурированный слепок состояния
echo "Слепок памяти: eth0 имеет MAC ${MAC_TABLE["eth0"]}"

Мы перешли от простого выброса текста на экран к созданию структурированной модели данных в памяти скрипта. Теперь скрипт «знает» состояние сети. Следующий шаг — научить его принимать решения на основе этих данных.

Условные операторы и проверка доступности хостов

Условные операторы и проверка доступности хостов

В прошлой главе мы научились извлекать IP-адреса шлюзов из вывода системных утилит и аккуратно складывать их в ассоциативные массивы. Но собранные данные сами по себе мертвы. Скрипт автоматизации становится по-настоящему полезным только тогда, когда обретает способность принимать решения: переключить маршрут, если основной канал упал, или отправить алерт, если BGP-сессия разорвалась.

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

Анатомия ветвления: как if понимает сетевые команды

В большинстве языков программирования (например, в Python) оператор if ожидает на вход логическое значение: True или False. Из-за этого начинающие писать на Bash часто пытаются сначала выполнить команду, сохранить ее результат, а потом сравнивать его с чем-то.

В Bash парадигма иная. if работает напрямую с кодами возврата, о которых мы говорили в первой главе. Ему не нужны промежуточные переменные.

В Bash оператор if принимает решение, просто запуская команду, написанную сразу после него. Если команда завершилась успешно (код 0) — выполняется блок then. Если произошла ошибка (любой не-нулевой код) — блок else.

Посмотрим на классическую задачу — проверку доступности DNS-сервера. Мы добавим к нашему «тихому» пингу флаг -W 1, чтобы скрипт не зависал надолго, ожидая ответа от мертвого хоста (таймаут 1 секунда):

if ping -c 1 -W 1 8.8.8.8 > /dev/null; then
    echo "Primary DNS is UP. Routing is optimal."
else
    echo "Primary DNS is DOWN. Initiating failover..."
fi

Здесь if сам запускает ping. Нам не нужно парсить вывод утилиты или вручную проверять статус завершения — Bash делает это бесшовно. Это идеальный паттерн для любых сетевых проверок: от curl до ssh.

Проверка значений: конструкция [[ ]]

Запуск команд — это отлично, но что делать, если нам нужно проверить значение переменной, которую мы сформировали ранее? Например, мы извлекли текущий MTU интерфейса с помощью подстановки команд $() и хотим узнать, равен ли он 1500.

Для сравнения строк и чисел в Bash используется встроенная конструкция [[ ]]. Внутри этих двойных квадратных скобок работают специальные операторы сравнения.

Важное правило Bash, которое часто сбивает с толку: числа и строки сравниваются разными операторами.

Тип данных Равно Не равно Больше Меньше
Строки == != > <
Числа -eq (equal) -ne (not equal) -gt (greater) -lt (less)

Представим математическую логику срабатывания алерта: тревога поднимается, если текущее значение метрики MM превышает заданный порог TmaxT_{max}, то есть при условии M>TmaxM > T_{max}. В контексте сети MM может быть количеством потерянных пакетов, а Tmax=5T_{max} = 5. Если потерь больше пяти — канал деградировал.

В Bash это математическое неравенство запишется с использованием числового оператора -gt:

PACKET_LOSS=12
MAX_LOSS=5

if [[ $PACKET_LOSS -gt $MAX_LOSS ]]; then
    echo "Warning: Packet loss is $PACKET_LOSS%. Link degraded."
fi

А вот пример со строками. Допустим, мы сохранили состояние интерфейса в переменную и хотим убедиться, что он административно поднят:

IFACE_STATE="DOWN"

if [[ $IFACE_STATE == "UP" ]]; then
    echo "Interface is ready for traffic."
elif [[ $IFACE_STATE == "DOWN" ]]; then
    echo "Interface is administratively down."
else
    echo "Unknown state."
fi

Примечание: всегда используйте двойные скобки [[ ]], а не одинарные [ ]. Двойные скобки — это современный синтаксис Bash, который защищает от ошибок, если переменная окажется пустой или будет содержать пробелы.

Логическое «короткое замыкание» (Short-circuit)

Сетевые инженеры любят лаконичность. Писать конструкцию if-then-fi для каждого чиха — значит раздувать код. Когда нужно выполнить всего одно простое действие в зависимости от успеха или неудачи команды, Bash предлагает элегантный механизм логических операторов && (И) и || (ИЛИ).

Этот механизм называется «коротким замыканием». Интерпретатор читает команду слева направо и останавливается, как только общий результат становится очевиден.

Оператор && (Выполнить, если успех)

Правая часть выполнится только в том случае, если левая завершилась без ошибок.

ping -c 1 -W 1 192.168.1.1 > /dev/null && echo "Gateway reachable"

Если пинг не прошел, Bash даже не попытается выполнить echo, потому что логическое «И» уже обречено на провал.

Оператор || (Выполнить, если ошибка)

Правая часть выполнится только если левая завершилась с ошибкой. Это идеальный инструмент для обработки отказов (fallback).

ping -c 1 -W 1 10.0.0.1 > /dev/null || echo "ALARM: Uplink 1 is DEAD!"

Их можно комбинировать в цепочки, создавая компактные тернарные операторы:

ping -c 1 -W 1 8.8.4.4 > /dev/null && echo "UP" || echo "DOWN"

Практика: Скрипт выбора активного аплинка

Соберем изученное в единый сценарий. Представьте, что у нас есть два VPN-туннеля до центрального офиса. Нам нужно определить, какой из них сейчас жив, и записать его IP в переменную ACTIVE_UPLINK для дальнейшей маршрутизации. Если мертвы оба — скрипт должен завершить работу с критической ошибкой.

#!/bin/bash

UPLINK_A="10.0.0.1"
UPLINK_B="10.0.0.2"
ACTIVE_UPLINK=""

# Проверяем первый канал
if ping -c 1 -W 1 $UPLINK_A > /dev/null; then
    ACTIVE_UPLINK=$UPLINK_A
    echo "Using primary uplink: $ACTIVE_UPLINK"
# Если первый лежит, проверяем второй
elif ping -c 1 -W 1 $UPLINK_B > /dev/null; then
    ACTIVE_UPLINK=$UPLINK_B
    echo "Primary is down. Using backup uplink: $ACTIVE_UPLINK"
# Если лежат оба
else
    echo "CRITICAL: Both uplinks are down! Network isolated."
    exit 1
fi

# Здесь могла бы быть логика перестроения маршрутов,
# использующая найденный $ACTIVE_UPLINK

В этом скрипте мы объединили переменные, проверку доступности «на лету» через if и логику ветвления. Мы научили скрипт адаптироваться к состоянию сети.

Однако сейчас мы жестко прописали адреса в коде и проверили всего два хоста. А что, если у нас в ассоциативном массиве лежит 50 коммутаторов доступа, и нам нужно проверить каждый? Писать 50 блоков if — путь в никуда. Для массовой обработки данных нам потребуются циклы, к которым мы перейдем в следующей главе.

Циклы для обхода сетевых диапазонов и подсетей

Циклы для обхода сетевых диапазонов и подсетей

В прошлых главах мы научились точечно проверять доступность узлов и принимать решения с помощью условных конструкций. Но что если перед вами стоит задача проверить не один сервер, а ассоциативный массив из 50 коммутаторов ядра? Или просканировать всю подсеть на наличие «живых» хостов? Писать десятки блоков if вручную — путь в никуда. Масштабирование автоматизации начинается там, где появляются циклы.

Цикл позволяет взять уже знакомую нам логику проверки одного узла и применить её к целому списку, массиву или диапазону адресов.

Обход массивов: цикл for

Самый частый сценарий в сетевой автоматизации — перебор заранее известного списка устройств. Для этого используется цикл for. Он берет набор элементов и по очереди подставляет каждый из них во временную переменную, выполняя тело цикла.

Вспомним ассоциативные массивы. Допустим, у нас есть словарь, где ключи — это имена коммутаторов, а значения — их IP-адреса управления. Нам нужно пройтись по всем ключам, извлечь адреса и проверить их статус.

declare -A CORE_SWITCHES=(
    ["sw-core-01"]="10.0.1.11"
    ["sw-core-02"]="10.0.1.12"
    ["sw-dist-01"]="10.0.2.11"
)

# Синтаксис ${!ARRAY[@]} возвращает список всех ключей массива
for switch_name in "${!CORE_SWITCHES[@]}"; do
    ip_addr="${CORE_SWITCHES[$switch_name]}"

    # Используем короткое замыкание для вывода статуса
    ping -c 1 -W 1 "$ip_addr" > /dev/null && echo "$switch_name ($ip_addr) is UP" || echo "$switch_name ($ip_addr) is DOWN"
done

Здесь переменная switch_name на каждой итерации принимает новое значение ключа. Этот подход позволяет отвязать логику скрипта от конкретного количества оборудования: добавили в массив еще 10 коммутаторов — скрипт обработает их без единого изменения в коде цикла.

Генерация диапазонов: сканирование подсетей

Часто список IP-адресов не задан жестко, а представляет собой непрерывный диапазон внутри подсети. Чтобы понять, сколько адресов нам нужно перебрать, вспомним базовую формулу расчета количества доступных хостов:

N=232M2N = 2^{32 - M} - 2

Где:

  • NN — количество IP-адресов, доступных для назначения устройствам.
  • MM — префикс маски подсети (например, 24 для маски 255.255.255.0).
  • Вычитание двойки убирает адрес самой сети и широковещательный (broadcast) адрес.

Практический пример: для стандартной подсети /24 префикс M=24M = 24. Подставляем в формулу: N=232242=282=2562=254N = 2^{32 - 24} - 2 = 2^8 - 2 = 256 - 2 = 254 хоста.

Чтобы сгенерировать последовательность чисел от 1 до 254 в Bash, используется механизм brace expansion (генерация последовательностей) с синтаксисом {начало..конец}.

Напишем скрипт для базового пинг-сканирования подсети 10.10.5.0/24:

SUBNET="10.10.5"

echo "Начинаем сканирование сети $SUBNET.0/24..."

for octet in {1..254}; do
    target_ip="$SUBNET.$octet"
    ping -c 1 -W 1 "$target_ip" > /dev/null && echo "Хост $target_ip активен"
done

Интерпретатор Bash разворачивает конструкцию {1..254} в строку 1 2 3 ... 254 еще до того, как цикл начнет выполняться. Это делает код лаконичным и избавляет от необходимости писать сложные математические счетчики.

Построчное чтение файлов: цикл while read

Списки оборудования не всегда хранятся внутри самого скрипта. В реальной практике сетевой инженер выгружает инвентарный список из IPAM-системы (например, NetBox) в виде обычного текстового файла.

Использовать for для чтения файлов — плохая практика, так как for разбивает текст не только по строкам, но и по пробелам. Для корректной обработки файлов используется связка цикла while и встроенной команды read.

Построчное чтение (while read) — паттерн, при котором текстовый поток передается на вход цикла, и команда read безопасно считывает его строго строка за строкой, сохраняя пробелы внутри строк.

Допустим, у нас есть файл inventory.txt со списком маршрутизаторов:

192.168.100.1    router-hq-main
192.168.100.2    router-hq-backup
10.50.0.1        router-branch-kzn

Скрипт для чтения этого файла:

while read -r ip hostname; do
    # Пропускаем пустые строки
    [[ -z "$ip" ]] && continue

    echo "Проверка узла: $hostname по адресу $ip"
    # Здесь могла бы быть логика подключения
done < inventory.txt

Обратите внимание на конструкцию < inventory.txt в самом конце. Мы перенаправляем содержимое файла на стандартный ввод (stdin) всего цикла. Команда read -r ip hostname автоматически разбивает каждую строку на два слова и помещает их в соответствующие переменные.

Сравнение подходов к итерации

Инструмент Источник данных Поведение Когда использовать
for Массивы, списки слов, {1..10} Разбивает по пробелам и переносам строк Перебор массивов, генерация IP-адресов по октетам
while read Текстовые файлы, вывод других команд (|) Читает строго по одной строке за раз Обработка логов, чтение CSV-файлов, парсинг выгрузок

Управление потоком: break и continue

Иногда нам не нужно выполнять цикл до самого конца или требуется пропустить определенные элементы. Для этого существуют операторы управления потоком.

continue — немедленно прерывает текущую итерацию и переходит к следующему элементу в цикле.

Пример: мы сканируем подсеть, но точно знаем, что адреса .1 (шлюз) и .2 (резервный шлюз) всегда активны, и пинговать их нет смысла.

for octet in {1..254}; do
    if [[ $octet -eq 1 ]] || [[ $octet -eq 2 ]]; then
        continue # Пропускаем шлюзы
    fi

    ping -c 1 -W 1 "192.168.20.$octet" > /dev/null && echo "Нашли устройство: 192.168.20.$octet"
done

break — полностью останавливает работу всего цикла, даже если в списке еще остались необработанные элементы.

Пример: нам нужно найти первый свободный IP-адрес в пуле для выделения новой виртуальной машине. Как только адрес найден, продолжать сканирование бессмысленно.

FREE_IP=""

for octet in {100..200}; do
    ip="172.16.0.$octet"

    # Если пинг НЕ прошел (код возврата не 0)
    if ! ping -c 1 -W 1 "$ip" > /dev/null; then
        FREE_IP="$ip"
        echo "Найден первый свободный адрес: $FREE_IP"
        break # Останавливаем цикл
    fi
done

echo "Выделяем адрес $FREE_IP для новой ВМ..."

Сквозной пример: автоматизация проверки пулов

Соберем изученные концепции воедино. Напишем скрипт, который читает список подсетей из файла, генерирует для каждой из них диапазон адресов и ищет активные хосты, пропуская адреса шлюзов.

# Предполагаем, что файл subnets.txt содержит строки вида "10.20.30" (без последнего октета)
while read -r subnet; do
    echo "--- Анализ подсети $subnet.0/24 ---"

    for host in {1..254}; do
        # Пропускаем первые 5 адресов, зарезервированные под инфраструктуру
        [[ $host -le 5 ]] && continue

        target="$subnet.$host"
        ping -c 1 -W 1 "$target" > /dev/null && echo "[+] Активен: $target"
    done
done < subnets.txt

Этот скрипт демонстрирует вложенные циклы: while отвечает за перебор крупных блоков (подсетей), а for детализирует работу внутри каждого блока (перебор хостов).

Однако, если вы запустите этот скрипт для нескольких подсетей /24, вы заметите проблему: он работает катастрофически медленно. Пинг каждого из 254 адресов занимает около секунды (по таймауту, если хост недоступен). Последовательный обход одной подсети займет более 4 минут. Циклы отлично подходят для логики, но для сетевого сканирования нам потребуется выполнять задачи параллельно — эту проблему мы решим в следующих главах.

Функции и модульность в скриптах автоматизации

Функции и модульность в скриптах автоматизации

Представьте скрипт мониторинга на 500 строк, который обходит ядро сети, уровень распределения и сотню коммутаторов доступа. В нем 40 раз встречается конструкция проверки доступности с помощью ping, и вдруг служба безопасности требует изменить таймаут проверки с одной секунды на три. Вам придется найти и вручную исправить все 40 вхождений. Опечатка в одном из них — и логика мониторинга сломается. Чтобы не превращать код в хрупкую «простыню» из скопированных блоков, в Bash применяются функции и модульное разделение.

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

Анатомия функции в Bash

В отличие от языков вроде Python, в Bash функции больше похожи на миниатюрные самостоятельные скрипты внутри основного сценария.

Синтаксис объявления минималистичен. Ключевое слово function использовать можно, но для совместимости со строгими POSIX-стандартами сетевых ОС лучше обходиться без него:

check_gateway() {
    ping -c 1 -W 1 192.168.1.1 > /dev/null
    if [[ $? -eq 0 ]]; then
        echo "Gateway is UP"
    else
        echo "Gateway is DOWN"
    fi
}

Вызов функции происходит точно так же, как вызов любой системной команды — просто по имени, без круглых скобок:

check_gateway

Позиционные параметры: как передавать данные

Вы наверняка заметили, что круглые скобки при объявлении check_gateway() пусты. В Bash невозможно объявить именованные аргументы внутри этих скобок. Вместо этого функция принимает данные через позиционные параметры — встроенные переменные $1, $2, $3 и так далее.

Все переданные аргументы вместе доступны в переменной $@.

Сделаем нашу функцию универсальной, чтобы она принимала IP-адрес целевого узла и допустимый процент потерь:

check_node() {
    # $1 — IP-адрес
    # $2 — допустимый процент потерь (максимум)

    echo "Проверяем узел $1 с порогом потерь $2%..."
    # Здесь будет логика проверки
}

# Вызов функции с передачей двух аргументов
check_node 10.15.0.25 10

Позиционные параметры внутри функции перекрывают параметры самого скрипта. Если вы запустили скрипт как ./monitor.sh 192.168.1.1, то глобальный $1 равен 192.168.1.1. Но внутри функции check_node 10.0.0.5 локальный $1 будет равен 10.0.0.5.

Локальная область видимости: защита от побочных эффектов

По умолчанию все переменные в Bash — глобальные. Это самая частая причина трудноуловимых багов в сетевых скриптах автоматизации.

Рассмотрим классическую ошибку. У нас есть цикл for, обходящий подсеть, и внутри вызывается функция:

# АНТИПАТТЕРН: Глобальные переменные ломают логику

connect_device() {
    target=$1
    echo "Подключение к $target..."
    # Имитация работы
}

for target in 10.0.0.1 10.0.0.2; do
    connect_device $target
    echo "Обработан хост: $target"
done

Хотя в этом простом примере цикл отработает визуально корректно (так как переменная перезаписывается тем же значением), внутри функции переменная target изменяется глобально. В сложных сценариях с вложенными циклами (например, while) это приводит к бесконечным циклам или пропуску устройств.

Правило: всегда используйте ключевое слово local для переменных внутри функций. Оно изолирует данные.

# ПРАВИЛЬНО: Использование local

connect_device() {
    local target_ip=$1
    echo "Подключение к $target_ip..."
}

Два способа вернуть результат

Функция в Bash не может вернуть массив или сложный объект привычным оператором return. У нас есть два принципиально разных механизма возврата, которые зависят от того, что именно вы хотите получить: статус или данные.

Задача Механизм Как получить результат
Сообщить об успехе/ошибке return 0 (успех) или return 1 (ошибка) Проверить $? или использовать короткое замыкание && или ||
Вернуть текстовые данные (IP, MAC, конфиг) echo "данные" Использовать подстановку команд: RESULT=$(my_func)

Способ 1: Возврат статуса (Код возврата)

Идеально подходит для проверок (условий). Например, нам нужно убедиться, что круговая задержка не превышает критический порог. Допустим, по SLA задержка RTT50RTT \leq 50 мс считается нормой, а если RTT>50RTT > 50 мс, канал считается деградировавшим.

is_latency_acceptable() {
    local ip=$1
    local max_rtt=$2

    # Имитируем получение RTT (в следующих главах научимся парсить реальный вывод)
    local current_rtt=30

    if [[ $current_rtt -le $max_rtt ]]; then
        return 0 # Успех (True)
    else
        return 1 # Ошибка (False)
    fi
}

# Использование функции как логического условия
if is_latency_acceptable 10.20.30.40 50; then
    echo "Канал в норме."
fi

Способ 2: Возврат данных (Подстановка команд)

Если функция должна сгенерировать конфигурацию или извлечь конкретный IP-адрес, мы выводим текст в стандартный поток (stdout), а при вызове перехватываем его через $().

generate_bgp_neighbor() {
    local as_num=$1
    local neighbor_ip=$2

    echo "router bgp $as_num"
    echo " neighbor $neighbor_ip remote-as $as_num"
    echo " neighbor $neighbor_ip update-source Loopback0"
}

# Перехватываем вывод функции в переменную
BGP_CONFIG=$(generate_bgp_neighbor 65001 192.168.100.1)

Модульность: команда source

Когда функций становится много (проверка пинга, проверка SSH-доступа, расчет маски подсети), скрипт разрастается. Логичный шаг — вынести все вспомогательные функции в отдельный файл, создав собственную библиотеку.

Создадим файл net_lib.sh:

# Файл: net_lib.sh
# Библиотека сетевых функций

check_ping() {
    local ip=$1
    ping -c 2 -W 1 "$ip" > /dev/null 2>&1
    return $?
}

get_vendor_by_mac() {
    local mac=$1
    # Упрощенная заглушка для примера
    if [[ $mac == "00:1B:63"* ]]; then
        echo "Cisco"
    else
        echo "Unknown"
    fi
}

Теперь в основном скрипте мониторинга нам не нужно дублировать этот код. Мы подключаем библиотеку с помощью встроенной команды source (или её синонима — точки .). Эта команда считывает файл и выполняет его в текущем окружении, делая все функции доступными.

Соберем воедино знания из предыдущей главы (цикл while read) и новые концепции:

# Файл: core_monitor.sh

# 1. Подключаем библиотеку функций
source ./net_lib.sh

# 2. Читаем инвентарный файл построчно
while read -r device_ip device_mac; do

    # Пропускаем пустые строки
    [[ -z "$device_ip" ]] && continue

    # 3. Вызываем функцию проверки статуса (возврат кода)
    if check_ping "$device_ip"; then
        # 4. Вызываем функцию получения данных (перехват вывода)
        vendor=$(get_vendor_by_mac "$device_mac")
        echo "Узел $device_ip доступен. Оборудование: $vendor"
    else
        echo "ВНИМАНИЕ: Узел $device_ip недоступен!"
    fi

done < inventory.txt

Такой подход меняет архитектуру ваших решений. Основной скрипт превращается в высокоуровневый оркестратор: он читает конфиги и принимает решения, а вся грязная работа с утилитами надежно спрятана в изолированных функциях библиотеки.

Парсинг вывода сетевых утилит с помощью grep и регулярных выражений

Парсинг вывода сетевых утилит с помощью grep и регулярных выражений

В предыдущих главах мы научились строить логику скриптов вокруг кодов возврата. Если ping завершился с кодом 0 — узел жив, если с 1 — недоступен. Но в реальной сети понятие «доступности» не бинарно. Что, если узел отвечает, но задержка выросла с нормальных 10 миллисекунд до катастрофических 500? Код возврата по-прежнему будет нулевым, но для сетевых сервисов это равносильно аварии. Нам нужно научиться извлекать конкретные числа из текстового вывода утилит.

Сетевое оборудование и системные команды Linux создавались для того, чтобы их читал человек. Их вывод полон пробелов, поясняющих слов и декоративных символов. Чтобы превратить этот текст в строгие данные для автоматизации, нам потребуется механизм поиска по шаблонам — регулярные выражения.

От фиксированных строк к шаблонам: ERE против BRE

Вы уже знакомы с утилитой grep для фильтрации строк. Команда grep "REACHABLE" ищет точное совпадение слова. Но IP-адреса, MAC-адреса и значения задержек всегда разные. Мы не можем искать конкретный IP, нам нужно искать любой IP.

Для этого используются регулярные выражения (RegEx) — специальный язык описания текстовых шаблонов.

Исторически в Linux сложилось два стандарта регулярных выражений: базовые (BRE) и расширенные (ERE). По умолчанию grep использует BRE, в котором большинство управляющих символов (например, + или ?) нужно экранировать обратным слешем, что делает шаблоны нечитаемыми.

В скриптах автоматизации всегда используйте флаг -E (grep -E), который включает расширенные регулярные выражения (ERE). Это избавит вас от необходимости экранировать каждый спецсимвол и сделает код чище.

Основные элементы синтаксиса ERE, необходимые сетевому инженеру:

Символ Значение Пример шаблона Что найдет
^, $ Начало и конец строки ^10\. Строки, строго начинающиеся с 10.
. Любой одиночный символ eth. eth0, eth1, etha
[ ] Любой символ из списка/диапазона [0-9A-Fa-f] Одну шестнадцатеричную цифру
* Ноль или более повторений 10* 1, 10, 100, 1000
+ Одно или более повторений [0-9]+ Любое целое число (5, 42, 1024)
{n,m} От n до m повторений [0-9]{1,3} Число от одной до трех цифр
(A|B) Логическое ИЛИ (группировка) (UP|DOWN) Слово UP или слово DOWN

Примечание: точка . в регулярных выражениях означает любой символ. Если вам нужно найти реальную точку (например, в IP-адресе), её необходимо экранировать обратным слешем: \.

Точное извлечение данных: флаг -o

По умолчанию grep выводит строку целиком, если в ней найдено совпадение. Но если мы ищем MAC-адрес в конфигурационном файле, нам не нужна вся строка с комментариями и пробелами — нам нужен только сам MAC-адрес.

Здесь на помощь приходит флаг -o (--only-matching). Он заставляет grep отбросить весь текст строки и вывести только ту её часть, которая совпала с шаблоном.

Рассмотрим задачу: извлечь все MAC-адреса из вывода команды ip neigh или текстового дампа ARP-таблицы. MAC-адрес состоит из шести групп по два шестнадцатеричных символа, разделенных двоеточием.

Шаблон для одной группы с двоеточием: [0-9a-fA-F]{2}:. Таких групп пять, а в конце идет шестая группа без двоеточия.

# Извлекаем только MAC-адреса, игнорируя IP, интерфейсы и состояния
ip neigh | grep -E -o '([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}'

Если в выводе было 192.168.1.5 dev eth0 lladdr 00:1b:63:84:45:e6 REACHABLE, команда вернет ровно 00:1b:63:84:45:e6.

Хирургический парсинг: PCRE и магия оператора \K

Вернемся к обещанию из предыдущей главы: как извлечь точное значение средней задержки (Average RTT) из вывода команды ping?

Стандартный вывод ping в Linux после завершения работы выглядит так: rtt min/avg/max/mdev = 10.123/12.456/15.789/1.234 ms

Нам нужно число 12.456. Проблема в том, что оно окружено другими числами такого же формата. Если мы напишем grep -oE '[0-9]+\.[0-9]+', мы получим все четыре числа в столбик. Нам нужно опереться на контекст: среднее значение всегда идет после знака равно, первого пробела, первого числа и первого слеша.

Для таких сложных задач ERE бывает недостаточно. В grep встроен движок PCRE (Perl Compatible Regular Expressions), который активируется флагом -P. PCRE поддерживает продвинутые механизмы, главный из которых для нас — оператор \K.

Оператор \K означает: «найди всё, что написано до этого момента, но забудь это и не включай в финальный результат».

Соберем команду для извлечения Average RTT:

ping -c 4 8.8.8.8 | grep -oP '=\s*[0-9.]*/\K[0-9.]+'

Разберем, как работает шаблон =\s*[0-9.]*/\K[0-9.]+:

  1. =\s* — находит знак равно и любое количество пробелов после него.
  2. [0-9.]* — находит первое число (min RTT), состоящее из цифр и точек.
  3. / — находит первый слеш.
  4. \Kсбрасывает всё найденное выше. grep убедился, что контекст верный, но в вывод эти символы не попадут.
  5. [0-9.]+ — находит следующее число (avg RTT). Именно оно и будет выведено на экран благодаря флагу -o.

Этот паттерн — один из самых мощных инструментов в арсенале сетевого инженера при работе с Bash. Он позволяет «зацепиться» за известный текст слева, но извлечь только динамическое значение справа (например, interface \Keth[0-9]+).

Нативная проверка форматов в Bash: оператор =~

Часто нам нужно не извлечь данные из потока, а проверить значение переменной, которую скрипт уже получил (например, аргумент от пользователя). Вызывать для этого внешнюю утилиту grep через подстановку команд — избыточно и медленно.

В Bash (внутри двойных квадратных скобок [[ ]]) есть встроенный оператор регулярных выражений =~. Он позволяет проверить, соответствует ли строка слева шаблону ERE справа.

Напишем функцию, которая принимает IP-адрес и проверяет его формат перед тем, как запустить пинг.

validate_ip() {
    local ip=$1

    # Регулярное выражение для базовой проверки формата IPv4
    # (от 1 до 3 цифр, затем точка) повторенное 3 раза, и еще 1-3 цифры
    if [[ $ip =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
        return 0 # Формат верный
    else
        echo "Ошибка: '$ip' не является корректным IPv4 адресом." >&2
        return 1 # Формат неверный
    fi
}

# Использование:
if validate_ip "192.168.1.100"; then
    echo "IP прошел проверку, запускаем сканирование..."
fi

Важно: При использовании =~ в Bash шаблон справа не нужно брать в кавычки. Если вы обернете ^[0-9]+$ в кавычки, Bash будет искать буквальное совпадение со строкой, содержащей символы крышки, скобок и плюса, а не обрабатывать их как регулярное выражение.

Практика: Валидация SLA

Теперь мы можем объединить извлечение данных и математическую логику для проверки сетевого SLA (Service Level Agreement).

Допустим, по нашему SLA средняя задержка не должна превышать определенного порога:

RTTavgRTTmaxRTT_{avg} \leq RTT_{max}

Где RTTavgRTT_{avg} — текущая средняя задержка, а RTTmaxRTT_{max} — максимально допустимая задержка по договору (в миллисекундах). Например, если по договору RTTmaxRTT_{max} равно 50 мс, а текущая задержка RTTavgRTT_{avg} составила 12 мс, то условие 125012 \leq 50 выполняется, и SLA не нарушен.

#!/bin/bash

TARGET="8.8.8.8"
MAX_SLA_MS=50

# 1. Пингуем и извлекаем точное значение avg RTT
# Используем \K для отсечения контекста слева
AVG_RTT_RAW=$(ping -c 4 -q "$TARGET" | grep -oP '=\s*[0-9.]*/\K[0-9.]+')

# Если узел недоступен, переменная будет пустой
if [[ -z "$AVG_RTT_RAW" ]]; then
    echo "CRITICAL: Узел $TARGET недоступен!"
    exit 2
fi

# 2. Bash не умеет сравнивать дробные числа напрямую.
# Отбросим дробную часть с помощью встроенного удаления суффикса (удаляем точку и всё после неё)
AVG_RTT_INT=${AVG_RTT_RAW%.*}

# 3. Сравниваем с порогом SLA
if [[ "$AVG_RTT_INT" -gt "$MAX_SLA_MS" ]]; then
    echo "WARNING: Нарушение SLA! Текущий RTT: ${AVG_RTT_RAW}ms (Порог: ${MAX_SLA_MS}ms)"
    exit 1
else
    echo "OK: RTT в норме (${AVG_RTT_RAW}ms)"
    exit 0
fi

В этом сценарии мы использовали grep -oP для точного извлечения метрики из неструктурированного текста и применили её для принятия решения. Однако grep работает на уровне поиска отдельных фрагментов. Если нам потребуется не просто найти значение, а заменить его на лету, переформатировать конфигурационный файл или удалить из него пароли перед отправкой в лог — нам понадобится потоковый редактор. Этот инструмент мы разберем в следующей главе.

Потоковая обработка текстовых конфигураций через sed

Потоковая обработка текстовых конфигураций через sed

Представьте, что вам нужно обновить IP-адрес NTP-сервера в резервных копиях конфигураций для 500 коммутаторов. Вы можете написать скрипт с циклом, который найдет нужные файлы с помощью grep, но как заменить текст внутри? Открывать каждый файл в nano или vim — не вариант для автоматизации. Здесь на сцену выходит sed — инструмент, который не просто ищет текст, а модифицирует его прямо в конвейере.

В прошлой главе мы научились извлекать нужные данные с помощью регулярных выражений. Теперь мы применим эти же регулярные выражения для изменения конфигураций на лету.

Как мыслит потоковый редактор

В отличие от привычных текстовых редакторов, sed (Stream Editor) не загружает весь файл в память и не имеет интерактивного интерфейса.

sed работает как конвейерная лента: он берет одну строку текста, помещает её в рабочую область (pattern space), применяет к ней заданные вами команды, выводит результат и сразу переходит к следующей строке.

Это делает его невероятно быстрым и идеальным для обработки гигантских логов или конфигурационных файлов маршрутизаторов в пайплайнах Bash.

Базовая замена: команда s

Самая частая операция в sed — это замена текста (substitution). Синтаксис команды выглядит так: s/что_ищем/на_что_меняем/флаги.

Допустим, нам нужно изменить SNMP community с public на SecretComm в строке конфигурации:

echo "snmp-server community public RO" | sed 's/public/SecretComm/'
# Вывод: snmp-server community SecretComm RO

По умолчанию sed заменяет только первое совпадение в строке. Если у вас есть конфигурация интерфейса, где нужно заменить все вхождения VLAN ID, необходимо использовать флаг g (global):

echo "vlan 10, name VLAN10, untagged 10" | sed 's/10/20/g'
# Вывод: vlan 20, name VLAN20, untagged 20

Проблема слешей и альтернативные разделители

Сетевым инженерам часто приходится работать с подсетями (например, 10.0.0.0/24) или путями к файлам. Если использовать стандартный слеш / в качестве разделителя sed, команда превратится в нечитаемую кашу из экранированных символов:

sed 's/10.0.0.0\/24/192.168.1.0\/24/'

Прелесть sed в том, что разделителем может быть любой символ, следующий сразу за буквой s. Удобно использовать решетку # или вертикальную черту |:

echo "network 10.0.0.0/24 area 0" | sed 's#10.0.0.0/24#192.168.1.0/24#'
# Вывод: network 192.168.1.0/24 area 0

Адресация: меняем только то, что нужно

Часто требуется применить замену не ко всему файлу, а только к определенным строкам. sed позволяет указать «адрес» перед командой. Адресом может быть номер строки или — что гораздо полезнее — регулярное выражение.

Представим, что мы хотим увеличить MTU до 9000, но только на интерфейсах GigabitEthernet, не трогая FastEthernet или Loopback.

sed '/^interface GigabitEthernet/ s/mtu 1500/mtu 9000/' config.txt

Логика читается так: «Найди строки, начинающиеся с interface GigabitEthernet. Только в этих строках выполни замену mtu 1500 на mtu 9000».

Основные команды обработки текста

Замена — не единственная функция sed. Вот таблица команд, которые чаще всего применяются при чистке и форматировании сетевых конфигов:

Команда Действие Пример использования
s Замена текста s/old/new/
d Удаление строки /^!/d (удаляет строки, начинающиеся с !)
p Печать строки /BGP/p (дублирует совпадения; для вывода только их нужен флаг -n)
a Добавление после /router ospf/a \ log-adjacency-changes
i Вставка перед /interface Vlan1/i \ description Management

Примечание: в командах a и i обратный слеш перед пробелом (\ ) используется в GNU sed, чтобы сохранить отступ (indentation) для вставляемой строки, что критично для конфигураций вроде Cisco.

Пример очистки конфига Cisco: Чтобы читать конфигурацию было проще, мы можем удалить все комментарии (строки, начинающиеся с !) и пустые строки. Команды можно объединять точкой с запятой:

sed '/^!/d; /^$/d' cisco_backup.cfg

Группы захвата и обратные ссылки

Так же, как и grep, sed поддерживает расширенные регулярные выражения (ERE) с помощью флага -E. Это открывает доступ к мощному механизму — группам захвата (capture groups).

Если вы заключаете часть регулярного выражения в круглые скобки (), sed запоминает совпавший текст. К этому тексту можно обратиться в блоке замены с помощью обратных ссылок: \1 для первой группы, \2 для второй и так далее.

Задача: переформатировать вывод статического IP-адреса из одного стандарта в другой. Исходная строка: ip address 192.168.1.10 255.255.255.0 Нужный формат: address 192.168.1.10 netmask 255.255.255.0

echo "ip address 192.168.1.10 255.255.255.0" | \
sed -E 's/ip address ([0-9.]+) ([0-9.]+)/address \1 netmask \2/'

Здесь \1 вернуло первый найденный IP-адрес, а \2 — маску подсети.

Редактирование файлов «на месте» (-i)

До сих пор мы выводили результат работы sed в терминал (stdout). Чтобы сохранить изменения, можно использовать перенаправление в новый файл (> new_config.txt). Но если вам нужно изменить сам исходный файл, используется флаг -i (in-place).

Важно: Прямое редактирование файлов без бэкапа — прямой путь к потере данных, если регулярное выражение написано с ошибкой.

Поэтому всегда используйте флаг -i с суффиксом, например -i.bak. sed автоматически создаст резервную копию оригинального файла перед применением изменений.

sed -i.bak 's/ntp server 1.1.1.1/ntp server 10.0.0.5/g' router_config.txt

В результате router_config.txt будет содержать новые данные, а рядом появится нетронутый router_config.txt.bak.

Кульминация: генератор конфигураций

Соберем изученное в практический скрипт. У нас есть шаблон конфигурации template.cfg, в котором оставлены плейсхолдеры. Нам нужно сгенерировать готовый конфиг для конкретного коммутатора, подставив значения из переменных Bash.

Чтобы sed мог читать переменные Bash, само выражение нужно оборачивать не в одинарные, а в двойные кавычки. Для передачи сразу нескольких команд замены используется флаг -e.

#!/bin/bash

# Данные для конкретного устройства
TARGET_HOST="sw-core-01"
TARGET_IP="10.10.10.5"
TARGET_VLAN="100"

# Шаблон template.cfg содержит строки вида:
# hostname {{HOSTNAME}}
# interface Vlan{{MGMT_VLAN}}
#  ip address {{MGMT_IP}} 255.255.255.0

# Генерация итогового файла
sed -e "s/{{HOSTNAME}}/$TARGET_HOST/" \
    -e "s/{{MGMT_IP}}/$TARGET_IP/" \
    -e "s/{{MGMT_VLAN}}/$TARGET_VLAN/" \
    template.cfg > "${TARGET_HOST}_ready.cfg"

echo "Конфигурация для $TARGET_HOST успешно сгенерирована."

Этот подход — основа для создания собственных систем автоматического провижининга (Zero Touch Provisioning), пока мы не перейдем к более сложным инструментам шаблонизации.

Генерация отчетов и извлечение метрик с помощью awk

Генерация отчетов и извлечение метрик с помощью awk

Вы отфильтровали конфигурацию маршрутизатора через grep и заменили нужные IP-адреса с помощью sed. Но что, если задача сложнее? Представьте: у вас есть выгрузка логов файрвола или статистика интерфейсов сотен коммутаторов. Вам нужно найти все порты с ошибками, просуммировать объем отброшенного трафика в мегабайтах и вывести красивую таблицу с итогами.

Инструменты, которые мы изучили ранее, здесь пасуют. grep умеет только находить текст, а sed — заменять его. Ни один из них не умеет складывать числа или выравнивать колонки. Для задач, где текст превращается в данные и метрики, сетевому инженеру нужен awk.

Парадигма awk: текст как база данных

awk — это не просто утилита, это полноценный язык программирования для обработки данных. Его главное отличие от других инструментов командной строки заключается в подходе к чтению информации.

awk видит любой текст не как сплошной поток символов, а как структурированную таблицу, где каждая строка — это запись, а каждое слово — поле.

По умолчанию awk разбивает строку на поля по пробелам и знакам табуляции. Для обращения к этим полям используются встроенные переменные:

  • $0 — вся строка целиком.
  • $1, $2, $3 — первое, второе, третье поле соответственно.
  • NF (Number of Fields) — количество полей в строке. Соответственно, $NF — это значение последнего поля, независимо от их общего количества.
  • NR (Number of Records) — порядковый номер текущей строки.

Сравним применимость трех главных текстовых утилит Bash:

Инструмент Главная сила Чего делать не умеет
grep Поиск и точечная фильтрация строк Изменять текст, считать математику
sed Потоковая замена текста и редактирование Арифметика, сложная логика ветвления
awk Столбцовые данные, агрегация, отчеты Редактировать файл «на месте» (без костылей)

Изменение разделителя полей

В сетевых задачах данные редко разделены идеальными пробелами. Часто мы работаем с CSV-выгрузками, логами, разделенными запятыми, или системными файлами, где используется двоеточие.

Чтобы научить awk понимать нестандартные границы колонок, используется флаг -F (Field separator).

Допустим, у нас есть файл ports.csv — выгрузка статистики с коммутатора:

Port,Status,VLAN,RX_Bytes,TX_Bytes
eth0,UP,10,15728640,5242880
eth1,DOWN,20,0,0
eth2,UP,10,3145728,1048576

Если мы хотим вывести только названия портов и их VLAN, мы указываем запятую в качестве разделителя:

awk -F',' '{ print $1, $3 }' ports.csv

Математика и структура программы

Главная суперсила awk — встроенная арифметика. Программа на awk состоит из трех логических блоков, хотя любой из них можно опустить:

  1. BEGIN { ... } — выполняется ровно один раз до чтения первой строки файла. Идеально для вывода заголовков таблиц или инициализации переменных.
  2. /Условие/ { ... } — основное тело. Выполняется для каждой строки, которая соответствует условию.
  3. END { ... } — выполняется один раз после прочтения последней строки. Здесь обычно выводят итоговые суммы и метрики.

Вернемся к нашему файлу ports.csv. Нам нужно посчитать суммарный трафик (входящий плюс исходящий) для каждого активного порта и перевести его из байтов в мегабайты.

Для этого используем формулу:

TrafficMB=RX+TX10242Traffic_{MB} = \frac{RX + TX}{1024^2}

Разберем элементы этой формулы: TrafficMBTraffic_{MB} — это итоговое значение в мегабайтах, которое мы хотим получить. RXRX — входящие байты (в нашем CSV это колонка $4), TXTX — исходящие байты (колонка $5). Делитель 102421024^2 (или 10485761048576) — это константа для перевода байтов в мегабайты. На практике, если порт eth0 принял 15728640 байт и отправил 5242880 байт, суммарный объем составит 20 мегабайт.

Напишем логику фильтрации и подсчета:

awk -F',' '$2 == "UP" { print $1, ($4 + $5) / 1048576 }' ports.csv

Здесь $2 == "UP" — это условие. Блок в фигурных скобках выполнится только для тех строк, где статус порта равен UP. Обратите внимание: в отличие от Bash, внутри awk для математических операций не нужны двойные круглые скобки $(()).

Форматирование отчета и агрегация данных

Команда print выводит данные как есть, что часто выглядит неряшливо, особенно при работе с дробными числами. Для создания профессиональных отчетов используется printf (print formatted), который позволяет жестко задать ширину колонок и количество знаков после запятой.

Синтаксис printf опирается на плейсхолдеры:

  • %s — строка. Например, %-10s означает строку, выровненную по левому краю (минус) с жесткой шириной в 10 символов.
  • %f — число с плавающей точкой. Например, %8.2f означает число, под которое выделено 8 символов, из которых 2 — после запятой.

Кроме того, awk позволяет создавать переменные прямо на лету для накопления сумм. Напишем полноценный скрипт генерации отчета:

awk -F',' '
BEGIN {
    # Печатаем заголовок таблицы до начала обработки данных
    printf "%-10s %-10s %-15s\n", "PORT", "VLAN", "TRAFFIC (MB)"
    print "---------------------------------------"
}
# Пропускаем первую строку с заголовками CSV и берем только активные порты
NR > 1 && $2 == "UP" {
    # Считаем трафик для текущего порта
    traffic_mb = ($4 + $5) / 1048576

    # Прибавляем к глобальной переменной (инициализируется нулем автоматически)
    total_mb += traffic_mb

    # Выводим отформатированную строку
    printf "%-10s %-10s %-15.2f\n", $1, $3, traffic_mb
}
END {
    # Выводим подвал таблицы и итоговую сумму
    print "---------------------------------------"
    printf "Total active ports traffic: %.2f MB\n", total_mb
}
' ports.csv

Интеграция с Bash: передача переменных

Часто скрипт автоматизации должен быть гибким. Например, номер VLAN, по которому мы строим отчет, должен передаваться скрипту в качестве аргумента.

Мы не можем просто написать $BASH_VARIABLE внутри одинарных кавычек awk, так как интерпретатор Bash их не раскроет. Правильный способ передать внешние данные внутрь awk — использовать флаг -v (variable).

TARGET_VLAN=10

awk -F',' -v vlan="$TARGET_VLAN" '
    NR > 1 && $3 == vlan {
        printf "Port %s in VLAN %s is %s\n", $1, vlan, $2
    }
' ports.csv

В этом примере флаг -v vlan="$TARGET_VLAN" создает внутри пространства awk переменную vlan и безопасно присваивает ей значение из Bash. Это позволяет легко встраивать мощные отчеты awk внутрь больших циклов и функций, которые мы рассматривали в предыдущих главах.

Теперь мы умеем извлекать метрики из плоских текстовых таблиц и логов. Однако в современных сетях оборудование всё чаще отдает данные не в виде таблиц, а в виде вложенных структур. О том, как парсить такие ответы, мы поговорим далее.

Работа со сложными форматами данных: парсинг JSON через jq

Работа со сложными форматами данных: парсинг JSON через jq

Представьте, что вы запросили статус интерфейсов у современного коммутатора (например, Arista EOS или Cisco Nexus) через его API. Вместо привычного вывода show interfaces вы получаете «простыню» из 500 строк фигурных скобок и кавычек. Вы пытаетесь найти IP-адрес упавшего порта с помощью grep "ipAddress" | awk '{print $2}', но получаете адреса вообще всех интерфейсов, потому что строковый парсер не понимает контекста и вложенности.

Эпоха неструктурированного текста в сетях уходит. Современное оборудование общается объектами, чаще всего в формате JSON. Чтобы извлекать из них метрики для систем мониторинга, текстовые инструменты предыдущих глав не подойдут. Нам нужен инструмент, который понимает структуру данных.

jq — это легковесный консольный процессор JSON. Если sed и awk — это хирургические инструменты для строк, то jq — это скальпель для объектов и массивов.

Парадигма jq: фильтры вместо строк

В awk мы мыслили записями и полями. В jq мы мыслим фильтрами. Программа на jq — это фильтр, который принимает на вход JSON, трансформирует его и выдает результат.

Инструмент Единица данных Разделитель Как обращается к данным
awk Строка текста Пробел, запятая, | По номеру колонки: $1, $2
jq JSON-объект / массив Иерархия (вложенность) По имени ключа: .interface.mtu

Базовая навигация: точечная нотация

Допустим, наша система автоматизации сохранила ответ от коммутатора в файл bgp_status.json:

{
  "vrf": "default",
  "routerId": "192.168.1.254",
  "neighbors": [
    {
      "ip": "10.0.0.1",
      "asn": 65001,
      "state": "Established",
      "prefixesReceived": 154
    },
    {
      "ip": "10.0.0.2",
      "asn": 65002,
      "state": "Idle",
      "prefixesReceived": 0
    }
  ]
}

Чтобы извлечь значение конкретного ключа, используется точечная нотация. Точка . обозначает текущий объект.

jq '.routerId' bgp_status.json

Вывод:

"192.168.1.254"

Работа с массивами и конвейер внутри jq

Сетевые данные редко бывают одиночными. Обычно это списки: VLAN-ы, MAC-адреса, соседи BGP. В JSON списки хранятся в массивах [ ].

Если мы запросим .neighbors, jq вернет весь массив целиком. Но чтобы анализировать каждого соседа отдельно, массив нужно «развернуть» (unwrap) в поток независимых объектов. Для этого к имени ключа добавляются пустые квадратные скобки [].

jq '.neighbors[]' bgp_status.json

Теперь на выходе не один массив, а два отдельных JSON-объекта. Это критически важный шаг, потому что теперь мы можем пропустить этот поток через внутренний конвейер jq.

Внутри jq есть свой оператор pipe |. Он работает точно так же, как в Bash, но передает не текст, а JSON-объекты от одного фильтра к другому.

Извлечем только IP-адреса всех соседей:

jq '.neighbors[] | .ip' bgp_status.json

Вывод:

"10.0.0.1"
"10.0.0.2"

Умная фильтрация: функция select

В системах мониторинга нам редко нужны все данные. Нас интересуют аномалии. Например, сессии BGP, которые не находятся в состоянии «Established», или соседи, от которых принято критически мало маршрутов (например, если количество принятых префиксов P<10P < 10).

В этой формуле PP — это текущее количество маршрутов (префиксов), полученных от соседа, а 1010 — минимальный порог. На практике: если BGP-сосед передал нам P=5P = 5 маршрутов вместо обычной полной таблицы, сессия формально работает, но маршрутизация нарушена, что требует алерта.

Для фильтрации в jq есть встроенная функция select(). Она пропускает дальше по конвейеру только те объекты, для которых условие внутри скобок истинно.

Найдем всех соседей, чье состояние отличается от Established:

jq '.neighbors[] | select(.state != "Established")' bgp_status.json

Вывод:

{
  "ip": "10.0.0.2",
  "asn": 65002,
  "state": "Idle",
  "prefixesReceived": 0
}

Мы можем усложнить логику. Допустим, мы хотим получить только IP-адрес проблемного соседа, чтобы передать его дальше в Bash-скрипт для отправки алерта:

jq '.neighbors[] | select(.state != "Established") | .ip' bgp_status.json

Вывод:

"10.0.0.2"

Интеграция с Bash: сырой вывод (raw output)

Обратите внимание на кавычки в предыдущем выводе. jq по умолчанию генерирует валидный JSON. Строка "10.0.0.2" — это JSON-строка.

Если мы попытаемся использовать это значение в Bash (например, для команды ping), кавычки сломают команду, так как Bash воспримет их как часть IP-адреса.

Чтобы jq выдал чистый текст без кавычек, используется флаг -r (raw-output):

jq -r '.neighbors[] | select(.state != "Established") | .ip' bgp_status.json

Вывод:

10.0.0.2

Сквозной пример: мониторинг BGP-соседей

Соберем изученное в практический скрипт. Наша задача — прочитать JSON-файл с состоянием BGP, найти все упавшие сессии и сгенерировать понятные текстовые алерты.

#!/bin/bash

# Файл, который симулирует ответ от API маршрутизатора
BGP_FILE="bgp_status.json"

# Извлекаем IP-адреса проблемных соседей в массив Bash
# Используем подстановку процессов <() и флаг -r
mapfile -t BROKEN_PEERS < <(jq -r '.neighbors[] | select(.state != "Established") | .ip' "$BGP_FILE")

# Проверяем, есть ли упавшие сессии
if [[ ${#BROKEN_PEERS[@]} -eq 0 ]]; then
    echo "OK: Все BGP сессии в норме."
    exit 0
fi

# Обрабатываем каждого проблемного соседа
for peer in "${BROKEN_PEERS[@]}"; do
    # Делаем дополнительный запрос к jq, чтобы вытащить точный статус конкретного пира
    # Передаем переменную Bash внутрь jq с помощью аргумента --arg
    STATE=$(jq -r --arg IP "$peer" '.neighbors[] | select(.ip == $IP) | .state' "$BGP_FILE")

    echo "CRITICAL: BGP сосед $peer недоступен! Текущее состояние: $STATE"
done

exit 1

Разбор новых элементов:

  1. Мы использовали конструкцию mapfile -t ARRAY < <(command) — это самый безопасный способ сохранить многострочный вывод (в нашем случае список IP от jq) в индексный массив Bash без потери пробелов и спецсимволов.
  2. Внутри цикла for нам понадобилось передать переменную Bash ($peer) внутрь фильтра jq. Встраивать переменные Bash напрямую в строку jq через двойные кавычки — плохая практика (может привести к инъекциям кода). Правильный путь — флаг --arg VAR_NAME "value", который создает внутреннюю переменную $VAR_NAME в самом jq.

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

Шаблонизация конфигурационных файлов сетевых устройств

Шаблонизация конфигурационных файлов сетевых устройств

В прошлой главе мы научились извлекать нужные параметры из сложных JSON-структур. Теперь перед нами обратная задача: данные на руках (IP-адреса, номера автономных систем, имена интерфейсов), и их нужно превратить в готовые конфигурации для сетевого оборудования.

Можно ли собрать конфиг маршрутизатора с помощью десятков команд echo? Можно. Но если конфигурация занимает больше трех строк, код скрипта быстро превратится в нечитаемое месиво из кавычек и перенаправлений вывода. Для генерации текстовых файлов в Bash существуют гораздо более элегантные инструменты — встроенный механизм Heredoc и утилита envsubst.

Here Document (Heredoc): многострочные блоки

Heredoc (Here Document) — это способ передачи многострочного текстового блока на стандартный ввод (stdin) команды. Чаще всего его используют вместе с cat для создания файлов.

Синтаксис выглядит так: << и специальное слово-маркер (обычно используют EOF — End Of File, но это может быть любое слово, например CONFIG). Текст читается до тех пор, пока интерпретатор не встретит маркер на отдельной строке.

ROUTER_HOSTNAME="R1-CORE"
OSPF_PROCESS="100"
ROUTER_ID="10.0.0.1"

cat <<EOF > ospf_config.txt
hostname ${ROUTER_HOSTNAME}
!
router ospf ${OSPF_PROCESS}
 router-id ${ROUTER_ID}
 passive-interface default
 no passive-interface GigabitEthernet0/0/0
EOF

Главная сила Heredoc в том, что Bash автоматически подставляет значения переменных внутри блока, сохраняя все переносы строк и отступы. Вам не нужно экранировать кавычки или писать echo на каждой строке.

Управление подстановкой: EOF против 'EOF'

Иногда в конфигурации оборудования встречаются символы $, которые Bash попытается интерпретировать как переменные (например, при настройке регулярных выражений в BGP AS-path списках или при задании хэшированных паролей).

Если вы хотите, чтобы текст передался «как есть», без подстановки переменных, маркер нужно взять в одинарные кавычки: <<'EOF'.

# Переменные НЕ будут подставлены
cat <<'EOF' > route_map.txt
ip as-path access-list 1 permit ^65000_[0-9]+$
!
route-map BGP_IN permit 10
 match as-path 1
EOF

Проблема отступов и <<-EOF

Сетевые инженеры любят красиво форматировать код скриптов. Но если вы сдвинете блок Heredoc вправо с помощью пробелов для красоты кода, эти пробелы попадут в итоговый конфигурационный файл.

Чтобы этого избежать, используется модификатор <<-EOF (с дефисом).

Оператор <<-EOF приказывает Bash удалить все ведущие символы табуляции (Tab) в каждой строке Heredoc-блока перед записью.

Важно: удаляются только табы, но не пробелы.

generate_vlan() {
    local vlan_id=$1
    local vlan_name=$2

    # Используем табы для отступа самого блока,
    # а пробел перед 'name' сохранится для синтаксиса Cisco
	cat <<-EOF >> vlans.cfg
	vlan ${vlan_id}
	 name ${vlan_name}
	EOF
}

Разделение логики и данных: утилита envsubst

Heredoc отлично подходит для небольших вставок. Но если вам нужно сгенерировать конфигурацию на 500 строк, хранить этот текст прямо внутри Bash-скрипта — плохая практика. Скрипт становится трудно читать и поддерживать.

Здесь на помощь приходит envsubst — стандартная утилита Linux, созданная специально для подстановки переменных окружения в текстовые шаблоны. Она позволяет вынести шаблон в отдельный файл.

Допустим, у нас есть файл-шаблон bgp_peer.template:

router bgp ${LOCAL_AS}
 neighbor ${PEER_IP} remote-as ${PEER_AS}
 neighbor ${PEER_IP} description ${PEER_DESC}
 address-family ipv4 unicast
  neighbor ${PEER_IP} activate
  neighbor ${PEER_IP} route-map RM_IN in
 exit-address-family

Чтобы envsubst подставил значения, переменные должны быть экспортированы в окружение (environment), так как утилита запускается как отдельный процесс и не видит локальных переменных Bash.

# Экспортируем переменные, чтобы envsubst их увидел
export LOCAL_AS="65001"
export PEER_IP="192.168.10.2"
export PEER_AS="65002"
export PEER_DESC="UPSTREAM_ISP"

# Читаем шаблон, подставляем переменные и пишем в итоговый конфиг
envsubst < bgp_peer.template > final_router.cfg

Сравнение подходов

Характеристика Heredoc (<<EOF) envsubst
Где хранится шаблон? Внутри Bash-скрипта В отдельном файле
Видимость переменных Видит любые переменные скрипта Видит только экспортированные (export)
Идеальный сценарий Короткие блоки (3-10 строк), динамическая генерация мелких кусков Длинные, стандартизированные конфигурации (VPN-туннели, BGP-пиринги)

Кульминация: массовая генерация конфигураций

Свяжем наши знания воедино. Представим, что мы получили список новых BGP-пиров и нам нужно сгенерировать для них конфигурационный файл. Мы используем цикл для перебора данных и envsubst для заполнения шаблона.

У нас есть файл шаблона peer.tmpl:

 neighbor ${IP} remote-as ${ASN}
 neighbor ${IP} description ${DESC}

И скрипт, который генерирует итоговый конфиг:

#!/bin/bash

# Инициализируем итоговый файл
CONFIG_FILE="bgp_generated.cfg"
export LOCAL_AS="65000"

# Записываем заголовок с помощью Heredoc
cat <<EOF > "${CONFIG_FILE}"
! Сгенерировано автоматически
router bgp ${LOCAL_AS}
 bgp log-neighbor-changes
EOF

# Массив с данными пиров (IP, ASN, Описание)
PEERS=(
    "10.10.10.1 65100 CORE_A"
    "10.10.10.2 65200 CORE_B"
    "10.10.20.1 65300 EDGE_X"
)

# Проходим циклом по массиву
for peer_data in "${PEERS[@]}"; do
    # Читаем строку в отдельные переменные
    read -r ip asn desc <<< "$peer_data"

    # Экспортируем их для envsubst
    export IP="$ip"
    export ASN="$asn"
    export DESC="$desc"

    # Дописываем (>>) сгенерированный блок в итоговый файл
    envsubst < peer.tmpl >> "${CONFIG_FILE}"
done

echo "Конфигурация успешно сохранена в ${CONFIG_FILE}"

В результате мы получаем чистый, структурированный файл bgp_generated.cfg, собранный из внешнего шаблона и массива данных. Скрипт остается компактным, а шаблон можно в любой момент отредактировать без изменения логики кода.

Теперь, когда у нас есть готовые текстовые конфигурации, возникает следующий логичный вопрос: как доставить их на оборудование? Копировать текст руками в терминал — не наш метод.

Интерактивный ввод и автоматизация ответов с помощью expect

Интерактивный ввод и автоматизация ответов с помощью expect

Мы научились виртуозно парсить данные и генерировать идеальные конфигурационные файлы с помощью шаблонов. Но как доставить этот конфиг на оборудование? Если современный маршрутизатор поддерживает API, задача решается отправкой HTTP-запроса. Однако в реальности сети полны legacy-коммутаторов, маршрутизаторов и шлюзов, которые понимают только старый добрый CLI через SSH или Telnet.

Попытка отправить команды через стандартный конвейер Bash вроде echo "conf t" | ssh admin@10.0.0.1 разобьется о суровую реальность: SSH требует интерактивного ввода пароля с клавиатуры (через терминальный интерфейс TTY, а не стандартный ввод), а само сетевое устройство может внезапно спросить Are you sure? [y/n] или остановить вывод длинного лога надписью --More--. Bash не умеет «читать с экрана» и нажимать кнопки в ответ.

Для решения этой задачи существует expect — инструмент, который работает как виртуальный робот-машинистка. Он запускает программу, читает ее текстовый вывод в реальном времени, ждет появления определенных паттернов и отправляет заранее заготовленные нажатия клавиш.

Анатомия expect

Хотя expect часто используется внутри Bash-скриптов, под капотом это самостоятельный язык, расширение Tcl. Его логика строится на трех базовых командах:

  1. spawn — запускает интерактивный процесс (например, сессию SSH).
  2. expect — ждет появления заданной строки или регулярного выражения в выводе запущенного процесса.
  3. send — отправляет строку в процесс, имитируя ввод с клавиатуры.

Важное отличие: при работе с send для имитации нажатия клавиши Enter (возврат каретки) используется символ \r, а не привычный для Linux перевод строки \n. Сетевое оборудование ожидает именно \r.

Сравним подходы:

Задача Bash (не сработает для TTY) expect
Запуск утилиты ssh user@host spawn ssh user@host
Ожидание вопроса Скрипт слепо отправляет данные expect "Password:"
Ввод ответа echo "my_pass" send "my_pass\r"

Интеграция expect в Bash-скрипт

Писать отдельные файлы для expect неудобно, если основная логика обхода сети и генерации конфигов уже реализована на Bash. Мы можем встроить код expect прямо в наш Bash-скрипт, используя уже знакомый механизм Heredoc.

Рассмотрим скрипт, который подключается к коммутатору, выполняет команду show inventory и сохраняет результат. Поскольку мы не экранируем EOF кавычками, Bash подставит значения переменных $USER, $PASS и $IP внутрь блока expect до его выполнения.

#!/bin/bash

USER="admin"
PASS="Cisco123!"
IP="10.10.10.5"

# Запускаем expect и передаем ему инструкции через Heredoc
expect <<-EOF
    # Отключаем вывод отладочной информации самого expect
    log_user 0

    # Запускаем SSH-сессию
    spawn ssh $USER@$IP

    # Ждем приглашения для ввода пароля
    expect "word:"
    send "$PASS\r"

    # Ждем появления промпта коммутатора (# или >)
    expect -re "[#>]"

    # Включаем вывод, чтобы получить результат команды
    log_user 1
    send "show inventory\r"

    # Ждем возвращения промпта после выполнения команды
    expect -re "[#>]"

    # Завершаем сессию
    send "exit\r"
    expect eof
EOF

Команда expect eof в конце говорит скрипту дождаться естественного завершения запущенного процесса (закрытия SSH-сессии), чтобы не оборвать выполнение на полуслове.

Ветвление: обработка непредсказуемых сценариев

При первом подключении к новому устройству по SSH сервер попросит подтвердить RSA-ключ: Are you sure you want to continue connecting (yes/no/[fingerprint])?. Если устройство уже известно, этого вопроса не будет, и сразу появится Password:.

Если мы напишем жесткую последовательность expect "yes/no", а сервер спросит пароль, скрипт зависнет в ожидании. Для обработки таких ситуаций в expect есть блок ветвления, который работает как конечный автомат.

spawn ssh $USER@$IP

expect {
    "yes/no" {
        send "yes\r"
        exp_continue
    }
    "word:" {
        send "$PASS\r"
    }
    timeout {
        puts "Ошибка: таймаут при подключении"
        exit 1
    }
}

Как это работает:

  • expect одновременно ждет любое из перечисленных совпадений.
  • Если встречается yes/no, скрипт отправляет yes\r. Команда exp_continue заставляет expect остаться в этом же блоке и продолжить ожидание (ведь после yes сервер попросит пароль).
  • Если встречается word: (часть от Password:), скрипт вводит пароль и выходит из блока ветвления, продолжая выполнение следующих команд.
  • Если ничего не происходит в течение заданного времени, срабатывает блок timeout.

Управление таймаутами

Сетевое оборудование может отвечать медленно. Выполнение команды copy running-config startup-config или применение большого ACL занимает время. По умолчанию expect ждет совпадения 10 секунд, после чего молча идет дальше, что приведет к рассинхронизации ввода.

Время ожидания контролируется внутренней переменной timeout. Если нам нужно применить тяжелый конфиг, мы должны увеличить это значение.

Математически общее время работы скрипта при последовательном обходе можно выразить как TtotalN×ttimeoutT_{total} \leq N \times t_{timeout}, где NN — количество устройств, а ttimeoutt_{timeout} — максимальное время ожидания ответа от одного узла. Если ttimeout=60t_{timeout} = 60 с, обход 50 коммутаторов может занять до 50 минут при недоступности сети.

# Устанавливаем таймаут в 30 секунд для долгих операций
set timeout 30

send "write memory\r"
expect {
    "OK" { puts "Конфиг сохранен" }
    timeout {
        puts "Устройство думает слишком долго!"
        exit 1
    }
}

Сквозной пример: доставка сгенерированного конфига

Соберем знания воедино. В прошлой главе мы сгенерировали конфигурацию BGP. Теперь напишем Bash-функцию, которая принимает IP-адрес и многострочный конфиг, логируется на устройство и применяет его построчно.

#!/bin/bash

deploy_config() {
    local target_ip=$1
    local config_payload=$2
    local user="netadmin"
    local pass="SuperSecret"

    # Вызываем expect, передавая переменные окружения
    expect <<-EOF
        set timeout 15
        spawn ssh -o StrictHostKeyChecking=no $user@$target_ip

        expect {
            "word:" { send "$pass\r" }
            timeout { exit 1 }
        }

        expect -re "[#>]"
        send "configure terminal\r"
        expect "(config)#"

        # Разбиваем конфиг на строки и отправляем по одной
        set commands [split "$config_payload" "\n"]
        foreach cmd \$commands {
            if { \$cmd ne "" } {
                send "\$cmd\r"
                # Ждем промпта после каждой команды для надежности
                expect "(config"
            }
        }

        send "end\r"
        expect -re "[#>]"
        send "write memory\r"

        # Увеличиваем таймаут для сохранения
        set timeout 30
        expect -re "[#>]"
        send "exit\r"
        expect eof
EOF
}

# Имитируем сгенерированный ранее конфиг
BGP_CONF="router bgp 65001
neighbor 10.0.0.2 remote-as 65002
neighbor 10.0.0.2 description PEER-MAIN"

# Разворачиваем конфиг на маршрутизаторе
deploy_config "192.168.100.1" "$BGP_CONF"

В этом примере мы использовали Tcl-команду split для разбиения Bash-переменной $config_payload на список строк. Обратите внимание на экранирование \$commands и \$cmd — это необходимо, чтобы Bash не попытался подставить свои пустые переменные до запуска expect, а оставил знаки $ для обработки самим Tcl.

Инструмент expect незаменим для старого оборудования, но у него есть критический недостаток: пароли приходится хранить в открытом виде внутри скриптов или передавать в переменных, что нарушает базовые принципы безопасности. Кроме того, установка SSH-сессии с вводом пароля занимает лишние секунды. В дальнейшем мы рассмотрим, как избавиться от интерактивного ввода паролей на уровне криптографии.

Беспарольный доступ по SSH и управление ключами

Беспарольный доступ по SSH и управление ключами

Использование expect решает проблему интерактивного ввода, но оставляет нас с серьезной уязвимостью: пароли хранятся в открытом виде внутри скриптов. К тому же, если коммутатор из-за нагрузки чуть дольше обычного отвечает на запрос логина, скрипт может рассинхронизироваться и упасть по таймауту. Для надежной автоматизации сотен устройств требуется механизм, который аутентифицирует скрипт мгновенно, без передачи секретов по сети и без малейшего риска зависания на строке Password:. Этот стандарт индустрии — асимметричная криптография и SSH-ключи.

Вместо единого пароля создается пара: закрытый ключ (private key) и открытый ключ (public key).

Асимметричная аутентификация использует механизм цифровой подписи. Вы раздаете сетевым устройствам открытый ключ (образец вашей подписи). При попытке входа коммутатор генерирует случайную строку (вызов). Ваш SSH-клиент подписывает этот вызов с помощью закрытого ключа (ваша уникальная печать), а коммутатор проверяет подлинность подписи, используя открытый ключ. Пароль по сети не передается вообще.

Генерация ключей: Ed25519 против RSA

Для создания пары ключей используется системная утилита ssh-keygen. В сетевой среде мы постоянно сталкиваемся с зоопарком оборудования разных поколений, поэтому выбор алгоритма шифрования критичен.

Алгоритм Команда генерации Поддержка оборудованием Особенности
Ed25519 ssh-keygen -t ed25519 Современные ОС (Cumulus, Arista EOS, современные Linux-серверы) Высокая скорость, короткая длина ключа, максимальная криптографическая стойкость.
RSA ssh-keygen -t rsa -b 4096 Устаревшее (старые версии Cisco IOS, Junos, Huawei VRP) Требует ключей длиной не менее 2048 бит (лучше 4096), работает медленнее, но поддерживается везде.

Сгенерируем современный ключ для нашего скрипта мониторинга:

ssh-keygen -t ed25519 -C "monitoring-script@jumphost" -f ~/.ssh/id_ed25519_mon

Флаг -C добавляет текстовый комментарий. Это крайне важно: когда администратор сети будет просматривать конфигурацию роутера, он должен понимать, какому сервису принадлежит ключ. Флаг -f указывает нестандартный путь, чтобы жестко отделить ключ автоматизации от вашего личного ключа инженера.

При генерации утилита запросит кодовую фразу (passphrase). Если оставить ее пустой, закрытый ключ будет лежать на жестком диске в открытом виде — скрипт сможет читать его без препятствий. Но если сервер мониторинга скомпрометируют, атакующий просто скопирует файл и получит мгновенный root-доступ ко всей вашей сети.

ssh-agent: компромисс между безопасностью и автоматизацией

Чтобы защитить закрытый ключ, мы задаем ему надежную кодовую фразу. Но как скрипт введет эту фразу в фоновом режиме? Здесь на помощь приходит ssh-agent — системный процесс, который хранит расшифрованные ключи в защищенной области оперативной памяти.

Вы запускаете агента и единожды (например, после перезагрузки сервера) вводите пароль от ключа:

eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519_mon

Агент создает UNIX-сокет (специальный файл для обмена данными) и записывает путь к нему в переменную окружения SSH_AUTH_SOCK. Теперь любой скрипт, запущенный в этом же окружении, сможет прозрачно использовать расшифрованный ключ из памяти, не зная самой кодовой фразы и не обращаясь к зашифрованному файлу на диске.

Доставка ключей на оборудование

Чтобы устройство пустило нас без пароля, открытую часть ключа (файл с расширением .pub) нужно поместить в конфигурацию целевого узла.

Для Linux-подобных систем (серверы, Cumulus Linux, SONiC) существует готовая утилита:

ssh-copy-id -i ~/.ssh/id_ed25519_mon.pub admin@10.10.5.11

Она подключится по паролю и аккуратно добавит ключ в файл ~/.ssh/authorized_keys.

Для классического сетевого оборудования (Cisco, Juniper) открытый ключ добавляется через CLI. Мы можем использовать expect из прошлой главы, чтобы написать скрипт единоразовой инициализации узла. Открытый ключ выглядит как обычная строка текста, которую легко прочитать встроенными средствами Bash:

PUB_KEY=$(cat ~/.ssh/id_ed25519_mon.pub)

Эту строку скрипт на базе expect может передать в конфигурационный режим роутера (например, в блок ip ssh pubkey-chain для Cisco). После того как ключ импортирован, пароли для данного узла больше не понадобятся.

BatchMode: защита скриптов от зависания

Когда ключи раскатаны, скрипт начинает работу. Но сеть динамична: коммутатор могут заменить по гарантии, конфигурацию — сбросить к заводским настройкам, а ключ — случайно удалить. Если скрипт попытается зайти на такой узел, SSH не найдет валидного ключа и выведет в терминал запрос Password:, ожидая ручного ввода. Скрипт зависнет навсегда, заблокировав весь процесс мониторинга.

Чтобы превратить SSH из интерактивной утилиты в строгий инструмент автоматизации, используется флаг BatchMode=yes. Он категорически запрещает SSH запрашивать любые пароли. Если аутентификация по ключу не удалась, команда мгновенно завершается с ненулевым кодом возврата.

Дополнительно применяется параметр StrictHostKeyChecking=accept-new (в старых версиях no), чтобы скрипт не зависал на вопросе Are you sure you want to continue connecting? при первом подключении к новому или замененному узлу.

Объединим это в скрипте инвентаризации, который опрашивает узлы, извлекает данные и корректно обрабатывает сбои доступа:

#!/bin/bash

# Массив целевых коммутаторов
declare -a SWITCHES=("10.10.5.11" "10.10.5.12" "10.10.5.13")

# Опции SSH для строгого неинтерактивного режима
SSH_OPTS="-o BatchMode=yes -o StrictHostKeyChecking=accept-new -i ~/.ssh/id_ed25519_mon"

for IP in "${SWITCHES[@]}"; do
    # Попытка выполнить команду через SSH, подавляя стандартный вывод ошибок
    OUTPUT=$(ssh $SSH_OPTS admin@"$IP" "show version | include Uptime" 2>/dev/null)

    # Проверка успешности подключения по коду возврата
    if [[ $? -eq 0 ]]; then
        echo "[OK] $IP: $OUTPUT"
    else
        echo "[ERROR] $IP: Доступ по ключу отклонен. Требуется повторная раскатка."
    fi
done

В этом сценарии BatchMode=yes гарантирует, что цикл отработает предсказуемо и быстро, не требуя вмешательства инженера. Мы получили надежный, безопасный и неблокирующий транспорт, поверх которого теперь можно запускать массовый сбор метрик и конфигураций.

Параллельное выполнение задач через xargs и GNU Parallel

Параллельное выполнение задач через xargs и GNU Parallel

Представьте, что вам нужно опросить по SSH 500 коммутаторов доступа, чтобы собрать их текущий аптайм. Если подключение, выполнение команды и отключение занимает в среднем 2 секунды, обычный цикл for потратит на эту задачу почти 17 минут. Для системы мониторинга, которая должна реагировать на инциденты в реальном времени, данные устареют еще до того, как скрипт завершит работу.

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

Нативный Bash: фоновые процессы и синхронизация

Самый простой способ выполнить задачу параллельно — отправить её в фон. В Bash для этого используется оператор &, который ставится в конце команды. Интерпретатор запускает процесс и мгновенно переходит к следующей строке скрипта, не дожидаясь ответа.

Чтобы скрипт не завершился раньше, чем отработают все фоновые задачи, применяется встроенная команда wait. Она приостанавливает выполнение родительского скрипта до тех пор, пока все дочерние процессы не вернут код возврата.

#!/bin/bash

# Запускаем три проверки параллельно
ping -c 1 -W 1 10.10.1.1 > /dev/null &
ping -c 1 -W 1 10.10.1.2 > /dev/null &
ping -c 1 -W 1 10.10.1.3 > /dev/null &

echo "Запросы отправлены. Ожидание ответов..."
wait
echo "Все проверки завершены!"

Этот подход отлично работает для 3–5 задач. Но что, если мы читаем список из 1000 IP-адресов? Цикл while read с оператором & мгновенно породит 1000 одновременных процессов. Это приведет к исчерпанию ресурсов (CPU, памяти, файловых дескрипторов) на сервере мониторинга или к срабатыванию механизмов защиты (например, лимита MaxStartups в SSH-демоне) на целевом оборудовании.

Нам нужен не просто параллелизм, а пул потоков (thread pool) — механизм, который будет поддерживать строго заданное количество активных задач, запуская новые только по мере завершения старых.

Контролируемый параллелизм с xargs

Утилита xargs изначально создавалась для преобразования стандартного ввода в аргументы командной строки. Однако её флаг -P (или --max-procs) превращает её в мощный менеджер параллельных задач.

Теоретическое время выполнения параллельной задачи можно описать формулой:

TN×tPT \approx \frac{N \times t}{P}

Где TT — общее время работы, NN — общее количество хостов, tt — время обработки одного хоста, а PP — количество одновременных потоков (значение флага -P). Если 500 хостов по 2 секунды обрабатывать в 50 потоков, время сократится с 17 минут до ~20 секунд.

Рассмотрим пример массовой проверки доступности из файла inventory.txt:

cat inventory.txt | xargs -I {} -P 50 ping -c 1 -W 1 {}

Разберем параметры:

  • -I {} — определяет строку-заполнитель. xargs берет каждую строку из ввода и подставляет её туда, где находится {}.
  • -P 50 — указывает xargs поддерживать ровно 50 одновременно работающих процессов ping.
  • Если указать -P 0, xargs попытается запустить столько процессов, сколько строк на входе (возврат к проблеме исчерпания ресурсов).

Проблема «каши» в терминале (Output Interleaving)

У xargs есть серьезный недостаток при работе с текстом. Если несколько параллельных процессов одновременно пытаются вывести данные в стандартный вывод (stdout), их строки могут перемешаться. Половина ответа от одного коммутатора склеится с ошибкой от другого. Для систем мониторинга, где мы в дальнейшем парсим этот вывод, это катастрофа.

GNU Parallel: инструмент для продакшена

Для сложных сетевых задач, особенно связанных с SSH и сбором многострочных конфигураций, стандартом де-факто является утилита parallel (GNU Parallel).

Её главное архитектурное отличие от xargsбуферизация вывода. parallel перехватывает stdout и stderr каждого запущенного процесса и выводит их на экран только после того, как процесс полностью завершится. Вывод разных команд никогда не перемешивается.

Сравним два инструмента:

Характеристика xargs -P GNU Parallel
Смешивание вывода Да (строки могут разорваться) Нет (строгая группировка по задачам)
Сохранение порядка Нет (кто первый завершился, тот и вывел) Да (при использовании флага -k)
Визуализация Отсутствует Индикаторы прогресса (--bar, --eta)
Синтаксис подстановки Требует -I {} {} работает по умолчанию

Использование xargs оправдано для «тихих» задач, где важен только код возврата (например, массовый ping > /dev/null). Если задача предполагает сбор и чтение текста (конфиги, таблицы маршрутизации), используйте GNU Parallel.

Практический кейс: Массовый сбор аптайма по SSH

Объединим настроенный ранее беспарольный доступ по SSH и GNU Parallel для безопасного и быстрого сбора данных с оборудования.

У нас есть файл switches.txt со списком IP-адресов. Мы хотим выполнить команду show system uptime на каждом из них, используя 20 параллельных потоков, и сохранить результат в единый лог, не нарушив порядок строк.

#!/bin/bash

# Файл со списком IP
HOSTS_FILE="switches.txt"
# Файл для результатов
OUTPUT_LOG="uptime_report.txt"

# Очищаем лог перед запуском
> "$OUTPUT_LOG"

echo "Начинаем сбор данных с $(wc -l < $HOSTS_FILE) устройств..."

# Запускаем GNU Parallel
cat "$HOSTS_FILE" | parallel -k -j 20 \
    "echo -n 'Host {}: '; ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new monitor@{} 'show system uptime'" >> "$OUTPUT_LOG"

echo "Сбор завершен. Результаты в $OUTPUT_LOG"

Особенности этого решения:

  • -j 20 (jobs) — аналог -P в xargs, задает лимит в 20 одновременных SSH-сессий.
  • -k (keep order) — заставляет parallel выводить результаты строго в том порядке, в котором IP-адреса записаны в switches.txt, даже если 10-й коммутатор ответил быстрее 1-го.
  • Мы используем ключи BatchMode и StrictHostKeyChecking, чтобы SSH-клиент не пытался запросить пароль или подтверждение отпечатка ключа (что мгновенно сломало бы автоматизацию в фоне).

Использование контролируемого пула потоков позволяет масштабировать Bash-скрипты до уровня enterprise-сетей, опрашивая тысячи устройств за десятки секунд без риска обрушить сервер мониторинга.

Сбор метрик сетевых интерфейсов через sysfs и procfs

Сбор метрик сетевых интерфейсов через sysfs и procfs

Вызов утилиты ip -s link для получения статистики по интерфейсу занимает около 2–3 миллисекунд. Кажется, что это мгновенно. Но если ваш скрипт мониторинга опрашивает 50 интерфейсов каждую секунду, вы начинаете порождать десятки тысяч новых процессов в час. Системный вызов fork(), необходимый для запуска внешней утилиты, потребляет процессорное время и память. В высоконагруженных системах мониторинга парсинг вывода консольных команд — это непозволительная роскошь.

Ядро Linux уже собирает всю сетевую статистику в реальном времени. Вместо того чтобы просить сторонние утилиты достать эти данные и красиво их отформатировать, скрипт может забирать их напрямую из памяти ядра. Мостом между ядром и пользовательским пространством (userspace) служат виртуальные файловые системы.

Виртуальная файловая система sysfs

В директории /sys смонтирована файловая система sysfs. Она не хранит данные на жестком диске. Каждый файл здесь — это окно к определенной структуре данных внутри работающего ядра Linux.

Сетевые интерфейсы представлены в директории /sys/class/net/. Если заглянуть внутрь папки конкретного интерфейса, например /sys/class/net/eth0/, мы увидим набор файлов, каждый из которых содержит ровно одну метрику.

Файл Содержимое и смысл
operstate Текущее состояние интерфейса (строка: up, down, unknown).
carrier Состояние физического линка (число: 1 — кабель подключен/линк есть, 0 — нет).
speed Согласованная скорость линка в Мбит/с (например, 1000 или 10000).
address MAC-адрес интерфейса.
mtu Текущее значение Maximum Transmission Unit.

Главное преимущество sysfs для Bash-скриптов — формат «один файл = одно значение». Нам не нужны grep, sed или jq, чтобы вытащить нужную цифру.

Чтобы прочитать значение максимально эффективно, не создавая дополнительных процессов (даже cat), используется встроенная команда Bash read с перенаправлением ввода:

# Читаем состояние линка напрямую в переменную
read -r LINK_STATE < /sys/class/net/eth0/carrier

if [[ $LINK_STATE -eq 1 ]]; then
    echo "Физический линк активен"
fi

Директория statistics

Внутри папки интерфейса есть поддиректория statistics/. В ней хранятся счетчики трафика и ошибок.

Наиболее востребованные файлы:

  • rx_bytes и tx_bytes — общее количество принятых и отправленных байт.
  • rx_packets и tx_packets — количество пакетов.
  • rx_errors и tx_errors — количество пакетов с ошибками (например, несовпадение контрольной суммы FCS).
  • rx_dropped и tx_dropped — пакеты, отброшенные ядром (часто из-за нехватки буферов).

Эти значения являются кумулятивными счетчиками. Они обнуляются только при перезагрузке системы или пересоздании виртуального интерфейса. Ядро не хранит текущую скорость (Мбит/с), оно хранит только абсолютное количество переданных данных с момента старта.

procfs: Взгляд на все интерфейсы разом

Если sysfs удобен для точечного извлечения метрик конкретного интерфейса, то виртуальная файловая система procfs (смонтирована в /proc) позволяет получить снимок состояния всей сети одним чтением.

Файл /proc/net/dev содержит таблицу со статистикой по всем сетевым интерфейсам системы.

Интерфейс /proc/net/dev является классическим и поддерживается даже в очень старых ядрах Linux, где sysfs может быть урезан или отсутствовать (например, в некоторых встроенных системах на базе BusyBox).

Если прочитать этот файл, мы увидим заголовок из двух строк и далее по одной строке на каждый интерфейс:

Inter-|   Receive                                                |  Transmit
 face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
    lo: 1548813   14589    0    0    0     0          0         0 1548813   14589    0    0    0     0       0          0
  eth0: 8493012   34912    0   12    0     0          0       120 4910234   21034    0    0    0     0       0          0

Поскольку данные представлены в виде структурированного текста с колонками, этот файл идеально подходит для обработки с помощью awk. Например, чтобы извлечь имя интерфейса и количество принятых байт (первое и второе поля после двоеточия), достаточно пропустить первые две строки заголовка:

awk 'NR > 2 {
    sub(/:/, "", $1); # Удаляем двоеточие после имени интерфейса
    print $1, $2
}' /proc/net/dev

Расчет пропускной способности (Bandwidth)

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

Логика расчета скорости опирается на два измерения, разнесенных во времени:

  1. Читаем текущее значение счетчика байт (B1B_1).
  2. Ждем фиксированный интервал времени (TT секунд).
  3. Читаем новое значение счетчика (B2B_2).

Формула расчета скорости в битах в секунду: Скорость (бит/с) = (B2B1)×8/T(B_2 - B_1) \times 8 / T

Для перевода в мегабиты в секунду (Мбит/с) результат нужно разделить на 1 000 000 (в сетях используются десятичные мегабиты, а не мебибиты).

Практический скрипт: Монитор скорости интерфейса

Напишем скрипт iface_monitor.sh, который каждую секунду выводит текущую скорость скачивания (RX) и отдачи (TX) для заданного интерфейса.

Так как Bash работает только с целыми числами, а при делении на миллион нам понадобятся дробные значения (например, 1.5 Мбит/с), мы делегируем математику утилите awk.

#!/bin/bash

IFACE=$1

if [[ -z "$IFACE" ]]; then
    echo "Использование: $0 <интерфейс>"
    exit 1
fi

RX_FILE="/sys/class/net/$IFACE/statistics/rx_bytes"
TX_FILE="/sys/class/net/$IFACE/statistics/tx_bytes"

if [[ ! -f "$RX_FILE" ]]; then
    echo "Ошибка: Интерфейс $IFACE не найден."
    exit 1
fi

# Читаем начальные значения
read -r RX_BYTES_1 < "$RX_FILE"
read -r TX_BYTES_1 < "$TX_FILE"

echo "Мониторинг $IFACE (Мбит/с)... Нажмите Ctrl+C для выхода."

# Бесконечный цикл мониторинга
while true; do
    sleep 1

    # Читаем новые значения
    read -r RX_BYTES_2 < "$RX_FILE"
    read -r TX_BYTES_2 < "$TX_FILE"

    # Вычисляем разницу и переводим в Мбит/с с помощью awk
    awk -v rx1="$RX_BYTES_1" -v rx2="$RX_BYTES_2" \
        -v tx1="$TX_BYTES_1" -v tx2="$TX_BYTES_2" 'BEGIN {

        rx_mbps = ((rx2 - rx1) * 8) / 1000000
        tx_mbps = ((tx2 - tx1) * 8) / 1000000

        printf "IN: %6.2f Mbps | OUT: %6.2f Mbps\n", rx_mbps, tx_mbps
    }'

    # Текущие значения становятся предыдущими для следующей итерации
    RX_BYTES_1=$RX_BYTES_2
    TX_BYTES_1=$TX_BYTES_2
done

Этот скрипт работает предельно эффективно. Внутри цикла while нет ни одного вызова cat, grep или внешних сетевых утилит. Единственный внешний процесс, который запускается на каждой итерации — это легковесный awk для математических вычислений, в то время как чтение данных из ядра происходит мгновенно средствами самого интерпретатора Bash.

Понимание структуры sysfs и procfs позволяет создавать агенты мониторинга, которые могут работать на слабом сетевом оборудовании (коммутаторах с Linux на борту, домашних роутерах на OpenWrt или IoT-шлюзах), не создавая нагрузки на CPU и не влияя на обработку полезного трафика.

Мониторинг доступности портов и сокетов с помощью nc и ss

Мониторинг доступности портов и сокетов с помощью nc и ss

Узел пингуется, но веб-интерфейс не открывается, а BGP-сессия лежит. Знакомая ситуация? ICMP-эхо (ping) проверяет только сетевой уровень (L3) и отвечает на вопрос «жив ли IP-стек». Но для сетевого инженера куда важнее знать, работает ли конкретный сервис на транспортном уровне (L4). В этой главе мы разберем, как проверять удаленные порты с помощью nc и анализировать локальные сокеты через ss.

Активные проверки удаленных узлов с помощью Netcat (nc)

Утилита nc (Netcat) — это «швейцарский нож» для TCP/UDP. Для задач мониторинга нас интересует ее режим сканирования, который позволяет проверить доступность порта без отправки полезной нагрузки.

Проверка TCP-портов

Для проверки TCP-порта используются три ключа:

  • -z (Zero-I/O) — устанавливает соединение и сразу закрывает его, не отправляя данные.
  • -w (Wait) — задает таймаут ожидания ответа в секундах.
  • -v (Verbose) — выводит текстовый результат (в скриптах обычно опускается, так как нам важен только статус).

Пример проверки доступности порта SSH:

nc -z -w 2 10.10.5.11 22

Как и у большинства утилит в Bash, результат работы nc нужно проверять через код возврата $?. Если порт открыт (успешное TCP-рукопожатие), утилита вернет 0. Если порт закрыт или заблокирован файрволом (произошел таймаут) — вернется ошибка.

Важно: Таймаут проверки должен быть строго меньше интервала запуска самого скрипта мониторинга. Если скрипт запускается каждую секунду, а таймаут nc равен 2 секундам, то при недоступности узла процессы начнут наслаиваться друг на друга и быстро исчерпают ресурсы системы.

Ловушка UDP: почему nc -u вам врет

Протокол UDP работает без установления соединения (stateless). У него нет механизма рукопожатия (как SYN/ACK в TCP), подтверждающего, что порт открыт.

Если вы попытаетесь проверить UDP-порт DNS-сервера:

nc -z -u -w 2 8.8.8.8 53

Утилита nc отправит пустой UDP-пакет и будет ждать. Если целевой порт закрыт, операционная система удаленного узла должна отправить в ответ ICMP-сообщение Port Unreachable. Получив его, nc поймет, что порт недоступен.

Но что, если на пути стоит файрвол, который просто отбрасывает (Drop) пакеты? В этом случае nc не получит никакого ответа. Поскольку для UDP отсутствие ответа — это норма, утилита решит, что пакет успешно доставлен, и вернет код 0 (успех).

Вывод: Проверка UDP-портов через nc -z -u дает ложноположительные результаты при наличии файрволов. Для надежного мониторинга UDP-сервисов нужно отправлять специфичный для протокола запрос (например, реальный DNS-запрос через dig или nslookup) и парсить ответ.

Мониторинг локальных состояний с помощью ss

Активные проверки (Netcat) показывают взгляд «снаружи». Но иногда нужно проверить состояние сокетов на самом Linux-маршрутизаторе (например, убедиться, что демон маршрутизации слушает нужный интерфейс или BGP-сессия перешла в рабочее состояние).

Утилита ss (Socket Statistics) — это современная, более быстрая замена устаревшему netstat. Она напрямую читает данные из ядра Linux.

Базовый синтаксис и флаги

Чаще всего ss используется с комбинацией флагов для фильтрации вывода:

Флаг Назначение
-t Показывать только TCP-сокеты
-u Показывать только UDP-сокеты
-l Показывать только прослушиваемые (Listening) порты
-n Не резолвить IP-адреса и порты в имена (значительно ускоряет работу)
-p Показывать PID и имя процесса (требует прав root)

Чтобы найти все локальные сервисы, ожидающие TCP-подключений, используйте:

ss -tln

Фильтрация по состояниям TCP

Настоящая мощь ss раскрывается в ее встроенном языке фильтрации. Вместо того чтобы передавать весь вывод в grep или awk, вы можете заставить ss отдать только нужные данные на уровне ядра.

Синтаксис фильтрации состояний: ss [флаги] state [состояние] [фильтр адресов]

Например, проверим, есть ли у нас установленная (Established) BGP-сессия (порт 179) с конкретным соседом 10.10.5.1:

ss -tn state established dst 10.10.5.1 dport = :179

Если вывод пуст — сессия лежит. Это работает гораздо быстрее и надежнее, чем парсинг огромного вывода netstat через конвейер.

Практический скрипт: Комплексный мониторинг сервисов

Соберем изученные инструменты в единый скрипт. Он будет читать список критичных сервисов, проверять удаленные TCP-порты с помощью nc и контролировать локальные BGP-сессии с помощью ss.

#!/bin/bash

# Массив сервисов в формате "IP PORT PROTOCOL NAME"
SERVICES=(
    "10.10.5.11 22 tcp Core_SSH"
    "10.10.5.12 443 tcp Core_Web"
)

echo "--- Проверка удаленных сервисов ---"
for svc in "${SERVICES[@]}"; do
    # Читаем строку массива в отдельные переменные с помощью встроенной команды read
    read -r ip port proto name <<< "$svc"

    if [[ "$proto" == "tcp" ]]; then
        # Проверяем порт с таймаутом 2 секунды
        if nc -z -w 2 "$ip" "$port"; then
            echo "[ OK ] $name ($ip:$port) доступен"
        else
            echo "[FAIL] $name ($ip:$port) не отвечает"
        fi
    fi
done

echo ""
echo "--- Проверка локальных BGP-сессий ---"

# Проверка установленных соединений локального маршрутизатора
BGP_PEERS=("10.10.5.1" "10.10.5.2")

for peer in "${BGP_PEERS[@]}"; do
    # Ищем установленные TCP-сессии до порта 179 конкретного пира (флаг -H убирает заголовок)
    SESSION=$(ss -H -tn state established dst "$peer" dport = :179)

    # Если строка SESSION не пустая (-n), значит сессия есть
    if [[ -n "$SESSION" ]]; then
        echo "[ UP ] BGP-сессия с $peer установлена"
    else
        echo "[DOWN] BGP-сессия с $peer отсутствует"
    fi
done

Этот скрипт работает на уровне транспортных протоколов и дает гораздо более точную картину здоровья сети, чем обычный ICMP-мониторинг. В дальнейшем мы научимся связывать такие проверки с системами алертинга, чтобы вовремя узнавать о сбоях.

Автоматизация резервного копирования конфигураций по SFTP и TFTP

Автоматизация резервного копирования конфигураций по SFTP и TFTP

Система мониторинга, которую мы строили в предыдущих главах, может мгновенно сообщить, что ядро сети недоступно. Но если в этот момент у вас нет актуальной резервной копии конфигурации вышедшего из строя коммутатора, знание о проблеме не поможет её решить. Мониторинг без автоматического бэкапа — это просто инструмент для наблюдения за катастрофой.

В этой статье мы разберем, как с помощью Bash организовать регулярное сохранение конфигураций сетевого оборудования, используя два классических протокола: легковесный TFTP и защищенный SFTP.

Две парадигмы: Push против Pull

При автоматизации резервного копирования сетевой инженер всегда выбирает между двумя архитектурными подходами: заставить коммутатор отправить конфиг на сервер (Push) или заставить сервер забрать конфиг с коммутатора (Pull).

Характеристика TFTP (Push-модель) SFTP / SCP (Pull-модель)
Инициатор Сетевое устройство (клиент) Bash-скрипт на сервере (клиент)
Аутентификация Отсутствует (анонимный доступ) По SSH-ключам
Шифрование Нет (данные идут в открытом виде) Да (туннель SSH)
Сложность скрипта Высокая (нужно имитировать ввод в CLI) Низкая (нативные утилиты Linux)

Исторически сети строились на TFTP. Но сегодня, когда конфигурации содержат хэши паролей и ключи IPSec, передавать их в открытом виде по UDP неприемлемо, поэтому стандартом де-факто стал протокол SFTP (или более старый протокол SCP).

TFTP: Инициирование отправки через SSH

Поскольку TFTP-сервер на Linux (например, tftpd-hpa) работает пассивно и просто ждет входящих подключений на UDP-порт 69, наш Bash-скрипт не может «скачать» файл по TFTP напрямую. Скрипт должен подключиться к сетевому устройству по SSH и дать ему команду на отправку.

Для старого оборудования (например, Cisco IOS) это выглядит как отправка команды в неинтерактивном режиме. Чтобы коммутатор не задавал уточняющих вопросов (имя файла, IP сервера), используется пайплайн для передачи символов возврата каретки (Enter), либо предварительная настройка file prompt quiet на самом устройстве.

Пример команды, которую наш скрипт отправляет на коммутатор для инициации бэкапа:

# Передаем пустые строки (Enter) через echo, чтобы подтвердить дефолтные промпты Cisco IOS
echo -e "\n\n\n" | ssh -o BatchMode=yes admin@10.10.5.11 "copy running-config tftp://10.10.5.100/sw11-confg"

Важно: Этот подход хрупок. Если коммутатор изменит формат диалога или сеть моргнет, скрипт не сможет корректно обработать ошибку. Для надежной работы с интерактивным CLI лучше использовать expect (как мы делали в 11 главе), но в современных сетях от этого уходят в пользу Pull-модели.

SCP и SFTP: Безопасное извлечение (Pull)

Если оборудование поддерживает SSH, оно почти наверняка поддерживает SCP (Secure Copy) или SFTP (SSH File Transfer Protocol). В этом случае наш Bash-сервер выступает активным клиентом. Нам не нужно парсить вывод CLI — мы просто копируем файл так же, как делали бы это между двумя Linux-серверами.

Использование SCP

SCP — самый простой способ забрать файл одной строкой. Он использует те же ключи и параметры, что и обычный SSH.

#!/bin/bash

BACKUP_DIR="/var/backups/network"
mkdir -p "$BACKUP_DIR"

# Забираем конфиг с Juniper (где он хранится в виде файла)
scp -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
    svc_backup@10.10.5.21:/config/juniper.conf.gz \
    "$BACKUP_DIR/core-rtr-01.conf.gz"

Использование SFTP в пакетном режиме (Batch Mode)

Некоторые вендоры (например, Arista EOS или современные версии Cisco IOS XE) предоставляют доступ к виртуальной файловой системе через SFTP. Интерактивная утилита sftp требует ввода команд (get, cd), но для автоматизации в Bash существует пакетный режим (флаг -b).

Мы можем комбинировать sftp -b с механизмом Heredoc, чтобы передать команды скачивания прямо из скрипта, не создавая внешних файлов:

#!/bin/bash

TARGET_IP="10.10.5.22"
BACKUP_DIR="/var/backups/network"

# Флаг -b - указывает читать команды из файла.
# Подстановка процесса <(...) позволяет передать Heredoc как файл.
sftp -o BatchMode=yes -b <(cat <<EOF
cd /mnt/flash
get startup-config $BACKUP_DIR/arista-sw-01.cfg
bye
EOF
) svc_backup@$TARGET_IP

Версионирование: добавление временных меток

Если скрипт будет ежедневно скачивать arista-sw-01.cfg, он будет перезаписывать вчерашний файл. Чтобы получить историю изменений, к имени файла необходимо добавлять метку времени.

В Bash для этого используется команда date с форматированием. Самые частые форматы:

  • date +%F — выдаст 2026-07-25 (удобно для ежедневных бэкапов).
  • date +%Y%m%d_%H%M%S — выдаст 20260725_143000 (для частых бэкапов).

Интегрируем это в наш процесс:

TIMESTAMP=$(date +%F)
HOSTNAME="arista-sw-01"
FILENAME="${HOSTNAME}_${TIMESTAMP}.cfg"

scp -o BatchMode=yes svc_backup@10.10.5.22:/mnt/flash/startup-config "/var/backups/network/$FILENAME"

В результате в директории начнут копиться файлы вида arista-sw-01_2026-07-25.cfg.

Ротация: автоматическая очистка старых копий

Дисковое пространство сервера мониторинга не бесконечно. Если скрипт будет работать годами, папка /var/backups/network забьет весь диск. Скрипт резервного копирования всегда должен завершаться процедурой ротации — удалением файлов старше определенного возраста.

Для этого в Linux существует утилита find с параметром -mtime (modification time).

# Найти в папке бэкапов все обычные файлы (-type f),
# которые были изменены более 30 дней назад (-mtime +30) и удалить их (-delete)
find /var/backups/network -type f -mtime +30 -delete

Осторожно: Никогда не используйте -delete вместе с переменными путями без проверок. Если переменная $BACKUP_DIR окажется пустой из-за ошибки в скрипте, команда find / -type f -mtime +30 -delete начнет уничтожать системные файлы Linux.

Итоговый скрипт массового резервного копирования

Объединим циклы, работу с массивами, SCP, временные метки и ротацию в единый рабочий инструмент.

#!/bin/bash

# 1. Настройки
BACKUP_ROOT="/var/backups/network"
TIMESTAMP=$(date +%F)
RETENTION_DAYS=30

# Ассоциативный массив: Ключ - IP, Значение - Имя хоста
declare -A DEVICES=(
    ["10.10.5.21"]="core-rtr-01"
    ["10.10.5.22"]="dist-sw-01"
    ["10.10.5.23"]="dist-sw-02"
)

# 2. Создание структуры директорий
mkdir -p "$BACKUP_ROOT"

# 3. Сбор конфигураций
for IP in "${!DEVICES[@]}"; do
    HOSTNAME="${DEVICES[$IP]}"
    DEST_FILE="$BACKUP_ROOT/${HOSTNAME}_${TIMESTAMP}.cfg"

    echo "Starting backup for $HOSTNAME ($IP)..."

    # Пытаемся забрать конфиг. Если код возврата 0 - успех.
    if scp -o BatchMode=yes -o StrictHostKeyChecking=accept-new -q \
        svc_backup@$IP:/running-config "$DEST_FILE"; then
        echo "Success: $HOSTNAME"
    else
        echo "Error: Failed to backup $HOSTNAME" >&2
        # В будущих главах здесь появится отправка алерта в мессенджер
    fi
done

# 4. Очистка старых бэкапов (Ротация)
echo "Cleaning up backups older than $RETENTION_DAYS days..."
# Защита от пустой переменной пути
if [[ -d "$BACKUP_ROOT" ]]; then
    find "$BACKUP_ROOT" -type f -name "*.cfg" -mtime +$RETENTION_DAYS -delete
fi

echo "Backup process finished."

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

Интеграция скриптов с API сетевого оборудования через curl

Интеграция скриптов с API сетевого оборудования через curl

Вы обновили прошивку коммутатора, и в выводе show interfaces вендор добавил один новый столбец или лишний пробел. Ваш скрипт, годами собиравший метрики через awk '{print $4}', внезапно начинает собирать мусор, ломая графики в системе мониторинга. Парсинг CLI-вывода — это всегда ходьба по минному полю.

Современный подход к автоматизации сетей строится на REST API (RESTCONF, NX-API, eAPI). Оборудование отдает данные не в виде текста, предназначенного для глаз человека, а в виде строго структурированного JSON. В этой статье мы научимся общаться с сетевыми API напрямую из Bash с помощью утилиты curl, объединив её с уже знакомым нам jq в единый конвейер без промежуточных файлов.

Базовый арсенал curl для сетевика

Утилита curl — это универсальный инструмент для передачи данных по сети. При работе с внутренним сетевым оборудованием нам почти всегда требуется обходить проблемы с самоподписанными сертификатами, передавать авторизационные данные и управлять заголовками.

Основные флаги, которые станут вашим стандартом:

Флаг Назначение в сетевых скриптах
-s Silent-режим. Отключает вывод прогресс-бара и статистики. Критически важен, если результат передается по конвейеру в jq, иначе служебный вывод curl сломает парсер.
-k Отключает проверку SSL-сертификата. Большинство коммутаторов используют самоподписанные сертификаты, без этого флага curl откажется устанавливать соединение.
-u Передача учетных данных в формате user:password для Basic-авторизации.
-H Добавление HTTP-заголовка. Чаще всего используется для указания формата данных: Accept: application/yang-data+json.
-X Указание HTTP-метода (GET, POST, PUT, DELETE). Если не указан, по умолчанию используется GET.
-d Передача тела запроса (payload). При использовании этого флага метод автоматически меняется на POST.

REST API — архитектурный стиль взаимодействия компонентов, где каждое устройство предоставляет набор URL-адресов (эндпоинтов), обращение к которым с разными HTTP-методами позволяет читать или изменять конфигурацию.

Получение данных: GET-запросы и прямой конвейер в jq

Рассмотрим задачу: нам нужно получить оперативный статус интерфейса GigabitEthernet1 с маршрутизатора Cisco IOS XE по протоколу RESTCONF.

Вместо того чтобы сохранять ответ в файл, мы направим поток данных напрямую в jq, выполняя обещание обходиться без временных файлов на диске.

#!/bin/bash

ROUTER_IP="10.10.5.50"
AUTH="admin:Cisco123"
ENDPOINT="https://${ROUTER_IP}/restconf/data/Cisco-IOS-XE-interfaces-oper:interfaces/interface=GigabitEthernet1"

# Запрашиваем данные и сразу извлекаем значение oper-status
STATUS=$(curl -s -k -u "$AUTH" -H "Accept: application/yang-data+json" "$ENDPOINT" | \
         jq -r '.["Cisco-IOS-XE-interfaces-oper:interface"]."oper-status"')

echo "Статус интерфейса: $STATUS"

Флаг -s гарантирует, что в конвейер | попадет только чистый JSON-ответ от маршрутизатора. Флаг -r в jq снимает кавычки, и в переменную STATUS записывается чистое значение (например, up или down).

Отправка команд: POST-запросы и передача JSON через stdin

Чтение данных — лишь половина дела. Часто нужно отправить команду на устройство, например, запросить таблицу маршрутизации или изменить конфигурацию. Для этого используется метод POST и передача JSON-структуры в теле запроса.

Сетевые инженеры часто совершают ошибку, пытаясь собрать сложный JSON в одну строку внутри команды curl. Это делает код нечитаемым и уязвимым к ошибкам экранирования кавычек.

Элегантное решение — использовать конструкцию Heredoc совместно с флагом -d @-. Символ @ указывает curl читать данные из файла, а дефис - означает стандартный ввод (stdin).

Пример отправки команды show version через Arista eAPI:

#!/bin/bash

SWITCH_IP="10.10.5.60"
AUTH="admin:AristaSecret"
ENDPOINT="https://${SWITCH_IP}/command-api"

# Передаем JSON-тело запроса через Heredoc напрямую в curl
RESPONSE=$(curl -s -k -u "$AUTH" -H "Content-Type: application/json" -X POST "$ENDPOINT" -d @- <<EOF
{
  "jsonrpc": "2.0",
  "method": "runCmds",
  "params": {
    "version": 1,
    "cmds": ["show version"],
    "format": "json"
  },
  "id": 1
}
EOF
)

# Извлекаем версию ОС
OS_VERSION=$(echo "$RESPONSE" | jq -r '.result[0].version')
echo "Версия EOS: $OS_VERSION"

Такой подход позволяет сохранить визуальную структуру JSON прямо внутри Bash-скрипта, что значительно упрощает поддержку кода.

Безопасная обработка ответов: извлечение HTTP-кодов

Предыдущие примеры работают идеально, пока сеть и оборудование в порядке. Но что произойдет, если пароль изменится? Маршрутизатор вернет ошибку 401 (Unauthorized), причем тело ответа может быть пустым или содержать HTML-страницу авторизации. Конвейер передаст этот HTML в jq, который завершится с критической ошибкой парсинга.

Чтобы скрипт был отказоустойчивым, мы должны проверять HTTP-код ответа до того, как пытаться распарсить тело. Успешность запроса определяется условием 200C<300200 \leq C < 300, где CC — полученный HTTP-код. Например, если сервер возвращает C=200C = 200 (OK), условие выполняется и скрипт безопасно передает данные в парсер. Если же возвращается C=404C = 404 (Not Found) или C=401C = 401 (Unauthorized), условие не выполняется, и скрипт может корректно обработать ошибку, не вызывая сбой jq.

Проблема в том, что curl по умолчанию выводит только тело ответа. Чтобы получить HTTP-код, используется параметр -w "%{http_code}". Но как разделить тело ответа (для jq) и код (для if) без создания временных файлов?

Используем продвинутый паттерн Bash с подстановкой вывода: мы заставим curl добавить код ответа с новой строки в самом конце вывода, а затем с помощью sed и tail разделим эти данные.

#!/bin/bash

ROUTER_IP="10.10.5.50"
AUTH="admin:wrong_password"
ENDPOINT="https://${ROUTER_IP}/restconf/data/Cisco-IOS-XE-native:native/hostname"

# 1. Выполняем запрос. \n%{http_code} добавит код ответа новой строкой в конец
RAW_OUTPUT=$(curl -s -k -u "$AUTH" -H "Accept: application/yang-data+json" \
             -w "\n%{http_code}" "$ENDPOINT")

# 2. Извлекаем последнюю строку (это наш HTTP-код)
HTTP_CODE=$(echo "$RAW_OUTPUT" | tail -n1)

# 3. Извлекаем всё, кроме последней строки (это тело ответа)
BODY=$(echo "$RAW_OUTPUT" | sed '$d')

# 4. Проверяем успешность запроса
if [[ "$HTTP_CODE" -ge 200 && "$HTTP_CODE" -lt 300 ]]; then
    HOSTNAME=$(echo "$BODY" | jq -r '."Cisco-IOS-XE-native:hostname"')
    echo "Успешно. Hostname: $HOSTNAME"
else
    echo "Ошибка API! HTTP Код: $HTTP_CODE"
    # Здесь можно вывести BODY для отладки, если API возвращает описание ошибки
    echo "Детали: $BODY"
fi

Этот шаблон является золотым стандартом для интеграции Bash-скриптов с любыми REST API. Он защищает логику от неожиданных ответов балансировщиков, файрволов или страниц авторизации, гарантируя, что jq получит на вход только валидные данные.

Теперь мы умеем надежно собирать метрики и управлять конфигурацией через API, обходя хрупкость CLI-парсинга. Но если скрипт зафиксировал сбой (например, недоступность API или ошибку резервного копирования), он должен сообщить об этом команде. Как превратить наши скрипты в полноценных ботов, мы разберем на следующем этапе.

Отправка уведомлений в Telegram и Slack при сетевых инцидентах

Отправка уведомлений в Telegram и Slack при сетевых инцидентах

Скрипт успешно обнаружил упавшую BGP-сессию или неудачную попытку резервного копирования конфигурации. Что дальше? Если он просто завершится с кодом ошибки или запишет строку в локальный лог-файл, инженеры узнают о проблеме только после звонка недовольного клиента. Автоматизация теряет половину своей ценности, если её результаты невидимы для команды.

В этой статье мы превратим наши bash-скрипты в полноценных ботов, которые будут пушить алерты туда, где команда работает каждый день — в мессенджеры.

Архитектура уведомлений: API против Webhook

Для отправки сообщений из скрипта мы будем использовать HTTP-запросы через уже знакомую нам утилиту curl. Однако подходы Telegram и Slack к приему таких запросов немного отличаются.

Характеристика Telegram Bot API Slack Incoming Webhook
Точка входа (URL) Единый API для всех ботов: api.telegram.org Уникальный сгенерированный URL для конкретного канала
Аутентификация Токен бота передается прямо в URL Скрыта внутри уникального URL вебхука
Адресация Требуется явно указать chat_id в теле запроса Канал уже жестко привязан к URL вебхука
Формат данных JSON или application/x-www-form-urlencoded Строго JSON

Оба сервиса ожидают POST-запрос с полезной нагрузкой. Главная задача bash-скрипта — правильно сформировать этот запрос и безопасно передать в него текст инцидента.

Telegram: отправка сообщений через Bot API

Чтобы скрипт мог писать в Telegram, вам потребуется создать бота через официального @BotFather (он выдаст токен) и узнать ID чата или группы, куда бот добавлен.

API Telegram максимально прямолинеен. Метод sendMessage ожидает POST-запрос, содержащий как минимум два параметра: chat_id и text.

BOT_TOKEN="123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ"
CHAT_ID="-1001234567890"
MESSAGE="⚠️ Узел 10.0.0.5 недоступен. Потеря пакетов 100%."

curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
    -H "Content-Type: application/json" \
    -d "{\"chat_id\": \"${CHAT_ID}\", \"text\": \"${MESSAGE}\"}"

Здесь мы интерполируем переменные Bash прямо в строку JSON. Для простых текстовых сообщений это работает, но таит в себе критическую уязвимость, которую мы разберем чуть позже.

Telegram поддерживает форматирование текста. Если добавить в JSON параметр "parse_mode": "MarkdownV2", вы сможете использовать жирный шрифт, моноширинный текст для логов и ссылки.

Slack: использование Incoming Webhooks

Slack использует механизм вебхуков. Администратор рабочего пространства создает интеграцию "Incoming Webhook" для нужного канала и получает длинный уникальный URL. Отправка POST-запроса с JSON на этот URL автоматически публикует сообщение.

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

SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T0000/B000/XXXX"
INCIDENT_TEXT="BGP сессия с AS65001 упала (Idle)"

# Формируем JSON с красной полосой (color: danger)
JSON_PAYLOAD=$(cat <<EOF
{
  "attachments": [
    {
      "color": "danger",
      "title": "🚨 Сетевой инцидент",
      "text": "${INCIDENT_TEXT}"
    }
  ]
}
EOF
)

curl -s -X POST "${SLACK_WEBHOOK_URL}" \
    -H "Content-Type: application/json" \
    -d "${JSON_PAYLOAD}"

Ловушка ручной сборки JSON

В обоих примерах выше мы подставляли переменную Bash (${MESSAGE} или ${INCIDENT_TEXT}) напрямую в строку JSON. В реальной эксплуатации систем мониторинга это гарантированно приведет к поломке скрипта.

Представьте, что текст ошибки пришел от сетевого устройства и содержит двойные кавычки или переносы строк: MESSAGE='Ошибка интерфейса: "GigabitEthernet1/0/1" down'

Если подставить это в наш JSON для Telegram напрямую, получится невалидная структура: {"chat_id": "123", "text": "Ошибка интерфейса: "GigabitEthernet1/0/1" down"}

Парсер API споткнется о лишние кавычки, вернет ошибку HTTP 400, и команда не получит критический алерт.

Безопасная генерация JSON с помощью jq

Вместо того чтобы собирать JSON как обычный текст, мы должны поручить это специализированному инструменту. Утилита jq, которую мы ранее использовали для чтения ответов API, умеет конструировать безопасный JSON с нуля.

Для этого используется флаг -n (null input — не читать данные из stdin) и аргумент --arg для передачи переменных Bash внутрь jq:

# Опасная строка с кавычками и переносом
RAW_TEXT="Интерфейс \"Gi1/0/1\" упал.
Проверьте линк."

# Безопасная генерация JSON
SAFE_JSON=$(jq -n --arg chat "$CHAT_ID" --arg msg "$RAW_TEXT" \
    '{chat_id: $chat, text: $msg}')

echo "$SAFE_JSON"

Вывод команды покажет, что jq автоматически экранировал кавычки (\") и переносы строк (\n), создав идеальный JSON:

{
  "chat_id": "-1001234567890",
  "text": "Интерфейс \"Gi1/0/1\" упал.\nПроверьте линк."
}

Всегда используйте паттерн jq -n --arg при отправке динамических данных (логов, ответов оборудования, имен интерфейсов) во внешние API.

Практическая реализация: универсальная функция алертинга

Свяжем всё вместе. Напишем функцию, которая инкапсулирует логику отправки в Slack, безопасно форматирует сообщение и обрабатывает статус HTTP-ответа. Эту функцию можно поместить в вашу библиотеку скриптов и вызывать при любых инцидентах.

#!/bin/bash

SLACK_WEBHOOK="https://hooks.slack.com/services/T0000/B000/XXXX"

# Функция принимает два аргумента: статус (CRIT или OK) и текст сообщения
send_slack_alert() {
    local status="$1"
    local message="$2"
    local color=""
    local title=""

    # Определяем цвет и заголовок на основе статуса
    if [[ "$status" == "CRIT" ]]; then
        color="#FF0000" # Красный
        title="🚨 АВАРИЯ"
    elif [[ "$status" == "OK" ]]; then
        color="#36A64F" # Зеленый
        title="✅ ВОССТАНОВЛЕНИЕ"
    else
        color="#AAAAAA" # Серый по умолчанию
        title="ℹ️ ИНФО"
    fi

    # Безопасно генерируем JSON payload
    local payload
    payload=$(jq -n \
        --arg color "$color" \
        --arg title "$title" \
        --arg msg "$message" \
        '{
            attachments: [
                {
                    color: $color,
                    title: $title,
                    text: $msg
                }
            ]
        }')

    # Отправляем запрос и сохраняем HTTP-код
    local http_code
    http_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${SLACK_WEBHOOK}" \
        -H "Content-Type: application/json" \
        -d "$payload")

    if [[ "$http_code" -ne 200 ]]; then
        echo "Ошибка отправки алерта. HTTP код: $http_code" >&2
        return 1
    fi
}

# --- Пример использования в скрипте мониторинга ---

TARGET_IP="10.10.5.1"
BACKUP_FILE="/var/backups/sw-core-01.cfg"

# Имитация проверки бэкапа (из главы про резервное копирование)
if ! ls "$BACKUP_FILE" >/dev/null 2>&1; then
    # Отправляем критический алерт, если файл не найден
    ERROR_DETAILS="Файл $BACKUP_FILE не найден на сервере."
    send_slack_alert "CRIT" "Сбой резервного копирования узла $TARGET_IP. $ERROR_DETAILS"
else
    # Если всё хорошо, можно отправить уведомление об успехе (опционально)
    send_slack_alert "OK" "Резервная копия $TARGET_IP успешно создана."
fi

Теперь ваш скрипт не просто работает в фоне, а активно взаимодействует с командой. Если процесс сломается на этапе загрузки конфигурации или проверки BGP-сессии, дежурный инженер мгновенно получит отформатированное пуш-уведомление на телефон.

Обработка сигналов завершения и безопасный выход из скриптов

Обработка сигналов завершения и безопасный выход из скриптов

Вы запускаете скрипт, который обновляет списки контроля доступа (ACL) на пятидесяти пограничных маршрутизаторах. На двадцать третьем устройстве вы замечаете опечатку в IP-адресе подсети, выведенном в консоль, и рефлекторно нажимаете Ctrl+C. Скрипт мгновенно обрывается.

Что осталось «за кадром» этого прерывания? На диске сервера остался временный файл с расшифрованным паролем от TACACS+. На маршрутизаторе зависла открытая конфигурационная сессия, блокирующая изменения для других инженеров. А система мониторинга так и не получила алерт, потому что выполнение до него не дошло.

Автоматизация сетевых задач требует предсказуемости. Скрипт должен уметь убирать за собой мусор и корректно закрывать сетевые сессии, даже если его работу грубо прервали.

Анатомия прерываний: POSIX-сигналы

Когда вы нажимаете Ctrl+C или останавливаете службу через systemctl stop, операционная система не просто «выключает» процесс. Она отправляет ему программное уведомление — POSIX-сигнал.

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

Сигнал Номер Источник Можно ли перехватить? Назначение в контексте скриптов
SIGINT 2 Ctrl+C с клавиатуры Да Интерактивное прерывание пользователем.
SIGTERM 15 Утилита kill без флагов, systemd Да Мягкая просьба завершить работу (стандартный способ остановки демонов).
SIGKILL 9 kill -9 Нет Безусловное уничтожение процесса ядром ОС. Скрипт не успеет ничего сделать.

Когда Bash получает фатальный сигнал и не имеет инструкций по его обработке, он немедленно завершает работу с кодом возврата, который вычисляется по формуле: C=128+SC = 128 + S где SS — номер сигнала. Например, при прерывании через Ctrl+C (S=2S = 2) код возврата скрипта будет C=130C = 130.

Команда trap: ловушка для сигналов

Для перехвата сигналов во время выполнения скрипта используется встроенная команда trap. Она связывает выполнение определенной команды или функции с получением списка сигналов.

Синтаксис выглядит так: trap 'команды' СИГНАЛ1 СИГНАЛ2

Рассмотрим базовый пример. Скрипт выгружает объемную таблицу маршрутизации во временный файл для последующего анализа. Если прервать его до завершения, файл останется на диске.

#!/bin/bash

TEMP_FILE="/tmp/bgp_routes_$$.txt"

# Устанавливаем ловушку: удалить файл при получении SIGINT или SIGTERM
trap 'echo "Прерывание! Удаляю $TEMP_FILE"; rm -f "$TEMP_FILE"; exit 1' SIGINT SIGTERM

echo "Выгрузка Full View BGP (может занять время)..."
# Имитация долгого процесса
sleep 30 > "$TEMP_FILE"

echo "Анализ завершен."
rm -f "$TEMP_FILE"

Здесь переменная $$ содержит идентификатор текущего процесса (PID), что делает имя файла уникальным. Если во время sleep нажать Ctrl+C, сработает trap, выведет сообщение, удалит файл и завершит скрипт с кодом ошибки.

Псевдосигнал EXIT и универсальная очистка

В предыдущем примере есть архитектурный изъян: команду rm -f "$TEMP_FILE" пришлось написать дважды — внутри trap для обработки прерываний и в конце скрипта для штатного завершения. Если временных файлов или сетевых сессий станет больше, дублирование кода приведет к ошибкам.

Bash предоставляет элегантное решение — псевдосигнал EXIT (или 0). Ловушка, повешенная на EXIT, срабатывает всегда, когда скрипт завершает работу: дошел ли он до конца, упал ли из-за ошибки выполнения (например, при использовании set -e) или был прерван через SIGINT/SIGTERM.

Хорошей практикой является вынос логики очистки в отдельную функцию.

#!/bin/bash

TEMP_DIR="/tmp/fw_audit_$$"

cleanup() {
    local exit_code=$?
    echo "Очистка ресурсов перед выходом..."
    rm -rf "$TEMP_DIR"

    # Можно использовать код возврата для финального логирования
    if [ $exit_code -ne 0 ]; then
        echo "Скрипт завершился с ошибкой (код $exit_code)."
    fi
    # trap EXIT автоматически сохраняет оригинальный код возврата скрипта
}

# Вешаем функцию cleanup на событие выхода
trap cleanup EXIT

mkdir -p "$TEMP_DIR"
echo "Сбор логов с межсетевых экранов..."

# Если здесь произойдет прерывание, функция cleanup все равно выполнится
sleep 10

echo "Сбор успешно завершен."
# Скрипт заканчивается, срабатывает trap EXIT

Управление состоянием сетевых сессий

Удаление локальных файлов — простая задача. Гораздо критичнее управлять состоянием на удаленном оборудовании.

Современные сетевые устройства (Cisco ACI, Palo Alto, NSX-T) работают через REST API с использованием токенов авторизации (Bearer/Session tokens). У каждого токена есть время жизни (TTL). Если скрипт авторизовался, получил токен, но был прерван и не отправил запрос на Logout, сессия останется висеть в памяти контроллера. При частых запусках таких скриптов пул доступных сессий на устройстве быстро исчерпается, и API перестанет отвечать (API Exhaustion).

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

#!/bin/bash

API_URL="https://10.0.0.10/api"
AUTH_TOKEN=""

logout_api() {
    if [ -n "$AUTH_TOKEN" ]; then
        echo "Отзыв API-токена..."
        curl -s -k -X POST "$API_URL/logout" \
             -H "Authorization: Bearer $AUTH_TOKEN" > /dev/null
    fi
}

# Гарантируем, что logout произойдет при любом сценарии завершения
trap logout_api EXIT

echo "Авторизация на контроллере..."
# Получаем токен (предполагаем, что jq извлекает его из ответа)
AUTH_TOKEN=$(curl -s -k -X POST "$API_URL/login" -d '{"user":"admin","pass":"secret"}' | jq -r .token)

if [ -z "$AUTH_TOKEN" ] || [ "$AUTH_TOKEN" == "null" ]; then
    echo "Ошибка авторизации"
    exit 1 # Сработает trap EXIT, но токен пуст, поэтому curl не выполнится
fi

echo "Применение политик маршрутизации..."
sleep 5 # Имитация работы с API

echo "Политики применены успешно."
# По завершении скрипта автоматически вызовется logout_api

Благодаря проверке if [ -n "$AUTH_TOKEN" ] внутри функции очистки, скрипт не будет пытаться разлогиниться, если прерывание произошло до успешной авторизации.

Игнорирование сигналов в критических секциях

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

Представьте процесс применения конфигурации BGP. Сначала скрипт отправляет команды, а затем выполняет commit. Если нажать Ctrl+C ровно между отправкой команд и коммитом, устройство может остаться в нестабильном состоянии "uncommitted changes".

Чтобы защитить критический участок кода, можно передать команде trap пустую строку '' вместо действия. Это заставит Bash полностью игнорировать указанные сигналы. После завершения опасной операции стандартное поведение восстанавливается передачей символа -.

#!/bin/bash

echo "Подготовка конфигурации..."
sleep 2

# Начало критической секции: игнорируем прерывания
trap '' SIGINT SIGTERM
echo "[КРИТИЧЕСКАЯ СЕКЦИЯ] Применение BGP конфигурации. Не прерывать!"

# Если сейчас нажать Ctrl+C, скрипт проигнорирует нажатие
sleep 5
echo "Commit выполнен."

# Конец критической секции: возвращаем стандартную реакцию на сигналы
trap - SIGINT SIGTERM

echo "Проверка связности..."
sleep 5 # Здесь прерывание снова работает

Грамотное управление сигналами превращает Bash-скрипт из хрупкой последовательности команд в надежный инструмент, который уважает состояние сетевой инфраструктуры и не оставляет после себя «грязных» следов.

Логирование работы сценариев и интеграция с syslog

Логирование работы сценариев и интеграция с syslog

Когда вы запускаете скрипт вручную в терминале, обычного echo "BGP session down" вполне достаточно — вы сразу видите результат на экране. Но как только сценарий переносится на сервер для фонового выполнения, вывод в стандартный поток теряется. Если в три часа ночи скрипт не смог подключиться к коммутатору, утром вы не узнаете ни причину, ни точное время сбоя. Скриптам, работающим в автоматическом режиме, нужен надежный след.

Самый очевидный подход — перенаправить вывод в текстовый файл с помощью оператора >>. Однако сырой текст без временных меток и контекста трудно анализировать. Более того, сетевая инфраструктура уже имеет устоявшийся стандарт сбора событий — протокол syslog. Вместо того чтобы плодить разрозненные текстовые файлы, логичнее научить Bash-скрипты отправлять сообщения в ту же систему, куда пишут ваши маршрутизаторы и межсетевые экраны.

Базовое форматирование логов внутри скрипта

Если задача не требует интеграции с глобальными системами мониторинга, можно обойтись записью в локальный файл. Чтобы такой лог был читаемым, каждое сообщение должно сопровождаться датой, временем и уровнем критичности (Severity).

Вместо вызова echo перед каждым действием создается единая функция логирования:

LOG_FILE="/var/log/network_monitor.log"

log_msg() {
    local level="$1"
    local msg="$2"
    local timestamp=$(date "+%Y-%m-%d %H:%M:%S")

    echo "[${timestamp}] [${level}] ${msg}" >> "${LOG_FILE}"
}

# Использование в коде
ping -c 3 10.0.0.1 > /dev/null
if [[ $? -ne 0 ]]; then
    log_msg "ERROR" "Core router 10.0.0.1 is unreachable"
else
    log_msg "INFO" "Core router 10.0.0.1 is UP"
fi

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

Интеграция с системным журналом через logger

В Linux-системах за централизованный сбор логов отвечает демон rsyslog или systemd-journald. Для взаимодействия с ними из командной строки существует встроенная утилита logger. Она берет переданный текст и корректно передает его системному демону, который сам проставит точное время, имя хоста и сохранит данные в /var/log/syslog (или /var/log/messages).

Простейший вызов выглядит так:

logger "Configuration backup for Switch-01 completed"

Архитектура syslog: Facility и Severity

Протокол syslog классифицирует каждое сообщение по двум координатам, чтобы системы фильтрации могли правильно их маршрутизировать:

  1. Facility (Категория источника) — указывает, какая подсистема сгенерировала лог (например, mail, cron, auth). Для пользовательских процессов и сетевого оборудования зарезервированы категории от local0 до local7.
  2. Severity (Уровень критичности) — определяет важность события.
Severity Значение Применение в сетевых скриптах
emerg Система неработоспособна Падение ядра сети, изоляция дата-центра
alert Требуется немедленное вмешательство Отказ резервного канала при мертвом основном
crit Критическое состояние Падение BGP-сессии с основным аплинком
err Ошибка Скрипт не смог авторизоваться на устройстве
warning Предупреждение Высокая утилизация канала (80%), но трафик идет
notice Нормальное, но важное событие Изменение конфигурации оборудования
info Информационное сообщение Успешное выполнение бэкапа
debug Отладочная информация Сырые ответы API для поиска багов

Утилита logger позволяет явно задать эти параметры через флаг -p (priority) в формате facility.severity. Также полезно использовать флаг -t (tag), чтобы пометить сообщение именем вашего скрипта — это сильно упростит поиск в общем потоке.

logger -p local0.err -t BGP_MONITOR "Neighbor 192.168.1.5 state changed to IDLE"
logger -p local0.info -t BGP_MONITOR "Routing table parsed successfully, 850k prefixes"

Отправка логов на удаленный коллектор

Сетевые инженеры редко анализируют логи на самих серверах. Обычно данные собираются в централизованных системах (Splunk, Graylog, ELK, или выделенный syslog-сервер). Утилита logger умеет отправлять сообщения по сети напрямую, минуя локальные файлы демона журналирования.

Для отправки по UDP на порт 514 (стандарт syslog) используется флаг -n (server) и -P (port):

logger -n 10.100.0.50 -P 514 -p local1.crit -t FIREWALL_API "Token expired, cannot apply rules"

Если коллектор требует надежной доставки по TCP, добавляется флаг -T:

logger -n 10.100.0.50 -P 514 -T -p local1.notice -t CONFIG_BACKUP "Pushing diff to Git"

Глобальное перенаправление потоков вывода

Писать logger перед каждой строкой, которую нужно сохранить, утомительно. Если скрипт объемный, логичнее перехватить весь его стандартный вывод (stdout) и поток ошибок (stderr), автоматически направляя их в syslog.

Для этого используется встроенная команда exec, которая управляет файловыми дескрипторами текущего процесса оболочки. В сочетании с подстановкой процессов >(...) мы можем подменить стандартный вывод на входной поток утилиты logger.

#!/bin/bash

# Перенаправляем stdout (дескриптор 1) в syslog с уровнем info
exec 1> >(logger -p local0.info -t NET_SYNC_SCRIPT)

# Перенаправляем stderr (дескриптор 2) в syslog с уровнем err
exec 2> >(logger -p local0.err -t NET_SYNC_SCRIPT)

echo "Starting synchronization..." # Попадет в syslog как info
ls /non_existent_directory        # Ошибка попадет в syslog как err

# Вызов внешней утилиты: ее вывод тоже автоматически уйдет в syslog
curl -s -f -u admin:Cisco https://10.0.0.1/restconf/data/interfaces

Использование exec для подмены дескрипторов делает код чище: вам не нужно менять логику работы утилит (например, curl или awk). Любой текст, который должен был появиться на экране, прозрачно инкапсулируется в syslog-сообщения.

Интеграция логирования с обработкой сигналов

Вспомним механизм перехвата сигналов trap. Логирование — это первое, что должен сделать скрипт перед тем, как завершиться принудительно. Если процесс убит сигналом SIGTERM (например, при перезагрузке сервера), важно зафиксировать этот факт, иначе прерванный бэкап конфигураций будет выглядеть как мистический сбой.

#!/bin/bash

# Настраиваем глобальное логирование
exec 1> >(logger -p local0.info -t DEVICE_POLLER)
exec 2> >(logger -p local0.err -t DEVICE_POLLER)

# Функция очистки и финального лога
cleanup() {
    logger -p local0.warning -t DEVICE_POLLER "Script terminated prematurely by system signal"
    # Здесь мог бы быть код отзыва API-токена
    exit 1
}

# Перехватываем сигналы прерывания
trap cleanup SIGINT SIGTERM

echo "Connecting to core switches..."
# Имитация долгой сетевой задачи
sleep 100
echo "Polling finished."

Если во время выполнения sleep 100 послать скрипту сигнал завершения, в системном журнале останется четкий след: DEVICE_POLLER: Script terminated prematurely by system signal.

Такой подход превращает Bash-скрипт из невидимого фонового процесса в полноценного участника сетевой инфраструктуры, который общается с системами мониторинга на одном языке с маршрутизаторами.

Временные файлы, блокировки flock и предотвращение повторного запуска

Временные файлы, блокировки flock и предотвращение повторного запуска

Представьте классическую ситуацию: вы написали скрипт, который опрашивает 500 коммутаторов по API, собирает таблицу MAC-адресов и сохраняет ее в базу. Скрипт добавлен в планировщик задач и запускается каждые 2 минуты. В обычный день опрос занимает 90 секунд. Но однажды ядро сети начинает штормить, API устройств отвечают с задержкой, и время выполнения скрипта вырастает до 3 минут.

Возникает математическая проблема: Texec>TintervalT_{exec} > T_{interval}.

Первый запуск еще не завершился, а планировщик уже стартует второй. Затем третий. Процессы накапливаются, оперативная память сервера мониторинга исчерпывается, а сетевое оборудование получает лавину дублирующихся API-запросов, что окончательно добивает его control plane. Этот эффект снежного кома — одна из самых частых причин падения самописных систем мониторинга.

Чтобы этого избежать, скрипт должен уметь понимать, что его предыдущая копия еще работает, и корректно завершаться.

Наивный подход и состояние гонки

Самая очевидная идея — использовать файл-флаг. В начале скрипт проверяет наличие файла: если он есть, значит, другая копия работает. Если нет — создает файл, выполняет задачу и удаляет его в конце.

LOCKFILE="/tmp/mac_poller.lock"

if [ -f "$LOCKFILE" ]; then
    echo "Скрипт уже запущен."
    exit 1
fi

touch "$LOCKFILE"
# ... тяжелый опрос сети ...
rm "$LOCKFILE"

В 99% случаев это сработает. Но здесь кроется фундаментальная уязвимость, известная как состояние гонки (Race Condition), а конкретно — TOCTOU (Time-of-check to time-of-use).

Между проверкой [ -f "$LOCKFILE" ] и созданием файла touch "$LOCKFILE" проходит несколько миллисекунд. Если планировщик или система оркестрации случайно запустят два экземпляра скрипта в одно и то же мгновение, оба процесса одновременно проверят отсутствие файла, оба решат, что путь свободен, и оба создадут блокировку. Защита будет пробита.

Нам нужна атомарная операция — действие, которое выполняется ядром операционной системы как единое целое, без возможности вклиниться между проверкой и созданием.

Управление блокировками через flock

В Linux для атомарных блокировок файлов используется утилита flock (от системного вызова flock()). Она запрашивает у ядра ОС эксклюзивный доступ к файлу. Если файл уже заблокирован другим процессом, ядро гарантированно не отдаст его второму.

Базовый синтаксис flock позволяет обернуть выполнение команды:

flock -n /var/lock/mac_poller.lock ./mac_poller.sh

Флаг -n (non-blocking) критически важен. Без него второй процесс просто «повиснет» в ожидании, пока первый отпустит файл. С флагом -n второй процесс мгновенно завершится с ошибкой, если блокировка уже занята.

Однако оборачивать скрипт снаружи не всегда удобно — мы хотим, чтобы скрипт сам защищал себя изнутри, независимо от того, как именно его вызвали. Для этого в Bash используется элегантный трюк с файловыми дескрипторами.

Самоблокировка скрипта изнутри

Вместо того чтобы блокировать внешнюю команду, мы можем открыть файл блокировки прямо в текущем процессе Bash и повесить замок на этот канал связи.

# 1. Открываем файл на запись и назначаем ему файловый дескриптор 200
exec 200>/var/lock/mac_poller.lock

# 2. Пытаемся получить эксклюзивную неблокирующую блокировку на дескриптор 200
flock -n 200 || {
    echo "Критическая ошибка: Предыдущий опрос еще не завершен." >&2
    exit 1
}

# 3. Основная логика скрипта
echo "Начинаем опрос сети..."

Как это работает:

  1. Команда exec 200>file приказывает интерпретатору Bash открыть файл и связать его с дескриптором 200 (число выбрано произвольно, главное — не использовать стандартные 0, 1 и 2).
  2. flock -n 200 обращается к ядру: «дай мне эксклюзивные права на этот открытый канал».
  3. Если другой экземпляр скрипта уже держит этот дескриптор заблокированным, flock возвращает ложь, срабатывает оператор ||, и скрипт завершается.

Главное преимущество этого метода: вам не нужно вручную снимать блокировку или удалять файл. Как только скрипт завершит работу (успешно, по ошибке или будет убит сигналом SIGKILL), операционная система автоматически закроет все его файловые дескрипторы, и блокировка спадет сама собой.

Безопасные временные файлы: mktemp

Когда мы гарантировали, что скрипт работает в единственном экземпляре, возникает следующая задача. При сборе данных с сотен устройств нам нужно где-то хранить промежуточные результаты (например, сырые JSON-ответы от API маршрутизаторов перед их парсингом).

Писать данные прямо в /tmp/router1_bgp.json — плохая практика. Если кто-то (или что-то) создаст директорию или файл с таким же именем, скрипт либо упадет, либо перезапишет чужие данные.

Для безопасного создания временных файлов и директорий с уникальными, непредсказуемыми именами используется утилита mktemp.

# Создание временного файла
TEMP_FILE=$(mktemp -t bgp_dump_XXXXXX)
echo "Файл создан: $TEMP_FILE" # Выведет что-то вроде /tmp/bgp_dump_aB3dE1

# Создание временной директории (флаг -d)
TEMP_DIR=$(mktemp -d -t netmon_XXXXXX)
echo "Директория создана: $TEMP_DIR"

Символы XXXXXX в шаблоне имени автоматически заменяются на случайную буквенно-цифровую строку. Флаг -t гарантирует, что файл будет создан в системной директории для временных файлов (обычно /tmp).

Объединяем картину: блокировка, временные файлы и очистка

Теперь свяжем новые инструменты с механизмом перехвата сигналов, который мы разбирали ранее.

Хотя операционная система сама снимет блокировку flock при завершении процесса, она не будет автоматически удалять созданные вами временные директории mktemp. Если скрипт запускается каждые две минуты и оставляет после себя папку с мегабайтами сырых логов, диск сервера мониторинга переполнится очень быстро.

Мы используем псевдосигнал EXIT для гарантированной очистки мусора, независимо от того, как завершился скрипт.

Архитектурный паттерн надежного сетевого скрипта

  1. Захватить эксклюзивную блокировку (flock).
  2. Создать уникальную рабочую среду (mktemp).
  3. Назначить уборщика (trap EXIT).
  4. Выполнить сетевые операции.

Посмотрим, как это выглядит в готовом к продакшену коде:

#!/bin/bash
set -e

SCRIPT_NAME=$(basename "$0")
LOCK_FILE="/var/lock/${SCRIPT_NAME}.lock"

# 1. Защита от двойного запуска
exec 200>"$LOCK_FILE"
flock -n 200 || {
    echo "Отказ: процесс $SCRIPT_NAME уже выполняется." >&2
    exit 1
}

# 2. Создание изолированной временной директории
WORK_DIR=$(mktemp -d -t "${SCRIPT_NAME}_XXXXXX")

# 3. Гарантированная очистка при выходе
cleanup() {
    # Сохраняем код возврата скрипта, чтобы не потерять его при очистке
    local exit_code=$?
    echo "Очистка временной директории: $WORK_DIR"
    rm -rf "$WORK_DIR"
    exit "$exit_code"
}
trap cleanup EXIT

# 4. Основная логика (безопасная зона)
echo "Сбор данных маршрутизации..."
# Имитация тяжелого запроса к API маршрутизатора
curl -s -H "Authorization: Bearer $TOKEN" \
     "https://10.0.0.1/restconf/data/bgp" > "$WORK_DIR/r1_bgp.json"

# Обработка данных внутри временной директории
ROUTES_COUNT=$(jq '.routes | length' "$WORK_DIR/r1_bgp.json")
echo "Найдено маршрутов: $ROUTES_COUNT"

# По завершении скрипта trap EXIT автоматически вызовет cleanup

Такая структура гарантирует, что ваш поллер никогда не устроит DDoS-атаку на сеть из-за накопившихся процессов, а файловая система сервера не обрастет забытыми временными файлами, даже если скрипт будет прерван администратором через Ctrl+C.

Отладка сложных сценариев: set -x, trap и анализ ошибок

Отладка сложных сценариев: set -x, trap и анализ ошибок

Представьте ситуацию: ваш bash-скрипт должен скачать резервную копию конфигурации ядра сети, сохранить её во временную директорию, а затем очистить старые бэкапы. Из-за опечатки переменная $BACKUP_DIR оказывается пустой. Скрипт доходит до команды rm -rf $BACKUP_DIR/ и превращает её в rm -rf /. Bash послушно начинает удалять корневую файловую систему сервера мониторинга.

По умолчанию Bash невероятно оптимистичен. Если какая-то команда завершается с ошибкой, интерпретатор просто переходит к следующей строке. Для простых однострочников это удобно, но для комплексных систем автоматизации, работающих с сетевым оборудованием, такое поведение — прямой путь к авариям.

В этой статье мы разберем, как заставить Bash строго контролировать ход выполнения, выявлять скрытые ошибки в конвейерах и автоматически собирать детальный контекст (post-mortem) при падениях.

Неофициальный строгий режим Bash

Чтобы скрипт не продолжал работу после критического сбоя, его поведение нужно жестко регламентировать. В сообществе разработчиков стандартом де-факто стала комбинация флагов, которую часто называют «строгим режимом» (strict mode). Она включается одной строкой в самом начале скрипта, сразу после shebang:

set -euo pipefail

Разберем, что делает каждый из этих флагов и от каких сетевых катастроф он защищает.

Флаг Полное имя Описание и применение
-e errexit Немедленно завершает скрипт, если любая команда возвращает ненулевой код (ошибку). Исключение — команды внутри условий if или циклов while. Если ssh admin@router "commit" упадет из-за таймаута, скрипт остановится, а не пойдет дальше удалять старый конфиг.
-u nounset Завершает скрипт при попытке использовать необъявленную или пустую переменную. Это та самая защита от rm -rf $EMPTY_VAR/. Если вы забыли передать аргумент или опечатались в имени переменной (например, $BGP_PERE вместо $BGP_PEER), скрипт упадет до того, как нанесет урон.
-o pipefail pipefail Меняет логику возврата кодов в конвейерах (pipes).

Флаг pipefail заслуживает отдельного внимания. Вспомним классическую задачу: мы забираем метрики с коммутатора по API и сразу фильтруем их.

curl -s -H "Auth: $TOKEN" https://10.0.0.1/api/stats | jq -r '.cpu_load'

Если коммутатор недоступен, curl завершится с ошибкой (например, код 7 — Failed to connect). Однако утилита jq, получив на вход пустоту, просто ничего не выведет и завершится успешно (код 0). По умолчанию Bash считает кодом возврата всего конвейера код последней команды. Скрипт решит, что всё прошло отлично.

Включение set -o pipefail заставляет конвейер возвращать код ошибки самой правой команды, которая завершилась неудачно. В нашем примере конвейер вернет ошибку curl, и благодаря флагу -e скрипт немедленно остановится.

Трассировка выполнения: заглядываем под капот

Строгий режим отлично справляется с остановкой скрипта при сбоях. Но когда скрипт из 300 строк внезапно завершает работу без вывода текста, возникает новая проблема: как понять, на какой именно строке и из-за чего он упал?

Здесь на помощь приходит режим трассировки — флаг -x (xtrace).

set -x

При его включении Bash выводит в терминал каждую команду после того, как подставит в неё все переменные, но до её реального выполнения.

xtrace (set -x) — режим отладки, при котором интерпретатор печатает каждую выполняемую строку кода с развернутыми значениями переменных.

Однако стандартный вывод set -x бывает трудно читать в больших логах. Каждая строка просто начинается с символа +. Чтобы сделать трассировку по-настоящему полезной для отладки, нужно переопределить системную переменную PS4.

Переменная PS4 определяет префикс, который печатается перед каждой строкой в режиме set -x. Мы можем добавить туда имя файла и номер текущей строки:

export PS4='+(${BASH_SOURCE}:${LINENO}): '

Теперь при падении скрипта мы увидим не просто непонятную команду, а точные координаты:

+(bgp_poller.sh:42): curl -s https://10.0.0.5/api/bgp
+(bgp_poller.sh:42): jq -r '.state'

Автоматический Post-Mortem с помощью trap ERR

Ранее мы уже использовали команду trap для перехвата сигналов от операционной системы (например, SIGINT при нажатии Ctrl+C) и псевдосигнала EXIT для очистки временных файлов.

Для профессиональной отладки существует еще один важнейший псевдосигнал — ERR. Он генерируется каждый раз, когда команда завершается с ошибкой (ненулевым кодом). Если объединить trap ERR со строгим режимом, мы сможем не просто остановить скрипт, но и автоматически выполнить функцию-обработчик перед смертью процесса.

Внутри обработчика нам доступны две системные переменные, содержащие бесценный контекст:

  1. $LINENO — номер строки, на которой произошла ошибка.
  2. $BASH_COMMAND — точная строка кода (команда), которая вызвала ошибку.

Свяжем это с системой логирования (syslog), чтобы ошибки автономных скриптов мониторинга не терялись в пустоте:

# Функция обработки критических ошибок
error_handler() {
    local line_num="$1"
    local failed_cmd="$2"

    # Отправляем алерт в syslog (Facility: local0, Severity: err)
    logger -p local0.err "CRITICAL: Скрипт упал на строке $line_num. Команда: $failed_cmd"

    # Здесь же можно добавить curl-запрос для алерта в Telegram/Slack
}

# Назначаем ловушку на сигнал ERR
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR

Важный нюанс: при передаче $BASH_COMMAND в функцию обязательно берите её в двойные кавычки, так как команда может содержать пробелы.

Сборка воедино: отказоустойчивый скрипт

Посмотрим, как все эти механизмы работают вместе в реальной задаче. Напишем скрипт, который опрашивает маршрутизатор, но намеренно допустим ошибку в URL API, чтобы спровоцировать сбой.

#!/bin/bash

# 1. Включаем строгий режим
set -euo pipefail

# 2. Настраиваем информативный вывод для отладки (если включим set -x)
export PS4='+(${BASH_SOURCE}:${LINENO}): '

# 3. Определяем функцию-обработчик ошибок
error_handler() {
    local line="$1"
    local cmd="$2"
    echo "[ERROR] Сбой на строке $line!" >&2
    echo "[ERROR] Проблемная команда: $cmd" >&2
    # В реальной жизни тут будет отправка лога в syslog или мессенджер
}

# 4. Вешаем ловушку на любые ошибки
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR

# ==========================================
# Основная логика скрипта
# ==========================================

ROUTER_IP="192.168.100.1"
# Намеренная ошибка: несуществующий эндпоинт /api/v99/
API_URL="https://${ROUTER_IP}/api/v99/interfaces"

echo "Начинаем опрос маршрутизатора $ROUTER_IP..."

# Этот конвейер упадет.
# curl получит HTTP 404, а утилита jq не сможет распарсить HTML-ответ от веб-сервера.
# Благодаря pipefail, ошибка jq (или curl, если использовать флаг -f) будет замечена.
INTERFACE_STATE=$(curl -s -f "$API_URL" | jq -r '.status')

echo "Статус интерфейса: $INTERFACE_STATE"

При запуске этого скрипта мы не увидим попыток вывести пустой статус или продолжить работу. Выполнение прервется мгновенно, и trap ERR выдаст четкий диагноз:

Начинаем опрос маршрутизатора 192.168.100.1...
[ERROR] Сбой на строке 34!
[ERROR] Проблемная команда: INTERFACE_STATE=$(curl -s -f "$API_URL" | jq -r '.status')

Используя строгий режим set -euo pipefail, переопределение PS4 для трассировки и trap ERR для автоматического логирования, вы превращаете хрупкие Bash-скрипты в предсказуемые инструменты. Они больше не будут молча портить конфигурации или отправлять пустые отчеты. Если что-то пойдет не так, скрипт безопасно остановится и сам сообщит вам, где именно искать проблему.

В следующей главе мы разберем еще один критический аспект отказоустойчивости — управление зависаниями сетевых команд и обработку таймаутов.

Написание отказоустойчивых скриптов: обработка таймаутов сетевых команд

Написание отказоустойчивых скриптов: обработка таймаутов сетевых команд

Вы включили строгий режим set -euo pipefail, настроили перехват ошибок через trap ERR и запустили скрипт сбора метрик. Он идеально работает днем, но однажды ночью намертво зависает на третьем устройстве из ста. Утром вы обнаруживаете процесс, который висит в оперативной памяти уже восемь часов. Причина — «молчаливое» падение сети (blackhole routing) или дроп пакетов файрволом. Без явного отказа в соединении (TCP RST) сетевые утилиты могут ждать ответа до истечения системного таймаута ОС, который часто составляет долгие минуты.

Скрипт мониторинга не имеет права зависать. Если один узел недоступен, автоматика должна зафиксировать этот факт и немедленно перейти к следующему.

Встроенные таймауты сетевых утилит

Прежде чем оборачивать вызовы во внешние таймеры, следует использовать встроенные механизмы самих команд. В сетевом взаимодействии важно различать два вида таймаутов:

Таймаут соединения (Connection Timeout) — максимальное время ожидания установки TCP-сессии (трехкратного рукопожатия) до начала передачи данных.

Таймаут выполнения (Execution Timeout / Deadline) — жесткий лимит времени на полное завершение работы команды, включая скачивание или отправку всех данных.

Разница критична. Если сервер принял соединение, но из-за высокой нагрузки начал отдавать ответ по одному байту в секунду, таймаут соединения не сработает, и скрипт зависнет.

Таблица сравнения флагов для популярных утилит:

Утилита Таймаут соединения Таймаут выполнения (Deadline)
curl --connect-timeout 5 -m 10 (или --max-time 10)
ssh -o ConnectTimeout=5 Нет встроенного флага
ping -W 2 (ожидание одного ответа) -w 10 (общее время работы команды)
nc -w 5 Нет встроенного флага

При работе с API сетевого оборудования всегда используйте оба флага curl. Конструкция curl --connect-timeout 3 -m 10 гарантирует, что команда завершится максимум через 10 секунд при любом сценарии деградации сети.

Утилита timeout и борьба с зависаниями

Что делать, если утилита не поддерживает таймаут выполнения (например, интерактивная сессия ssh или сложный конвейер обработки текста)? Для этого используется системная утилита timeout из пакета GNU coreutils.

Синтаксис предельно прост: timeout 5s ./custom_script.sh

По истечении пяти секунд timeout отправляет дочернему процессу сигнал SIGTERM (мягкая просьба завершиться). Однако процесс может проигнорировать SIGTERM, если он заблокирован системным вызовом ядра или намеренно перехватывает этот сигнал.

Для гарантированного уничтожения зависших процессов применяется флаг -k (kill-after):

timeout -k 2s 10s expect get_config.exp

Эта команда дает скрипту 10 секунд на нормальную работу. Если он не успел — отправляется SIGTERM. Если спустя еще 2 секунды процесс всё еще жив, ядро ОС наносит безусловный удар сигналом SIGKILL, который невозможно проигнорировать.

Обработка кодов возврата в условиях строгого режима

Главная сложность работы с timeout — его интеграция со строгим режимом Bash. Если обернутая команда завершилась успешно, timeout прозрачно возвращает ее оригинальный код (обычно 0). Но если время вышло, timeout прерывает процесс и возвращает специфичный код 124. В случае, если сработал флаг -k и процесс был убит через SIGKILL, код возврата меняется на 137 (базовый код 128 плюс номер сигнала SIGKILL, равный 9).

В скрипте с активным set -e получение кода 124 или 137 немедленно приведет к аварийной остановке всего сценария. Нам же нужно просто залогировать проблему и продолжить цикл. Для безопасного перехвата используется логическое ИЛИ (||):

set -euo pipefail

# Очищаем переменную, чтобы не подхватить значение от предыдущих вызовов
unset STATUS
# Конструкция || STATUS=$? предотвращает падение скрипта от set -e
timeout 5s ./slow_task.sh || STATUS=$?

if [[ ${STATUS:-0} -eq 124 || ${STATUS:-0} -eq 137 ]]; then
    logger -p local0.err "Таймаут: slow_task.sh выполнялся слишком долго"
elif [[ ${STATUS:-0} -ne 0 ]]; then
    logger -p local0.err "Ошибка: slow_task.sh завершился с кодом $STATUS"
fi

Здесь переменная STATUS получит код ошибки, но само выражение || STATUS=$? вернет 0 (успешное присваивание), поэтому set -e не прервет выполнение. Перед вызовом команды переменную следует очищать (unset STATUS), чтобы не подхватить код ошибки от предыдущих итераций цикла. Конструкция ${STATUS:-0} защищает от падения из-за флага -u (unbound variable): если команда выполнилась успешно, присваивания не произойдет, переменная STATUS останется неинициализированной, и Bash подставит значение 0 по умолчанию.

Практический пример: отказоустойчивый опрос массива устройств

Соберем механизмы воедино. Напишем скрипт, который обходит массив коммутаторов. Сначала он пытается получить данные через REST API с помощью curl и встроенных таймаутов. В случае неудачи — запускает резервный expect-скрипт для сбора данных через CLI, ограничивая его время работы внешней утилитой timeout.

#!/bin/bash
set -euo pipefail

DEVICES=("10.0.0.1" "10.0.0.2" "10.0.0.3")
API_ENDPOINT="/restconf/data/interfaces"

for IP in "${DEVICES[@]}"; do
    echo "Опрос устройства $IP..."

    # Попытка 1: curl со встроенными таймаутами
    # 3 сек на подключение, 10 сек на весь запрос
    if curl -s --connect-timeout 3 -m 10 "https://$IP$API_ENDPOINT" > /dev/null; then
        logger -p local0.info "Успешно опрошен $IP через API"
        continue
    fi

    logger -p local0.warn "API недоступен на $IP, запускаем резервный SSH-скрипт"

    unset STATUS
    # Попытка 2: внешняя утилита timeout для expect-скрипта
    # 15 сек на работу, затем SIGTERM, через 3 сек - SIGKILL
    timeout -k 3s 15s expect backup_poll.exp "$IP" || STATUS=$?

    if [[ ${STATUS:-0} -eq 124 || ${STATUS:-0} -eq 137 ]]; then
        logger -p local0.err "КРИТИЧНО: Таймаут резервного опроса для $IP. Устройство пропущено."
    elif [[ ${STATUS:-0} -ne 0 ]]; then
        logger -p local0.err "Ошибка SSH-скрипта на $IP (код ${STATUS:-0})"
    else
        logger -p local0.info "Успешно опрошен $IP через SSH"
    fi
done

Этот цикл никогда не зависнет бесконечно. Даже если коммутатор физически включен, но его Control Plane полностью игнорирует пакеты, curl сдастся через 10 секунд, а резервный CLI-опрос будет гарантированно уничтожен ядром ОС максимум через 18 секунд. Скрипт зафиксирует инцидент в syslog и штатно продолжит работу с остальной сетью.

Развертывание скриптов мониторинга через cron и systemd-таймеры

Развертывание скриптов мониторинга через cron и systemd-таймеры

Ваш скрипт готов. Он опрашивает сетевое оборудование по API, корректно обрабатывает таймауты, пишет логи в syslog и безопасно завершается при ошибках. Но пока он запускается только вручную. Чтобы превратить скрипт в полноценную систему мониторинга, его нужно заставить работать автономно и по строгому расписанию.

В этой главе мы разберем два архитектурных подхода к планированию задач в Linux: классический cron и современный механизм systemd-таймеров.

Классический подход: планировщик cron

cron — это системный демон, который выполняет задачи по расписанию. Это самый старый, известный и распространенный инструмент в UNIX-системах.

Для управления расписанием пользователя используется команда crontab -e. Она открывает текстовый файл, где каждая строка представляет собой одну задачу. Синтаксис расписания состоит из пяти полей времени, за которыми следует выполняемая команда.

Минуты Часы День месяца Месяц День недели Команда
0-59 0-23 1-31 1-12 0-7 (0 и 7 — воскресенье) /path/to/script.sh

Символ * означает «каждый». Знак / используется для указания шага.

Например, запуск скрипта проверки BGP-сессий каждые 5 минут выглядит так:

*/5 * * * * /opt/network/check_bgp.sh

Главная ловушка cron: пустое окружение

Самая частая проблема, с которой сталкиваются инженеры при переносе рабочего скрипта в cron — скрипт внезапно перестает работать, ссылаясь на то, что команды не найдены (например, jq: command not found).

Это происходит потому, что cron запускает процессы в максимально урезанном окружении. Переменная PATH в cron по умолчанию обычно содержит только /usr/bin:/bin. Если вы используете утилиты, установленные в /usr/local/bin или /opt/bin, планировщик их просто не увидит.

Есть два способа решить эту проблему:

  1. Абсолютные пути в скрипте. Использовать полные пути для всех внешних вызовов: /usr/local/bin/jq вместо jq. Это делает код громоздким.
  2. Переопределение PATH в crontab. Вы можете задать переменные окружения прямо в начале файла расписания:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
*/5 * * * * /opt/network/check_bgp.sh

Проблема перекрытия процессов

Минимальный шаг в cron — 1 минута. Если вы настроили ежеминутный опрос 500 коммутаторов, и из-за сетевой деградации скрипт начал выполняться 70 секунд, начнется перекрытие (Overlap). cron запустит второй экземпляр скрипта, пока первый еще не завершился. Это приведет к лавинообразному росту нагрузки.

Ранее мы решали эту проблему внутри самого скрипта с помощью атомарных блокировок flock. Однако в современных системах задачу контроля за состоянием процессов логичнее делегировать самому планировщику.

Современный подход: systemd-таймеры

Система инициализации systemd предлагает свой механизм расписаний. В отличие от cron, где всё пишется в одну строку, в systemd логика разделена на два файла (юнита):

  1. Service-юнит (.service) — описывает, что нужно запустить.
  2. Timer-юнит (.timer) — описывает, когда это нужно запускать.

Этот подход требует написания большего количества строк конфигурации, но предоставляет колоссальные преимущества для сетевого мониторинга.

Шаг 1: Создание Service-юнита

Создадим файл /etc/systemd/system/fw-poller.service. Этот юнит будет описывать наш скрипт поллинга межсетевых экранов.

[Unit]
Description=Firewall API Poller
After=network.target

[Service]
Type=oneshot
ExecStart=/opt/network/fw_poller.sh
User=netadmin

Ключевой параметр здесь — Type=oneshot. Он говорит systemd, что процесс выполнит разовую задачу и завершится.

Встроенное логирование: Одно из главных преимуществ systemd заключается в том, что стандартный вывод (stdout) и вывод ошибок (stderr) скрипта автоматически перехватываются и отправляются в системный журнал (journald). Вам больше не нужно выстраивать сложные конструкции с перенаправлением вывода в logger — достаточно обычного echo внутри скрипта, и данные навсегда сохранятся с правильными временными метками.

Шаг 2: Создание Timer-юнита

Теперь создадим файл расписания /etc/systemd/system/fw-poller.timer с точно таким же именем, но другим расширением.

[Unit]
Description=Run Firewall API Poller every 30 seconds

[Timer]
OnBootSec=1m
OnUnitActiveSec=30s
AccuracySec=1ms

[Install]
WantedBy=timers.target

Разберем параметры блока [Timer]:

  • OnBootSec=1m — задает начальный триггер (через 1 минуту после загрузки системы). Если при ручной активации таймера это время уже прошло, он сработает немедленно. Без начального триггера (или аналога вроде OnActiveSec) таймер с одним лишь OnUnitActiveSec никогда не запустится.
  • OnUnitActiveSec=30s — запускать сервис через 30 секунд после того, как он был активен в последний раз. Это позволяет реализовать субминутные интервалы (например, опрос интерфейсов каждые 10 секунд), что невозможно в cron.
  • AccuracySec=1ms — по умолчанию systemd группирует таймеры и может задерживать их выполнение до 1 минуты для экономии заряда батареи (что актуально для ноутбуков, но вредно для серверов мониторинга). Этот параметр заставляет таймер срабатывать максимально точно.

Альтернативой OnUnitActiveSec является директива OnCalendar, которая использует синтаксис абсолютного времени, похожий на cron, но более читаемый: OnCalendar=*-*-* 00/4:00:00 (запуск каждые 4 часа).

Шаг 3: Активация таймера

После создания файлов необходимо применить конфигурацию. Мы активируем только таймер (сервис будет вызываться таймером автоматически):

# Перечитываем конфигурацию systemd
sudo systemctl daemon-reload

# Добавляем таймер в автозагрузку
sudo systemctl enable fw-poller.timer

# Запускаем таймер
sudo systemctl start fw-poller.timer

Проверить статус всех активных таймеров в системе можно командой:

systemctl list-timers

Защита от перекрытия процессов "из коробки"

Главная архитектурная особенность systemd-таймеров: если таймер сработал, а связанный с ним .service всё ещё выполняется (например, API межсетевого экрана отвечает очень медленно), systemd не запустит второй экземпляр скрипта.

Запуск будет пропущен до следующего интервала. Это полностью исключает состояние гонки (Race Condition) и перегрузку сервера мониторинга без необходимости писать сложную логику с flock внутри самого Bash-скрипта.

Сравнение подходов

Чтобы выбрать правильный инструмент для конкретной задачи, сопоставим их возможности.

Характеристика cron systemd-таймеры
Минимальный интервал 1 минута Меньше секунды
Защита от перекрытий Нет (нужен flock) Встроена (не запустит копию)
Логирование вывода Отправка на email (по умолчанию) Автоматически в journald
Управление окружением Урезанный PATH, ручная настройка Полный контроль через юнит-файл
Сложность настройки Одна строка в crontab Два конфигурационных файла

Для простых, некритичных задач (например, ночная ротация логов или бэкап конфигураций раз в сутки) cron остается отличным и быстрым выбором.

Однако для систем сетевого мониторинга, где важна частота опроса, жесткий контроль за процессами и централизованный сбор логов, стандартом де-факто является systemd.

Просмотр логов нашего поллера теперь осуществляется стандартными средствами ОС:

journalctl -u fw-poller.service -f

Перевод скриптов на запуск по расписанию решает задачу периодического сбора метрик. Но некоторые сетевые задачи требуют мгновенной реакции — например, непрерывный пинг критического узла или удержание постоянного TCP-соединения с потоковым API оборудования. Для таких задач парадигма периодического запуска не подходит, и скрипт должен работать в фоне постоянно.

Проектирование демона для непрерывного пингования критических узлов

Проектирование демона для непрерывного пингования критических узлов

Если вам нужно зафиксировать трехсекундную потерю связности с BGP-пиром, системные таймеры и планировщики не помогут. Минимальный шаг запуска для классического cron — одна минута. Системные таймеры (например, systemd) позволяют запускать задачи ежесекундно, но такой частый запуск скрипта порождает серьезную проблему: накладные расходы на создание новых процессов. Чтобы вести по-настоящему непрерывный мониторинг, нам нужен процесс, который запускается один раз и работает бесконечно, — демон.

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

Архитектура непрерывного опроса: Forking против Streaming

Представим задачу: нужно ежесекундно проверять доступность шлюза 10.100.1.1.

Наивный подход — запустить бесконечный цикл while true, внутри которого вызывается утилита ping с отправкой одного пакета (-c 1), а затем скрипт засыпает на секунду (sleep 1).

Характеристика Наивный цикл (while true; do ping -c 1; sleep 1; done) Потоковый парсинг (ping | while read)
Создание процессов (Fork) Ежесекундно создаются новые процессы ping и sleep. Процесс ping создается один раз при старте.
Нагрузка на CPU Высокая. Ядро ОС постоянно выделяет PID и память. Минимальная. Ядро просто передает текст в пайп.
Точность интервалов Плавает. Время выполнения самого ping суммируется со sleep. Строгая. Интервал контролируется таймерами внутри ping.

Если мы мониторим 50 узлов ежесекундно наивным методом, математика неумолима: 50×60×2=600050 \times 60 \times 2 = 6000.

Здесь 50 — количество узлов, 60 — секунд в минуте, а 2 — количество создаваемых процессов на каждой итерации (ping и sleep). В результате мы получаем 6000 новых процессов каждую минуту. Это засоряет таблицу процессов и тратит ресурсы маршрутизатора или сервера мониторинга.

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

Утилита ping как генератор потока данных

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

В Linux-версии ping (пакет iputils) для этого существует флаг -O (Outstanding). Он заставляет утилиту печатать сообщение «no answer yet», если ответ на ICMP-запрос не пришел в отведенное время.

Запустим ping -O -i 1 10.100.1.1:

64 bytes from 10.100.1.1: icmp_seq=1 ttl=64 time=0.3 ms
64 bytes from 10.100.1.1: icmp_seq=2 ttl=64 time=0.2 ms
no answer yet for icmp_seq=3
no answer yet for icmp_seq=4
64 bytes from 10.100.1.1: icmp_seq=5 ttl=64 time=0.4 ms

Теперь у нас есть предсказуемый текстовый поток. Строка содержит либо bytes from (успех), либо no answer (потеря).

Проблема Subshell при чтении потоков

Казалось бы, достаточно направить вывод ping в цикл while read через стандартный конвейер (pipe):

# ВНИМАНИЕ: Код с логической ошибкой
LOSS_COUNTER=0

ping -O -i 1 10.100.1.1 | while read -r line; do
    if [[ "$line" == *"no answer"* ]]; then
        LOSS_COUNTER=$((LOSS_COUNTER + 1))
    fi
done

echo "Потерь: $LOSS_COUNTER" # Всегда выведет 0!

Здесь кроется одна из самых коварных ловушек Bash. Оператор конвейера | создает для правой части выражения (цикла while) отдельный дочерний процесс — subshell. Все переменные, измененные внутри этого subshell, будут уничтожены ядром ОС сразу после завершения цикла. Переменная LOSS_COUNTER в основном скрипте останется равна нулю.

Чтобы цикл while выполнялся в контексте основного скрипта и мог изменять глобальные переменные, мы используем механизм Process Substitution (подстановка процессов).

Синтаксис < <(команда) запускает команду в фоне, подключает ее вывод к анонимному файловому дескриптору и передает этот дескриптор на вход циклу:

LOSS_COUNTER=0

while read -r line; do
    if [[ "$line" == *"no answer"* ]]; then
        LOSS_COUNTER=$((LOSS_COUNTER + 1))
    fi
done < <(ping -O -i 1 10.100.1.1)

В такой конструкции цикл работает в основном процессе, и накопление состояния (счетчик потерь) будет работать корректно.

Накопление состояния и пороговые значения (Thresholds)

Реальная сеть не идеальна. Один потерянный ICMP-пакет не является поводом будить дежурного инженера. Демон должен обладать «памятью» — накапливать счетчик ошибок и сбрасывать его при восстановлении связи.

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

Накопление состояния — это паттерн программирования демонов, при котором скрипт принимает решения не на основе единичного события, а на основе исторического контекста (серии событий), хранящегося в оперативной памяти процесса.

Превращение скрипта в системный сервис

Чтобы скрипт стал настоящим демоном, им должен управлять менеджер процессов — systemd. Ранее мы использовали Type=oneshot для задач, которые выполняются и завершаются. Для бесконечных процессов используется Type=simple.

Создадим юнит-файл /etc/systemd/system/ping-monitor.service:

[Unit]
Description=Continuous Ping Monitor for Core Gateway
After=network.target

[Service]
# Указываем, что это долгоживущий процесс
Type=simple
ExecStart=/usr/local/bin/ping_monitor.sh

# Политика перезапуска при падении скрипта (например, если убьют процесс ping)
Restart=always
# Пауза перед перезапуском, чтобы избежать циклического падения (CrashLoop)
RestartSec=3

[Install]
WantedBy=multi-user.target

Параметр Restart=always гарантирует, что если наш Bash-скрипт завершится с ошибкой (или будет принудительно остановлен сигналом SIGKILL), systemd автоматически поднимет его через 3 секунды.

Итоговый скрипт демона

Соберем все концепции воедино: строгий режим, логирование в syslog, подстановку процессов и накопление состояния. Мы будем использовать функцию отправки в Slack из предыдущих глав, предполагая, что она вынесена в отдельный файл.

Файл /usr/local/bin/ping_monitor.sh:

#!/usr/bin/env bash
set -euo pipefail

# Подключаем внешние функции (например, send_slack_alert)
source /usr/local/lib/network_alerts.sh

TARGET="10.100.1.1"
THRESHOLD=3
CONSECUTIVE_LOSSES=0
STATE="UP" # Отслеживаем текущее состояние, чтобы не спамить алертами

logger -p local0.info "Ping daemon started for $TARGET"

# Используем trap для корректного завершения
trap 'logger -p local0.info "Ping daemon stopped"; exit 0' SIGINT SIGTERM

# Читаем бесконечный поток через Process Substitution
while read -r line; do

    if [[ "$line" == *"no answer"* ]]; then
        CONSECUTIVE_LOSSES=$((CONSECUTIVE_LOSSES + 1))

        # Проверяем превышение порога и текущее состояние
        if [[ $CONSECUTIVE_LOSSES -ge $THRESHOLD ]] && [[ "$STATE" == "UP" ]]; then
            STATE="DOWN"
            logger -p local0.err "CRITICAL: $TARGET is unreachable ($CONSECUTIVE_LOSSES losses)"
            send_slack_alert "CRIT" "Шлюз $TARGET недоступен!"
        fi

    elif [[ "$line" == *"bytes from"* ]]; then
        # Если узел был в дауне, но ответил — генерируем алерт о восстановлении
        if [[ "$STATE" == "DOWN" ]]; then
            STATE="UP"
            logger -p local0.info "RECOVERY: $TARGET is back online"
            send_slack_alert "OK" "Шлюз $TARGET снова доступен."
        fi

        # Сбрасываем счетчик ошибок при любом успешном ответе
        CONSECUTIVE_LOSSES=0
    fi

done < <(ping -O -i 1 "$TARGET")

Этот скрипт не создает нагрузку на систему, мгновенно реагирует на изменения в сети и отправляет уведомление ровно один раз при падении и один раз при восстановлении, избегая спама благодаря переменной STATE.

Сборка комплексного дашборда сетевой активности в HTML

Сборка комплексного дашборда сетевой активности в HTML

Наши демоны непрерывно пингуют критические шлюзы, таймеры systemd регулярно опрашивают API коммутаторов, а логи аккуратно складываются в syslog. Под капотом система мониторинга работает как часы. Но когда дежурному инженеру NOC (Network Operations Center) нужно мгновенно оценить состояние сети, просматривать текстовые файлы и выводы консоли слишком долго. Нам нужна единая визуальная панель — дашборд.

Вместо того чтобы разворачивать тяжелые стеки вроде Grafana или писать бэкенд на Python, мы используем сам Bash для генерации статической веб-страницы.

Bash как генератор статических сайтов

Подход, который мы применим, называется SSG (Static Site Generation). Его суть в том, что веб-сервер (например, Nginx) просто отдает готовый HTML-файл, не выполняя никаких вычислений при запросе клиента. Всю вычислительную работу берет на себя Bash-скрипт, который запускается по расписанию, собирает данные из наших временных файлов состояния и формирует обновленную страницу.

Преимущества такого подхода для сетевого оборудования очевидны:

  • Нулевая нагрузка на чтение: страницу могут открыть хоть сто инженеров одновременно, это не вызовет новых запросов к хрупкому API коммутаторов.
  • Безопасность: веб-сервер не имеет доступа к выполнению скриптов, он отдает только статику.

Подготовка метрик: расчет SLA

Для дашборда нам понадобятся не просто статусы, но и историческая метрика надежности — SLA (Service Level Agreement). Если наш демон из предыдущих глав накапливает счетчики успешных и проваленных проверок, мы можем вычислить процент доступности узла.

Базовая формула доступности выглядит так:

SLA=UptimeUptime+Downtime×100SLA = \frac{Uptime}{Uptime + Downtime} \times 100

Где UptimeUptime — количество успешных проверок (например, прошедших пингов), а DowntimeDowntime — количество потерянных пакетов. Например, если за сутки мы отправили 1000 пингов, из которых 995 вернулись успешно (Uptime=995Uptime = 995), а 5 потерялись (Downtime=5Downtime = 5), то доступность составит SLA=995995+5×100=99.5%SLA = \frac{995}{995 + 5} \times 100 = 99.5\%. В Bash мы можем вычислить это с помощью утилиты bc или awk, округлив до двух знаков после запятой, и передать готовое значение в наш генератор HTML.

Структура генератора HTML

Сборка HTML-страницы в Bash логически делится на три этапа, которые склеиваются в один файл:

  1. Заголовок (Header): Неизменная часть страницы с тегами <html>, <head> и CSS-стилями.
  2. Тело (Body): Динамическая часть, где скрипт читает данные (например, CSV-файл с состояниями) и в цикле генерирует строки таблицы <tr>.
  3. Подвал (Footer): Закрывающие теги </table> и </html>.

Для вывода многострочных блоков HTML мы будем использовать конструкцию Heredoc, разбивая её на статические и динамические секции.

Динамическая стилизация (CSS-классы)

Чтобы дашборд читался с первого взгляда, мы свяжем текстовые статусы узлов с CSS-классами.

Статус в логах CSS-класс Визуальный эффект
UP .status-UP Зеленый фон, темно-зеленый текст
DOWN .status-DOWN Красный фон, бордовый текст
MAINT .status-MAINT Серый фон, приглушенный текст

В цикле обработки скрипт будет подставлять значение переменной $STATUS прямо в атрибут class HTML-тега.

Проблема состояния гонки при записи HTML

Если мы будем перенаправлять вывод нашего скрипта напрямую в рабочий файл веб-сервера (например, > /var/www/html/index.html), мы столкнемся с классической проблемой.

Запись файла занимает время (пусть и миллисекунды). Если браузер инженера запросит страницу ровно в тот момент, когда Bash записал только половину таблицы, на экране отобразится сломанный HTML без закрывающих тегов.

Атомарная подмена (Atomic Swap) Чтобы избежать отдачи «полуготовых» файлов, новый HTML всегда формируется во временном файле (через mktemp). После завершения сборки временный файл перемещается на место рабочего утилитой mv. На уровне файловой системы Linux операция mv (системный вызов rename) происходит атомарно, но только в пределах одной файловой системы. Если создать временный файл в стандартной директории /tmp (которая часто является отдельной файловой системой tmpfs), утилита mv выполнит неатомарное копирование с последующим удалением. Поэтому временный файл нужно создавать в той же директории, что и целевой (например, /var/www/html). В этом случае веб-сервер всегда будет видеть либо старый, либо полностью новый файл.

Полный скрипт генерации дашборда

Соберем все концепции воедино. Допустим, наши демоны поллинга обновляют простой CSV-файл /var/cache/netmon/state.csv в формате Hostname,IP,Status,SLA.

#!/bin/bash
set -euo pipefail

DATA_FILE="/var/cache/netmon/state.csv"
WEB_DIR="/var/www/html"
# Создаем временный файл для безопасной генерации в той же файловой системе
TMP_HTML=$(mktemp "${WEB_DIR}/.tmp.XXXXXX")

# 1. HEADER: Статическая часть и CSS (кавычки 'EOF' отключают подстановку переменных)
cat << 'EOF' > "$TMP_HTML"
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="refresh" content="30">
    <title>NOC Dashboard</title>
    <style>
        body { font-family: sans-serif; background: #1e1e2e; color: #cdd6f4; padding: 20px; }
        table { width: 100%; border-collapse: collapse; margin-top: 20px; }
        th, td { padding: 12px; border: 1px solid #45475a; text-align: left; }
        th { background-color: #313244; }
        .status-UP { background-color: #a6e3a1; color: #11111b; font-weight: bold; }
        .status-DOWN { background-color: #f38ba8; color: #11111b; font-weight: bold; }
        .status-MAINT { background-color: #a6adc8; color: #11111b; font-weight: bold; }
    </style>
</head>
<body>
    <h1>Сводка сетевой активности</h1>
    <p>Последнее обновление:
EOF

# Вставляем текущее время (динамическая вставка вне Heredoc)
date '+%Y-%m-%d %H:%M:%S' >> "$TMP_HTML"

# Открываем таблицу
cat << 'EOF' >> "$TMP_HTML"
    </p>
    <table>
        <tr>
            <th>Узел</th>
            <th>IP-адрес</th>
            <th>Статус</th>
            <th>SLA (%)</th>
        </tr>
EOF

# 2. BODY: Читаем CSV и генерируем строки таблицы
# Если файла еще нет, пропускаем цикл без ошибки
if [ -f "$DATA_FILE" ]; then
    while IFS=',' read -r host ip status sla; do
        # Инъекция статуса в CSS-класс
        class="status-${status}"

        # Генерируем строку таблицы, используя переменные
        cat << HTML_ROW >> "$TMP_HTML"
        <tr>
            <td>${host}</td>
            <td>${ip}</td>
            <td class="${class}">${status}</td>
            <td>${sla}</td>
        </tr>
HTML_ROW
    done < "$DATA_FILE"
fi

# 3. FOOTER: Закрываем теги
cat << 'EOF' >> "$TMP_HTML"
    </table>
</body>
</html>
EOF

# Выставляем права на чтение для веб-сервера
chmod 644 "$TMP_HTML"

# Атомарно подменяем рабочий файл
mv "$TMP_HTML" "${WEB_DIR}/index.html"

Этот скрипт можно повесить на systemd-таймер с интервалом в 1 минуту (OnUnitActiveSec=1m). Мета-тег <meta http-equiv="refresh" content="30"> в секции <head> заставит браузер дежурного инженера автоматически перезапрашивать страницу каждые 30 секунд.

В результате мы получили легковесный, отказоустойчивый дашборд, который невозможно «положить» большим количеством просмотров, так как вся тяжелая работа по опросу оборудования изолирована в фоновых демонах, а генерация HTML занимает доли секунды.

Интеграция Bash-скриптов в пайплайны CI/CD для валидации сетевых конфигов

Интеграция Bash-скриптов в пайплайны CI/CD для валидации сетевых конфигов

Представьте ситуацию: инженер вносит правки в конфигурацию BGP, опечатывается в номере автономной системы и отправляет изменения на маршрутизаторы ядра. Через минуту падает связность между дата-центрами. Чтобы предотвратить подобные катастрофы, сетевая инфраструктура переходит к подходу Infrastructure as Code (IaC). В этой парадигме конфигурация хранится в Git, а применение изменений происходит только после автоматической проверки.

Пайплайн CI/CD (Continuous Integration / Continuous Deployment) — это конвейер, который запускается при каждом коммите. Но под капотом большинства современных CI/CD-систем скрывается предельно простой механизм: они запускают эфемерный Linux-контейнер (Runner), клонируют туда репозиторий и выполняют ваши Bash-скрипты.

Язык общения с пайплайном: Exit Codes

Пайплайн не умеет читать логи или понимать текст в консоли. Единственный критерий, по которому система CI/CD (будь то GitLab CI, GitHub Actions или Jenkins) определяет успешность проверки — это код возврата (Exit Code) последнего выполненного процесса.

Runner считает задачу успешной, если скрипт завершился с кодом 0. Любое значение 0\neq 0 мгновенно останавливает конвейер, помечает сборку красным крестом (Failed) и блокирует дальнейшее развертывание конфигурации на реальном оборудовании.

В локальных скриптах мы привыкли использовать строгий режим, прерывающий выполнение при первой же ошибке. Однако в CI/CD этот подход часто вредит: если в конфигурации допущено десять синтаксических ошибок, разработчик хочет увидеть в логах пайплайна их все за один запуск, а не исправлять по одной, перезапуская конвейер десять раз.

Паттерн Error Accumulator (Накопитель ошибок)

Для валидации в CI/CD применяется паттерн «мягкого падения». Мы отлавливаем ошибки, выводим информацию о них в stdout (чтобы они попали в веб-интерфейс CI-системы), увеличиваем счетчик ошибок, но позволяем скрипту проверить оставшиеся файлы. И только в самом конце скрипт возвращает ненулевой статус, если счетчик >0> 0.

Сравним два подхода:

Характеристика Локальный Fail-Fast скрипт CI/CD скрипт (Error Accumulator)
Реакция на ошибку Немедленный exit 1 Инкремент переменной ERRORS=$((ERRORS + 1))
Полнота отчета Видна только первая ошибка Видны все ошибки во всех файлах
Использование set -e Включено глобально Не используется или обходится через ||
Завершение По факту падения команды Явный exit 1 в конце, если ERRORS > 0

Практика: Скрипт валидации сетевых намерений

Допустим, наша сеть конфигурируется на основе JSON-файлов (сетевых намерений), из которых позже генерируются CLI-команды. Перед деплоем нам нужно проверить два условия:

  1. Файл является валидным JSON (нет пропущенных запятых или кавычек).
  2. Номер BGP AS соответствует корпоративному стандарту для приватных сетей: 64512ASN6553464512 \leq ASN \leq 65534.

Напишем скрипт validate_configs.sh, который пайплайн будет запускать при каждом изменении в директории intents/.

#!/bin/bash
# Не включаем set -e, так как мы сами контролируем поток выполнения при ошибках
set -uo pipefail

CONFIG_DIR="intents"
ERRORS=0

echo "Запуск валидации конфигураций в директории: $CONFIG_DIR"

# Находим все JSON файлы
while IFS= read -r -d '' file; do
    echo "Проверка файла: $file"

    # 1. Проверка синтаксиса JSON
    # Используем jq empty, который читает файл, но ничего не выводит.
    # Если синтаксис сломан, jq вернет ошибку.
    if ! jq empty "$file" >/dev/null 2>&1; then
        echo "[ОШИБКА] Некорректный синтаксис JSON в файле $file"
        ERRORS=$((ERRORS + 1))
        continue # Если JSON сломан, дальнейшие проверки этого файла бессмысленны
    fi

    # 2. Бизнес-логика: проверка диапазона BGP ASN
    # Извлекаем значение bgp_asn. Подавляем ошибки, если ключа нет.
    ASN=$(jq -r '.bgp_asn // empty' "$file")

    if [[ -n "$ASN" ]]; then
        if (( ASN < 64512 || ASN > 65534 )); then
            echo "[ОШИБКА] BGP ASN $ASN в файле $file выходит за рамки приватного диапазона (64512-65534)"
            ERRORS=$((ERRORS + 1))
        fi
    fi

done < <(find "$CONFIG_DIR" -type f -name "*.json" -print0)

# Финальный вердикт для CI/CD Runner
if (( ERRORS > 0 )); then
    echo "Валидация провалена. Найдено ошибок: $ERRORS."
    exit 1
else
    echo "Валидация успешно завершена. Ошибок не найдено."
    exit 0
fi

Разбор ключевых механизмов

  • jq empty: Идеальный инструмент для линтинга. Флаг empty заставляет утилиту распарсить JSON, но не передавать его дальше по конвейеру. Если парсинг не удался, код возврата будет 0\neq 0, что перехватывается конструкцией if ! ....
  • jq -r '.bgp_asn // empty': Безопасное извлечение. Если в конфигурации интерфейса нет ключа BGP, скрипт не должен падать. Оператор // empty возвращает пустоту вместо строки null.
  • Математический контекст (( ... )): Встроенная арифметика Bash позволяет элегантно проверять диапазоны без вызова внешних утилит вроде test или awk.

Подключение скрипта к CI/CD

Чтобы наша проверка заработала, нужно объяснить системе CI/CD, когда и как запускать скрипт. В качестве примера рассмотрим конфигурацию для GitLab CI. В корень репозитория кладется файл .gitlab-ci.yml.

stages:
  - validate
  - deploy

validate_network_intents:
  stage: validate
  image: alpine:latest
  before_script:
    # Устанавливаем зависимости в пустом контейнере Runner'а
    - apk add --no-cache bash jq
  script:
    # Запускаем наш Bash-скрипт
    - bash .ci/validate_configs.sh

Когда инженер делает git push, GitLab поднимает контейнер alpine, устанавливает bash и jq, а затем выполняет validate_configs.sh. Если скрипт завершается с exit 1 (сработал наш Error Accumulator), стадия validate окрашивается в красный цвет. Стадия deploy (где происходит реальная отправка конфигов на коммутаторы) отменяется автоматически. Пайплайн защитил сеть от человеческой ошибки.

Интеграция Bash-скриптов в CI/CD превращает ваши локальные наработки в автоматизированный шлюз безопасности. Вы больше не полагаетесь на внимательность инженера — правила диктует код.

Финальное тестирование и оптимизация производительности скриптов

Финальное тестирование и оптимизация производительности скриптов

Представьте ситуацию: вы написали скрипт, который опрашивает 5000 коммутаторов по API. Планировщик запускает его каждые 5 минут (300 секунд). Сначала всё работает отлично, но по мере добавления новых устройств скрипт начинает выполняться 400 секунд. Возникает перекрытие процессов, система мониторинга начинает пропускать метрики, а сервер автоматизации задыхается от нагрузки.

Как понять, на что именно уходит время, и как ускорить Bash-скрипт в десятки раз без переписывания его на другой язык? В этой финальной главе мы разберем профилирование, устранение главных «пожирателей» процессорного времени и стресс-тестирование сетевых сценариев.

Профилирование: поиск узкого горлышка

Чтобы оптимизировать код, нужно точно знать, какая строка тормозит выполнение. Обычная утилита time покажет только общее время работы скрипта. Нам нужна построчная трассировка.

Ранее мы уже использовали режим отладки и переопределяли переменную PS4 для вывода номеров строк. Чтобы превратить это в инструмент профилирования, добавим к выводу временные метки. Использовать утилиту date внутри PS4 нельзя — она сама замедлит скрипт. Начиная с Bash версии 5.0, доступна встроенная переменная EPOCHREALTIME, которая возвращает Unix-время с микросекундной точностью, не порождая новых процессов (в отличие от вызова внешней утилиты date).

Включим профилирование в начале проблемного участка кода:

# Настраиваем формат: время в секундах.микросекундах, файл и строка
export PS4='+ $EPOCHREALTIME ${BASH_SOURCE}:${LINENO}: '

set -x  # Включаем трассировку
# ... проблемный цикл опроса ...
set +x  # Выключаем трассировку

В логах вы увидите точное время перед выполнением каждой команды:

  • 1715001234.105231 poller.sh:42: curl -s http://10.0.0.5/api
  • 1715001234.908122 poller.sh:43: echo '{"status":"ok"}'
  • 1715001234.912455 poller.sh:44: jq -r .status

Вычислив разницу между соседними метками, вы точно определите, где скрипт «зависает»: на ожидании ответа от сети или на локальной обработке текста.

Штраф за форк (Fork Penalty)

Если профилирование показывает, что время теряется на локальных операциях, главная причина почти всегда одна — вызов внешних утилит внутри циклов.

Каждое обращение к awk, sed, grep или cut заставляет ядро ОС выполнять дорогостоящие системные вызовы fork() (создание копии процесса) и exec() (замена контекста на новую программу). Если цикл выполняется 5000 раз, вызов одного grep внутри него создаст 5000 новых процессов.

Если время выполнения одной полезной итерации равно tt, а накладные расходы на создание процесса — cc, то общее время выполнения TT для NN устройств составит:

T=N×(t+c)T = N \times (t + c)

Где:

  • TT — общее время выполнения скрипта.
  • NN — количество устройств (число итераций цикла).
  • tt — время полезной работы в одной итерации.
  • cc — накладные расходы на системные вызовы fork() и exec().

Пример: если сама обработка текста в памяти занимает t=0.1t = 0.1 мс, а запуск внешнего grep требует c=1c = 1 мс, то для N=5000N = 5000 коммутаторов общее время составит 5000×1.1=55005000 \times 1.1 = 5500 мс (5.5 секунд). Из них 5 секунд уйдет впустую только на создание процессов.

В Bash значение cc часто многократно превышает tt. Чтобы свести cc к нулю, нужно заменить внешние утилиты встроенными механизмами интерпретатора — строковыми операциями (Parameter Expansion) и встроенными регулярными выражениями. Они выполняются мгновенно прямо в оперативной памяти Bash.

Внешняя утилита (медленно) Встроенный аналог Bash (мгновенно) Описание
echo $VAR | grep -q "UP" [[ $VAR =~ "UP" ]] Проверка вхождения подстроки или регулярного выражения
echo $MAC | sed 's/://g' ${MAC//:/} Глобальная замена (удаление всех двоеточий)
echo $IP | cut -d. -f1 ${IP%%.*} Удаление максимального суффикса (останется первый октет)
echo $STR | tr 'a-z' 'A-Z' ${STR^^} Перевод строки в верхний регистр

Пример оптимизации

Допустим, мы парсим список интерфейсов и приводим их MAC-адреса к формату без разделителей.

Было (медленно):

for mac in "${MAC_LIST[@]}"; do
    # Три внешних процесса на каждую итерацию!
    clean_mac=$(echo "$mac" | sed 's/[:-]//g' | tr 'a-z' 'A-Z')
    # ... логика ...
done

Стало (быстро):

for mac in "${MAC_LIST[@]}"; do
    # Ноль новых процессов. Работа напрямую в памяти.
    clean_mac="${mac//[:-]/}"  # удаляем : и -
    clean_mac="${clean_mac^^}" # переводим в верхний регистр
    # ... логика ...
done

На массиве из 10 000 элементов первый вариант отработает за 15\approx 15 секунд, а второй — за 0.050.05 секунды. Скорость возрастает в 300 раз.

Оптимизация дискового I/O: удержание дескрипторов

Вторая частая проблема — работа с файлами внутри циклов. Запись метрик или логов часто реализуют так:

for ip in "${DEVICES[@]}"; do
    echo "$ip, UP" >> /var/log/network_state.csv
done

При каждой итерации Bash открывает файл, перемещает указатель в конец, записывает данные и закрывает файл. На больших объемах это создает избыточную нагрузку на дисковую подсистему (I/O).

Решение — открыть файловый дескриптор один раз перед циклом с помощью встроенной команды exec.

# Открываем файловый дескриптор 3 для добавления (append)
exec 3>> /var/log/network_state.csv

for ip in "${DEVICES[@]}"; do
    # Пишем напрямую в открытый дескриптор
    echo "$ip, UP" >&3
done

# Закрываем дескриптор после завершения работы
exec 3>&-

Этот подход радикально снижает количество системных вызовов к файловой системе при генерации объемных отчетов или статических дашбордов.

Стресс-тестирование: Chaos Engineering для скриптов

Скрипт может работать молниеносно на локальном процессоре, но намертво зависнуть в продакшене из-за медленного ответа API маршрутизатора. Чтобы убедиться, что архитектура отказоустойчива, нужно протестировать скрипт в условиях деградации сети.

Вместо того чтобы ломать реальное оборудование, мы можем поднять локальный mock-сервер на базе утилиты nc (Netcat), который намеренно задерживает ответы.

Запустим в соседнем терминале заглушку, имитирующую «тормозящий» API:

while true; do
    # Ждем 3 секунды, затем отдаем валидный HTTP-ответ
    (sleep 3; echo -e "HTTP/1.1 200 OK\r\n\r\n{\"status\":\"ok\"}") | nc -l 8080
done

Теперь направим наш оптимизированный скрипт на http://127.0.0.1:8080. Это позволит на практике проверить:

  1. Корректно ли отрабатывают таймауты выполнения (Execution Timeouts).
  2. Не накапливаются ли зависшие процессы в памяти, если сервер не отвечает.
  3. Успевает ли пул потоков переключаться на другие задачи, пока ждет ответа от «проблемного» узла.

Если ваш скрипт корректно обрабатывает такие задержки, логирует ошибки и завершается в строго отведенное окно планировщика — он готов к бою.

Заключение

Bash — это непревзойденный «клей» для сетевой инфраструктуры. За 28 глав мы прошли путь от простых пинговалок до отказоустойчивых демонов, систем резервного копирования и CI/CD пайплайнов. Вы научились управлять потоками, безопасно обрабатывать сигналы ядра и интегрироваться с REST API.

Но у любого инструмента есть предел. Если ваш скрипт начинает требовать сложных структур данных (например, глубоко вложенных словарей), многопоточной синхронизации с разделяемой памятью или обработки сотен мегабайт JSON — это сигнал к переходу на Python или Go.

Сила Bash не в том, чтобы заменить полноценные языки программирования. Его суперспособность — возможность решить критическую сетевую задачу прямо сейчас, на любом Linux-сервере в мире, используя только встроенные утилиты операционной системы.