Dostawcy usług chmurowych od lat zarabiają na starannie zapakowanych usługach zarządzanych. Czy to bazy danych czy brokerzy wiadomości, deweloperzy tacy jak my nie wydają się mieć problemu z płaceniem trochę więcej, aby mieć rzeczy pod opieką. Ale zaraz, czy nie jesteśmy zazwyczaj ostatnimi ludźmi, którzy decydują się na mniejszą optymalizację i mniejszą kontrolę? Dlaczego właśnie teraz postanowiliśmy inaczej? Gdybym miał zgadywać, powiedziałbym, że to częściowo dlatego, że DevOps po stronie serwera jest do bani.
Jako deweloper, konfigurowanie lub usuwanie błędów w VPS-ie to zwykle praca, której nie da się policzyć i która nie jest szczególnie satysfakcjonująca. W najlepszym wypadku, Twoja aplikacja prawdopodobnie będzie działała tak samo jak Twoje środowisko lokalne. Jak moglibyśmy usprawnić tę nieuniknioną część naszej pracy? Cóż, możemy ją zautomatyzować.
Paramiko i SCP to dwie biblioteki Pythona, których możemy użyć razem do zautomatyzowania zadań, które chcielibyśmy wykonać na zdalnym hoście, takich jak restartowanie usług, aktualizacje czy pobieranie plików dziennika. Przyjrzymy się, jak wygląda pisanie skryptów przy użyciu tych bibliotek. Ostrzegam: w tym tutorialu jest spora ilość kodu, co sprawia, że jestem na tyle podekscytowany, że zmuszam innych do wzięcia udziału w moich maniakalnych epizodach kodowo-tutorialnych. Jeśli zaczynasz się gubić, pełne repo można znaleźć tutaj:
Ustawianie kluczy SSH
Aby uwierzytelnić połączenie SSH, musimy skonfigurować prywatny klucz RSA SSH (nie mylić z OpenSSH). Możemy wygenerować klucz za pomocą następującego polecenia:
W tym momencie zostaniemy poproszeni o podanie nazwy dla naszego klucza. Nazwij go, jak chcesz:
Następnie zostaniesz poproszony o podanie hasła (nie krępuj się zostawić tego pustego).
Teraz, gdy mamy już nasz klucz, musimy go skopiować na nasz zdalny host. Najłatwiej jest to zrobić za pomocą ssh-copy-id
:
Weryfikacja naszego klucza SSH
Jeśli chciałbyś sprawdzić jakie klucze już posiadasz, można je znaleźć w katalogu .ssh
w systemie:
Szukamy kluczy, które zaczynają się od następującego nagłówka:
Nie krępuj się zrobić tego samego na swoim FPS.
Rozpoczęcie naszego skryptu
Zainstalujmy nasze biblioteki. Odpal dowolne środowisko wirtualne i daj się ponieść emocjom:
Jeszcze jedna rzecz, zanim napiszemy jakiś sensowny kod Pythona! Utwórz plik konfiguracyjny, który będzie zawierał zmienne potrzebne do połączenia się z naszym hostem. Oto podstawowe informacje o tym, czego potrzebujemy, aby dostać się do naszego serwera:
- Host: Adres IP lub URL zdalnego hosta, do którego próbujemy się dostać.
- Nazwa użytkownika: To jest nazwa użytkownika, której używasz do SSH na swoim serwerze.
- Fraza hasła (opcjonalnie): Jeśli podczas tworzenia klucza ssh podałeś frazę hasła, podaj ją tutaj. Pamiętaj, że fraza hasła klucza SSH nie jest taka sama jak hasło użytkownika.
- Klucz SSH: Ścieżka do pliku z kluczem, który utworzyliśmy wcześniej. W systemie OSX znajduje się on w folderze ~/.ssh. Klucz SSH, do którego się odwołujemy, musi mieć dołączony klucz z rozszerzeniem .pub. Jest to nasz klucz publiczny; jeśli podążałeś za nim wcześniej, powinien on już zostać wygenerowany dla Ciebie.
Jeśli próbujesz wysyłać lub pobierać pliki ze zdalnego hosta, będziesz musiał dołączyć jeszcze dwie zmienne:
- Remote Path: Ścieżka do zdalnego katalogu, który chcemy wykorzystać do przesyłania plików. Możemy albo przesyłać rzeczy do tego katalogu albo pobierać jego zawartość.
- Local Path: Ta sama idea co powyżej, ale w odwrotnej kolejności. Dla naszej wygody, lokalna ścieżka, której będziemy używać to po prostu /data, i zawiera obrazki słodkich lisich gifów.
Teraz mamy wszystko, czego potrzebujemy, aby stworzyć porządny plik config.py:
Tworzenie klienta SSH
Powstanie klasa RemoteClient, która będzie obsługiwać interakcje z naszym zdalnym hostem. Zanim zaczniemy zbytnio kombinować, zacznijmy od zainicjowania klasy RemoteClient za pomocą zmiennych, które utworzyliśmy w config.py:
Jak na razie nic imponującego: po prostu ustawiliśmy kilka zmiennych i przekazaliśmy je do bezużytecznej klasy. Podnieśmy więc poprzeczkę, nie wychodząc z naszego konstruktora:
Dodaliśmy trzy nowe rzeczy, które mają być instancjonowane wraz z naszą klasą:
-
self.client
: self.client będzie ostatecznie służył jako obiekt połączenia w naszej klasie, podobnie do tego, jak miałeś do czynienia z terminologią taką jakconn
w bibliotekach bazodanowych. Nasze połączenie będzieNone
dopóki nie połączymy się jawnie z naszym zdalnym hostem. -
self.scp
: Podobny do self.client, ale obsługuje wyłącznie połączenia do przesyłania plików. -
self.__upload_ssh_key()
nie jest zmienną, ale raczej funkcją, która ma być uruchamiana automatycznie za każdym razem, gdy nasz klient zostanie zainicjowany. Wywołanie__upload_ssh_key()
mówi naszemu obiektowi RemoteClient, aby sprawdził lokalne klucze ssh natychmiast po utworzeniu, tak abyśmy mogli spróbować przekazać je do naszego zdalnego hosta. W przeciwnym razie, nie bylibyśmy w stanie nawiązać połączenia.
Uploading SSH Keys to a Remote Host
Dotarliśmy do części tego ćwiczenia, w której musimy wywalić trochę niszczycielsko brzydkiego kodu. Jest to typowy moment, w którym emocjonalnie gorsze jednostki poddają się czystej, nudnej mętności zrozumienia kluczy SSH i utrzymywania połączeń. Nie popełnij błędu: uwierzytelnianie i zarządzanie połączeniami z czymkolwiek programistycznie jest przytłaczająco nudne… chyba, że twoim przewodnikiem jest czarujący mistrz słowa, służący jako twój kochający obrońca przez niebezpieczną ciemność. Niektórzy ludzie nazywają ten post samouczkiem. Ja zamierzam nazywać go sztuką.
RemoteClient zacznie od dwóch prywatnych metod: __get_ssh_key()
oraz __upload_ssh_key()
. Pierwsza z nich pobierze lokalnie przechowywany klucz publiczny, a jeśli się powiedzie, druga dostarczy ten klucz publiczny do naszego zdalnego hosta jako gałązkę oliwną dostępu. Gdy lokalnie utworzony klucz publiczny istnieje na zdalnej maszynie, maszyna ta na zawsze zaufa naszym prośbom o połączenie się z nią: nie potrzeba żadnych haseł. Po drodze włączymy odpowiednie logowanie, na wszelki wypadek, gdybyśmy napotkali jakieś problemy:
_get_ssh_key()
jest dość prosty: weryfikuje czy istnieje klucz SSH w ścieżce, którą podaliśmy w naszym configu do połączenia z naszym hostem. Jeśli plik faktycznie istnieje, ustawiamy naszą zmienną self.ssh_key
, więc ten klucz może być załadowany i używany przez naszego klienta od tego momentu. Paramiko dostarcza nam podmoduł o nazwie RSAKey, aby łatwo obsługiwać wszystkie rzeczy związane z kluczami RSA, jak np. parsowanie pliku klucza prywatnego do użytecznego uwierzytelnienia połączenia. Oto co tutaj otrzymujemy:
Gdyby nasz klucz RSA był niezrozumiałą bzdurą zamiast prawdziwym kluczem, wyjątek SSHException Paramiko wychwyciłby to i wcześnie podniósł wyjątek wyjaśniający właśnie to. Prawidłowe wykorzystanie obsługi błędów w bibliotece zabiera wiele z domysłów na temat „co poszło nie tak”, szczególnie w przypadkach, gdy istnieje potencjał dla wielu niewiadomych w niszowej przestrzeni, z którą żaden z nas nie zadziera często.
_upload_ssh_key()
to miejsce, w którym dostajemy się do zakleszczenia naszego klucza SSH w gardle naszego zdalnego serwera podczas krzyczenia, „LOOK! TERAZ MOŻESZ MI ZAUFAĆ NA ZAWSZE!”. Aby to osiągnąć, idę trochę „starą szkołą”, przekazując komendy basha przez Pythona os.system
. O ile ktoś nie uświadomi mi czystszego podejścia w komentarzach, założę, że jest to najbardziej paskudny sposób na obsługę przekazywania kluczy do zdalnego serwera.
Standardowy, nie-Pythonowy sposób przekazywania kluczy do hosta wygląda tak:
To jest dokładnie to, co osiągamy w naszej funkcji w Pythonie, która wygląda tak:
system(f'ssh-copy-id -i {self.ssh_key_filepath} \ {self.user}@{self.host}>/dev/null 2>&1')
Podejrzewam, że nie pozwolisz mi przeoczyć tego /dev/null 2>&1
bitu? Dobrze. Jeśli musisz wiedzieć, oto jakiś facet na StackOverflow wyjaśniający to lepiej niż ja mogę:
>
jest dla przekierowania/dev/null
jest czarną dziurą, w której wszelkie wysłane dane, zostaną odrzucone.2
to deskryptor pliku dla Standard Error.>
jest dla przekierowania.&
to symbol deskryptora pliku (bez niego następujący1
byłby uważany za nazwę pliku).1
to deskryptor pliku dla Standard O.
Więc w zasadzie mówimy naszemu zdalnemu serwerowi, że coś mu dajemy, a on odpowiada „gdzie mam to położyć”, na co my odpowiadamy „nigdzie w fizycznej przestrzeni, ponieważ to nie jest przedmiot, ale raczej wieczny symbol naszej przyjaźni”. Nasz zdalny gospodarz jest wtedy zalany wdzięcznością i wzruszeniem, bo tak, komputery mają emocje, ale nie możemy sobie tym teraz zawracać głowy.
Połączenie z naszym klientem
Dodamy do naszego klienta metodę o nazwie connect()
aby obsłużyć połączenie z naszym hostem:
Porozkładajmy to na czynniki pierwsze:
-
client = SSHClient()
Ustawia scenę do stworzenia obiektu reprezentującego naszego klienta SSH. Poniższe linie skonfigurują ten obiekt tak, aby był bardziej użyteczny. -
load_system_host_keys()
instruuje naszego klienta, aby szukał wszystkich hostów, z którymi łączył się w przeszłości, patrząc na nasz systemowy plik known_hosts i znajdując klucze SSH, których oczekuje nasz host. Nigdy nie łączyliśmy się z naszym hostem w przeszłości, więc musimy jawnie określić nasz klucz SSH. -
set_missing_host_key_policy()
mówi Paramiko co zrobić w przypadku nieznanej pary kluczy. Jest to oczekiwanie na „politykę” wbudowaną w Paramiko, do której będziemy się odnosićAutoAddPolicy()
. Ustawienie naszej polityki na „auto-add” oznacza, że jeśli spróbujemy połączyć się z nierozpoznanym hostem, Paramiko automatycznie doda brakujący klucz lokalnie. -
connect()
jest najważniejszą metodą SSHClienta (jak można sobie wyobrazić). W końcu jesteśmy w stanie przekazać nasz host, użytkownika i klucz SSH, aby osiągnąć to, na co wszyscy czekaliśmy: wspaniałe połączenie SSH z naszym serwerem! Metodaconnect()
pozwala na dużą elastyczność dzięki szerokiemu wachlarzowi opcjonalnych argumentów w postaci słów kluczowych. Tak się składa, że podałem tutaj kilka: ustawienie look_for_keys naTrue
daje Paramiko pozwolenie na rozejrzenie się w naszym folderze ~/.ssh w celu samodzielnego odkrycia kluczy SSH, a ustawienie timeout automatycznie zamknie połączenia, które prawdopodobnie zapomnimy zamknąć. Moglibyśmy nawet przekazać zmienne dla takich rzeczy jak port i hasło, jeśli zdecydowalibyśmy się połączyć z naszym hostem w ten sposób.
Rozłączanie
Powinniśmy zamykać połączenia do naszego zdalnego hosta, kiedy tylko skończymy z nich korzystać. Niedopełnienie tego obowiązku nie musi być katastrofalne, ale miałem kilka przypadków, w których wystarczająca ilość wiszących połączeń spowodowała maksymalne ograniczenie ruchu przychodzącego na porcie 22. Niezależnie od tego, czy Twój przypadek użycia może uznać restart za katastrofę czy łagodną niedogodność, po prostu zamknijmy nasze cholerne połączenia jak dorośli, tak jakbyśmy wycierali nasze tyłki po zrobieniu kupy. Bez względu na higienę połączenia, jestem zwolennikiem ustawiania zmiennej timeout (jak widzieliśmy wcześniej). W każdym razie. voila:
Fun fact: ustawienie self.client.close()
faktycznie ustawia self.client
na równe None
, co jest przydatne w przypadkach, w których możesz chcieć sprawdzić, czy połączenie jest już otwarte.
Wykonywanie komend uniksowych
Mamy teraz wspaniałą klasę Pythona, która potrafi znaleźć klucze RSA, połączyć się i rozłączyć. Brakuje jej jednak możliwości zrobienia, cóż, czegokolwiek użytecznego.
Możemy to naprawić i w końcu zacząć robić „rzeczy” dzięki zupełnie nowej metodzie wykonywania poleceń, którą trafnie nazwę execute_commands()
(to prawda, „polecenia” w znaczeniu potencjalnie więcej niż jedno, zajmiemy się tym za chwilę). Cała ta praca wykonywana jest przez wbudowaną metodę klienta Paramiko exec_command()
, która przyjmuje pojedynczy ciąg znaków jako polecenie i wykonuje je:
Funkcja, którą właśnie stworzyliśmy execute_commands()
oczekuje listy ciągów znaków do wykonania jako komendy. Jest to częściowo dla wygody, ale również dlatego, że Paramiko nie będzie uruchamiać żadnych zmian „stanu” (jak zmiana katalogów) pomiędzy komendami, więc każda komenda przekazywana do Paramiko powinna zakładać, że pracujemy z roota naszego serwera. Pozwoliłem sobie przekazać trzy takie komendy w następujący sposób:
Mogę wyświetlić zawartość katalogu poprzez łańcuchowanie cd path/to/dir && ls
, ale uruchomienie cd path/to/dir
a następnie ls
spowodowałoby nicość, ponieważ ls
za drugim razem zwraca listę plików w katalogu głównym naszego serwera.
Zauważysz, że client.exec_command(cmd)
zwraca trzy wartości zamiast jednej: może to być przydatne, aby zobaczyć, które dane wejściowe dały jakie wyniki. Na przykład, oto pełne logi dla przykładu, który podałem, gdzie przekazałem trzy komendy do remote.execute_commands()
:
Niezwykle piękne rzeczy. Teraz możesz zobaczyć, jakie strony są na moim serwerze, jakie boty mnie spamują i ile procesów węzłowych mam uruchomionych.
Nie chcę tracić więcej czasu na sztukę wykonywania poleceń, ale warto wspomnieć o tym, dlaczego po każdej komendzie wywołujemy stdout.channel.recv_exit_status()
. Oczekiwanie na powrót recv_exit_status()
po uruchomieniu client.exec_command()
wymusza synchroniczne wykonywanie naszych poleceń, w przeciwnym razie istnieje prawdopodobieństwo, że nasza zdalna maszyna nie będzie w stanie rozszyfrować poleceń tak szybko, jak my je przekazujemy.
Wysyłanie (i pobieranie) plików przez SCP
SCP odnosi się zarówno do protokołu kopiowania plików na zdalne maszyny (secure copy protocol), jak i do biblioteki Pythona, która go wykorzystuje. Mamy już zainstalowaną bibliotekę SCP, więc zaimportuj to gówno.
Biblioteki SCP i Paramiko uzupełniają się wzajemnie, czyniąc przesyłanie plików przez SCP super łatwym. SCPClient() tworzy obiekt, który oczekuje „transportu” od Paramiko, który zapewniamy za pomocą self.conn.get_transport()
. Tworzenie połączenia SCP jest pod względem składniowym podczepione pod naszego klienta SSH, ale te połączenia są oddzielne. Możliwe jest zamknięcie połączenia SSH i pozostawienie otwartego połączenia SCP, więc nie rób tego. Otwórz połączenie SCP w ten sposób:
Wysyłanie pojedynczego pliku jest nudne, więc zamiast tego wyślijmy cały katalog plików. bulk_upload()
przyjmuje listę ścieżek do plików, a następnie wywołuje __upload_single_file()
Nasza metoda oczekuje otrzymania dwóch ciągów znaków: pierwszy to lokalna ścieżka do naszego pliku, a drugi to ścieżka do zdalnego katalogu, do którego chcemy przesłać plik.
Metoda SCP put()
prześle lokalny plik do naszego zdalnego hosta. Zastąpi to istniejące pliki o tej samej nazwie, jeśli tak się składa, że istnieją w miejscu docelowym, które podaliśmy. To wszystko, co trzeba zrobić!
Pobieranie plików
Odpowiednikiem metody SCP put()
jest metoda get()
:
Nasz wielki, piękny skrypt
Mamy teraz chorą klasę Pythona do obsługi SSH i SCP ze zdalnym hostem… zabierzmy się do pracy! Poniższy snippet jest szybkim sposobem na przetestowanie tego, co zbudowaliśmy do tej pory. W skrócie, skrypt ten szuka lokalnego folderu wypełnionego plikami (w moim przypadku, wypełniłem folder gifami z lisami 🦊).
Sprawdź, jak łatwo jest stworzyć main.py, który obsługuje złożone zadania na zdalnych maszynach dzięki naszej klasie RemoteClient:
Tutaj jest wyjście naszej funkcji wysyłania:
To zadziałało! Nie wierzysz mi? Dlaczego nie sprawdzimy tego sami, uruchamiając remote.execute_commands()
?
Tutaj to masz. Prosto z ust lisa.
Take It And Run With It
W tym miejscu chciałbym poświęcić chwilę, aby podziękować wam wszystkim i przeprosić, że wciąż tu jesteście. Złożyłem sobie przysięgę, że przestanę publikować tutoriale o długości ponad dwóch tysięcy słów, a ten wygląda na pięć tysięcy słów bzdur. Popracuję nad tym. Nowy rok, nowy ja.
Dla waszej wygody, umieściłem źródła tego tutoriala na Githubie. Czujcie się swobodnie i korzystajcie z niego! Aby zakończyć, zostawię cię z mięsem i ziemniakami klasy Client, którą razem stworzyliśmy:
Pełny kod źródłowy dla tego tutoriala można znaleźć tutaj:
Engineer z ciągłym kryzysem tożsamości. Łamie wszystko, zanim nauczy się najlepszych praktyk. Całkowicie normalny i stabilny emocjonalnie.