Są wszędzie, na każdym systemie i kontynencie. Utrzymują mnóstwo infrastruktury na świecie oraz mnóstwo serwerów. Zazwyczaj są niewidoczne dla świata, ale wciąż śmiertelnie ważne. Są te proste, na chwilę, tymczasowe, jak i te zaawansowane. Czasem wręcz robią za normalnie oprogramowanie, bo tak prościej, lub tak się przyjęło, a czasem tylko za klej. Często usunięcie jednego, nawet prostego jest niemożliwe, bo wszystko się sypnie jak domek z kart. To jedna z ważniejszych umiejętności w arsenale DevOpsów.

Kurs pisania skryptów w bash, dobre i złe praktyki


Film o pisaniu dobrych skryptów w bash



Skrypty bash

Jeśli jakieś rzekome AI nas kiedyś zastąpi, to one mają największą szansę być w pierwszym szeregu tego AI. O kim lub o czym mowa? O skryptach bashowych. Temat zawsze popularny, dla adminów, devopsów, programistów. Ale przez lata narosło wiele problemów, złych praktyk, podejść, nawyków i czas najwyższy wziąć się za nie w tym właśnie filmie i w kolejnych filmach. Dlatego dziś zajmiemy się tym, jak naprawdę porządnie pisze się te niepozorne skrypty, które utrzymują po cichu wszystko w ryzach. To ważna umiejętność dla DevOps Engineera, często wymagana na rozmowach o pracę

Skrypty bash są dosyć wdzięcznym tematem, bo większość adminów, jak tylko odkryje, jakie to fajnie sobie te komendy, zamiast tak klepać ręcznie, to wklepać do skryptu, potem jeszcze wsadzić w crona no to już w ogóle kosmos, więc rzucają się jak dziki w sosnę i piszą te skrypty pod wpływem chwili i emocji, a później robi się problem, bo kto podczas eskscytacji myślałby sobie o dobrych praktykach? No kto?

Nasz przykładowy skrypt w bashu (diagram)

Dziś pokażę Wam przykład dobrego i złego skryptu oraz podejścia i specjalnego narzędzia, które powinno od teraz być Waszym stałym pomocnikiem. Zanim jednak do skryptu, zobaczcie co za skrypt będziemu tu płodzić, bo aż go rozrysowałem. W rozumieniu powinien być prosty.

diagram działania skryptu w bash

Czyli startujemy skrypt i już na dzień dobry sprawdzamy czy nasz plik blokady (file_lock) istnieje. Ten mechanizm, ten plik blokady, jest po to, aby ten skrypt odpalony jednocześnie więcej niż raz, mniejsza już o powód, nie narobił dziwnych rzeczy. Więc jak skrypt wykrywa plik blokady, to kończy działanie, i jak na dobre praktyki przystało, wychodzi z kodem wyjścia innym niż 0, dla przykładu tutaj jest to kod 1. Jeżeli plik blokady nie istnieje to oczywiście skrypt go tworzy i od teraz ta instancja tego skryptu, ma takie jakby wyłączne prawo, aby coś robić. No to w kolejnym stepie skrypt wchodzi do katalogu określanego WORKING_DIR, to w nim będzie się wszystko działo. Po wejściu do WORKING_DIR, skrypt sprawdza czy plik WORKING_DIR/config istnieje, jak istnieje to zmienia mu nazwę na old.config.unixtime suffix, a jak nie istnieje to po prostu tworzy go rzucając najnowszy unixtime do tego pliku. Jak plik nie istniał, to zostanie utworzony i jesteśmy w domu. Następnie, skrypt stara się zachować 3 najnowsze takie pliki config i ich kopie, a resztę usunąć, aby przy częstym odpalaniu np. tych plików za dużo się nie narobiło. Po wszystkim oczywiście plik blokady jest usuwany i następuje koniec ciężkiej pracy skryptu. Przykład banalny, ale jakże idealny, aby pokazać Wam, jak ważne są odpowiednie praktyki pisania skryptów i odpowiednie do tego narzędzia.

