Секрети в Bash для початківців: оптимізація для Linux і DevOps
На зображенні випадковий код для привернення уваги :)
Привіт всім. Я багато років працюю на різних посадах інфраструктурного інженера та хочу розказати про декілька моментів щодо shell-скриптів.
Мої перші мови — BASIC та Assembler, перші батники ще в DOS та Windows 9x, і на них я робив багато автоматизації адміністрування.
Я взагалі досі вважаю себе прихильником Windows.
Але вважаю, що shell-скриптинг дуже незаслужено недооцінюють. shell-скрипти дуже глибоко проникли в *nix і технічно й ідеологічно, вони виросли разом з *nix, а пізніше з Linux та стандартами POSIX.
Найпопулярніший shell інтерпретатор — це bash. Але озираючись на сумісність, потрібно відрізняти POSIX shell від bash, бо деякі нововведення в bash не підтримуються в більш простих оболонках, таких як sh чи dash. Інши сучасні інтерпретатори zsh, fish — можуть навіть більше, особливо в адмініструванні.
В DevOps практиках я намагаюсь користуватися найбільш поширеними або вже встановленими інструментами для сумісності та скороченню технологічного стеку, тому пишу на bash чи на POSIX-сумісному шелл.
Це важливо, бо в сучасному світі ви можете зустріти саме простіший POSIX shell в embedded чи контейнерах.
У статті я для спрощення буду писати bash, якщо це буде важливо, окремо буду писати POSIX shell.
Зазвичай bash, на відміну від універсальних скриптових мов програмування, використовується найчастіше для роботи з іншими продуктами, а не у ролі самостійних застосунків.
Тобто на bash пишуть різні враппери, адміністративні скрипти для конфігурації та інсталяції, скрипти для бекапу, збірки та створення пакетів, пайплайнів, моніторингові грабери, «клей» для інтеграції різних продуктів, підготовки оточення та інші проміжні речі.
Бо він під рукою майже всюди і є велика кількість потужного консольного софта, який дуже і дуже легко інтегрувати саме в bash-скрипт.
Також в продакшені bash може бути дуже мало, а ось у CI/CD та адмініструванні він зустрічається набагато частіше, тому його знання для адмінів та DevOps-ів обов’язкове.
Звісно, можна писати і самостійні програми на bash (в мене є такі, наприклад ось), але зазвичай він не дуже оптимізований під перформанс, тому це скоріше спортивне програмування чи персональні звички або окремі потреби (є й таке). Але bash — це повноцінна скриптова мова, із своїми таємницями та перевагами.
На жаль, багато айтішніків, які вважають bash дуже простим, навіть не намагаються прочитати основи документації. Пишуть навмацьки, тому і роблять багато архітектурних помилок та лаються на неінтуїтивну поведінку.
Ця стаття не має якусь велику ціль чи конкретний топік, я хочу просто розібрати декілька рандомних прикладів, які досить часто зустрічаються в bash-скриптингу.
Зміст:
1. Розбиваємо строку
2. Розберемося з перенаправленнями інпуту, а саме <, << та <<<
3. Простий приклад FizzBuzz та навіщо писати #!/usr/bin/env bash
4. Autocomplete
5. read замість sleep
6. Лапки....
1. Розбиваємо строку
Цей приклад зустрічається повсякденно. Треба розбити строку на декілька значень чи викусити щось із структурованої або поганоструктурованої строки. Наприклад, маємо строку «c1 c2 c3». Треба прочитати значення другої колонки.
A) Багато хто використовує cut, просту команду, що створена саме для таких цілей:
$ echo "c1 c2 c3" | cut -d " " -f 2 c2
B) Дехто користується awk, яка підтримує регулярки, має внутрішню мову і може робити багато речей з текстом:
$ echo "c1 c2 c3" | awk '{print $2}' c2
C) Можна навіть викусити щось регуляркою через grep — тут я беру щось між двух spacers та користуюсь можливостями grep — опція -o:
$ echo "c1 c2 c3" | grep -oP '\s\K\S*(?=\s)' c2
Але все ці приклади використовують зовнішні команди.
Тобто операційна система повинна виділити пам’ять, ресурси, форкнути новий процес, потім завершити його та передати значення назад в скрипт.
Під Windows ця проблема ще більша, бо архітектура така, що створення нового процесу займає набагато більше ресурсів. Наприклад, в емуляції cygwin це може зайняти на два порядки довше, ніж в Linux чи навіть під Windows WSL.
Це, можливо, не має особливого значення, якщо ви робите якусь одноразову процедуру. Але якщо треба виконати команду у циклі чи це моніторинговий скрипт, що виконується кожну хвилину, то краще його оптимізувати (або навіть обрати perl/python/etc.).
D) Спробуємо розбити строку внутрішньою командою read ось так:
$ read one two three <<< "c1 c2 c3"; echo $two c2
Для read також можна змінити сепаратор на інший символ через спеціальну змінну IFS (internal field separator) і працювати з CSV:
$ IFS=";" ; read one two three <<< "с1;с2;с3;с4" ; echo $two c2В останньому прикладі ми читаєм у три змінні, але маємо чотири колонки. Тому в змінну three буде записана решта значень:
$ IFS=";" ; read one two three <<< "с1;с2;с3;с4" ; echo $three c3 c4 $ IFS=";" ; read one two three <<< "с1;с2;с3;с4;c5;c6" ; echo $one; echo $two; echo $three c1 c2 c3 c4 c5 c6
E) Якщо у вас простий випадок, то строку можна легко конвертнути у масив:
$ myarray=($(echo "c1 c2 c3" )); echo "${myarray[1]}" c2Чи якщо строка у змінній, конструкція виглядає простіше:
$ string="c1 c2 c3"; myarray=($string); echo "${myarray[1]}" c2
Індексація в shell починається з нуля. Також треба дивитись на конфлікти, якщо в елементах є спейс чи лапки, тощо.
F) Якщо у вас складний випадок неструктурованого тексту, можна використати bash variable expansion, та вирізати необхідне за декілька підходів, і навіть
$ A="с1 с2 с3"; b=${A#* }; echo ${b% *} c2
Ще раз зроблю акцент на тому, що три останні варіанти працюють з внутрішніми командами Shell, тому виконуються дуже швидко. У
Нижче приблизні заміри на звичайній віртуалці (2 cpu):
time { for ((i=0;i<1000;i++)); do echo "c1 c2 c3" | cut -d " " -f 2;done } real 0m3.537s user 0m1.902s sys 0m1.277s time { for ((i=0;i<1000;i++));do read one two three<<<"с1 с2 с3"; echo $two;done } real 0m0.163s user 0m0.022s sys 0m0.032s time { for ((i=0;i<1000;i++));do A="с1 с2 с3"; b="${A#* }"; echo "${b% *}";done } real 0m0.061s user 0m0.015s sys 0m0.009s
Також зверніть увагу, що одна команда read виконувалась майже втричі довше, ніж дві команди з variable expansion.
Якщо спробувати це запустити в git-bash чи cygwin/mings під Windows, де робота з терміналом буде емульована, то все це буде на два-три порядки повільніше, та займе вже не секунди а хвилини! (За винятком WSL, бо там віртуальний Linux ;)
2. Розберемося з перенаправленнями інпуту, а саме <, << та <<<
A) Перенаправлення «<» — це перенаправлення з файлу.
Тобто Bash відкриє файл та передасть значення в команду як неіменований пайп. Це одне з найпопулярніших перенаправлень. А ще це корисно, якщо ви хочете приховати ім’я файлу. Наприклад, спробуйте виконати отак:
$ wc -l readme.txt 28 readme.txt $ wc -l <readme.txt 28
Але в *nix також можна перенапрявляти інформацію з пристріїв, процесів, тощо. Наприклад, генеруємо рандомну строку байт:
read -N10 </dev/randomне забувайте — все є файл ;)
B) Конструкція з «<<» зветься Document Here.
Використовується, коли треба передати якийсь текст в програму, але ви не хочете створювати окремий файл для цього. Тоді ми робимо щось типу «attach» файла прямо в скрипті.
Синтаксис:
cat << EOF ваш текст можливо декілька строк EOF
В цьому випадку EOF — це просто абревіатура (End Of File) для маркера початку та закінчення тексту. Ви можете використати EOF чи будь-яку іншу строку як маркер:
cat << ATTACH ваш текст можливо, декілька строк ATTACH
Для тексту також виконається bash variable expansion (тобто змінні будуть замінені на значення, будуть виконані command substitutions тощо), але вам не потрібно мучитися з ескейпом лапок. Дані будуть передані, як є, що іноді дуже спрощує справу.
Приклад виконання для оракл-клієнта в скрипті:
sqlplus -s <<MYQUERY connect user/pass select 1 from dual; select 1 from dual; select "$myvariable" from dual; quit; MYQUERY
Зауважу, що креденшели відкритим текстом у скрипті — це, можливо, не дуже добре.
Але так — набагато безпечніше, ніж креденшели відкритим текстом аргументами у командному рядку. Якщо просто виконати команду, то інші юзери можуть побачити пароль просто передивляючись список процесів, тому краще так не робити.:
$ sqlplus -s username/password@host:port/service @myfile.sql
C) Останнє перенаправлення «<<<».
Ця конструкція — просто передача однієї строки в команду. Приклад з тим же sqlplus:
$ sqlplus <<< "select * from dual"
Можна відправити і багаторядковий текст:
$ sqlplus <<< "select * from dual; select * from dual; quit;"
Але якщо вам потрібні лапки у вашому квері, то на відміну від Document Here, в даному випадку треба все екранувати.
$ sqlplus <<< "select * from dual; select \"$myvariabe\" from dual; quit;"
D) В каментах запропонували розглянути 2>&1
Давайте розглянемо що це, та як користуватись.
Як ми знаємо, за стандартом для процесів відкривається три хендлера 0 — stdin, 1 — stdout, 2 — stderr. Якщо будете відкривати інши, можно брати 3, 4, 5 і так далі.
Перенаправлення підтримуюсть синтаксис
1>, 1>>, 2>, 3>...А якщо номер не вказаний, то за замовченням буде 1 (stdout).
&1
— це референс до першого хендлеру. 2>&1
— це перенаправити другий хендлер у перший. Тобто stderr в stdout. Але stderr та stdout це не destination з точку зору вже запущеного процесу, це вже відкріти хендлери кудись.
Найчастіше вони ведуть до конкретного терміналу вашої сессії, але може бути і інше. Можете напряму глянути який термінал використано для поточного процесу, наприклад отак:
ls -l /proc/$$/fd
Розберемо три приклади:find . -name "*.txt" 2>&1
Ця команда каже, щоб перенаправити stderr в stdout, при цьому stdout нікуди не перенаправлен, і на цей момент прикріплений до нашої консолі, тому візуально нічого не зміниться, ми побачимо все виведення команди в консолі.
find . -name "*.txt" >file.log 2>&1
В даному випадку, ми перенаправили stdout в файл file.log, а потім stderr в stdout, таким чином ми просто обидва output виводи перенаправляємо в файл file.log, і це корисно що не треба двічи писати ім’я того ж самого файла.
find . -name "*.txt" 2>&1 >file.log
Команда схожа на попередню, але на момент перенаправлення stderr в stdout, він ще не був перенаправлений у файл, а просто пов’язан за виводом консолі, тому у нас stdout піде у файл file.log, а stderr піде у консоль. До цього це буде саме як stdout, що дозволяє, наприклад, зробити отакий трюк:find . -name "*.txt" 2>&1 >file.log | grep "errors"
Це дуже корисно, бо для символа пайп, нема можливості вказати хендлер — синтаксис на кшалт 2| чи 1| не підтримується, тому якщо треба перекинути stderr через пайп, потрібно як раз цей трюк — спочатку перенаправити його в stdout, що я іноді використовую, коли треба обробити саме вихлоп stderr.
P.S. Майже всі стандартні консольні тулзи (особливо gnu tools), коректно виводять information та error/warning повідомлення у відповідні stdout/stderr, тобто на це можно впевнено спиратися. Якщо ви девелопер та пишете консольну тулзу, то це ваша відповідальність писати std::cout
чи std::cerr
у конкретному випадку.
3. Простий приклад FizzBuzz та навіщо писати #!/usr/bin/env bash
#!/usr/bin/env bash for i in {1..100}; do [ $((i%3)) -eq 0 ] && a[$i]="Fizz" [ $((i%5)) -eq 0 ] && a[$i]+="Buzz" printf "${a[$i]:-$i} " done
Ми викликаємо env, яка виконує іншу програму.
Для чого це робиться?
Команда env дуже корисна, коли треба задати змінну, ім’я якої несумісне із Bash variable name convention (наприклад, env ssl.cert.name=/home/test/mycert.crt java -jar myapp.jar
).
Але найголовніше, навіщо ми виконуємо bash через env — це запуск bash, який може бути десь в PATH.
В різних дистрибутивах Linux/Unix/MacOS та інше, bash може бути в /bin чи /usr/bin, чи може ще десь, тому запуск через env (який завжди /usr/bin/env) робить наш скрипт більш сумісним із різними системами.
Далі в нашому FizzBuzz, залежно від ситуації ми наповнюємо значення масиву а.
І нарешті виводимо значення масиву a, але якщо там пусто, то виводимо просто індекс i.
В Bash ми можемо виконати операцію = чи += із змінними, але у нас нема -=, /=, *=, тому що в Bash нема строгої типізації змінних. І тому всі операції повинні бути сумісними як з числовими, так і текстовими. += просто виконує конкатенацію текстових змінних, та складає числові.
4. Autocomplete
Мабуть, всі користуються автокомплітом для файлів та каталогів. Але автокомпліт працює не тільки з ними. Ще він автодоповнює імена змінних і функцій, а також аліасів. Тому, якщо ви хочете зробити собі швидкий аліас, можна підібрати ім’я таким чином, щоб його можна було швидко автодоповнити з однієї-двох літер. Наприклад, писати з uppercase-літер.
alias DEBUGLOG="tail -f $HOME/myapp/debug.log"
function DECRYPT() {
echo '$1' | openssl enc -pass file:$HOME/secret.key -d -aes-256-cbc -a
}
Ну і не забувайте, що функції, покладені в .profile чи в .bashrc також можна визивати інтерактивно. І це непогана заміна для alias, якщо треба виконати щось більше, ніж одну-дві команди, просто пишемо
DEB<tab> чи DEC<tab>.
5. read замість sleep
sleep дозволяє зробити невеличку паузу. Але якщо замінити його на read -t <time>, то у разі інтерактивного виконання скрипта можна скіпнути цю паузу, натиснувши enter.
А ще цікаво, що read, як і sleep, підтримують долі секунд, тобто можна read -t 0.5
чи sleep 0.2
.
Ну і іноді буває потрібно запускати скрипт з дефолтними значеннями чи з кастомними — read з таймаутом дозволить почекати кастомних значень. А якщо ніхто нічого не ввів (або одразу натиснув enter з пустим значенням), то це можна перевірити та замінити пусте значення на дефолтне. І скрипт можна запускати вручну чи автоматично — він просто почекає та автоматично продовжить роботу.
6. Лапки....
На останнє, проблеми з лапками та розкриттями змінних переслідують майже всіх новачків.
Найчастіше можно побачити помилки, що маємо із пустими змінними, чи змінними, чи значення мають пробіли чи інши спеціальні символи.
Приклади:[ $myvar == "yes" ]
Якщо $myvar пуста, то буде не false а помилка синтаксису.
Якщо у $myvar будуть пробіли — це також буде помилка синтаксису.
Правильно: [ "$myvar" == "yes" ]
.
Ще можно використовувати продвинутий test у bash: [[ $myvar == "yes" ]]
, але це несумісно з POSIX.
Розберемо роботу з файлами. Маємо файл с пробілом:
$ find . . ./test.sh ./hello world.txt
Пробуємо передати список файлів отак:
find . | xargs echo . ./a.sh ./hello world.txt
Виглядає непогано. Але давайте перевіримо кожний елемент:
find . | xargs -n1 echo . ./test.sh ./hello world.txt
Виявляється, що насправді файл з пробілом було порізано на два елементи.
Виправляємо:
find . -print0 | xargs -0 -n1 echo . ./test.sh ./hello world.txtТак працює, тому що find та xargs мають опції, щоб змінити сепаратор на нульовий символ.
В різних випадках треба чи використовувати лапки, чи міняти дефолтний сепаратор, чи те й інше одразу. Мабуть потрібно деякий час поексперементувати, додати додаткові логи та команди, щоб перевірити та знайти для вашого випадка найкращу конструкцію.
7. Wildcard (маски файлів)
Ще маленька дрібничка про wildcard в *NIX та Windows.У давні-давні часи, в файловій системі FAT, ім’я файла та розширення — були окремі поля. Тому в ДОС та першіх версіях Windows, всі файли можно було знайти маскою *.*
В *NIX взагалі нема такого як росширення, є просто ім’я файла, в ньому може бути крапка чи крапки. Тому знайти всі файли — це просто *. А якщо спробувати шукати *.*, то в *NIX це файли, в яких є хоча б одна крапка.
Наразі Windows також може знайти все за маскою *, але для забезпечення обратної сумісності, *.* також буде шукати всі файли в Windows, але тільки файли в імені яких є хоча б одна крапка в *NIX.
На сьогодні все, запрошую до коментарів тих, кому цікаві деталі.
26 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів