Disassembler

Artificial intelligence is no match for natural stupidity.
11února2012

Základní debugování shellových skriptů


Shellové skripty používám rád a často. Jsou fajn, protože pro jejich běh není potřeba instalovat žádné interprety nebo jiné balíky a jsou snadno přenositelné. Dá se říci, že jejich jedinou závislostí je přítomnost správné verze shellu, a ta je stejně na drtivé většině systémů už předem splněna. Postupem času se však od psaní jednoduchých několikařádkových skriptíků propracujete až k výrobě složitějších nástrojů a utilit a dříve nebo později se dostanete do situace, kdy budete potřebovat tyhle záležitosti nějakým způsobem ladit. IDE na vývoj shell skriptů sice existují, ale mou první volbou by bylo použít to, co se dá najít snad na každém *nixovém systému. Cat, vi a shell samotný.

Sestavení skriptu


Příklady vám budu ilustrovat na jednoduchém skriptu, který bude mít za úkol spočítat kolik spamů došlo v předchozím dni a na základě tohoto počtu vypíše hlášku. Nejprve skript sestavíme, abyste viděli, jak vlastně funguje.

Předpokládejme, že máme poštovní server s postfixem a amavisem. V takovém případě vypadá záznam o spamu třeba takto:

Feb 10 22:05:30 mail amavis[61521]: (61521-03) Blocked SPAM, [123.45.67.8] [123.45.67.8] <spambot@example.com> -> <user@mydomain.cz>, quarantine: z/spam-zyzMsAWgQfPO.gz, Message-ID: <4f357835.40e686d9@example.com>, mail_id: zyzMsAWgQfPO, Hits: 14.196, size: 884, 15142 ms

Budeme tedy hledat výskyt řetězce Blocked SPAM v souboru /var/log/mail.log. Zajímají nás ale jen záznamy z předchozího dne, takže ještě předtím, než budeme hledat spamy, grepnem jen ty řádky, které začínají včerejším datem.

export DATE=`date -d yesterday +'%b %e'`
SPAM_COUNT=`grep "^$DATE" /var/log/mail.log | grep -c "Blocked SPAM"`

Číslo uložené v proměnné SPAM_COUNT pak porovnáme a výpíšeme nějaké bláboly. A protože je to slušně vychovaný shell script, přidáme na začátek shebang s cestou k interpretu. Celý skript, se kterým budeme operovat, tedy zní:

#!/bin/bash

export DATE=`date -d yesterday +'%b %e'`
SPAM_COUNT=`grep "^$DATE" /var/log/mail.log | grep -c "Blocked SPAM"`
if [ $SPAM_COUNT -eq 0 ]; then
	echo "Zadny spam neprisel"
else if [ $SPAM_COUNT -lt 100 ]; then
	echo "Neco prislo, ale moc toho nebylo"
else
	echo "Lovely spam! Wonderful spam!"
fi

Zalamování se smíchy


Budeme předstírat, že jsem svůj skript napsal v notepadu na windowsovské mašině a na cílový server zkopíroval pomocí SCP. A že SCP bylo hloupé a neumělo si automaticky vyjednat mód kopírování, takže všechno přenášelo binárně. I spustil jsem skript a on mi odpověděl:

user@mail:~$ ./script.sh
-bash: ./script.sh: /bin/bash^M: chybný interpretr: Adresář nebo soubor neexistuje

Na první pohled je něco špatně, protože /bin/bash^M jsem do skriptu nepsal. Nepsal? Ale psal, jen o tom nevím. ^M je totiž balast, který *nixové systémy vidí místo windowsowského a macovského CR (carriage return) znaku. Tento řídící znak pochází ještě z dob počítačového pravěku a posouvá kurzor na začátek řádku. *nixové systémy však pro tento účel používají znak LF (line feed) a CR jako posun na další řádek neinterpretují. Nechápu, proč se za nějakých třicet let koexistence nebyli tvůrci jednotlivých systémů schopni domluvit na jednotném užití a raději se ani nebudu pouštět do úvah, který ze systémů se chová „nejsprávněji“. Prostě už to tak je a my, prostí adminové, jsme odsouzeni k tomu, si těchto diverzit každý den užívat.

Jestli jsou konce řádků v souboru v pořádku, můžeme zjistit pomocí cat -A.

user@mail:~$ cat -A script.sh
#!/bin/bash^M$
^M$
export DATE=`date -d yesterday +'%b %e'`^M$
SPAM_COUNT=`grep "^$DATE" /var/log/mail.log | grep -c "Blocked SPAM"`^M$
if [ $SPAM_COUNT -eq 0 ]; then^M$
^Iecho "Zadny spam neprisel"^M$
else if [ $SPAM_COUNT -lt 100 ]; then^M$
^Iecho "Neco prislo, ale moc toho nebylo"^M$
else^M$
^Iecho "Lovely spam! Wonderful spam!"^M$
fi^M$

Parametr -A , který je aliasem pro parametry -vET nám zobrazí „neviditelné“ znaky. Mimo svých ^M tu ještě vidím znak ^I, který zastupuje tabulátor, a také $, který indikuje konec řádku. Už tedy vidím, co nevidím a můžu se pustit do odstraňování. Mám ověřené dvě metody. První z nich používá sed, je jednodušší (obzvlášť pokud potřebujete opravit větší množství souborů), ale nemusí být vždy a všude účinná.

sed -i 's/\x0D$//' script.sh

0x0D je ASCII hodnota znaku CR. Sed jednoduše prošmejdí soubor a všechny CR na koncích řádků vymaže. Druhá metoda je nepatrně složitější, ale zato mě zatím nezklamala. Soubor otevřete ve vi či nějakém podobném vi-like editoru a přikažte mu

