neděle 26. května 2013

Přesměrování a roury v Bashi - není všechno tak jasné na první pohled

Každý, kdo trochu skriptuje v shellu, ví, co jsou to roury a přesměrování. K tomuto tématu není problém najít dokumentaci a člověk má hned pocit, že ví, jak se věci mají. Prostě tady udělám rouru z dmesg do grepu, kterej mi vyzobe co potřebuju a výsledek přesměruju do souboru, žádná věda. Ovšem shelly jsou pěkné kurvy, při psaní shellových skriptů není problém se dostat do situace, kdy čumíte na ten zápis, všechno vám dává smysl, ale shell tomu nerozumí a dělá něco jiného, čemuž zas nerozumíte vy. Unixový shell zkrátka funguje jinak, než běžný programovací jazyk. Není nad to si v tom udělat jasno a protože v rourácení je pár chytáků a nikde jsem nenašel článek, který to bere tak nějak globálně podle mých představ, napsal jsem si ho sám. Zda-li bude jasný, natož přínosný, i vám, to je otázka, ale já jsem si při psaní a testování ujasnil fůru věcí. Samozřejmě jsem se hrabal v Bashi, ale průběžně jsem testoval i Dash. ...


Souborové deskriptory (file descriptors - fd)

Souborové deskriptory jednotlivých procesů je to, kolem čeho se všechna ta přesměrování a roury točí. Deskriptory popisují připojené soubory využívané procesem a v linuxových systémech je najdete v souborovém pseudosystému /proc, který poskytuje informace o všech běžících procesech a další stavové informace a statistiky kernelu. Není to klasický souborový systém, je to defakto interface, který při požadavku na čtení souboru vrátí příslušná data, případně něco někam do paměti zapíše. Proto také všechny soubory, které tu najdete, mají nulovou velikost, jejich obsah se vygeneruje až ve chvíli, kdy je třeba.

Ke každému procesu je tedy k mání i seznam deskriptorů souborů k procesu připojených a otevřených. Ty se nachází v cestě

/proc/PID/fd/

kde PID je číslo procesu.

Deskriptory 0-2 jsou vyhrazeny pro standardní vstup a výstupy:

0 - stdin  - standardní vstup
1 - stdout - standardní výstup - slušné aplikace sem posílají jen užitečná data
2 - stderr - chybový výstup - všechna chybová hlášení by měla jít sem

Když shell vytváří nový proces, automaticky připojí tyto deskriptory podle sebe, proces je od něj zdědí, pokud není definováno jejich přesměrování. Deskriptory je možné vytvářet i nové, rozsah je desetibitový, typu integer, tedy celá čísla 0-1023. Manuál Bashe radí být opatrný při obsazování deskriptorů nad 9, protože by je mohl využívat pro své interní potřeby. Například pokud spustíte skript ze souboru, připojuje Bash na 255 zdrojový soubor (Dash na 10), ale co jsem testoval, pokud se rozhodnete tento deskriptor obsadit vlastním souborem, shell se prostě o jeden posune, přesměrování, které je definováno v zápisu příkazu má přednost.

V souborovém systému máte k dispozici i linky na zmíněné základní deskriptory:

/dev/stdin
/dev/stdout
/dev/stderr

Ve skutečnosti jsou to linky na:

/proc/self/fd/0
/proc/self/fd/1
/proc/self/fd/2

Adresář /proc/self odkazuje na proces, ze kterého je k němu přistupováno:
ls -l /proc/self

lrwxrwxrwx 1 root root 0 May 22 22:02 /proc/self -> 29108

Příkaz:
ls -l /proc/self/fd

vypíše své vlastní souborové deskriptory:

lrwx------ 1 gdh gdh 64 kvě 24 17:15 0 -> /dev/pts/1
lrwx------ 1 gdh gdh 64 kvě 24 17:15 1 -> /dev/pts/1
lrwx------ 1 gdh gdh 64 kvě 24 17:15 2 -> /dev/pts/1
lr-x------ 1 gdh gdh 64 kvě 24 17:15 3 -> /proc/6819/fd