Zanim ruszymy dalej, zainstalujemy program, który zwie się shellcheck. Jest to taki jakby linter do basha, napisany w Haskelu, dzięki któremu będziemy pisać znacznie lepsze skrypty bash. Ten program powinien być standardowo dostępny w repozytoriach popularnych dystrybucji, w Ubuntu jak i w CentOSie jest on dostępny i można go bez trudu zainstalować. Chętni i ambitni mogą iść na githuba i spróbować skompilować go sobie sami.

Ah ja już tych januszy skrypciarstwa widzę jak mówią: jo przecie prosty skrypt chce nopisoc, ze 2 linie w porywach, jutro sie to przecie skasuje w pidziet i koniec historii a ty mi tu jakies shellchecki kazujesz instalowac, a ic pan na tamto drzewo i daj se siana.

No i tu teraz, ktoś może mi tu rzec, że na co to wszystko, jak np. ja tylko piszę skrypt co ma parę linii, albo jest na chwilę, albo do jutra, albo inne takie. Tak ze skryptami bash jest, że zazwyczaj są na parę linii, albo na chwilę, albo tymczasowo, albo do jutra, a z 90% z nich kończy jako takie wieloletnie potworki, co mogą mieć setki lub nawet tysiące linii kodu. Dlatego tak ważne jest nie januszować na wstępie, tylko od razu, nawet do 2 komend na krzyż, użyć właściwego podejścia i narzędzia.

Przykład złego skryptu

Czas zająć się samym skryptem. Na początek bierzemy przykład zły, taki, którym nie powinniśmy się sugerować. Jak wiecie ze schematu, skrypt wydaje się prosty, ale spójrzcie tylko co wyjdzie w detalach.

 1#!/bin/bash
 2
 3### Ten skrypt służy za prezentację złych praktyk, nie odpalaj go, bo możesz stracić 
 4### swoje pliki!
 5
 6### Tutaj dodatkowa linia dla nieostrożnych, co jednak metody Kopiego&Pasty sobie 
 7### nie pożałują
 8echo "Only for presentation purposes, do not run this - can remove your files!"
 9exit 255
10
11### ZŁA PRAKTYKA: nawet nie wiemy, czy $1 jest tutaj ustawione
12WORKING_DIR=$1
13
14# Checking if lock script exists
15### ZŁA PRAKTYKA: zahardcodowany plik - duża podatność na błędy - gdzie indziej może być
16### zahardcodowany z literówką
17if [ -e "/tmp/bad-script-lck-file" ]; then
18	echo "File lock found, another instance is running?"
19	exit 1
20fi
21
22# Create lock file to prevent another script do something while this one is working
23### ZŁE PRAKTYKI
24###  - zahardcodowany plik blokady
25###  - co jeśli komenda touch będzie z błędem (uprawnienia, brak miejsca na dysku?)
26touch /tmp/bad-script-lck-file
27
28# Checking if WORKING_DIR exist
29### ZŁE PRAKTYKI:
30### 1) To jedynie sprawdza istnienie samego katalogu, a nie np. praw wejścia do niego (sprawdź to na /root np)
31### 2) Na tę chwilę mamy utworzony plik blokady, jeśli tu zakończymy, plik blokady pozostanie
32if [ ! -d "$WORKING_DIR" ]; then
33	echo "$WORKING_DIR does not exist, leaving."
34	exit 2
35fi
36
37# Go to WORKING_DIR
38### ZŁE PRAKTYKI: co jeśli nie możemy wejść do katalogu mimo, że on istnieje?
39cd $WORKNG_DIR
40
41# Working with config file
42### ZŁE PRAKTYKI:
43### 1) Plik config hardcoded (znowu coś hardcoded) - narażenie na błędy
44### 2) Komenda mv nie jest w ciapkach (cudzysłowiach) - może dojść do jakiegoś rozsplitowania jej
45### 3) `` nie jest zalecane przy "wrapowaniu" komendy, używa się konstrukcji $()
46### 4) W ogóle co jeśli komenda mv wykona się z błędem z jakiegoś powodu?
47### 5) Co jeśli skierowanie strumienia do pliku config też będzie z błędem? (np. pełny dysk)
48### 6) Tak w ogóle w tym miejscu to my nawet nie wiemy, czy jesteśmy we właściwym katalogu
49###    więc możemy bawić się plikami "config", które do tej zabawy nie przewidziano...
50if [ -e "config" ]; then
51	mv config old.config.`date +%s`
52else
53	`date +%s` > config
54fi
55
56# Removing old timestamp file - keeping only last three
57### !!! NIEBEZPIECZNE !!!
58### ZŁE PRAKTYKI:
59### 1) Bardzo groźny moment - nie wiemy nawet czy ta komenda będzie wykonana we właściwym katalogu :-)
60### 2) Nie sprawdzamy tutaj żadnego kodu błędu
61### 3) Nie używamy np. globów, aby "zawęzić" listę plików i nie ryzykować usunięciem postronnych
62rm -f `ls -t | tail -n +4`
63
64# Finally remove lock file to allow another instances of this script to work
65### BAD PRACTISES:
66### ZŁE PRAKTYKI:
67### 1) Niektóre corner case poprzednio "olano", a tu jest koniec skryptu
68### 2) Co jeśli komenda rm zwróci błąd?
69rm -f /tmp/bad-script-lck-file

