Moje odkazy
Obsah článku:
vydáno: 4. 6. 2019 16:32, aktualizováno: 18. 2. 2024 01:10
Internet běží sice převážně na TCP/IP, ale v rámci jednoho počítače máme i vhodnější způsoby komunikace. V tomto článku se podíváme na unixové doménové sokety a jejich použití v Javě. Předáme si souborové popisovače (FD) z rodičovského procesu potomkovi a ukážeme si princip socket activation. Nakonfigurujeme si služby v klasickém xinetd i moderním systemd a nakonec propojíme Jetty a Apache HTTPD pomocí unixového soketu.
UDS jsou formou mezi-procesové komunikace (IPC) v rámci jednoho počítače. Podporují jak proudová spojení (obdoba TCP), tak posílání datagramů (obdoba UDP). Přiřadit si je k vrstvám TCP/IP nebo OSI moc dobře nejde, protože UDS nahrazují jak TCP/UDP (proudy a datagramy), tak i IP (adresaci) a všechno pod ním. (mimochodem na tenhle problém jsem narazil při návrhu nového typu hlaviček pro tcpdump…) Nicméně pro jednoduchost je můžeme považovat za náhradu TCP a UDP v rámci jednoho počítače.
Adresou ve světě UDS je cesta k souboru resp. textový řetězec o maximální délce definované v sys/un.h
, což na většině dnešních systémů bude 108 (char sun_path[108]
), ale může to být až _POSIX_PATH_MAX tzn. 256 bajtů. Výhodou je, že se nepoužívají nějaká nicneříkající čísla (IP adresy a porty), ale textový řetězec. UDS mohou být i abstraktní (začínají nulovým bajtem a nemají žádnou vazbu na souborový systém).
Zásadním omezením UDS je to, že jsou pouze lokální – lze jimi propojit jen dva procesy běžící na témže počítači (ale můžou běžet pod různými uživateli). Výhody pak jsou:
/run/moje-aplikace
místo 127.0.0.1:56789
UDS jsou jednoduše skvělá věc a pokud nepotřebujete komunikovat po síti, určitě byste o nich měli uvažovat.
Java umí komunikovat přes TCP, UDP, SCTP…, ale UDS ve standardní knihovně bohužel nenajdeme (což zřejmě souvisí s tím, že UDS nejsou úplně všude a Java si hodně zakládá na tom, že je multiplatformní – UDS jsou přeci jen specifické pro unixové/posixové/gnu systémy – byť těch je většina). Tradičně se tento nedostatek řeší pomocí knihoven, které staví nad JNI nebo JNA a volají nativní funkce operačního systému. Těchto knihoven existuje hned několik:
a není ani problém si pomocí JNA napsat vlastní nebo si pomocí JNA volat libovolné jiné nativní funkce a udělat si vlastní integraci s operačním systémem. Nicméně o těchto knihovnách už bylo napsáno dost… ve zbytku článku se budu věnovat něčemu přeci jen zábavnějšímu.
UDS (nebo třeba TCP či UDP soket nebo otevřený soubor) si totiž aplikace nemusí vytvářet sama. Tzn. aplikace nemusí vědět, kam se připojit nebo o jaký typ (zda síťový nebo unixový) soketu půjde. O tom, kde se bude naslouchat nebo kam se připojit, rozhodne ten, kdo danou aplikaci spouští (její rodičovský proces), a „podstrčí“ jí již vytvořený soket. Ano, jde o ony v Javě dobře známé a oblíbené principy Inversion of Control (IoC) a Dependency injection (DI), které jsou zde akorát posunuté až na rozhraní mezi Javou a operačním systémem.
Tento přístup je výhodný z několika důvodů:
Nevýhodou tohoto přístupu je to, že otevřené sokety předáváme z rodičovského procesu do procesu aplikace pouze v okamžiku, kdy aplikace startuje. Tudíž tento přístup nelze použít pro sokety či soubory, o kterých v době startu nevíme. Pokud by se např. jednalo o aplikaci, která se až během svého života rozhodne, na jakém UDS bude naslouchat nebo na jakou síťovou adresu se připojí, pak předání soketu z rodičovského procesu potomkovi nelze použít (tento scénář lze řešit pomocí posílání FD přes UDS, ale o tom až někdy příště…).
Ačkoli předávání soketů z rodičovského procesu potomkovi může vypadat na první pohled trochu magicky, ve skutečnosti je to přirozené (a naopak je potřeba vyvinout určitou snahu, pokud nechceme, aby se tak dělo).
FD je identifikátor (číslo) odkazující na otevřený soubor v rámci daného procesu. Ovšem soubor je zde abstraktní pojem – nemusí to být totiž klasický soubor na disku, ale může to být i roura nebo soket (a to jak ten naslouchající, tak navázaná/přijatá spojení, která z něj vzešla). Aneb „všechno je soubor“.
Když např. v Bashi přesměrováváme vstupy a výstupy:
echo ahoj 1> /dev/null
echo ahoj 2> /dev/null
echo ahoj 1>&2
tak to 1 a 2 jsou právě souborové popisovače STDOUT
a STDERR
(standardní a chybový výstup). Když Bash (rodičovský proces) spouští proces programu echo
, tak mu nastaví FD 1 resp. 2 tak, aby směřoval na soubor /dev/null
, případně jiný soubor, který jsme na příkazovém řádku zadali. Program echo
tedy neví, zda posílá svůj výstup na terminál, do souboru nebo třeba někam po síti – pouze zapisuje na FD 1 a nic víc neřeší. Kam je FD 1 napojen, to je starost toho, kdo tento program spustil. (abychom byli úplně přesní: proces dokáže zjistit, jestli jeho vstup nebo výstup jsou terminál – ale musí se sám snažit to zjistit – za normálních okolností se od toho abstrahuje)
Nízkoúrovňové funkce, systémová volání, pro čtení, zápis a další operace pracují právě s těmito identifikátory. Nad těmito funkcemi jsou pak postaveny veškeré vyšší abstrakce a API.
FD jako identifikátor je platný v rámci jednoho procesu tzn. FD 123 v jednom procesu může ukazovat na jiný otevřený soubor nebo soket než FD 123 v jiném procesu. A zároveň různé FD v různých procesech můžou ukazovat na jeden otevřený soubor v systému. Podrobnosti rozebírá např. kniha The Linux Programming Interface:
Souborové popisovače 0, 1 a 2 mají speciální význam (STDIN, STDOUT a STDERR). Vyšší čísla FD mohou odkazovat na libovolné další otevřené soubory/sokety předávané z rodičovského procesu potomkovi (nebo později otevřené potomkem).
FD si můžeme vypsat jednak ze syntetického souborového systému /proc
:
# v jednom terminálu si spustíme proces:
cat > x.txt
# a ve druhém si vypíšeme jeho FD:
ll /proc/$(pidof cat)/fd
# celkem 0
# dr-x------ 2 hacker hacker 0 čen 2 19:08 ./
# dr-xr-xr-x 9 hacker hacker 0 čen 2 19:07 ../
# lrwx------ 1 hacker hacker 64 čen 2 19:08 0 -> /dev/pts/10
# l-wx------ 1 hacker hacker 64 čen 2 19:08 1 -> /home/hacker/x.txt
# lrwx------ 1 hacker hacker 64 čen 2 19:08 2 -> /dev/pts/10
nebo pomocí příkazu lsof
:
lsof -p $(pidof cat)
# COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
# cat 1858 hacker cwd DIR 253,5 4096 3407873 /home/hacker
# cat 1858 hacker rtd DIR 253,3 4096 2 /
# cat 1858 hacker txt REG 253,3 35064 2359389 /bin/cat
# cat 1858 hacker mem REG 253,3 4253040 1051651 /usr/lib/locale/locale-archive
# cat 1858 hacker mem REG 253,3 2030544 1835623 /lib/x86_64-linux-gnu/libc-2.27.so
# cat 1858 hacker mem REG 253,3 170960 1835615 /lib/x86_64-linux-gnu/ld-2.27.so
# cat 1858 hacker 0u CHR 136,10 0t0 13 /dev/pts/10
# cat 1858 hacker 1w REG 253,5 5 3459695 /home/hacker/x.txt
# cat 1858 hacker 2u CHR 136,10 0t0 13 /dev/pts/10
V obou případech vidíme, že standardní vstup (0) a chybový výstup (2) jsou napojené na terminál, zatímco standardní výstup (1) směřuje do souboru x.txt
. Na výpisu lsof
navíc ve sloupečku SIZE/OFF vidíme, že do souboru x.txt
bylo zapsáno 5 bajtů (v mém případě řetězec „ahoj“ a znak konce řádku, které jsem zadal do prvního terminálu).
fork()
a exec()
Nové procesy se vytvářejí pomocí systémového volání fork()
. To způsobí duplikaci původního procesu – včetně otevřených zdrojů jako jsou FD – doslova dojde k jeho rozdvojení (fork). A v tomto novém procesu běží kód původního programu. Pokud chceme spustit jiný program, zavoláme funkci exec()
, která načte jinou binárku z disku a nahradí jí původní běžící program. FD však zůstanou zachované, takže nově spuštěný program je má k dispozici. Obvykle se používá fork()
a exec()
dohromady, ale smysl má i volání jen jedné nebo druhé funkce samostatně.
Pokud nechceme, aby se FD předaly nově spuštěnému programu, musíme je po zavolání fork()
explicitně zavřít nebo jim nastavit příznak FD_CLOEXEC, který zajistí, že daný FD nepřežije volání exec()
.
Použití těchto funkcí je poměrně nízkoúrovňová záležitost, kterou často nechceme a nepotřebujeme řešit ručně, a raději pro nastavení FD a spuštění procesu použijeme hotové řešení – tím může být např. Bash nebo různé super-servery a init systémy.
Pomocí standardní knihovny Javy jsme schopní se dostat k UDS (nebo TCP či UDP) soketu resp. kanálu předanému z rodičovského procesu, pokud byl tento kanál nastaven na FD 0 – což běžně super-servery a init systémy dělají. V Javě nám stačí zavolat:
Channel inherited = System.inheritedChannel();
// Returns the channel inherited from the entity that created this
// @since 1.5
n.b. Pokud nám soket obsadil FD 0, je zřejmé, že nebudeme moci (snadno) číst ze standardního vstupu – nicméně v případě serverových služeb (démonů) to ani nepotřebujeme (naopak je to nežádoucí a je lepší STDIN explicitně zavřít, aby nemohlo dojít k čekání na čtení ze vstupu, ze kterého stejně nic nepřijde). Na standardní a chybový výstup však zapisovat můžeme (a to se hodí pro logování).
Může nastat několik situací a podle toho se bude lišit obsah proměnné inherited
:
null
– žádný kanál jsme nezdědili – toto je obvyklá situace – na FD 0 máme nějaký běžný vstup, ze kterého lze normálně číst: terminál, soubor či rouru (k tomu používáme System.in
a System.out
)java.nio.channels.SocketChannel
– navázané spojení s jednou konkrétní protistranou (TCP či proudový UDS)java.nio.channels.ServerSocketChannel
– serverový (naslouchající) soket (TCP či proudový UDS), na kterém je ovšem potřeba teprve zavolat accept()
a přijmout spojení (SocketChannel
), což za nás v předchozím případě dělal rodičovský procesjava.nio.channels.DatagramChannel
– kanál pro posílání datagramů (UDP nebo datagramový UDS)Pro otestování všech možností jsem napsal jednoduchou ukázku InheritedChannelDemo:
Channel inherited = System.inheritedChannel();
if (inherited == null) {
// STDIO je napojeno buď na terminál, běžné soubory nebo roury (<,>,|)
System.err.println("Java: STDIO není zděděný kanál.");
} else if (inherited instanceof SocketChannel) {
// např. při použití xinetd s wait=no
// socat stdio unix-connect:test.sock
try (SocketChannel socket = (SocketChannel) inherited) {
String zpráva = "Java: zděděný kanál je SocketChannel.\n";
socket.write(ByteBuffer.wrap(zpráva.getBytes()));
// System.err.print(zpráva);
}
} else if (inherited instanceof ServerSocketChannel) {
// např. při použití xinetd s wait=yes
// socat stdio unix-connect:test.sock
ServerSocketChannel server = (ServerSocketChannel) inherited;
for (int i = 0; i < 3; i++) { // obsloužíme tři spojení a ukončíme se
SocketChannel socket = server.accept(); // obsluhujeme v jednu chvíli jen jedno spojení
String zpráva = "Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #" + i + " SocketChannel.\n";
socket.write(ByteBuffer.wrap(zpráva.getBytes()));
System.err.print(zpráva);
socket.close();
}
} else if (inherited instanceof DatagramChannel) {
DatagramChannel server = (DatagramChannel) inherited;
server.configureBlocking(true);
ByteBuffer datagram = ByteBuffer.allocate(100);
server.receive(datagram);
System.err.println("Java: zděděný kanál je DatagramChannel, ze kterého jsme přijali datagram: " + new String(datagram.array()));
// echo ahoj | socat stdio unix-sendto:test.sock
} else {
throw new IllegalStateException("Nepodporovaný typ zděděného kanálu: " + inherited.getClass().getName()); // nemělo by nastat
}
Jde o ukázku – normální program by pravděpodobně nepodporoval všechny možnosti, ale např. jen ServerSocketChannel
, a šlo by ho tak napojit jak na TCP, tak na proudové UDS. Nebo by podporoval DatagramChannel
a šlo by ho napojit na UDP nebo datagramové UDS.
Pokud program spustíme obvyklým způsobem, přes java InheritedChannelDemo
, tak to půjde první větví a nic zajímavého se nestane. Program proto musíme spustit tak, aby mu rodičovský proces nastavil FD 0 na nějaký soket. K tomu nám poslouží následující nástroje.
xinetd je tzv. super-server, který nahrazuje původní inetd, a dnes již sám patří ke klasice. Super-server naslouchá na požadovaných soketech a v případě potřeby spouští příslušné služby – což, jak jsme si již říkali v úvodu – je výhodné, když potřebujeme šetřit zdroje: služba se díky tomu může spustit jen na chvíli a pak klidně zase ukončit.
xinetd podporuje protokoly TCP a UDP, ale ne UDS (to by tam měl někdo dopsat – byť to tedy bude trochu kolidovat s původním názvem „the Internet daemon“), takže je z hlediska tohoto článku trochu na nic, ale přesto si s ním můžeme náš program zkusit spustit – v Javě je nám totiž jedno, zda jde o TCP či proudové UDS nebo o UDP či datagramové UDS. Projdeme si všechny tři větve našeho javovského kódu.
V Debianu/Ubuntu si xinetd nainstalujeme pomocí apt install xinetd
a konfigurační soubory si zakládáme v adresáři /etc/xinetd.d/
. V jiných distribucích by to mělo být podobné.
V konfiguraci používáme type = UNLISTED
, protože jinak by se xinetd snažil hledat službu podle názvu v /etc/services
. Můžeme si změnit číslo portu a je potřeba si upravit cestu ke .class
souborům a uživatele, pod kterým program poběží.
Protože jsme nastavili wait = no
, xinetd nejen že vytvořil soket, ale dělá na něm i accept()
a přijímá příchozí spojení – a až pro ně spouští jednotlivé instance našeho programu. V Javě pak dostáváme rovnou SocketChannel
, ze kterého můžeme rovnou číst a zapisovat do něj (jde o spojení s jedním konktrétním klientem).
service pokus { socket_type = stream protocol = tcp port = 9999 type = UNLISTED wait = no user = hacker server = /usr/bin/java server_args = -cp /tmp/cesta-ke-class-souborům InheritedChannelDemo instances = 1 }
Pokud z Javy zapíšeme něco na STDOUT nebo STDERR, tak se to přepošle klientovi.
Když nastavíme wait = yes
, xinetd pouze vytvoří naslouchající soket a předá ho Javě – v ní si pak voláme accept()
a přijímáme jednotlivá příchozí spojení. Javovský proces tak není na jedno použití, ale obslouží více klientů.
service pokus { socket_type = stream protocol = tcp port = 9999 type = UNLISTED wait = yes user = hacker server = /usr/bin/java server_args = -cp /tmp/cesta-ke-class-souborům InheritedChannelDemo instances = 1 }
Při opakovaných spojeních se nám bude vypisovat:
Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #0 SocketChannel. Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #1 SocketChannel. Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #2 SocketChannel. Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #0 SocketChannel. Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #1 SocketChannel. Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #2 SocketChannel. Java: zděděný kanál je ServerSocketChannel, ze kteréhé jsme přijali spojení #0 SocketChannel. …
z čehož vidíme, že jedna instance obslouží vždy tři spojení (viz zdrojový kód výše), a pak se ukončí – a následně xinetd při čtvrtém spojení spustí novou.
Nastavili jsme socket_type
a protocol
tak, že naše aplikace bude dostávat datagramy – zprávy nikoli proudová spojení.
service pokus { socket_type = dgram protocol = udp port = 9999 type = UNLISTED wait = yes user = hacker server = /usr/bin/java server_args = -cp /tmp/cesta-ke-class-souborům InheritedChannelDemo instances = 1 }
Na datagramy můžeme z Javy také odpovídat (alespoň v případě UDP).
systemd je moderní init systém (a spousta dalších věcí), který se rozšířil na většinu současných distribucí. Mám k němu svoje výhrady, ale to teď nechme stranou… Kromě jednoduché deklarativní konfigurace má i další hezké vlastnosti jako je tzv. socket activation. A na rozdíl od xinetd podporuje i UDS.
Pro zprovoznění naší služby budeme potřebovat dva soubory v adresáři /etc/systemd/system/
:
# /etc/systemd/system/java-pokus.service:
[Unit]
Description=Java: pokus 1
Requires=java-pokus.socket
[Service]
User=hacker
StandardInput=socket
ExecStart=/usr/bin/java -cp '/tmp/cesta-ke-class-souborům' InheritedChannelDemo
[Install]
WantedBy=default.target
# /etc/systemd/system/java-pokus.socket:
[Unit]
Description=Java: pokus 1: soket
PartOf=java-pokus.service
[Socket]
ListenStream=/run/java-pokus
[Install]
WantedBy=sockets.target
Potom si obnovíme konfiguraci a spustíme naslouchání na soketu:
systemctl daemon-reload
systemctl start java-pokus.socket
A vyzkoušíme si socket activation:
# vypíšeme si stav soketu – běží:
systemctl status java-pokus.socket
# vypíšeme si stav služby – neběží:
systemctl status java-pokus.service
# připojíme se na soket jako klient:
socat stdio unix-connect:/run/java-pokus
# což nám vypíše:
# Java: zděděný kanál je ServerSocketChannel,
# ze kteréhé jsme přijali spojení #0 SocketChannel.
# a sužba už běží:
systemctl status java-pokus.service
# připojíme se ještě dvakrát:
socat stdio unix-connect:/run/java-pokus
socat stdio unix-connect:/run/java-pokus
# a sužba už zase neběží (viz InheritedChannelDemo.java):
systemctl status java-pokus.service
# systemd ji zase spustí při dalším pokusu o spojení
S pomocí systemd jsme tak zprovoznili službu naslouchající na proudovém UDS napsanou pouze v čisté Javě (jen s její standardní knihovnou).
systemd umí mnoho dalších věcí, lze naslouchat na TCP, UDP, UDS, ale i SCTP, POSIX MQ atd., umí SOCK_STREAM (proudy), SOCK_DGRAM (datagramy) i SOCK_SEQPACKET (podobné jako STREAM, ale zachovávají se hranice mezi pakety), jednu službu lze napojit na několik soketů současně (ta pak dostane více FD a musí si zjistit, který je který) a také umí přijímat spojení (volba Accept=true
), takže pro každé spojení se spustí nový proces a dostane FD odkazující na spojení s jedním konkrétním klientem (obdoba wait = no
u xinetd) z čehož v Javě bude SocketChannel
. Celkově se asi dá říct, že systemd dokáže nahradit všechny funkce xinetd, akorát komplexita tohoto softwaru je bohužel výrazně vyšší (což nesouvisí až tak s tím, že systemd podporuje různé protokoly, ale spíš s tím, že je na něj nabalena spousta jiných nástrojů a komponent).
Pokud potřebujeme jen vytvořit soket a předat jeho FD Javě, tak nepotřebujeme nic tak velkého jako je systemd. K tomu nám stačí pár řádků v céčku. Napsal jsem jako ukázku jednoduchý zavaděč – start.c:
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <stdio.h>
/**
* Otevře unixový doménový soket a napojí ho na FD 0 místo STDOUT.
* V Javě pak tento soket najdeme v System.inheritedChannel()
* a můžeme na něm zavolat accept() a přijmout příchozí spojení.
*
* TODO: kontrolovat návratové hodnoty
*
* @param argc
* @param argv parametry javy (tenhle program používáme místo příkazu java)
* @return
*/
int main(int argc, char**argv) {
struct sockaddr_un address;
char* socket_name = "test.sock"; // TODO: maximální délka (sun_path)
unlink(socket_name);
memset(&address, 0x00, sizeof (address));
address.sun_family = AF_UNIX;
strncpy(address.sun_path, socket_name, sizeof(address.sun_path));
int sock = socket(AF_UNIX, SOCK_STREAM, 0); // proud
//int sock = socket(AF_UNIX, SOCK_DGRAM, 0); // datagramy
bind(sock, (const struct sockaddr*) & address, sizeof (address));
listen(sock, 100);
int newStdOut = dup(0);
dup2(sock, 0);
dprintf(newStdOut, "Socket je na FD 0 a FD %d\n", sock);
dprintf(newStdOut, "STDIN přesunut na FD %d\n", newStdOut);
execv("/usr/bin/java", argv);
}
Program přeložíme a následně ho použijeme místo příkazu java
:
# přeložíme implicitně (bez Makefile):
make start
# spustíme program (místo java InheritedChannelDemo)
./start InheritedChannelDemo
# což to nám vypíše:
# Socket je na FD 0 a FD 3
# STDIN přesunut na FD 4
# Java: startujeme
Chybí tomu ještě pár věcí, ale základní scénář funguje. Program vytvoří UDS a začne na něm naslouchat, pak zduplikuje STDIN na první volný FD a UDS zduplikuje na FD 0. Následně spustí Javu s parametry příkazové řádky. Nedělá se fork, takže Java v paměti nahradí původní céčkovský program. Dále už to běží jako normální javovský proces, ale ve výpisu procesů ji najdeme pod původním názvem ./start
(nepřepsali jsme hodnotu argv[0]
) a původním PID (je to pořád ten samý proces).
Proudové UDS funguje dobře, ale u datagramového bohužel zjistíme, že Java zprávu sice přijme, ale má problém s posíláním odpovědí (což u UDP fungovalo viz příklady s xinetd). Patrně se nepočítá s UDS a adresa klienta se chybně interpretuje jako UDP adresa (IP + port), takže na ni nejde poslat odpověď přes UDS. Jako adresa klienta se objevují nesmyslné kombinace typu: 124.127.0.0:1661
nebo 128.127.0.0:39276
.
Použití System.inheritedChannel()
má tu nevýhodu, že přijdeme o STDIN – FD 0 je obsazený soketem. U serverových aplikací STDIN většinou nepotřebujeme, ale co když budeme chtít aplikaci napojit na UDS a zároveň číst STDIN? Pokud nám rodičovský proces zduplikoval původní STDIN na jiný FD (viz příklad výše), máme šanci se k němu dostat. Také můžeme chtít předat více soketů (což např. systemd umí: sd_listen_fds_with_names) a tam se vyšším číslům FD už nevyhneme. Mimochodem, StandardInput=socket
znamená, že nám systemd dá soket na FD 0 (jinak by byl na FD 3).
Jenže Java je vysokoúrovňový a multiplatformní jazyk, který se nás snaží od operačního systému dost odstínit, takže budeme muset trochu hackovat :-) Samozřejmě by to šlo přes volání nativních funkcí přes JNI nebo JNA, ale pokusíme se to napsat jen v Javě se standardní knihovnou.
Jedno z řešení, na které jsem narazil v diskusích, je: otevřít si v Javě soubor příslušného FD v adresáři /proc/self/fd/
. Viz FileDescriptorDemo – můžeme udělat něco jako:
String fd = "/proc/self/fd/4";
System.out.println("Java: čteme z " + fd);
try (FileInputStream fis = new FileInputStream(fd)) {
int vstup = fis.read();
System.out.println("Java: načteno: " + vstup);
}
To sice funguje, ale – jak nám napoví strace
– dochází k otevření souboru voláním openat()
a vytvoření nového FD (17), ze kterého se pak čte místo toho původního (4), a následně se zavře:
openat(AT_FDCWD, "/proc/self/fd/4", O_RDONLY) = 17 fstat(17, {st_mode=S_IFIFO|0600, st_size=0, ...}) = 0 read(17, "f", 1) = 1 write(1, "Java: na\304\215teno: 102", 19) = 19 write(1, "\n", 1) = 1 close(17) = 0
Z výpisu lsof
zjistíme, že FD 4 i FD 17 ukazují na stejný soubor:
lsof -p $(pidof start)
# java 10673 hacker 4u CHR 136,8 0t0 11 /dev/pts/8
# java 10673 hacker 17r CHR 136,8 0t0 11 /dev/pts/8
Ale není to úplně to, co bychom chtěli – bude nám to fungovat jen v případě, že vstup je terminál, roura (výstup jiného programu) nebo je na vstup přesměrovaný nějaký normální soubor. A ani v těchto případech to není optimální, protože k otevření souboru dojde dvakrát.
Tímhle způsobem nemůžeme pracovat se serverovým (naslouchajícím) soketem ani s navázaným spojením – v takovém případě při pokusu o otevření v Javě dostaneme chybu:
java.io.FileNotFoundException: /proc/self/fd/5 (Takové zařízení nebo adresa neexistuje)
Což je jiná chyba, než když se jen pokoušíme číst neexistující soubor:
java.io.FileNotFoundException: /proc/self/fd/xxx (Adresář nebo soubor neexistuje)
Větší šanci na úspěch máme, když budeme číst skutečně z toho FD, ze kterého číst chceme, a nebudeme se pokoušet znovu otevírat soubor a nový FD. K tomu budeme potřebovat trochu reflexe a drobné porušení pravidel (zavolání privátního konstruktoru):
private static FileDescriptor najdiFD(int fd) throws NoSuchMethodException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
Constructor<FileDescriptor> c = FileDescriptor.class.getDeclaredConstructor(Integer.TYPE);
c.setAccessible(true);
return c.newInstance(fd);
}
Pak můžeme udělat:
int fd = 4;
System.out.println("Java: čteme z FD " + fd);
try (FileInputStream fis = new FileInputStream(najdiFD(fd))) {
int vstup = fis.read();
System.out.println("Java: načteno: " + vstup);
}
a čteme skutečně z FD 4, takže nám to bude fungovat i se soketem (přijatým spojením), což si můžeme vyzkoušet tak, že do céčkového zavaděče přidáme accept(sock, NULL, NULL);
a v Javě nastavíme fd = 5;
. Podobným způsobem bychom se mohli dopracovat i k instanci ServerSocketChannel
viz třída InheritedServerSocketChannelImpl
, a mít tak možnost přijímat nová spojení na předaném předaném na libovolném FD.
Takové řešení je ale poměrně křehké a pokud bychom se k používání neveřejného API uchýlili, museli bychom svůj program důkladně testovat s každou verzí Javy. Nakonec je tedy lepší si napsat vlastní implementaci (nad JNI nebo pohodlnějším JNA), která bude jen vracet vlastní instance standardních javovských rozhraní (proudy, sokety, kanály…), ale nebude záviset na žádném neveřejném API.
Dost bylo hackování, teď se podíváme na nějaká hotová řešení, která podporují předávání soketu rodičovským procesem.
Jetty je webový server resp. servletový kontejner a obvykle se provozuje tak, že naslouchá na TCP portu 8080 nebo jiném, kde odpovídá na HTTP požadavky. My si ho ale nastavíme tak, aby naslouchal na UDS a odpovídal na HTTP požadavky tam.
Jetty má docela hezkou modulární konfiguraci. Modul se skládá z INI souboru a případných XML nebo i JAR souborů. Výchozím bodem je pak soubor start.ini
, ve kterém jsou vyjmenované moduly, které se mají aktivovat, a případně konfigurační parametry ve stylu klíč=hodnota
. Pro naše účely si založíme nový modul UDS, což obnáší vytvoření dvou souborů – modules/uds.mod
:
[description]
Enables a HTTP connector on unix domain socket (UDS).
[tags]
connector
http
[depend]
server
[xml]
etc/jetty-uds.xml
A soubor etc/jetty-uds.xml
, který je kopií původního etc/jetty-http.xml
, akorát jsme z něj odmazali parametry jetty.http.host
a jetty.http.port
a naopak přidali inheritChannel
:
<?xml version="1.0"?>
<!DOCTYPE Configure PUBLIC "-//Jetty//Configure//EN" "http://www.eclipse.org/jetty/configure_9_3.dtd">
<Configure id="Server" class="org.eclipse.jetty.server.Server">
<Call name="addConnector">
<Arg>
<New id="httpConnector" class="org.eclipse.jetty.server.ServerConnector">
<Arg name="server"><Ref refid="Server" /></Arg>
<Arg name="acceptors" type="int"><Property name="jetty.http.acceptors" deprecated="http.acceptors" default="-1"/></Arg>
<Arg name="selectors" type="int"><Property name="jetty.http.selectors" deprecated="http.selectors" default="-1"/></Arg>
<Arg name="factories">
<Array type="org.eclipse.jetty.server.ConnectionFactory">
<Item>
<New class="org.eclipse.jetty.server.HttpConnectionFactory">
<Arg name="config"><Ref refid="httpConfig" /></Arg>
<Arg name="compliance"><Call class="org.eclipse.jetty.http.HttpCompliance" name="valueOf"><Arg><Property name="jetty.http.compliance" default="RFC7230_LEGACY"/></Arg></Call></Arg>
</New>
</Item>
</Array>
</Arg>
<Set name="inheritChannel">true</Set>
<Set name="idleTimeout"><Property name="jetty.http.idleTimeout" deprecated="http.timeout" default="30000"/></Set>
<Set name="acceptorPriorityDelta"><Property name="jetty.http.acceptorPriorityDelta" deprecated="http.acceptorPriorityDelta" default="0"/></Set>
<Set name="acceptQueueSize"><Property name="jetty.http.acceptQueueSize" deprecated="http.acceptQueueSize" default="0"/></Set>
<Get name="SelectorManager">
<Set name="connectTimeout"><Property name="jetty.http.connectTimeout" default="15000"/></Set>
</Get>
</New>
</Arg>
</Call>
</Configure>
V souboru start.ini
teď vypneme modul HTTP a zapneme náš UDS:
# --module=http
--module=uds
Pokud bychom teď server Jetty spustili jen tak ručně, začal by naslouchat na náhodném TCP portu – to proto, že System.inheritedChannel()
by mu nic nevrátil a spadlo by to do výchozího režimu. Když ale Jetty spustíme přes xinetd, systemd nebo náš ručně psaný zavaděč a předáme mu soket na FD 0, Jetty začne naslouchat na něm místo na TCP portu.
Pro spuštění přes systemd poslouží následující konfigurace:
# /etc/systemd/system/jetty.socket:
[Unit]
Description=Jetty
PartOf=jetty.service
[Socket]
ListenStream=/run/jetty
SocketUser=root
SocketGroup=www-data
SocketMode=0660
[Install]
WantedBy=sockets.target
#/etc/systemd/system/jetty.service:
[Unit]
Description=Jetty
Requires=jetty.socket
[Service]
User=hacker
StandardInput=socket
WorkingDirectory=/home/hacker/temp/java-unix-socket/jetty-distribution-9.4.18.v20190429/
ExecStart=/usr/bin/java -jar /home/hacker/temp/java-unix-socket/jetty-distribution-9.4.18.v20190429/start.jar
StandardOutput=syslog
StandardError=syslog
[Install]
WantedBy=default.target
Cesty a uživatele si upravíme podle svého a práva nastavíme tak, aby k soketu mohl jen ten, kdo má. Zde je vidět výhoda deklarativní konfigurace – jednoduše si nastavíme oprávnění. Totéž bychom sice mohli udělat i v případě TCP soketu na localhostu – i tam bychom mohli řídit přístup podle toho, který lokální uživatel se na ten TCP port připojuje, ale je to přeci jen složitější konfigurace na více místech, která se snadněji opomene nebo se v ní udělá chyba (např. službu přesuneme na jiný port, ale konfiguraci iptables/nftables zapomeneme aktualizovat). Navíc firewall pro služby naslouchající jen na localhostu pravděpodobně většina správců vůbec neřeší.
Tomcat je rovněž webový server resp. servletový kontejner a na rozdíl od Jetty ve výchozím stavu naslouchá na několika TCP portech. Nejprve se tedy zbavíme těch, které úplně nutně nepotřebujeme – v souboru conf/server.xml
si nastavíme:
<Server port="-1" shutdown="SHUTDOWN">
tím deaktivujeme port, přes který se dá Tomcat vypnout (textovým příkazem SHUTDOWN
poslaným třeba přes socat
). A dále si zakomentujeme řádek:
<Connector port="8009" protocol="AJP/1.3" redirectPort="8443"/>
Teď už Tomcat naslouchá už jen na jediném portu (8080), což napravíme přidáním useInheritedChannel
:
<Connector
port="8080" protocol="HTTP/1.1"
useInheritedChannel="true"
connectionTimeout="20000"/>
Od teď bude Tomcat padat, pokud mu na FD 0 nepředáme soket, na kterém by měl odpovídat na HTTP požadavky. Spustíme si ho tedy pod systemd – zde můžeme vyjít z konfigurace pro Jetty, ve které jen změníme:
WorkingDirectory=/home/hacker/temp/java-unix-socket/apache-tomcat-9.0.20/
ExecStart=/home/hacker/temp/java-unix-socket/apache-tomcat-9.0.20/bin/catalina.sh run
Tomcat se spustí a dokonce i naslouchá na UDS, ale bohužel po připojení prvního klienta spadne na:
04-Jun-2019 19:38:50.720 SEVERE [http-nio-8080-Acceptor-0] org.apache.tomcat.util.net.NioEndpoint.setSocketOptions Error setting socket options java.net.SocketException: Operace není podporována at sun.nio.ch.Net.setIntOption0(Native Method) at sun.nio.ch.Net.setSocketOption(Net.java:334) at sun.nio.ch.SocketChannelImpl.setOption(SocketChannelImpl.java:190) at sun.nio.ch.SocketAdaptor.setBooleanOption(SocketAdaptor.java:271) at sun.nio.ch.SocketAdaptor.setTcpNoDelay(SocketAdaptor.java:306) at org.apache.tomcat.util.net.SocketProperties.setProperties(SocketProperties.java:204) at org.apache.tomcat.util.net.NioEndpoint.setSocketOptions(NioEndpoint.java:416) at org.apache.tomcat.util.net.NioEndpoint.setSocketOptions(NioEndpoint.java:76) at org.apache.tomcat.util.net.Acceptor.run(Acceptor.java:115) at java.lang.Thread.run(Thread.java:748)
Tomcat se zjevně na UDS soketu snaží nastavit TcpNoDelay
, což jaksi nemůže projít. A nepodařilo se mi to opravit ani přidáním tcpNoDelay="false"
. U Tomcatu jsme tedy s UDS zatím neuspěli. Nicméně si ho můžeme alespoň pustit na privilegovaném portu:
[Socket]
ListenStream=80
Takže to zase tak úplně k ničemu nebylo. Ještě se podívám, co by se s tím dalo dělat, protože úprava Tomcatu, aby podporoval i UDS, by neměla být složitá. Viz Bug 63568.
Máme tedy Jetty nebo jiný HTTP server naslouchající na UDS. Spojení si můžeme vyzkoušet pomocí nástrojů jako socat
, ale z běžného WWW prohlížeče se nepřipojíme. Tento náš server (poskytující např. jednu malou službu) můžeme jednoduše začlenit do naší konfigurace Apache na nějaké cestě nebo subdoméně:
<Location /jetty/ >
ProxyPass unix:/run/jetty|http://localhost/
</Location>
Apache nám tak zprostředkuje komunikaci s Jetty nebo jiným serverem a nepotřebujeme k tomu ani žádné zbytečné TCP porty na localhostu.
Dnes jsme si představili obecné výhody unixových doménových soketů (UDS) a předávání souborových popisovačů (FD) rodičovským procesem potomkovi. Ukázali jsme si, že v Javě můžeme s UDS pracovat i bez dodatečných knihoven – ve zkratce:
System.inheritedChannel()
je v Javě už od verze 1.5Dále jsme se podívali na konfiguraci xinetd a systemd a prakticky si předvedli princip socket activation. Nakonec jsme nakonfigurovali server Jetty tak, aby naslouchal jen na UDS, a zpřístupnili ho přes Apache HTTPD.
Tak v Tomcatu už to taky bude fungovat :-) Viz Allow keeping tcpNoDelay untouched (default).