:e ++ff=dos
:setlocal ff=unix
:wq

Pokud váš soubor doprasil mac, na prvím řádku nahraďte dos macem. Samozřejmě lze touto metodou konvertovat i nazpátek.

Já vím, co tomu je! Je to rozbitý!


Konce řádků jsem tedy opravil a můžu skript vesele spustit.

user@mail:~$ ./script.sh
./script.sh: řádek 12: chyba syntaxe: nenadálý konec souboru

Zase špatně. V tomto případě můj skript pravděpodobně obsahuje nějaký neukončený blok. Když si ale nechám pomocí cat -n vypsat čísla řádků

user@mail:~$ cat -n script.sh
     1  #!/bin/bash
     2
     3  export DATE=`date -d yesterday +'%b %e'`
     4  SPAM_COUNT=`grep "^$DATE" /var/log/mail.log | grep -c "Blocked SPAM"`
     5  if [ $SPAM_COUNT -eq 0 ]; then
     6          echo "Zadny spam neprisel"
     7  else if [ $SPAM_COUNT -lt 100 ]; then
     8          echo "Neco prislo, ale moc toho nebylo"
     9  else
    10          echo "Lovely spam! Wonderful spam!"
    11  fi

Zjistím, že řádek 12 je prázdný řádek na samotném konci skriptu. Chybová hláška mi tedy v nalezení umístění chyby moc nepomůže. Co mi ale pomůže, je spuštění skriptu s pár parametry shellu navíc.

user@mail:~$ /bin/bash -vx script.sh
#!/bin/bash

export DATE=`date -d yesterday +'%b %e'`
date -d yesterday +'%b %e'
++ date -d yesterday '+%b %e'
+ export 'DATE=úno 10'
+ DATE='úno 10'
SPAM_COUNT=`grep "^$DATE" /var/log/mail.log | grep -c "Blocked SPAM"`
grep "^$DATE" /var/log/mail.log | grep -c "Blocked SPAM"
++ grep -c 'Blocked SPAM'
++ grep '^úno 10' /var/log/mail.log
+ SPAM_COUNT=0
if [ $SPAM_COUNT -eq 0 ]; then
        echo "Zadny spam neprisel"
else if [ $SPAM_COUNT -lt 100 ]; then
        echo "Neco prislo, ale moc toho nebylo"
else
        echo "Lovely spam! Wonderful spam!"
fi
script.sh: řádek 12: chyba syntaxe: nenadálý konec souboru

Parametr -v určuje ukecanost bashe. V našem případě je ekvivalentní Windowsovskému @ECHO ON a před provedením každého řádku nebo bloku skriptu napřed vypíše jeho obsah. Parametr -x pak přikazuje shellu vypisovat návratové hodnoty subrutin, obsahy proměnných a další užitečné detaily. Jak je vidět z výpisu běhu skriptu, provedlo se vše až po řádek

if [ $SPAM_COUNT -eq 0 ]; then

Znamená to tedy, že chyba se týká tohoto bloku. Bystré oči hned prohlédnou moji lest a odhalí, že blok skutečně není uzavřen, protože ono fi na konci souboru patří bloku vnořenému, který byl však vytvořen neúmyslně, protože nepozornému programátorovi uteklo else if namísto správného elif. Po opravě syntaktické chyby už skript běží a vypíše

user@mail:~$ ./script.sh
Zadny spam neprisel

Hurá! Skript funguje. Škoda jen, že jsem se koukal do logů a vím, že jich nejmíň dobrá stovka přišla. I v tomto případě je záchranou ladění pomocí /bin/bash -vx, případně pouze/bin/bash -x. V tom se totiž dočteme

user@mail:~$ /bin/bash -x script.sh
++ date -d yesterday '+%b %e'
+ export 'DATE=úno 10'
+ DATE='úno 10'
++ grep '^úno 10' /var/log/mail.log
...

Chěli jsme grepovat Feb 10 a místo toho grepujeme úno 10. Chyba je tedy očividně v tom, že uživatel má český LC_TIME. Před zjišťováním data jej tedy v našem skriptu nastavíme na anglický a voila:

user@mail:~$ /bin/bash -vx script.sh
#!/bin/bash

export LC_TIME="en_US.UTF-8"
+ export LC_TIME=en_US.UTF-8
+ LC_TIME=en_US.UTF-8
export DATE=`date -d "yesterday" +'%b %e'`
date -d "yesterday" +'%b %e'
++ date -d 'yesterday' '+%b %e'
+ export 'DATE=Feb 10'
+ DATE='Feb 10'
SPAM_COUNT=`grep "^$DATE" /var/log/mail.log | grep -c "Blocked SPAM"`
grep "^$DATE" /var/log/mail.log | grep -c "Blocked SPAM"
++ grep '^Feb 10' /var/log/mail.log
++ grep -c 'Blocked SPAM'
+ SPAM_COUNT=142
if [ $SPAM_COUNT -eq 0 ]; then
        echo "Zadny spam neprisel"
elif [ $SPAM_COUNT -lt 100 ]; then
        echo "Neco prislo, ale moc toho nebylo"
else
        echo "Lovely spam! Wonderful spam!"
fi
+ '[' 142 -eq 0 ']'
+ '[' 142 -lt 100 ']'
+ echo 'Lovely spam! Wonderful spam!'
Lovely spam! Wonderful spam!

A našich 142 spamů je na světě. Bloody Vikings! You can't have egg bacon spam and sausage without the spam.