W samych komentarzach zamieściłem sporo informacji, ale jak widzicie, dużo jest tu błędów, które widać na pierwszy rzut oka. Np. mamy zahardcodowany skrypt blokady (linia 17, 26 i 69), nie sprawdzamy czy np. wejście do katalogu nam się udało, a później bez tej wiedzy na chłodno lecą tutaj niebezpieczne komendy.

Shellcheck

Dużo by opowiadać o problemach z powyższym kodem, to co jest tu ważne, to fakt, że odpalanie tego skryptu jest niebezpieczne i nie róbcie tego, dlatego dodałem do niego exit już na samym początku. Ale pytanie jest - skoro my tutaj widzimy ewidentne problemy, to czy nasze pomocnicze narzędzie również będzie to widzieć? Tak! Dlatego warto go teraz użyć i sprawdzić jakie informacje możemy uzyskać na etapie takiej właśnie analizy.

 1$ shellcheck bad-script 
 2
 3In bad-script line 38:
 4cd $WORKNG_DIR
 5^-- SC2164: Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
 6   ^-- SC2153: Possible misspelling: WORKNG_DIR may not be assigned, but WORKING_DIR is.
 7   ^-- SC2086: Double quote to prevent globbing and word splitting.
 8
 9
10In bad-script line 49:
11	mv config old.config.`date +%s`
12                             ^-- SC2046: Quote this to prevent word splitting.
13                             ^-- SC2006: Use $(..) instead of legacy `..`.
14
15
16In bad-script line 51:
17	`date +%s` > config
18        ^-- SC2092: Remove backticks to avoid executing output.
19        ^-- SC2006: Use $(..) instead of legacy `..`.
20
21
22In bad-script line 60:
23rm -f `ls -t | tail -n +4`
24      ^-- SC2046: Quote this to prevent word splitting.
25      ^-- SC2006: Use $(..) instead of legacy `..`.
26       ^-- SC2012: Use find instead of ls to better handle non-alphanumeric filenames.

Jak zatem widzicie, można użyć shellcheck bezpośrednio z linii komend jak na przykładzie, wtedy zwróci nam on ładnie pokolorowaną listę błędów, uwag lub sugestii z informacjami w których liniach. Można jeszcze np. shellchecka zintegrować z jakimś edytorem, np. tutaj na przykładzie widzicie integracje z vimem za pomocą vimowego pluginu syntastic.



Przykład dobrego skryptu - wersja pierwsza

Czas więc na dobry przykład. W dobrym przykładzie skorzystamy z builtina set w bashu do ustawienia pewnych przydatnych dla nas opcji, te opcje są też sprawdzane/sugerowane przez shellchecka.

I tak set -e spowoduje w wielkim skrócie, że jeżeli nie “obsłużymy” innego kodu wyjścia niż 0 jakiegoś polecenia w naszym skrypcie za pomocą pipeline operatora ||, to skrypt natychmiast zakończy pracę przy wystąpieniu takiego “nieobsłużonego” błędu.

Z kolei set -u spowoduje błąd w skrypcie, jeśli spróbujemy użyć niezainicjowanej zmiennej.

