Бегунов ЕгорСистемный администратор, DevOps
  1. Главная
  2. Блог
  3. Scripts

Python для системного администратора

Содержание

Был у меня Bash-скрипт, который обходил серверы и собирал занятое место на дисках. За пару лет он оброс awk, sed и проверками кодов возврата. Любая правка превращалась в квест с кавычками. Переписал на Python, и скрипт снова стало можно читать.

Дальше без теории: как поставить Python, где держать скрипты, как их настраивать, логировать и запускать. Сквозной пример — тот самый проверяльщик дисков. Он ходит по SSH, пишет отчёт в JSON и шлёт алерт в Telegram через API.

Что понадобится для Python для системного администратора

Требования и подготовка

Debian 12 или 13, Ubuntu 22.04 или 24.04. python3 там почти всегда уже стоит, только на минимальном Debian его иногда нет. Версии из коробки такие:

  • Debian 12 — Python 3.11;
  • Debian 13 — Python 3.13;
  • Ubuntu 22.04 — Python 3.10;
  • Ubuntu 24.04 — Python 3.12.

Для примеров ниже нужен 3.11 или новее, там появился tomllib для чтения TOML. На Ubuntu 22.04 конфиг придётся читать через пакет tomli или держать в JSON.

Кроме Python нужен SSH-доступ по ключу до серверов, которые будем опрашивать. И понимание, где Python действительно нужен.

Bash я оставляю для обвязки строк на двадцать. PowerShell — для Windows и AD. Всё, где есть логика, JSON, API или разбор ошибок, пишу на Python.

Установка и подготовка среды

Установка пакетов / компонентов

Ставим интерпретатор, модуль venv и pipx:

apt install python3 python3-venv python3-pip pipx

Системный Python трогаем только через apt. С Debian 12 и Ubuntu 23.04 pip install вне виртуального окружения падает с ошибкой externally-managed-environment. Это не баг, это защита системы от вас :).

Под скрипт заводим пользователя, каталоги и venv:

useradd --system --create-home --shell /usr/sbin/nologin diskwatch
mkdir -p /opt/diskwatch /etc/diskwatch
install -d -o diskwatch -g diskwatch /var/lib/diskwatch
python3 -m venv /opt/diskwatch/venv
/opt/diskwatch/venv/bin/pip install requests
/opt/diskwatch/venv/bin/pip freeze > /opt/diskwatch/requirements.txt

Утилиты, которые нужны как команды, а не как библиотеки, ставлю через pipx. Каждая получает свой venv и не мешает остальным. Так у меня живут ruff, pip-audit и прочие инструменты.

Ключ для SSH генерируем от имени пользователя скрипта:

sudo -u diskwatch ssh-keygen -t ed25519 -N "" -f /home/diskwatch/.ssh/id_ed25519

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

Базовая настройка Python для системного администратора

Основные параметры

Любой мой админский скрипт собран из одних и тех же кусков. Их немного, и все из стандартной библиотеки:

  • argparse — параметры командной строки. Минимум --config и --dry-run.
  • logging — вместо print. Уровни, формат и вывод в stderr, откуда его заберёт journald.
  • subprocess.run — внешние команды списком аргументов, с timeout и check=True.
  • pathlib — пути и файлы.
  • json и tomllib — отчёты и конфиги.
  • Код возврата: 0, если всё хорошо, и не 0, если нет. Мониторинг и systemd смотрят именно на него.

Формат лога делаю без даты. Под systemd время к каждой строке добавит journald, и дата будет дублироваться.

Секреты в код не пишу никогда. Токены и пароли приходят через переменные окружения. Их задаёт EnvironmentFile в unit-файле.

Основная конфигурация

Пример конфигурации

Конфиг — обычный TOML. Список серверов, порог и куда класть отчёт:

# /etc/diskwatch/config.toml
hosts = ["web01", "web02", "db01"]
threshold = 85
ssh_timeout = 5
report = "/var/lib/diskwatch/report.json"

Секреты лежат отдельно, в файле с правами 600:

# /etc/diskwatch/env
TG_TOKEN=123456:replace-me
TG_CHAT_ID=-1001234567890

Сам скрипт короткий, но в нём есть всё, о чём шла речь выше:

# /opt/diskwatch/diskwatch.py
import argparse
import json
import logging
import os
import subprocess
import sys
import tomllib
from pathlib import Path

import requests

log = logging.getLogger("diskwatch")
DF = ["df", "-P", "-x", "tmpfs", "-x", "devtmpfs", "-x", "squashfs", "-x", "overlay"]


def parse_df(host: str, text: str) -> list[dict]:
    rows = []
    for line in text.splitlines()[1:]:
        _, _, _, _, pcent, mount = line.split(maxsplit=5)
        rows.append({"host": host, "mount": mount, "used": int(pcent.rstrip("%"))})
    return rows