Vstupy a výstupy jsou připojené na pseudoterminál (emulátor terminálu spuštěný pod X) /dev/pts/1, protože je shell nastavuje podle sebe, takže se výstup vypisuje do terminálu a na vstup je připojena klávesnice. Na deskriptoru 3 je adresář, jehož obsah ls vypisuje. Každý procesem otevřený soubor (i adresář, či zařízení jsou soubor), se objeví na nejbližším volném deskriptoru.

S předchozím souvisí i další linky v souborovém systému, které se dále dají využít v přesměrováních, ale které jsou závislé na použití Bashe:

/dev/tcp/hostitel/port - pokud je jméno hostitele (něco jako www.bla.bla), nebo internetová adresa (IP) a port je celé číslo, nebo jméno služby (ssh, ftp, .. nahradí se standardním číslem portu), pokusí se Bash navázat TCP spojení na příslušný souborový deskriptor procesu. Není možné použít pro čtení cat /dev/tcp/..., protože tento soubor ve skutečnosti neexistuje, je nutné použít přesměrování cat < /dev/tcp/...

/dev/udp/hostitel/port - to samé jako předchozí, jen přes UDP protokol.

Ve skutečnosti v /dev nic takového nenajdete, Bash si tyto cesty interpretuje sám. Použití nastíním dále.

Základní přesměrování

Výše popsané souborové deskriptory můžete při spouštění procesu ovlivnit a nakázat shellu, kam je má připojit. Pro přesměrování se používají znaky > a < a jejich význam je následující:

fd > soubor/fd # jen pro zápis a pokud nemá shell nastaven noclobber (není výchozím nastavením Bashe, ani Dashe v Ubuntu), bude přepsán i existující soubor.
fd < soubor/fd # jen pro čtení
fd >| soubor   # jen pro zápis, existující soubor bude vždy přepsán
fd >> soubor   # jen pro zápis, ale bude se přidávat na konec souboru, pokud existuje
fd <> soubor   # pro zápis i čtení

Samozřejmě je respektováno nastavení práv souborového systému.
Přesměrovávat můžete i na jiný souborový deskriptor, v tomto případě musíte deskriptor na pravé straně rozlišit od normálního souboru tak, že před něj přidáte znak &. Při přesměrovávání jednoho deskriptoru  na druhý můžete používat jen operátory < a >. Pár příkladů použití objasní:

Přesměrování stdout (který se normálně vypisuje na obrazovku, je-li příkaz spuštěn z terminálu) do souboru:

příkaz > soubor
což je stejné jako
příkaz 1> soubor

Budete-li chtít do souboru zapisovat i stderr, zapíšete to takto:

příkaz &> soubor
to odpovídá zápisu:
příkaz > soubor 2>&1

Tady pozor, pokud byste k tomu přistupovali logikou - nejdřív přesměrovat stderr na stdout a ten pak do souboru. Je nutné si uvědomit, že souborové deskriptory nemohou ukazovat jeden na druhý. Když přesměrujete jeden deskriptor na druhý, nedojde k ničemu jinému, než ke zkopírování druhého do prvního, takže oba také ukazují na stejný soubor.

Přesměrování jsou vždy realizována jak jdou v zápisu za sebou, zleva do prava a při přesměrovávání jednoho souborového deskriptoru na druhý můžete změnit směr (čtení, zápis).

Také můžete vytvořit deskriptory nové:

příkaz 3> soubor0 4< soubor1 5<> soubor2

A také je můžete zavírat pomocí mínusu:

příkaz 0>&-

Takto třeba zahodíte standardní vstup.

Zavírání můžete využít při přesunu deskriptoru, přesměrujete jeden na druhý a první rovnou zavřete:

příkaz 4<&5-

Speciální přesměrování Bashe

Bash umí ještě přesměrování typu "Here Documents" a "Here Strings', ale také připojit službu přes TCP a UDP protokoly.

Here Documents

První z nich umožňuje připíchnout na vstup příkazu víceřádkový text přímo z těla skriptu, přičemž si na začátku definujete řetězec, po jehož výskytu na samostatném řádku se čtení dokumentu ukončí. Například:

cat <<EOF
tenhle text půjde na vstup příkazu cat
až po EOF na samostatném řádku
$((2*45)) - delimiter v uvozovkách by zamezil expanzi
EOF
echo tenhle řádek se již provede jako běžný příkaz

