Blog » Analizy i porady

Poradnik Bash Bushidō cz. VIII – 10 grzechów administratorów

Bash

W tym artykule poruszę temat, który może zostać niesłusznie uznany za krytykę administratorów systemów oraz Basha. Uważam jednak, że należy spojrzeć prawdzie w oczy i zauważyć, że od czasu do czasu zdarza nam się popełniać te same błędy. Nie zawsze są one naszą winą. Czasem ustanowione techniki i narzędzia (takie jak Bash) po prostu obiektywnie niezbyt nadają się do zadań, które są przed nami stawiane.

Chciałbym, by czytelnik nie zrozumiał mnie opacznie. Nie mam bowiem zamiaru udawać źródła wszelkiej mądrości i pisać o jedynym słusznym sposobie. Zawarte tutaj tezy są co prawda poparte dość solidnym doświadczeniem, niemniej wierzę, że zawsze można się czegoś nauczyć. Dobrym sposobem na to jest poddanie się krytyce, szczególnie przez ekspertów.

0. Bash jest trudny!

Składnia tablic w Bashu, w porównaniu do „poważniejszych” języków programowania, jest w najlepszym wypadku żartem. Mówiąc szczerze, mimo faktu, iż osobiście dosyć dużo piszę w Bashu, to muszę przyznać, że nawet słynący z niepojętej „logiki” JavaScript nie dorasta do pięt Bashowi.

Nawet przy tak trywialnej czynności jak definiowanie zmiennej, czeka na nas kilka pułapek. Spójrzmy na poniższe przykłady:

[Alex@liara ~]$ x = ala # 1
-bash: x: command not found
[Alex@liara ~]$ x=ala # 2
[Alex@liara ~]$ x=ala ma kota #3
-bash: ma: command not found
[Alex@liara ~]$ x="ala ma kota"
[Alex@liara ~]$ x= ":" 
[Alex@liara ~]$ echo $x
ala ma kota

W pierwszym przykładzie programista zapożyczył sobie znaną z innych języków formułkę definiującą zmienną x na ala. By zwiększyć czytelność, postanowił jednak rozdzielić zmienną białymi znakami. W drugim przykładzie zrobił to „poprawnie” i będąc zadowolonym, w trzecim przykładzie stwierdził, że zapisze całe zdanie. Logiczne wydaje się przecież, że jeśli Bash pozwala zdefiniować zmienną bez „ubierania” jej w pojedynczy lub podwójny apostrof (o różnicy później), to powinien pozwolić na zdefiniowanie także takiej zmiennej z białymi znakami. Nic bardziej mylnego! Jeśli po definicji zmiennej umieścimy komendę (tak Bash postrzega ciąg znaków „ma”), zmieni się także jej zakres (scope), co jeszcze bardziej skomplikuje sprawę. Pokazuje to ostatni przykład, w którym wykorzystujemy pustą instrukcję :, „zerując” równocześnie zmienną x.

By jeszcze lepiej to zobrazować i dodatkowo skomplikować sprawę, zdefiniujmy dwie zmienne oraz zainicjujmy je raz linijka po linijce, a drugi raz razem.

[Alex@liara ~]$ x=123
[Alex@liara ~]$ y=456
[Alex@liara ~]$ echo $x $y
123 456  # zachowanie oczekiwane
[Alex@liara ~]$ x=111 y=222
[Alex@liara ~]$ echo $x $y
111 222 # zachowanie oczekiwane
[Alex@liara ~]$ x=123 y=456 true
[Alex@liara ~]$ echo $x $y
111 222 # dla niedoświadczonych w skryptowaniu wynik może być nieoczywisty

" oraz '

Jakby tego było mało, należy jeszcze uwzględnić różne zastosowania " i ' oraz zmienne specjalne. W wielu językach obydwa cudzysłowy są sobie równoważne (np. w Python). W innych " służy do zapisu ciągów znaków , a ' do pojedynczego znaku. Przykładem takiego języka jest C. W Bashu zaś ciąg znaków " jest rozwijany (podstawiane są zmienne etc), a ' jest traktowany jako literał.

Poniżej kilka przykładów zmiennych specjalnych, event designatora lub innych mechanizmów, które moim zdaniem są dosyć nieoczywiste.

