ESXi UPS Monitor – automatyczne wyłączanie maszyn VM przy pracy na baterii

Miałem dość ręcznego wyłączania laba przy awarii zasilania

Mieszkam w miejscu, gdzie przerwy w zasilaniu zdarzają się kilka razy w roku. Dla homelaba to taki nieplanowany stress test. UPS przejmuje zasilanie, wszystko działa jeszcze przez chwilę i jeśli jestem w domu, mogę spokojnie zamknąć maszyny. Gorzej, jeśli akurat jestem poza domem – w sklepie, w pracy albo w podróży. UPS w końcu się rozładowuje, a maszyny VM dostają twarde odcięcie. Uszkodzone VMDK, niespójne bazy danych, ogólnie taki wtorek, którego wolę nie mieć.

Mam UPS APC z kartą zarządzającą AP9631. Ta karta mówi po SNMP. Skoro i tak wie, co się dzieje – czy jest zasilanie z sieci, jaki jest poziom baterii i ile czasu pracy zostało – to czemu nie miałaby zrobić części roboty za mnie?

Tak powstał esxi-ups-monitor: aplikacja w Go, która odpytuje UPS po SNMP, śledzi stan baterii, etapami wyłącza maszyny VM na ESXi, a po powrocie zasilania uruchamia je z powrotem. Działa w kontenerze Docker, wysyła powiadomienia na Telegramie na każdym etapie i od jakiegoś czasu obsługuje mój lab bez większych dramatów.

Sprzęt

AP9631 to karta zarządzająca APC, często spotykana w modelach Smart-UPS. Udostępnia pełne SNMP MIB z rzeczami, które są tu potrzebne: stan wyjścia, czyli online albo praca na baterii, procent naładowania baterii, szacowany czas pracy, obciążenie wyjścia i kilka innych parametrów.

W jednym zapytaniu SNMP GET odpytuję pięć OID-ów:

OID Co zwraca
.1.3.6.1.4.1.318.1.1.1.4.1.1.0 Status wyjścia: 2 = online, 3 = na baterii
.1.3.6.1.4.1.318.1.1.1.2.2.1.0 Poziom baterii, 0-100%
.1.3.6.1.4.1.318.1.1.1.2.2.3.0 Pozostały czas pracy (TimeTicks – trzeba podzielić przez 100, żeby dostać sekundy)
.1.3.6.1.4.1.318.1.1.1.2.1.1.0 Status baterii
.1.3.6.1.4.1.318.1.1.1.4.2.3.0 Procent obciążenia wyjścia

Najprostszy test połączenia wygląda tak:

snmpwalk -v2c -c public 192.168.1.100 .1.3.6.1.4.1.318.1.1.1.2.1.1.0

Jeśli dostaniesz w odpowiedzi liczbę, jesteś w domu. Jeśli nie, trzeba wrócić do konfiguracji sieci albo SNMP.

Dlaczego Go, dlaczego SNMP, dlaczego SSH

Dlaczego Go? Chciałem mieć pojedynczą binarkę, którą można wrzucić do obrazu Dockera i o niej zapomnieć. Go pasuje tu dobrze: biblioteka standardowa ogarnia SSH, a gosnmp obsługuje część SNMP. Wynikiem jest statyczna binarka około 15 MB działająca w Alpine. Bez interpretera, bez runtime’u, bez zbędnych zależności.

Dlaczego SNMP zamiast panelu WWW? AP9631 ma web UI, które dałoby się scrapować, ale SNMP jest stabilniejsze i mniej inwazyjne. Jeden pakiet UDP, jedna odpowiedź i koniec. Bez parsowania HTML-a, bez sesji, bez logowania do panelu. Do tego zadania SNMP jest po prostu właściwym narzędziem.

Dlaczego SSH bezpośrednio do ESXi zamiast vCenter? Nie używam vCenter. To pojedynczy host ESXi w labie. Nawet gdybym miał vCenter, jego API byłoby tu armatą na muchę. SSH daje bezpośredni dostęp do esxcli vm process kill --type=soft i esxcli system shutdown poweroff. Aplikacja przy pierwszym uruchomieniu generuje klucz SSH i wypisuje klucz publiczny w logach, więc konfiguracja zajmuje około 30 sekund.

Jak to działa

Rdzeniem jest maszyna stanów z czterema stanami: ONLINE, ON_BATTERY, RECOVERING i ESXI_SHUTDOWN. Co 10 sekund aplikacja odpytuje UPS, przekazuje status do maszyny stanów i wykonuje akcję, która z tego wynika.

ONLINE → ON_BATTERY

Gdy UPS zgłasza status wyjścia 3, czyli pracę na baterii, przechodzimy z ONLINE do ON_BATTERY. Startuje licznik czasu. Na Telegram trafia wiadomość z aktualnym stanem baterii.

ON_BATTERY: wykonywanie etapów

Etapy definiujesz w config.yaml. Każdy etap może mieć after_seconds i/lub runtime_remaining_seconds. Wystarczy spełnienie jednego warunku – tego, który nastąpi pierwszy. Przykład:

stages:
  - after_seconds: 300
    runtime_remaining_seconds: 900
    vms:
      - plex
      - forgejo-runner
      - test-server

  - after_seconds: 600
    runtime_remaining_seconds: 600
    vms:
      - grafana
      - influxdb
      - homeassistant

  - after_seconds: 900
    runtime_remaining_seconds: 420
    vms:
      - docker-prod