Te sprawy, których pilnują powyższe opcje, również są monitorowane przez shellchecka, ale zawsze to lepiej używać wszystkich dostępnych metod. To, na co warto zwrócić szczególną uwagę, to rozległa obsługa sytuacji, które mogą pójść źle w nowym skrypcie i przy każdej innej takiej sytuacji, skrypt zwraca inny kod błędu.

 1#!/bin/bash
 2
 3## Na wypadek "nieobsłużonego" błędu - kończ natychmiast
 4set -e 
 5
 6## Nie wykonuj jeśli znajdziesz niezaincjowaną zmienną
 7set -u
 8
 9## Definiujemy ważne zmienne, nawet te, które będą komendami
10working_dir=$1
11## Nasz plik blokady - ponieważ używamy tym razy zmiennej, mniejsza szansa na błąd w jednym z kilku miejsc
12lock_file="/tmp/good-script-lock-file"
13## Komendy, których będziemy używać, możemy na nie wpłynąć już tutaj
14cmd_rm="rm -f"
15cmd_mv="mv"
16cmd_find=(find . -maxdepth 1 -type f -iname "*config*" -printf "%p\n")
17prefix="old"
18config_file_name="config"
19how_many_newest_files_keep=3
20
21## Definiujemy funkcje, która będzie "sprzątać" pliki blokady, funkcję
22## bo nie raz i nie dwa tej funkcjonalności będziemy potrzebować
23function remove_lock_file() 
24{
25	if [ -e "${lock_file}" ]; then
26        ## Przy okazji sprawdzamy, czy możemy usunąć plik blokady, bo tu też może wystąpić
27        ## jakiś problem, który pokrzyżuje nam plany
28		${cmd_rm} "${lock_file}" || { 
29			echo "Cannot remove lock file: ${lock_file}" >&2;
30			exit 4
31		}
32	fi
33}
34
35## Przechwytujemy Ctrl+C i "sprzątamy", co by po takiej akcji plik blokady nie pozostał
36trap remove_lock_file SIGINT
37
38## Sprawdzamy czy plik blokady istnieje
39if [ -e "${lock_file}" ]; then
40	echo "File lock found, another instance is running?" >&2;
41	exit 1
42fi
43
44## Tworzymy plik blokady i dodajemy obsługę sytuacji, w której z jakiegoś powodu wystąpił błąd przy tym tworzeniu
45touch "${lock_file}" || {
46    echo "Cannot create lock file - exiting" >&2;
47	exit 2
48}
49
50## Wchodzimy do $working_dir lub "obslugujemy" błąd, jeśli nie możemy wejść do $working_dir
51cd "${working_dir}" || { 
52	echo "Cannot enter to working dir." >&2;
53    remove_lock_file
54    exit 3
55}
56
57## tworzymy zmienne config_file_path i pobieramy unixtime do dalszego użycia
58config_file_path="${working_dir}/${config_file_name}"
59time_now=$(date +%s)
60
61if [ -e "${config_file_path}" ]; then
62    ## Komenda mv też może zwrócić jakiś błąd więc też nie zapominamy o jego ew. obsłudze
63	${cmd_mv} "${config_file_path}" "${prefix}.${config_file_name}.${time_now}" || {
64	    echo "Cannot rename ${config_file_path} as ${prefix}.${config_file_name}.${time_now}"  >&2;
65		remove_lock_file
66		exit 5
67	}
68fi
69
70## W poniższej sytuacji również obsługujemy ew. błąd, który może wystąpić
71echo "${time_now}" > "${config_file_path}" || {
72	echo "Cannot save unixtime ${time_now} to ${config_file_path}" >&2;
73	remove_lock_file
74	exit 6
75}
76
77## Usuwamy stare i zostawiamy X ostatnich określonych w zmiennej how_many_newest_files_keep
78how_many=$((how_many_newest_files_keep + 1))
79
80## shellcheck zasugerował tutaj użyć komendy find zamiast ls z powodu lepszej 
81## obsługi niektórych nazw plików
82for file in $( "${cmd_find[@]}" | sort -rz | tail -n +${how_many}); do
83    ## Komenda rm może zwrócić błąd, który tutaj obsłużymy
84	${cmd_rm} "${file}" || {
85		echo "Cannot remove old file: ${file}" >&2;
86		remove_lock_file
87		exit 7
88	}
89done
90
91## Koniec pracy - sprzątamy
92remove_lock_file