[Alex@liara ~]$ echo "x"  # przykład trywialny
x
[Alex@liara ~]$ echo "Ala ma kota !" # rozwinięcie desygnatora zdarzeń
-bash: !": event not found
[Alex@liara ~]$ echo 'Ala ma kota !'
Ala ma kota!
[Alex@liara ~]$ echo "$nie_ma_mnie" # pusta lub niezaincjowana zmiena, w domyślnej konfiguracji Bash pozwala działać dalej, równocześnie nie informuje o zastanym stanie.
[Alex@liara ~]$ echo "$~" # $ służy do odwołania się do zmiennej, dlaczego więc $~ jako zmienna która nie istnieje zwraca nam Ciąg znaków?
$~
[Alex@liara ~]$ echo "$:" # Identyczny przykład
$:
[Alex@liara ~]$ echo "$!" # Zmienna specialna, PID ostatniego rozpoczętgo procesu działającego w tle.

[Alex@liara ~]$ echo "$$" # PID obecego Shella, także zmienna specialna.
3133
[Alex@liara ~]$ echo "$1" # pierwsza zmienna przekazana jako argument (pusta)

[Alex@liara ~]$ echo "$123" # zaczytanie pierwszej zmiennej (pustej) i ciągu 23
23

Najsmutniejsze w tym wszystkim jest to, że jest to dopiero wierzchołek góry lodowej. Przecież nie przeanalizowaliśmy tak prostych konstrukcji jak warunki, pętle, tworzenie funkcji czy nawet proste czytanie pliku linijka po linijce.

To, co chcę Wam drodzy czytelnicy niejako udowodnić, to fakt, iż wbrew obiegowej opinii Bash jest po prostu trudny. Często wręcz nielogiczny. Mam świadomość, że prawdziwi (z certyfikatem prawdziwości wydawanym przez konsorcjum runlevel_1.sh) fanatycy Basha będą bronić nawet najmniej intuicyjnej konstrukcji i odsyłać do manuala (do tak lichego źródła odsyłają tylko podszywające się pod fanatyków osoby bez certyfikatu) lub do stron info.

1. Brak systemu kontroli wersji

Jest to pierwszy grzech części osób piszących skrypty powłoki. Studenci pierwszego roku informatyki, tfu, pierwszego tygodnia bootcampa programistycznego uczą się używać systemu kontroli wersji i po krótkim czasie rozumieją potrzebę jego stosowania. Brak systemu kontroli wersji w procesie wytwarzania oprogramowania jest z dzisiejszej perspektywy czymś tak niepojętnym, że po prostu nie występuje. No chyba że chodzi o skrypty administracyjne zarządzające tym całym tyglem, w którym gotuje się multum różnych systemów operacyjnych, baz danych, serwerów aplikacji, serwerów logów, serwerów monitoringu i wiele, wiele więcej. Wówczas ze względu na jakże „niski” stopień skomplikowania, trzymanie skryptów bez wersjonowania po prostu się zdarza.

Ironią jest fakt, że twórca jądra, na którym stoi większość usług na świecie (i z którym styka się niemal każdy administrator świata), stworzył także najpopularniejszy system kontroli wersji.

Na szczęście wraz z coraz mniejszymi podziałami między zespołami deweloperskimi a utrzymaniowymi (tzw. DevOps), sytuacje, w których ważne (pojęcie względne) skrypty nie są wersjonowane oraz nie są opisywane w dokumentacji (np. wiki firmowa), zdarzają się coraz rzadziej.

2. Strict Mode

Drugim grzechem jest nieużywanie mechanizmów „uodparniających” skrypty na błędy. W języku Perl istnieje strict tzw. stric mode use strict. Zamienia on bardziej niebezpieczne i nieprzewidywalne zachowania w błędy. W przypadku Basha nie mamy aż tak zaawansowanych możliwości, jednak użycie set -e wydaje się być niezbędnym minimum. Rozbudowując nasz skrypt o set -euo pipefail dostajemy coś, co można by na wyrost nazwać „strict mode” Basha.

Co istotne, jeśli istnieje prawdopodobieństwo, że zadana komenda się nie uda, możemy chwilo wyłączyć opcje wyjścia skryptu przy napotkaniu błędu (należy ją oczywiście włączyć ponownie!). Innym rozwiązaniem jest użycie konstrukcji OR (||) wraz z true.
Przykładowe użycie:

false || true

Polecam przeczytać poprzednią część naszego poradnika, w którym można znaleźć wprowadzenie do tych przełączników oraz znacznie więcej: Bash Bushidō cz VII.

3. Shebang