def get_usage(host: str, timeout: int) -> list[dict]:
    cmd = ["ssh", "-o", "BatchMode=yes", "-o", f"ConnectTimeout={timeout}", host, *DF]
    out = subprocess.run(cmd, capture_output=True, text=True,
                         timeout=timeout + 10, check=True)
    return parse_df(host, out.stdout)


def notify(text: str) -> None:
    url = f"https://api.telegram.org/bot{os.environ['TG_TOKEN']}/sendMessage"
    r = requests.post(url, json={"chat_id": os.environ["TG_CHAT_ID"], "text": text},
                      timeout=10)
    r.raise_for_status()


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--config", default="/etc/diskwatch/config.toml")
    parser.add_argument("--dry-run", action="store_true")
    args = parser.parse_args()
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

    with open(args.config, "rb") as f:
        cfg = tomllib.load(f)

    problems, failed = [], []
    for host in cfg["hosts"]:
        try:
            rows = get_usage(host, cfg["ssh_timeout"])
        except subprocess.CalledProcessError as e:
            log.error("%s: ssh exit %d: %s", host, e.returncode, e.stderr.strip())
            failed.append(host)
            continue
        except (subprocess.TimeoutExpired, ValueError) as e:
            log.error("%s: %s", host, e)
            failed.append(host)
            continue
        problems += [r for r in rows if r["used"] >= cfg["threshold"]]

    Path(cfg["report"]).write_text(json.dumps(problems, indent=2), encoding="utf-8")

    if problems and not args.dry_run:
        notify("\n".join(f"{p['host']} {p['mount']}: {p['used']}%" for p in problems))

    log.info("hosts=%d problems=%d failed=%d",
             len(cfg["hosts"]), len(problems), len(failed))
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())

Пара моментов. df уходит в SSH списком аргументов, shell на нашей стороне не участвует. -x squashfs отсекает snap-пакеты на Ubuntu, они всегда заняты на 100%.

У requests.post стоит таймаут, без него скрипт может висеть на API вечно. А stderr от ssh попадает в лог, и причина обрыва видна сразу.

Запускаю через systemd timer раз в 15 минут:

# /etc/systemd/system/diskwatch.service
[Unit]
Description=Disk usage check over SSH

[Service]
Type=oneshot
User=diskwatch
EnvironmentFile=/etc/diskwatch/env
StateDirectory=diskwatch
ExecStart=/opt/diskwatch/venv/bin/python /opt/diskwatch/diskwatch.py
# /etc/systemd/system/diskwatch.timer
[Unit]
Description=Run diskwatch every 15 minutes

[Timer]
OnCalendar=*:0/15
Persistent=true

[Install]
WantedBy=timers.target

В ExecStart указан python из venv. Активировать окружение не нужно, библиотеки подхватятся сами.

Проверка работоспособности

Проверка и тестирование

Первый запуск делаю руками, от пользователя скрипта и с --dry-run:

sudo -u diskwatch /opt/diskwatch/venv/bin/python /opt/diskwatch/diskwatch.py --dry-run
echo $?

Запуск именно от этого пользователя важен. У root другие ключи, другой known_hosts и другие права. Скрипт, который работает от root, под сервисным пользователем падает на первом же SSH.

Отчёт смотрю через json.tool. Заодно это проверка, что JSON валидный:

python3 -m json.tool /var/lib/diskwatch/report.json

Разбор вывода df вынесен в отдельную функцию не случайно. На неё легко написать тест без серверов и сети:

# /opt/diskwatch/test_diskwatch.py
from diskwatch import parse_df


def test_parse_df():
    out = ("Filesystem 1024-blocks Used Available Capacity Mounted on\n"
           "/dev/sda1 100 90 10 90% /\n")
    assert parse_df("web01", out) == [{"host": "web01", "mount": "/", "used": 90}]

Запуск через pytest из того же venv:

/opt/diskwatch/venv/bin/pip install pytest
cd /opt/diskwatch && venv/bin/python -m pytest -q

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

systemctl daemon-reload
systemctl enable --now diskwatch.timer
systemctl start diskwatch.service
systemctl list-timers diskwatch.timer

Логи и диагностика

Всё, что скрипт пишет через logging, лежит в журнале:

journalctl -u diskwatch -n 50 --no-pager
journalctl -u diskwatch --since today | grep ERROR

Фильтр -p err тут не поможет. Уровни Python сами в приоритеты journald не превращаются, все строки придут с одним уровнем. Поэтому ищу по тексту.

Если скрипт упал с исключением, traceback тоже будет в журнале. Для ручной отладки удобен python3 -X dev. Он включает предупреждения, которые обычно молчат, например про незакрытые файлы.

Для проблем с SSH добавляю -v в команду и смотрю, на каком шаге рвётся. Чаще всего виноваты known_hosts или права на ключ.

