Wiele serwerów naraz
Po tej lekcji: kursant uruchamia komendy i skrypty na wielu serwerach przez SSH: ma porządny ~/.ssh/config, pisze pętlę po serwerach, która nie gubi maszyn i zgłasza błędy, i odpala ją równolegle przez &, xargs -P, GNU parallel albo parallel-ssh.
Wstęp
Wychodzi łatka bezpieczeństwa. Szef pyta: „Na których serwerach mamy starą wersję jądra?”. Trzydzieści serwerów. Możesz się zalogować na każdy po kolei, wpisać uname -r, przepisać do arkusza. Trzydzieści razy. Do trzeciego się nudzisz, przy piętnastym robisz literówkę, a dwudziesty się nie odzywa i nawet tego nie zauważasz.
Albo jedna linijka:
./na-wszystkich.sh -p 'uname -r'I po dwóch sekundach masz odpowiedź ze wszystkich, z nazwą serwera przy każdej linijce i listą tych, które nie odpowiedziały. Dziś ten skrypt napiszemy.
~/.ssh/config, czyli książka adresowa
Najpierw porządek w adresach. Zamiast pamiętać ssh -p 2222 -i ~/.ssh/klucz_firma admin@192.168.122.11, wpisujesz to raz do książki adresowej i dalej mówisz po prostu ssh web1. Jak w telefonie: nie wybierasz numeru, tylko klikasz „Mama”.
# ~/.ssh/config
Host web1
HostName 192.168.122.11
Host web2
HostName 192.168.122.12
Host db1
HostName 192.168.122.21
Port 2222
Host bastion
HostName vpn.firma.example
Host *.wew
ProxyJump bastion
Host *
User admin
IdentityFile ~/.ssh/id_ed25519
ConnectTimeout 5
ServerAliveInterval 30SSH czyta ten plik od góry i dla każdej opcji bierze pierwszą pasującą wartość. Dlatego ogólne Host * stoi na końcu, jak „w pozostałych sprawach proszę do okienka 5”. ProxyJump to przejście przez bramkę: najpierw na bastion, z niego dalej. Plik musi należeć do Ciebie i nie może być zapisywalny dla innych, więc chmod 600 ~/.ssh/config.
Co SSH naprawdę zrobi dla danej nazwy, sprawdzisz bez łączenia się:
ssh -G web1 | grep -E '^(user|hostname|port|connecttimeout) 'user admin
hostname 192.168.122.11
port 22
connecttimeout 5Użytkownik i adres przyszły z bloku web1 i z ogólnego Host *, a port 22 to wartość domyślna, bo dla web1 żadnego portu nie podałeś. Dla pętli to bez różnicy, czy serwer stoi w szafie obok, czy w innym kraju.
Pętla po serwerach
Pamiętasz pętlę, czyli taśmę przy kasie? Tym razem na taśmie jadą nazwy serwerów.
for s in web1 web2 db1; do
echo "== $s"
ssh "$s" uptime
done== web1
22:36:42 up 6:18, 0 user, load average: 0.53, 0.46, 0.30
== web2
22:36:42 up 6:18, 0 user, load average: 0.48, 0.45, 0.30
== db1
22:36:42 up 6:18, 0 user, load average: 0.48, 0.45, 0.30Działa. Ale nikt nie trzyma 30 nazw w pętli. Trzymasz je w pliku serwery.txt, jeden na linijkę. I tu czeka pierwsza pułapka, ta z sekcji „Typowy błąd”. Na razie zapamiętaj: w pętli while read zawsze ssh -n.
Uwaga na cudzysłowy, czyli koperty. Kto otwiera kopertę, Twój Bash czy zdalny?
ssh web1 "echo Jestem na $HOSTNAME"
ssh web1 'echo Jestem na $HOSTNAME'Jestem na vm
Jestem na web1Podwójne cudzysłowy: Twój Bash podstawia $HOSTNAME jeszcze u Ciebie, na laptopie o nazwie vm, i dopiero wysyła. Pojedyncze: koperta jedzie zaklejona i otwiera ją serwer. To jak wysłanie kumplowi listu „napisz, jaka jest u Ciebie pogoda”, tylko że zanim zakleiłeś kopertę, sam wpisałeś „słonecznie”.
Dłuższy skrypt? Nie kopiujesz go na każdy serwer. Wysyłasz go SSH-em na wejście zdalnego Basha:
ssh web2 'bash -s' -- prod < zdalny.shSerwer: web2, argument: prod
total used free shared buff/cache available
Mem: 7.8Gi 1.0Gi 4.7Gi 15Mi 2.5Gi 6.8Gibash -s czyta skrypt ze standardowego wejścia, a wszystko po -- to argumenty dla skryptu: $1 to prod.
Skrypt, który nie gubi serwerów
Dobra pętla po serwerach musi umieć cztery rzeczy: nie zawiesić się na pytaniu o hasło, nie czekać wiecznie na martwy serwer, podpisywać każdą linijkę nazwą serwera i na końcu powiedzieć, które zawiodły.
#!/usr/bin/env bash
# na-wszystkich.sh - uruchamia jedną komendę na wielu serwerach przez SSH
# Użycie: na-wszystkich.sh [-p] [-f PLIK] KOMENDA...
set -uo pipefail
plik=serwery.txt
rownolegle=0
uzycie() { echo "Użycie: $0 [-p] [-f PLIK_Z_SERWERAMI] KOMENDA..." >&2; exit 2; }
while getopts 'pf:h' opcja; do
case $opcja in
p) rownolegle=1 ;;
f) plik=$OPTARG ;;
*) uzycie ;;
esac
done
shift $((OPTIND - 1))
(( $# > 0 )) || uzycie
komenda="$*"
mapfile -t serwery < <(grep -Ev '^[[:space:]]*(#|$)' "$plik")
wyniki=$(mktemp -d)
trap 'rm -rf "$wyniki"' EXIT
na_jednym() {
local s=$1
ssh -n -o BatchMode=yes -o ConnectTimeout=5 "$s" "$komenda" \
> "$wyniki/$s.out" 2>&1
echo $? > "$wyniki/$s.kod"
}
for s in "${serwery[@]}"; do
if (( rownolegle )); then
na_jednym "$s" &
else
na_jednym "$s"
fi
done
wait
nieudane=()
for s in "${serwery[@]}"; do
sed "s/^/[$s] /" "$wyniki/$s.out"
[[ $(< "$wyniki/$s.kod") == 0 ]] || nieudane+=("$s")
done
if (( ${#nieudane[@]} > 0 )); then
echo "BŁĄD na: ${nieudane[*]}" >&2
exit 1
fiCo tu pracuje:
BatchMode=yesmówi SSH: niczego nie pytaj. Brak klucza? Błąd i dalej, zamiast pytania o hasło, które zawiesza całą pętlę o trzeciej w nocy.ConnectTimeout=5: na martwy serwer czekamy pięć sekund, nie dwie minuty.grep -Evwyrzuca z listy puste linijki i komentarze, więc wserwery.txtmożesz mieć# bazy.- Każdy serwer pisze do własnego pliku w folderze tymczasowym, a
trapz lekcji o sprzątaczce usuwa go na końcu. - Brak
set -ejest celowy: jeden padnięty serwer nie może zatrzymać całej pętli.
ShellCheck: czysto. Uruchamiam na czterech serwerach, z czego web3 jest wyłączony:
./na-wszystkich.sh 'uname -r; df -h / | tail -1'; echo "kod: $?"[web1] 6.8.0-48-generic
[web1] /dev/vda1 25G 6.1G 18G 26% /
[web2] 6.8.0-48-generic
[web2] /dev/vda1 25G 5.8G 18G 25% /
[db1] 6.8.0-45-generic
[db1] /dev/vda1 50G 21G 27G 44% /
[web3] ssh: connect to host 192.168.122.13 port 22: Connection refused
BŁĄD na: web3
kod: 1Kod wyjścia 1, czyli kciuk w dół, więc cron albo inny skrypt od razu wie, że coś poszło nie tak. Kod 255 od SSH znaczy błąd samego połączenia, a nie komendy.
Równolegle, czyli więcej kas w Biedronce
Po kolei to jedna kasa i kolejka. Jeśli komenda trwa dwie sekundy, 30 serwerów to minuta. Otwierasz więcej kas: każdy serwer obsługiwany jednocześnie. W skrypcie robi to & i wait:
time ./na-wszystkich.sh -f trzy.txt 'sleep 2; hostname'
time ./na-wszystkich.sh -p -f trzy.txt 'sleep 2; hostname'[web1] web1
[web2] web2
[db1] db1
real 0m6.769s
[web1] web1
[web2] web2
[db1] db1
real 0m2.349sSześć sekund kontra dwie. Przy 200 serwerach to różnica między „zaraz” a „po kawie”. Ale & odpala wszystko naraz. Dla 500 maszyn chcesz limit, ile kas naraz. Trzy gotowe narzędzia:
# xargs: wbudowany, -P to liczba naraz
xargs -P 3 -I{} ssh -n -o BatchMode=yes {} 'echo "$(hostname): $(nproc) rdzeni"' < trzy.txt
# GNU parallel: --tag dopisuje nazwę serwera, -j to liczba naraz
parallel --tag -j 10 ssh -n -o BatchMode=yes {} uptime -p :::: trzy.txt
# parallel-ssh: zrobione specjalnie do tego
parallel-ssh -h trzy.txt -i 'uptime -p'db1: 2 rdzeni
web2: 2 rdzeni
web1: 2 rdzeni
web2 up 6 hours, 19 minutes
db1 up 6 hours, 19 minutes
web1 up 6 hours, 19 minutes
[1] 22:37:19 [SUCCESS] db1
up 6 hours, 19 minutesZauważ kolejność: kto pierwszy skończył, ten pierwszy mówi. Dlatego --tag i podpisy są obowiązkowe, inaczej nie wiesz, czyja to odpowiedź.
[Ubuntu] sudo apt install parallel pssh, a program nazywa się parallel-ssh. [RHEL] parallel i pssh są w EPEL, tam komenda to po prostu pssh. GNU parallel przy pierwszym uruchomieniu prosi o cytowanie w pracach naukowych. Raz wpisujesz parallel --citation i już nie marudzi.
Szybciej: jedno połączenie na wiele komend
Każde ssh to nowe połączenie: uzgadnianie szyfrowania, sprawdzanie klucza. Jak wejście do biurowca z okazaniem dowodu za każdym razem, gdy idziesz po kawę. ControlMaster zostawia otwarte drzwi i kolejne ssh przechodzi bez kontroli:
Host *
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10mU mnie dziesięć kolejnych ssh web1 true: 2,36 sekundy bez tego, 0,33 sekundy z tym. Stan sprawdzasz ssh -O check web1, zamykasz ssh -O exit web1.
sudo w pętli
Na serwerze sudo pyta o hasło. A w pętli z -n i BatchMode nikt nie odpowie:
sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper
sudo: a password is requiredDo jednego serwera ręcznie: ssh -t web1 sudo systemctl restart nginx. -t daje terminal, więc możesz wpisać hasło. W pętli nie wklejaj hasła do skryptu ani do sudo -S. Daj kontu admina sudo bez hasła tylko na konkretne komendy, jak w lekcji o sudoers, albo użyj Ansible, który ma na to porządny mechanizm. O nim w następnej lekcji.
Jak to działa pod spodem
Gdy piszesz ssh web1 'uname -r', klient łączy się, loguje kluczem i prosi serwer o kanał typu „exec” z tekstem komendy. Serwer uruchamia Twoją powłokę logowania z -c 'uname -r'. Wyjście i błędy wracają osobnymi kanałami, czyli dwa głośniki działają nawet przez sieć, a kod wyjścia zdalnej komendy staje się kodem wyjścia ssh.
I najważniejsze dla pętli: standardowe wejście Twojego ssh jest przekazywane na serwer. Wszystko, co leży na wejściu, SSH zabiera i wysyła zdalnej komendzie. Nawet jeśli ta komenda go wcale nie czyta. -n podpina wejście do /dev/null, czyli mówi: niczego mi nie zabieraj.
Ściąga flag
| Komenda | Flaga | Co robi |
|---|---|---|
ssh | -n | nie zabieraj standardowego wejścia (obowiązkowe w while read) |
ssh | -o BatchMode=yes | nigdy nie pytaj o hasło ani o nic innego |
ssh | -o ConnectTimeout=5 | czekaj na połączenie najwyżej 5 s |
ssh | -o StrictHostKeyChecking=accept-new | nowy serwer dopisz sam, zmieniony klucz odrzuć |
ssh | -t | daj terminal (sudo z hasłem, top) |
ssh | -J bastion | połącz przez bramkę |
ssh | -G HOST | pokaż ustawienia z config, bez łączenia |
ssh | -O check / -O exit | stan / zamknięcie połączenia ControlMaster |
bash | -s -- ARG | czytaj skrypt z wejścia, z argumentami |
xargs | -P N -I{} | N naraz, {} zastępowane nazwą |
parallel | --tag -j N | podpisy przy wynikach, N naraz |
parallel-ssh | -h PLIK -i | lista hostów z pliku, wyniki od razu |
⚠ Typowy błąd
Lista serwerów w pliku i pętla, która wygląda niewinnie:
while read -r s; do
echo "== $s"
ssh "$s" hostname
done < serwery.txt== web1
web1I koniec. Trzy serwery w pliku, sprawdzony jeden. Bez żadnego błędu. Najgorszy rodzaj błędu, bo raport mówi „wszystko OK”.
Co się stało? read i ssh czytają z tego samego wejścia, czyli z pliku. read wziął pierwszą linijkę, a ssh połknął resztę i wysłał na serwer, gdzie hostname ją zignorował. Dla read nic nie zostało. Jak kolega, który w kolejce do kasy zgarnia całą taśmę zakupów do swojej siatki. Naprawa: ssh -n albo ssh ... < /dev/null. Z -n pętla wypisuje wszystkie trzy serwery.
✎ Zadanie
Postaw dwie maszyny wirtualne (albo jedną i localhost). Zapisz je w ~/.ssh/config pod nazwami lab1 i lab2, z logowaniem kluczem. Zrób plik serwery.txt z tymi dwiema nazwami i jedną nieistniejącą, lab9. Uruchom na-wszystkich.sh z komendami uptime i df -h /, najpierw po kolei, potem z -p, i porównaj czas przez time. Sprawdź, czy skrypt zgłasza lab9 jako błąd i kończy się kodem 1. Na koniec napisz pętlę while read bez -n i zobacz na własne oczy, jak gubi serwery.
Pokaż rozwiązanie
~/.ssh/config z dwiema maszynami:
Host lab1
HostName 192.168.122.11
Host lab2
HostName 192.168.122.12
Host lab*
User eryk
IdentityFile ~/.ssh/id_ed25519Plik serwery.txt: lab1, lab2, lab9. I porównanie:
time ./na-wszystkich.sh 'uptime; df -h /'
time ./na-wszystkich.sh -p 'uptime; df -h /'
echo "kod: $?"Na końcu każdego przebiegu powinno być BŁĄD na: lab9 i kod 1. Ciekawostka: lab9 nie istnieje w ~/.ssh/config, więc SSH próbuje znaleźć komputer o takiej nazwie w DNS i dostaje Could not resolve hostname. Wersja równoległa trwa mniej więcej tyle, co najwolniejszy serwer, a nie suma wszystkich. A pętla while read bez -n sprawdziła tylko lab1 i z czystym sumieniem poszła do domu.
Co dalej
Pętla przez SSH mówi serwerom, co mają zrobić. W następnej lekcji Ansible, który opisuje, jak serwery mają wyglądać: inwentarz, playbooki, moduły, handlery, sejf na hasła i suchy przebieg z --check --diff.