Všechno, co je mezi EOF (což je tu jako delimiter - oddělovač) je bráno jako text, který se dostane na vstup příkazu cat. Místo EOF si můžete zvolit jakýkoliv řetězec, který se nemůže ve zpracovávaném textu objevit na samostatném řádku.

Pokud zvolený delimiter (v mém případě EOF) není v uvozovkách, proběhne v předávaném textu k expanzi proměnných a dalších speciálních znaků, stejně jako se tak dějě v běžných řetězcích v dvojitých uvozovkách a speciální znaky je třeba escapovat zpětným lomítkem. Dáte-li ale delimiter do uvozovek (jednoduchých, nebo dvojitých), nedojde v dalším textu k žádné expanzi.

Pro přesměrování můžete místo "<<" použít "<<-", to vám umožní použít odsazování textu ve zdrojovém skriptu tabulátory, protože ty pak Bash při zpracování automaticky odstraní ze začátku všech řádků, včetně koncového delimiteru. Nemusíte si tak vizuálně rozbít zápis skriptu.

Here Strings

Další specialitou je přesměrování řetězce, tak můžete procesu na standardní vstup předat například i obsah proměnné.

cat <<< $XDG_CURRENT_DESKTOP

Internet přes TCP a UDP

Jak jsem již psal výše, Bash provozuje v rámci souborového systému zařízení schopné navázat spojení se vzdáleným počítačem přes tyto dva protokoly. Pokud budete chtít na souborový deskriptor připojit něco z internetu, můžete na to jít z terminálu Bashe zhruba takto:

exec 3<>/dev/tcp/checkip.dyndns.org/80
echo -e "GET /HTTP/1.1\r\nConnection: close\r\n\r\n" >&3
cat <&3 | sed -n '/<html/s/^.*: \([^<]*\).*/\1/p'

Tato šaráda připojí na souborový deskriptor 3 přes TCP protokol port 80 (běžný prohlížečový) domény checkip.dyndns.org. Příkazem echo pošlete serveru přes otevřený port požadavek na data hlavní stránky a ta pak pomocí cat přečtete a v sedu profiltrujete, abyste dostali jen čistou adresu. Oproti wget, curl a jiným specializovaným nástrojům jsou tu omezení, ale pro určité aplikace se to hodit může.

Změna přesměrování shellu za běhu

Jsou situace, kdy se může hodit změnit přesměrování aktuálního shellu a tudíž všech dalších příkazů přímo ve skriptu. K tomu se používá vestavěný příkaz exec (v Bashi, i Dashi). exec je schopný stávající shell nahradit příkazem, který dostane jako parametr a to v rámci stejného procesu, ale pokud mu žádný příkaz nedáte, je schopen jen zajistit přesměrování souborových deskriptorů, tedy vstupů a výstupů, shellu, ve kterém byl spuštěn. Všechny další příkazy, které shell provede, již dědí nově přesměrované deskriptory.

(echo do terminálu; exec >/dev/null; echo do černé díry)

Proč jsem to napsal do kulatých závorek? Aby se to spustilo v subshellu, jinak byste si do řiti přesměrovali standardní výstup terminálu a museli byste si ho vrátit třeba pomocí:

exec >&2

Komplikovanější to bude ve skriptu, ve kterém se rozhodnete přesměrovat stdout následujících příkazů na stderr  a pak to budete chtít vrátit zpět. Pak si musíte před přesměrováním stdout odložit na jiný deskriptor a odtud ho pak zase vrátit zpět:

echo posílám text na stdout
exec 3>&1>&2
echo teď na stderr
exec 1>&3-
echo a teď opět na stdout a pomocný deskriptor 3 byl zrušen


Roury