Ostatni etap

Gdy bateria jest już nisko, wchodzi ostatni etap. Aplikacja wyłącza ostatnie maszyny VM, w tym sam monitor, a potem wysyła do ESXi polecenie zamknięcia hosta z opóźnieniem.

final:
  after_seconds: 1200
  runtime_remaining_seconds: 300
  delay_seconds: 90
  vms:
    - esxi-ups-monitor

Opóźnienie daje kontenerowi czas na spokojne zakończenie pracy, zanim ESXi zacznie się wyłączać.

RECOVERING: zasilanie wraca

Gdy UPS znowu zgłasza zasilanie z sieci, aplikacja przechodzi do stanu RECOVERING. Odzyskiwanie czeka, aż bateria będzie miała wystarczający poziom naładowania i czas pracy:

recovery:
  startup_delay_seconds: 120
  startup_runtime_remaining_seconds: 1800

ESXI_SHUTDOWN: stan uśpienia

Po wyłączeniu hosta ESXi monitor zostaje w stanie ESXI_SHUTDOWN i czeka na powrót zasilania.

Maszyna stanów UPS: ONLINE, ON_BATTERY, RECOVERING, ESXI_SHUTDOWN. Przejścia: awaria zasilania, powrót zasilania, ostatni etap, powrót zasilania.
Schemat maszyny stanów

Wzorzec odzyskiwania

Uruchamianie działa według zasady LIFO, czyli last-in-first-out. Maszyny VM wyłączają się w kolejności etapów, a uruchamiają w odwrotnej kolejności. Ostatnia wyłączona maszyna startuje jako pierwsza. Dzięki temu podstawowa infrastruktura wraca dopiero wtedy, gdy zasilanie jest już stabilne.

Architektura ESXi UPS Monitor: APC UPS z AP9631 przez SNMP, aplikacja Go w Dockerze, klient SSH do ESXi, maszyna stanów, powiadomienia Telegram, host ESXi 8 z maszynami VM.
Schemat architektury

Dlaczego nie po prostu NUT?

NUT, czyli Network UPS Tools, to solidny projekt. Problemem był u mnie staged shutdown z uwzględnieniem pozostałego czasu pracy. NUT potrafi uruchamiać skrypty po zdarzeniach zasilania, ale jeśli chcesz mieć kilka etapów, progi czasowe i logikę OR, i tak kończysz z własnym skryptem.

Integracja z Telegramem, odzyskiwanie w kolejności LIFO i łagodne wyłączenie hosta ESXi z opóźnieniem to logika dopisana nad surowymi danymi z UPS-a. Ten monitor zastępuje skrypt, który i tak musiałbym napisać nad NUT-em, tylko ma od razu wbudowane odpytywanie SNMP.

Deployment

Uruchomienie zajmuje około pięciu minut. Klonujesz repo, edytujesz config.yaml i odpalasz docker-compose up -d. Przy pierwszym starcie kontener generuje klucz SSH i wypisuje klucz publiczny w logach. Ten klucz trzeba dodać do authorized keys na ESXi, a potem zrestartować kontener.

docker-compose up -d
docker-compose logs | grep "Public key"
# Wklej do authorized keys na ESXi
docker-compose restart

docker-compose.yml montuje konfigurację tylko do odczytu i tworzy katalog ./secrets na klucz SSH. Zmienne Telegrama trafiają przez .env. Gotowe binarki są dostępne na stronie releases dla linux-amd64, arm64, armv7 i armv6.

Rzeczy, na których się potknąłem

Kontener jest jedną z maszyn VM, którymi zarządza. Rozwiązaniem jest opóźnienie w ostatnim etapie. Monitor wyłącza sam siebie, a dopiero potem ESXi dostaje polecenie zamknięcia z opóźnieniem. Dzięki temu kontener ma czas wysłać ostatnie powiadomienie na Telegramie i domknąć połączenia.

SNMP TimeTicks są w setnych częściach sekundy. APC MIB zwraca pozostały czas pracy jako TimeTicks, czyli 1/100 sekundy. 30000 TimeTicks to 300 sekund, czyli 5 minut. Kod dzieli tę wartość przez 100.

Walidacja klucza hosta SSH. Użycie ssh.InsecureIgnoreHostKey() w homelabie jest dla mnie akceptowalne, zwłaszcza gdy klucz hosta zmienia się po reinstalacji ESXi.

ESXi znajduje maszyny VM po world ID, nie po nazwie. Funkcja ListVMs() parsuje wynik esxcli vm process list, żeby znaleźć world ID pasujące do nazw maszyn.

Oś czasu awarii zasilania: t=0 praca na baterii, t=5min etap 1, t=10min etap 2, t=15min etap 3, t=20min ostatni etap i wyłączenie hosta ESXi, t=25min powrót zasilania.
Sekwencja zdarzeń przy awarii zasilania

Kod

Projekt jest napisany w Go i leży tutaj: repo.noop.re/drops/esxi-apc. Licencja MIT. Pull requesty mile widziane.

Jeśli masz podobny setup – UPS APC z SNMP, host ESXi i kilka maszyn VM – sklonuj projekt, dopasuj konfigurację i daj znać, czy działa. Albo czy się wywraca. Jedno i drugie jest przydatnym feedbackiem.