Jak więc widać, skrypt poprawny jest przygotowany na niemal każdy możliwy błąd, jaki może wystąpić podczas jego pracy. To podejście rodem z normalnych języków programowania, choć wymaga naklepania nieco więcej kodu, później zwraca się z nawiązką, jeśli przyjdzie nam utrzymywać taki skrypt lub go rozwijać dalej.



Przykład dobrego skryptu - wersja poprawiona

Przykład tzw. “poprawny” wzbudził niemałe kontrowersje i spowodował, że pojawiły się słuszne sugestie iż może on wyglądać lepiej. Za owe sugestie wszystkim dziękuje, bo dzięki temu wpływacie na jakość tego artykułu i być może samych skryptów bash w niektórych projektach.

Co zmieniono w poprawionej wersji:

  • bardziej portowalny shebang został użyty
  • echo zastąpiono printf, zobacz dlaczego tutaj i tutaj
  • kwestie lockingu poprawiłem - teraz użyte jest mkdir + właściwy katalog do tego celu (/var/lock). Zasadniczo opcje były tutaj dwie: mkdir, który jest bardziej portable i flock, który jest właśnie idiomatyczny, czyli przeznaczony do takiego właśnie zastosowania. Jest też bardziej skomplikowany w użyciu od mkdir, zatem mkdir powinno tu być traktowane jako pewien kompromis pomiędzy przenośnością, łatwością użycia jak i celowością do takich zastosowań. Na sieci nie ma 100% zgodnych opinii, którego rozwiązania używać jako obiektywnie najlepszego, zwłaszcza w sytuacjach niestandardowych - gdy np. locki będą rezydować na NFS. Jeżeli naprawdę potrzebujemy niezawodnych portable locków, to będziemy już musieli sięgnąć po dodatkowe zewnętrzne mechanizmy (np. redis)
  • zrobiłem drobny refactoring funkcji i zmiennych, by łatwiej można było podmienić mechanizm lockowania na jakiś własny (spowodowało się pojawienie 2 dodatkowych funkcji). Dodatkowo funkcje te operacują na zmiennych lokalnych (local)