Roury umožňují řetězit procesy tak, že výstup jednoho je možné rourou připojit na vstup dalšího a stejně tak výstup dalšího můžete zas rourou poslat dál a tak se dá vzájemně propojit libovolné množství procesů, omezeni jste v podstatě jen pamětí. Roura je ve své podstatě FIFO (First In First Out) buffer - co jde první dovnitř, to jde první ven. Jeden proces data do roury zapisuje a druhý je čte, což se nemusí dít synchronně. Opět jde jen o přesměrování vstupů a výstupů, místo souboru je tu roura. Důležité je také to, že procesy spojené rourou se spouští paralelně, resp. příkaz za rourou nečeká, až ten před rourou skončí a roura zaniká až ve chvíli, kdy jsou oba zůčastněné procesy ukončeny. Řetězit se dají nejen samostatné příkazy, ale i celé celky běžící v subshellu - shell, který spouští jednotlivé příkazy, je také jen proces a protože připojení standardních souborových deskriptorů (stdin, stdout, stderr) procesy dědí po svém shellu (pokud nejsou přesměrovány jinak), napojí se stdin každého procesu v subshellu za rourou právě na onu rouru. Můžeme si to ukázat na příkladu:

echo -e 'první\ndruhý' | (read line; echo 1.$line; read line; echo 2.$line)

Roura se v zápisu Bashe, i Dashe, vytváří svislítkem "|" mezi spojovanými příkazy, potažmo procesy, případně bloky příkazů. echo pošle do roury dva řádky a subshell za rourou postupně spouští své příkazy. read čte stdin, pokud mu neurčíte jinak, a to jeden řádek najednou. Z výsledku, který tento skript vypíše je zřejmé, že i druhý příkaz read čte ze stejného vstupu, tedy ze stejné roury.

A co se stane, když spustíte stejný příkaz ještě jednou s tím, že vynecháte kulaté závorky definující subshell?

line=' nultý'; echo -e ' první\n druhý' | read line; echo 1.$line; read line; echo 2.$line

Tady by se dalo čekat, že první read bude číst z roury, uloží výsledek do proměnné $line, echo ho vypíše, ale další read již nebude číst z roury, protože zdědil deskriptor stdin po svém shellu, což je ten samý, ze kterého celý příkaz spouštíte. Druhý read už tedy bude číst vstup z klávesnice, stejně jako terminál, ve kterém shell běží. Ve skutečnosti ale v tomto případě ani echo, které za read následuje, nevypíše to, co read přečetl! Problém je v tom, že první read se spustil v subshellu. Oba příkazy, jak před rourou, tak za rourou, se spouští v subshellu, každý ve vlastním. Ale zatímco subshell zdědí po svém předkovi i proměnné (jen hodnoty, ne reference), proměnné vytvořené v subshellu se k předkovi nedostanou. Proto následující echo vypíše "1.nultý" místo "1.první", neboť proměnná $line přepsána nebyla, jen v subshellu chvíli paralelně existovala proměnná stejného jména. Pro názornost si v Bashi můžete spustit následující skript, kde každý jeho příkaz vypíše číslo procesu shellu, ve kterém je spuštěn:

echo 0. $BASHPID; echo 1. $BASHPID 1>&2 | echo 2. $BASHPID; echo 3. $BASHPID

Výsledek bude vypadat zhruba následovně:

0. 2354
1. 5997
2. 5998
3. 2354

Procesy kolem roury beží každý ve vlastním shellu, kdežto první a poslední, oba v tom základním, ze kterého byl celý skript spuštěn. Jistě je vám již jasné, proč jsem přesměroval stdout příkazu před rourou na stderr.

Pokud ale za rouru dáte cyklus, typicky třeba while, poběží ve stejném subshellu celý cyklus a poslední echo již opět ze základního shellu vypíše původní obsah proměnné $line:

line=nultý; echo -e 'první\ndruhý' | while read line; do echo $line; done; echo $line


Pár dalších příkladů s rourami


Naprosto standardní roura s grepem:
ls -a ~ | grep .bash

Předchozí vlastně principem funguje takto:
ls -a ~ | grep .bash /proc/self/fd/0
což se dá zapsat i takto:
ls -a ~ | grep .bash /dev/stdin

Jak již bylo řečeno, /proc/self ukazuje na proces, ze kterého tento adresář čtete a /dev/stdin je linkem na /proc/self/fd/0. grep bude číst svůj vlastní stdin, jakoby to byl soubor. Takto se i program, který stdin nečte, ale umí číst ze souboru, k obsahu roury dostane.