Trzecim grzechem administratorów jest używanie shebang w najprostszej wersji #!/bin/bash. Nie jest to problem, który jest oczywisty na pierwszy rzut oka. Niemniej każdy programista Pythona/Perla/Ruby'iego zna inny magiczny program /usr/bin/env, który ustawia się w pierwszej linii skryptu zamiast zapisanej na sztywno (pot. shardkodowanej) ścieżki do konkretnego interpretera. „Magia” /usr/bin/evn polega na użyciu zmiennej PATH do odnalezienia właściwego programu. W przypadku skryptów bashowych, co oczywiste, jest to Bash. Finalnie zamiast:

#!/bin/bash

używa się:

#!/usr/bin/env bash

Pozwala to na:

  1. Użycie innej wersji Basha niż domyślna systemowa.
  2. Zwiększenie przenaszalności skryptu. Np. Systemy z rodziny BSD często nie mają zainstalowanego Basha. Nawet gdy jest zainstalowany, to często /bin/bash nie jest prowadzącą do niego ścieżką.

Chciałbym też rozprawić się z pewnym twierdzeniem, że /usr/bin/env może nie występować w systemie. Jest to oczywiście prawda. Niemniej jest to mniej prawdopodobne niż nieistnienie /bin/bash. /usr/bin/env jest częścią coreutils i standardu POSIX. Na sam koniec chciałbym przedstawić najmniej wiążący dowód – wielokrotnie zdarzało mi się administrować systemami bez Basha, jednak /usr/bin/evn zawsze występowało.

4. Cron & timers

Chyba każdy w swoim życiu spotkał administratora, który lubował się w umieszczaniu tysiąca skryptów w cronach lub innego czasu regulatorach czasowych (timer). Jest to grzech śmiertelny, gdyż dostając środowisko z tysiącem nieopisanych skryptów poustawianych w cronach, mam ochotę śmiertelnie poznać osobę, która to środowisko stawiała.

Pomimo iluzorycznej wygody, prawda jest taka, że administrowanie takimi systemami po kimś jest z reguły mordęgą. Sam fakt, iż skrypty w cronie można umieszczać w tak wielu miejscach, sprawia, że często łatwiej jest je znaleźć, analizując logi, niż szukając w jednym z I folderów, czytając J crontabów różnych użytkowników na K maszynach.

Dodatkowo niektórzy sprytni administratorzy przerzucili się na timery dostępne w Systemd. Dochodzi więc kolejny punkt, który należy sprawdzić po takim niechlujnym administratorze.

Rozwiązaniem problemu cyklicznego wykonywania czynności mogą zająć się platformy CI/CD (np. Jenkins) lub Automatyzacji (Ansible AWX lub Rundeck).

5. Jeden system – jedna odpowiedzialność!

W programowaniu obiektowym istnieje zbiór zasad SOLID. Szukając inspiracji, dobrze jest móc się odnieść do innych zagadnień tej samej dziedziny (Computer Science) i zastanowić się nad ich zaimplementowaniem w nowym kontekście. Osobiście uważam, że zasada jednej odpowiedzialności powinna dotyczyć systemów. Oczywiście pojęcie odpowiedzialności jest szalenie niejednoznaczne, gdyż z reguły jest to usługa wystawiana przez maszynę i należy traktować to jako pewną abstrakcję. W innym wypadku jedynym serwisem (usługą), który maszyna udostępniałaby na zewnątrz, byłoby SSH. Maszyna może udostępniać wiele usług, o ile przykładowo jest zamknięta w inną logiczną całość – np. węzeł w klastrze Kubernetes.

Grzech administratora polega na wrzuceniu wielu serwisów i funkcjonalności na jeden „mocny” serwer. Często tworzy się w ten sposób SPOF (Single Point of Failure), który nie może zostać chwilowo wyłączony, by wykonać prace konserwacyjne.

Tutaj chciałbym zwrócić uwagę na fakt, iż w dzisiejszym świecie kontenerów i mikroserwisów zasadę jednej odpowiedzialności wprowadza się, zupełnie o niej nie myśląc.

6. Przenaszalność, a raczej jej brak