Kwestia $PATH to nie wiem czy jest paląca kwestia w tym skrypcie, gdyż ewentualny brak którejś komendy i tak spowoduje błąd na którymś etapie jego wykonania, więc dodatkowe sprawdzanie tego i upewnianie się, czy tam są jakieś ścieżki, czy ich nie ma, to sprawa drugorzędna moim zdaniem.

  1#!/usr/bin/env bash
  2
  3## Na wypadek "nieobsłużonego" błędu - kończ natychmiast
  4set -e 
  5
  6## Nie wykonuj jeśli znajdziesz niezaincjowaną zmienną
  7set -u
  8
  9## Definiujemy ważne zmienne, nawet te, które będą komendami
 10working_dir=$1
 11lock_file_or_dir="/var/lock/good-script-lock-dir"
 12cmd_locking="mkdir ${lock_file_or_dir}"
 13cmd_check_lock="test -d ${lock_file_or_dir}"
 14cmd_unlocking="rm -rf ${lock_file_or_dir}"
 15cmd_rm="rm -f"
 16cmd_mv="mv"
 17cmd_find="find"
 18cmd_find=(${cmd_find} . -maxdepth 1 -type f -iname "*config*" -printf "%p\n")
 19prefix="old"
 20config_file_name="config"
 21how_many_newest_files_keep=3
 22
 23## Sprawdza czy blokada jest aktywna
 24## 1 dla wartości prawda (true) - blokada jest aktywna
 25## 0 dla wartości fałsz (false) - blokady albo nie ma, albo jest jakiś błąd (w przypadku dziwnego błędu zawsze lepiej nie startować skryptu)
 26function is_already_running()
 27{
 28	local cmd_check_lock=${1}
 29
 30	${cmd_check_lock} || { 
 31		return 1
 32	}
 33
 34	return 0
 35}
 36
 37## Tworzymy blokadę
 38function create_lock()
 39{
 40	local cmd_locking=${1}
 41
 42	${cmd_locking} || {
 43		printf "Cannot create lock\n"
 44		exit 2
 45	}
 46}
 47
 48## Usuwamy blokadę
 49function remove_lock() 
 50{
 51	local cmd_unlocking="${1}"
 52	${cmd_unlocking} || {
 53		printf "Cannot unlock\n"
 54		exit 3
 55	}
 56}
 57
 58## Łapiemy m.in. Ctrl+C i sprzątamy blokadę
 59trap 'remove_lock "${cmd_unlocking}"' SIGINT SIGTERM
 60
 61## Używając naszych funkcji tutaj sprawdzamy, czy nie ma jakiejś blokady, czyli, że 
 62## nie ma drugiej instancji skryptu już działającej
 63if is_already_running "${cmd_check_lock}" ; then
 64	printf "Cannot acquire lock (another instance is running?) - exiting.\n"
 65	exit 1
 66fi
 67
 68## Tworzymy blokadę
 69create_lock "${cmd_locking}"
 70
 71## Wchodzimy do $working_dir lub "obslugujemy" błąd, jeśli nie możemy wejść do $working_dir
 72cd "${working_dir}" || { 
 73	remove_lock "${cmd_unlocking}"
 74	printf "Cannot enter to working dir.\n" >&2 ;
 75	exit 4
 76}
 77
 78## Tworzymy zmienne config_file_path i pobieramy unixtime do dalszego użycia
 79config_file_path="${working_dir}/${config_file_name}"
 80time_now=$(date +%s)
 81
 82if [ -e "${config_file_path}" ]; then
 83	## Komenda mv też może zwrócić jakiś błąd więc też nie zapominamy o jego ew. obsłudze
 84	${cmd_mv} "${config_file_path}" "${prefix}.${config_file_name}.${time_now}" || {
 85	    printf "Cannot rename %s as %s.%s.%s\n" "${config_file_path}" "${prefix}" "${config_file_name}" "${time_now}" >&2 ;
 86		remove_lock "${cmd_unlocking}"
 87		exit 5
 88	}
 89fi
 90
 91## W poniższej sytuacji również obsługujemy ew. błąd, który może wystąpić
 92printf "%s" "${time_now}" > "${config_file_path}" || {
 93	printf "Cannot save unixtime %s to %s\n" "${time_now}" "${config_file_path}" >&2 ;
 94	remove_lock "${cmd_unlocking}"
 95	exit 6
 96}
 97
 98## Usuwamy stare i zostawiamy X ostatnich określonych w zmiennej how_many_newest_files_keep
 99how_many=$((how_many_newest_files_keep + 1))
100
101## shellcheck zasugerował tutaj użyć komendy find zamiast ls z powodu lepszej 
102## obsługi niektórych nazw plików
103for file in $( "${cmd_find[@]}" | sort -rz | tail -n +${how_many}); do
104	## Komenda rm może zwrócić błąd, który tutaj obsłużymy
105	${cmd_rm} "${file}" || {
106		printf "Cannot remove old file: %s\n" "${file}" >&2 ;
107		remove_lock "${cmd_unlocking}"
108		exit 7
109	}
110done
111
112## Celowe opóźnienie do testów blokady, żadnego innego zastosowania ta linia nie posiada
113sleep 5
114
115## Koniec pracy - sprzątamy
116remove_lock "${cmd_unlocking}"



Podsumowanie

Na zaprezentowanych przykładach widać doskonale, że pisanie dobrych skryptów bash wymaga pewnej zmiany podejścia, na bardziej developerskie, a do tego dobrze jest użyć dodatkowego toola, takiego jak shellcheck, który nie tylko sprawdzi nasz skrypt, ale zasugeruje odpowiednie poprawki.

Do małych skryptów lub takich, które wiele nie robią, to podejście może wydawać się zupełnym overkillem, ale szybko się zwraca z nawiązką, w sytuacji, gdy jednak skrypt będzie musiał być debugowany lub rozwijany w przyszłości. Tym podejściem oszczędzamy sobie nerwów i pracy w przyszłości. Ceną za to jest nieco większa ilość kodu i konieczność obskakiwania wszelkich “corner cases”. Ale to podejście docenią zwłaszcza Ci, którzy napisali naprawdę dużo zabugowanych skryptów w życiu, które nieraz po paru miesiącach odsłaniały ogrom błędów, niedoróbek i nieraz zniszczeń spowodowanych niewłaściwym podejściem do developmentu (np. skrypty do automatycznego backupu danych)

Linki