Pro další příklad vezmu třeba echo, které neumí číst ani ze souboru, ani ze stdin. A přesto ho můžete donutit vypsat obsah roury na svém stdin, jen si musíte pomoct externím programem (ze základní výbavy):

ls -a ~ | xargs -d\n echo | grep .bash

xargs v tomto případě za echo plácne obsah stdin a celé to spustí jako příkaz. Protože ale výchozím chováním xargs je použít jako delimiter (oddělovač) mezeru, kterou nahradí původní konce řádků, je potřeba mu domluvit přepínačem -d.

Pojmenované roury

V případě, že by se vám hodila roura v trochu stabilnějším provedení, kterou by mohly různé procesy využívat pro předávání dat, můžete si vyrobilt rouru pojmenovanou. Takovou rouru můžete umístit kamkoli v souborovém systému a jako k souboru se k ní také budete chovat. Funguje stejně běžná roura, tedy co do ní vložíte, to je tam přesně do chvíle, než to zas odněkud přečtete.

Pojmenovanou rouru vytvoříte příkazem mkfifo, např.:

mkfifo /tmp/roura

Skript, který bude rouru trvale číst může vypadat nějak takto:

#!/bin/bash

trap "rm -f /tmp/roura" EXIT

roura="/tmp/roura"
[ -p $roura ] || mkfifo $roura

while [ -p $roura ]; do
  if read line < $roura; then
    if [ $line != "EOF" ]; then
      echo "roura: $line"
    else break
    fi
  fi
done
echo končím..

Tento skript čte rouru (pokud existuje -> test -p) do té doby, než z ní vytáhne řádek obsahující řetězec "EOF", pak skončí. Přidal jsem ještě příkaz trap, který ve chvíli, kdy zachytí signál EXIT, tedy skript skončil (jakýmkoliv způsobem, tedy krom odpojení od zásuvky), smaže i rouru.

Takže skript spustíte a z jiného terminálu otestujete:

echo test > /tmp/roura
echo EOF > /tmp/roura

Testování výstupu a vstupu

Pokud napíšete nějaký skript (program) pro shell, může se hodit umět rozlišit, zda jde výstup na obrazovku uživateli, nebo zda jde jinam, ať už na vstup dalšího programu, nebo třeba do souboru. Uživateli můžete výstup různě obarvit, vypisovat ve sloupcích a další věci, které se nehodí pro další strojové zpracování, kde je nevýhodný text se zbytečnými znaky navíc a je naopak většinou výhodné mít každou položku na novém řádku. Bash, i Dash umí testovat souborový deskriptor shellu, ve kterém běží a vrátit TRUE, když ukazuje na terminál (/dev/tty, /dev/pts). Můžete si zkusit:

[ -t 1 ] && echo je to teminál >&2 || echo není to terminál >&2

([ -t 1 ] && echo je to teminál >&2 || echo není to terminál >&2) > /dev/null

Stejně můžete testovat ostatní deskriptory. Nezjistíte tak, kam konkrétně jsou napojeny, ale to už si v tuto chvíli umíte zjistit sami.


Nahrazení procesu (Process Substitution)

Bash oplývá ještě další specialitou, která umí oproti rouře připojit k procesu vstupy a výstupy více skriptů. Není to klasické přesměrování, i když zápis je velmi podobný. Technicky jde o to, že shell vyrobí v podstatě pojmenovanou rouru v adresáři /dev/fd/[nn], do které připojí výstup, popřípadě vstup nahrazovaného procesu a tuto cestu dá procesu jako argumet, se kterým ho spustí. Těch argumenů může být více, podle potřeby.

Klasický příklad s echem, které odhalí, co se děje, protože echo jen vypíše, čím mu shell příkaz nahradil:

echo <(true)
/dev/fd/63

Pozor, mezi znaky "<" a "(" nesmí být mezera. Bash vyrábí první rouru s číslem 63 a další má číslo vždy o jedna nižší.
Takže pokud chcete porovnat dva seřazené seznamy, můžete to udělat následovně:

diff <(sort list1 | uniq) <(sort list2 | uniq)