Kolejnym przewinieniem autorów skryptów powłoki jest podejście do przenaszalności. Jest to jeden z moich ulubionych tematów, gdyż wiele osób korzystających z Basha lubi się przechwalać, że piszą w Bashu, ponieważ występuje on w każdym systemie i jego skrypty są przenaszalne. Otóż często jest to co najwyżej życzenie. Zawsze warto zadać sobie kilka pytań:

  1. Jaki jest cel przenaszalności, czy jest ona rzeczywiście potrzebna?
  2. Które komponenty są „generyczne” (np. polecenia rm, mv, touch etc), a które zależą od dystrybucji systemu (np. menadżer pakietów, dostępny firewall etc).
  3. Czy korzystamy z funkcjonalności dostępnych tylko w Bashu lub od pewnej jego wersji?
  4. Czy możemy przetestować przenaszalność, tj. czy mamy dostęp do innych środowisk, w których nasz skrypt będzie uruchamiany/potencjalnie uruchamiany.

Niestety z reguły, gdy zadaję te pytania, to często okazuje się, że posiadamy przenaszalny skrypt, który korzysta z apt do instalacji oprogramowania, dodając przy tym niestandardowe repozytoria. Na dodatek używany/testowany był tylko na Debianie Wheezy.

Bash

Na sam koniec chciałbym zaznaczyć, że jeżeli naprawdę zależy nam na przenaszalności, należy użyć sh, który w przeciwieństwie do Basha implementuje standard POSIX i co ważniejsze, występuje praktycznie na każdym serwerze Linuksowym/Uniksowym.

7. Funkcja, a co to?

Programowanie strukturalne jest pojęciem stworzonym przez wybitnego naukowca i informatyka Edsgera W. Dijkstrę. W swoim założeniu wprowadza logiczną strukturę programu, pozwalając unikać poleceń goto. Polecenia goto, zwane też Go To lub skokiem, są najszybszym sposobem do stworzenia kodu spaghetti. Dlatego w wielu firmach się ich nie używa. Co więcej, duża część języków po prostu nie wspiera takiej komendy/operacji.

Innym przełomowym „odkryciem” programowania strukturalnego jest podział programu na funkcje, podprogramy nazywane też procedurami (rozwinięciem tej idei jest programowanie proceduralne). Mająca już 70 lat idea często jednak nie jest stosowana przez ludzi piszących skrypty w Bashu. O ile tworzenie n (n>=1) funkcji dla n linijkowego skryptu jest niemądre, tak widząc skrypty mające często nawet ponad 100 linijek kodu bez rozbicia poszczególnych fragmentów na funkcje, świadczy często o słabym warsztacie programistycznym (aczkolwiek osoba taka wciąż może być nawet wybitnym administratorem).

Rozbicie skryptu/programu na funkcje pozwala także na stworzenie biblioteki zawierającej wyodrębnione w ten sposób fragmenty, co dalej prowadzi do reużywalności kodu oraz zapewnienia abstrakcji w skryptach. Ciało funkcji może zostać zmienione bez zmian w skrypcie. O ile interfejs (w Bashu osiągalny tylko na poziomie umownym!) pozostanie ten sam.

8 – Testowalność

Praktycznie wszystkie nowsze (tj. młodsze niż 30 lat) używane dziś języki programowania posiadają, często jako część swojego standardu, biblioteki oraz konstrukcje służące do testowania kodu.

Bash z kolei, no cóż .... Co prawda istnieje wiele naprawdę ciekawych, intuicyjnych projektów takich jak np. Bats, jednak ich użycie jest stosunkowo niewielkie.

Znamienne jest to, iż w życiu nie czytałem artykułu o Bashu, szczególnie tego uczącego pisania w nim, zaczynającego się od stworzenia testów. Nie spotkałem się też z TDD dla Basha (oczywiście w dobie Internetu może się okazać, że coś takiego istnieje, niemniej chodzi o jego powszechność). Wynika to z małej ilości wspieranych struktur danych, niemożliwości definiowania własnych typów (można to zrobić, jednak znów tylko na poziomie umownym) i wielu innych bolączek stricte programistycznych. Inną kategorią problemów jest testowalność operacji wykonywanych w Bashu tj. instalacji, konfiguracji i innych podobnych.

9. Paranoiczna odporność na działania niepożądane

Odporność na działania niepożądane jest tematem rzeką. O ile zabezpieczanie się przed błędami, takimi jak niezdefiniowana zmienna wraz z poleceniem rm czy odmowa dostępu do zadanego zasobu jest jak najbardziej oczekiwania, tak czasem administratorzy dochodzą do pewnych absurdów i corner-casów, o których żaden tester by nie pomyślał.