Безопасность и резервное копирование

Главное правило для subprocess — никакого shell=True с данными из конфига или из сети. Имя хоста вида web01; rm -rf / в shell превращается в команду. Список аргументов такой фокус не пропускает.

Имена хостов из конфига я ещё и проверяю регуляркой. Имя, которое начинается с дефиса, ssh примет за опцию.

Ключ, которым скрипт ходит на серверы, ограничиваю на стороне сервера. В authorized_keys ему разрешена одна команда и ничего больше:

# ~/.ssh/authorized_keys на целевом сервере
restrict,command="df -P -x tmpfs -x devtmpfs -x squashfs -x overlay" ssh-ed25519 AAAA... diskwatch

Украдут ключ — получат вывод df. Переживу.

Версии зависимостей фиксирую в requirements.txt и ставлю только из него. Перед обновлением прогоняю pip-audit, он сверяет пакеты с базой уязвимостей.

Env-файл с секретами принадлежит root, права 600, в git не попадает. В репозиторий кладу env.example с пустыми значениями.

Теперь резервное копирование. Venv не бэкаплю, он пересоздаётся за минуту из requirements.txt. Бэкапить нужно другое:

  • код, requirements.txt и тесты — в git;
  • /etc/diskwatch — в обычный бэкап /etc, секреты отдельно в хранилище паролей;
  • отчёты из /var/lib/diskwatch — если по ним строится история.

Восстановление тогда укладывается в три команды: git clone, python3 -m venv и pip install -r requirements.txt.

Типичные ошибки

externally-managed-environment. sudo pip install на Debian 12+ и Ubuntu 23.04+ заканчивается этой ошибкой. Флаг --break-system-packages её обходит, но название у флага честное. Ставьте в venv, утилиты — через pipx, системные библиотеки — через apt install python3-....

ModuleNotFoundError под cron или systemd. Руками скрипт работает, из планировщика нет. Обычно в shebang или в ExecStart стоит /usr/bin/python3, а библиотеки лежат в venv. Указывайте полный путь к python из venv.

Host key verification failed. Сервисный пользователь ни разу не ходил на сервер, known_hosts пустой. С BatchMode=yes ssh сразу упадёт с этой ошибкой. Без него при ручном запуске скрипт повиснет на вопросе про fingerprint. Ключи хостов добавляем заранее через ssh-keyscan или раздаём known_hosts централизованно.

Логи приходят с опозданием. print пишет в stdout, а без терминала он буферизуется. В журнале строки появляются пачкой в конце или не появляются, если процесс убит. Используйте logging в stderr или PYTHONUNBUFFERED=1 в unit-файле.

Запросы без timeout. requests по умолчанию ждёт ответа бесконечно. Один зависший API, и таймер больше не стартует, потому что прошлый запуск ещё идёт. Timeout указываю в каждом вызове.

Snap-пакеты в алертах. На Ubuntu df показывает squashfs-разделы snap, и они всегда заполнены на 100%. Без -x squashfs алерт висит вечно. С overlay от Docker похожая история: дубли одного и того же диска.

Относительные пути. Скрипт ищет config.toml рядом с собой, а под systemd рабочий каталог — /. Все пути либо абсолютные, либо от Path(__file__).parent.

Практические рекомендации

Обновление и обслуживание

Venv привязан к конкретной версии интерпретатора. Обновили Debian 12 до 13 — Python сменился с 3.11 на 3.13, и старые venv сломались. Лечится пересозданием:

rm -rf /opt/diskwatch/venv
python3 -m venv /opt/diskwatch/venv
/opt/diskwatch/venv/bin/pip install -r /opt/diskwatch/requirements.txt

Поэтому requirements.txt обязателен, даже если зависимость одна.

Библиотеки обновляю осознанно, а не по расписанию. pip list --outdated показывает, что устарело. Обновляю на тестовом сервере, прогоняю тесты и --dry-run, потом раскатываю.

Ещё несколько привычек:

  • Один скрипт — одна задача. Проверка дисков не отправляет заодно бэкапы.
  • ruff check перед коммитом. Ловит неиспользуемые импорты и кучу опечаток.
  • Type hints в сигнатурах. Через полгода они объясняют код лучше комментариев.
  • Для сложных сценариев по SSH берите paramiko или fabric. Для простого df хватает системного ssh с его конфигом и ключами.
  • Если скрипт укладывается в 20 строк без логики, оставьте его на Bash. Python ради Python не нужен.

Первый алерт этот скрипт прислал через час после запуска. Это был snap на 100%, -x squashfs я тогда ещё не добавил. Ну для мониторинга конечно лучше использовать Zabbix, тут больше про то как python может помочь в системном администрировании на небольшом примере — дальше всё зависит только от вашей фантазии и конкретных задач.