Je možné použít i ">(", ale tak často se to nepoužívá, tím připojíte do vytvořené roury stdin toho nahrazovaného procesu. Jeden z příkladů:

tar cf >(bzip2 -c > soubor.tar.bz2) adresář

Tady bude tar vyrábět z adresáře archiv, strkat ho do roury, ze které to zas bude vybírat a komprimovat bzip2, který, když mu nedáte soubor, bere data z stdin a cpe je (díky přepínači -c) na stdout, proto je přesměrován do souboru. Výsledkem je tedy zkomprimovaný archiv.

Výsledkem nahrazování procesu je cesta k souboru, takže využítí je všude tam, kde se pracuje se soubory, tak je samozřejmě i možné kombinovat je s klasickým přesměrováním. Můžu napsat třeba takovouto hovadinu (mezi špičatými závorkami je mezera):

grep .bash < <(ls -a ~)

Nahrazování procesů se používá i pro obcházení problému s proměnnými za rourou, které se ztratí v subshellu. S klasickou rourou, ze které čte read do proměnné, kterou chcete použít v dalším kódu, musíte dát všechno za rourou do subshellu, nebo si proměnnou předat přes soubor. Nahrazením procesu si zdrojovou část změníte na normální soubor a proces, který ho čte, nikdo do subshellu strkat nebude.

Pozor na vstupní a výstupní buffer

Při řetězení příkazů pomocí rour můžete v terminálu narazit na to, že vám data někde uprostřed váznou. Proč tomu tak je se podívejte na můj další zápisek
Bash - s jednou rourou výstup v terminálu vidím, se dvěma už ne ..

Závěr

Shell není normální programovací jazyk, primárně slouží ke spouštění procesů a z toho vyplývá řada specialit, které je třeba vzít na vědomí a počítat s nimi. Do té doby to bude ta kurva, co vás připravuje o čas a nervy, protože se "vůbec nechová logicky".

Co jsem tu napsal vychází především z toho, co jsem vypozoroval při hrátkách v terminálu a všechno jsem se snažil najít někde v dokumentaci a závěry si ověřit. Příklady jsem sice nevolil nijak hodnotné, ale těch je všude mrak, mně šlo o princip.

Pokud náhodou najdete nějakou nesrovnalost, nebo doplnění, budu rád, když se ozvete.

Jinak man bash je váš nejlepší přítel ;)

Na závěr jeden povedený přehled přesměrování, který vysvětluje i to, co by mě vysvětlovat nenapadlo:

http://wiki.bash-hackers.org/howto/redirection_tutorial

4 komentáře:

  1. Ahoj, pekny clanek. Mam dotaz, sel by presmerovat vystup opravdu za behu? Tedy pro priklad mam spusteny yes a poslu ho jako job na bg. On ale stale posila vystup na terminal a tak je terminal defacto nepouzitelny. Slo by vystup presmerovat do souboru za behu a pak teprve poslat job na pozadi? Nebo ... neslo by vystup presmerovat na jiny terminal? Jde mi o to aby job na pozadi nezahlcoval aktualni terminal.

    OdpovědětVymazat
    Odpovědi
    1. Ahoj. Tady nejde o to "opravdu za běhu", ale o "zvenčí, jinému procesu". Možnosti existují, zkus kouknout sem:
      http://stackoverflow.com/questions/593724/redirect-stderr-stdout-of-a-process-after-its-been-started-using-command-lin

      Vymazat
    2. Díky za nasměrování, gdb je použitelný. Sice metoda s dup2 nefunguje, jelikož po prvním výstupu se proces bůhvíproč ukončuje, ale metoda close/open popsaná níže funguje velmi dobře jak do null tak na jiný tty.

      Vymazat
    3. Díky za info, sám jsem to netestoval.

      Vymazat

Zkuste prosím při komentováni používat místo volby Anonymní volbu Název/adresa URL, kde vyplníte nějakou přezdívku, adresu zadávat nemusíte. Vědět, které příspěvky jsou od jednoho člověka, je fajn. Díky.

Pokud by se vám náhodou odeslaný komentář na stránce nezobrazil, vytáhnu ho z koše hned jak si toho všimnu. I Google spam filter se občas sekne.