Na tapetę i jako przykład chciałbym wziąć kontrowersyjny temat hardkodowania poleceń. Jest to naturalne w przypadku poleceń nieznajdujących się w zmiennej $PATH, które w skrypcie są dwu lub trzykrotnie używane. Niestety technika ta jest stosowana także w przypadku poleceń, które naturalnie znajdują się w zmiennej ścieżki. Zmienna PATH jest zmienną specjalnej troski w przypadku programów podnoszących uprawnienia (np. sudo), które nie pozwalają na użycie niestandardowej ścieżki. Mając na względzie kwestie bezpieczeństwa, część administratorów hardkoduje całe polecenia. Dla przykładu:

rm="/bin/rm"
...
# przykładow wywołanie
$rm "$my_config"

Dzięki temu nikt nam nie podstawi „fake rm”, czy innej niebezpiecznej komendy. Jest to jednak przykład myślenia życzeniowego. Z reguły zdarza się, że tak „zabezpieczony” skrypt posiada tylko wybrane komendy zapisane w ten sposób. A przecież każda może zostać zamieniona na rekursywne kasowanie katalogu! Co jeszcze istotniejsze, ktoś, kto może modyfikować naszą zmienną PATH, ma co najmniej takie same lub wyższe uprawnienia w systemie. A jeśli może nam podstawić „fake rm” bez zmiany PATH, to prawdopodobnie jest rootem. Wnioski proszę wyciągnąć samemu.

Na sam koniec chciałbym zauważyć, że tworzenie zmiennej, która jest/będzie wykonywaną komendą w wielu przypadkach może być przydatne. Jednym z nich jest budowanie komendy w trakcie trwania skryptu.

10. Pętelka

Podczas rozmowy z kilkoma naprawdę doświadczonymi „linuksiarzami” zdarzyło mi się usłyszeć o nieszczęsnej pętelce. O ile idea wykonywania operacji na maszynach w pętli jest jak najbardziej zrozumiała, tak jej realizacja często woła o pomstę do nieba. Przykładowe kroki „pętelki”.

  1. Administrator kopiuje na swojej maszynie skrypt do zadanego katalogu np. /home/admin/server_loops.
  2. Skrypt pętli kopiuje skrypty na zdalne maszyny (często wszystkie [sic!] maszyny w infrastrukturze) do unikatowego katalogu tymczasowego.
  3. Skrypt pętli zdalnie wykonuje skrypty w kolejności alfabetycznej. Skrypty często pomijają swoje wykonanie np. na podstawie nazwy hosta.
  4. Skrypt pętli usuwa zdalny katalog tymczasowy z zawartością.
  5. Skrypt pętli oczekuje na następne skrypty.

Takie podejście było zrozumiałe, gdy platformy automatyzacji nie miały takiej popularności jak dziś i każdy administrator kombinował, jak ułatwić sobie życie. O ile w przypadku Chef czy Puppeta wdrożenie potrafi być dość uciążliwe, tak zarówno Salt, jak i Ansible nie wymagają potężnych maszyn do działania i skalowania się.

Jako wady „pętelki” należy wymienić:

  • z reguły słaba kontrola nad grupami hostów, na których skrypty się wykonują;
  • z mojego doświadczenia wynika także, że skrypty używane w „pętelce” nie są wersjonowane;
  • w przeciwieństwie do platform automatyzacji, skrypty w Bashu z reguły nie są idempotentne ani nastawione na stanowość;
  • z reguły pojedynczy administrator trzyma całą wiedzę o tajemnej pętli.

Wracając na ziemię

Pisząc o powtarzających się błędach lub innych złych praktykach, chciałbym móc powiedzieć, że za każdym razem udaje mi się ich unikać i że mnie one nie dotyczą. Mówiąc zupełnie szczerze – nie! Wciąż zdarza mi się zapomnieć jednego lub drugiego ustawienia/parametru działania Basha. Stworzyć funkcję, która jest zbyt długa czy siłować się z zapewnieniem jakichkolwiek testów dla ważniejszych skryptów 🙂

Podziękowania

Jest to ostatni artykuł z serii Bash Bushidō. Mam szczerą nadzieję, że czytało Wam się je tak dobrze, jak mnie się je pisało 🙂 Chciałbym podziękować za wspólną przygodę i wędrówkę po drodze wojownika Basha.

2 komentarzy dla “Poradnik Bash Bushidō cz. VIII – 10 grzechów administratorów

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *