Gli esempi di immagini Docker utilizzate come singolo servizio in modalità testo non mancano. Non è difficile trovare tutorial che spieghino come costruire immagini che contengano Apache, Mysql, WordPress e molti altri software di uso comune che comunichino con l’esterno tramite porte HTTP.
Cosa succede se ho invece bisogno di pacchettizzare un programma che fa dell’output grafico? La cosa può sembrare futile e lontana dalla filosofia di Docker, ma pensiamo alla possibilità di costruire immagini pronte all’uso che contengano, ad esempio
- Un ambiente di sviluppo già configurato (JDev, IntelliJ, PhpStorm)
- Un client già configurato (SQL Developer, PGAdmin3, …)
- Qualche utility pronta per essere usata, ad esempio uno sniffer di rete o grub-customizer
Come si fa a costruire una simile immagine? L’intuito ci dice che, come minimo sindacale, il programma va installato e lanciato.
Proviamoci.
Ops … ma cosa installiamo? Per unire l’utile al dilettevole, invece di un noioso ambiente di sviluppo installeremo Frozen Bubble. Anche perché, in questo modo, se il capo mi scopre a giocarci posso sempre dire che sto testando gli script del mio articolo.
Ambiente di esecuzione
L’ambiente di esecuzione del tutorial è il seguente (ambienti diversi potrebbero richiedere piccoli adattamenti dei comandi):
docker -v
Docker version 18.03.1-ce, build 9ee9f40
lsb_release -a
LSB Version: :core-4.1-amd64:core-4.1-noarch
Distributor ID: Fedora
Description: Fedora release 27 (Twenty Seven)
Release: 27
Codename: TwentySeven
Creazione di una immagine di Frozen Bubble
La prima cosa da fare è crearci un’immagine che contenga il nostro programma. Consultiamo la documentazione, troviamo un container di base adeguato e costruiamo un Dockerfile per l’installazione. Il risultato è il seguente file, che chiameremo Dockerfile.V1
FROM ubuntu:16.04
RUN apt-get update
RUN apt-get install -y frozen-bubble
CMD /usr/games/frozen-bubble
Molto semplice tranne eventualmente l’istruzione CMD che avvia il tutto. La determiniamo consultando la documentazione del programma, oppure lanciando un’istanza dell’immagine e cercando l’eseguibile con una find, oppure controllando l’elenco dei files contenuti nel pacchetto.
Costruiamo l’immagine con il comando
docker build -t guiv1 -f Dockerfile.V1 .
(Attenzione al punto in fondo!) e lanciamola fiduciosamente con
docker run guiv1
Dopo alcune righe di output occupate dal copyright di Frozen Bubble compare un errore
[SDL Init] Error opening terminal: unknown.
Houston, abbiamo un problema.
Per risolverlo è necessario capire cosa ci sta bloccando. La cosa più probabile è che il programma non sappia dove e come visualizzare la propria schermata grafica, visto che non abbiamo configurato nulla a riguardo. Quando configuriamo un server con Docker, ci preoccupiamo di configurargli un posto dove scrivere i propri dati ed una o più porte con le quali comunicare col mondo esterno. Ma per un programma grafico? Per capirlo dobbiamo approfondire (pochissimo, lo prometto!) come funziona la grafica in Linux/Unix
Il server X
Sotto Linux/Unix i programmi grafici sono gestiti da X Window, un sistema che permette di eseguire interfacce grafiche in maniera distribuita. La cosa curiosa, e spesso fonte di confusione, è che si tratta di un sistema Client/Server “rovesciato”. Il computer remoto contiene l’applicazione grafica (un browser, Eclipse, Frozen Bubble) che fa richieste in qualità di client al server di gestione dei dispositivi, che si occupa di gestire l’I/O su video, mouse e tastiera e gira sulla nostra macchina. Queste richieste sono del tipo “disegna un rettangolo in una certa posizione”, “colora quest’area con questo colore di sfondo”, “accendi questo pixel” e via dicendo.
Quindi il client è la macchina “lontana”, il server quella “vicina” (in generale, quella su cui facciamo I/O). Questo protocollo, che naturalmente è un po’ più complesso di come lo stiamo descrivendo, permette di separare l’esecuzione di un programma dal suo rendering a video, per cui posso avere un programma eseguito remotamente e visibile in locale, a condizione di eseguire localmente (di qui la confusione) la parte server del protocollo.
Il seguente diagramma esplicita l’architettura.
La cosa funziona anche sotto Windows, esistono infatti diverse implementazioni del server X che permettono di visualizzare in finestra Windows programmi eseguiti in remoto su una macchina Linux.
Ma come posso configurare questa comunicazione fra client e server X? Ci sono parecchie possibilità, partiremo dalla più semplice.
Il minimo necessario per instaurare una comunicazione fra client e server è dato da:
- Una qualche forma di canale di comunicazione, sul quale verranno convogliati i comandi del protocollo X. E’ evidente che server e client X devono poter comunicare
- Il client deve sapere quali sono i dispositivi di I/O che può usare e su quale macchina li trova
Per quanto riguarda il primo problema, server e client X leggono e scrivono per default su un socket domain Unix, posizionato in /tmp/.X11-unix/. Ma noi che viviamo a pane e Docker sappiamo che possiamo utilizzare i bind mount volumes per montare singoli files (e quindi anche socket). Cosa succede se montiamo la /tmp/.X11-unix/ della macchina locale su quella del container? Forse useremo il nostro server X per intercettare i comandi grafici richiestici dal container?
Si, ma non basta. Qui entra in gioco l’altra componente di configurazione. Come faccio a sapere quali dispositivi fisici usare? Linux utilizza una variabile di ambiente chiamata DISPLAY che, semplificando un pochino, definisce una terna tastiera/mouse/schermo da usare per l’I/O. Se provate a visualizzarla, vedrete probabilmente che è della forma :0:0 e via dicendo … ovvero zeri. Lo zero rappresenta il dispositivo di default, quindi tipicamente l’hardware “fisico” della macchina.
Quindi se oltre a collegare il socket del server X del container con quello della mia macchina dico al container di usare i miei dispositivi di I/O condividendo la variabile di ambiente DISPLAY … cosa succede?
Proviamo!
docker run –env=”DISPLAY” –volume=”/tmp/.X11-unix:/tmp/.X11-unix:rw” guiv1
Non funziona, ma il messaggio di errore è cambiato.
[SDL Init] No protocol specified
No protocol specified
No protocol specified
Segmentation fault (core dumped)
Il motivo è dovuto al fatto che il processo client non ha l’accesso al server X (chi ha accesso al server X controlla l’hardware di I/O della macchina, per cui è normale che ci siano delle misure di sicurezza).
Il modo più rapido (e pericoloso!) di risolvere il problema è permettere a TUTTI l’accesso al nostro display:
xhost +
L’output non è dei più rassicuranti:
access control disabled, clients can connect from any host
Ma almeno ci conferma che ora il nostro programma potrà connettersi.
Proviamo a rilanciare il container. Si apre per un momento la finestra grafica, che poi però scompare. Non funziona, ma è cambiato ancora l messaggio di errore.
…
ALSA lib conf.c:4771:(snd_config_expand) Evaluate error: No such file or directory
ALSA lib pcm.c:2266:(snd_pcm_open_noupdate) Unknown PCM default
…
L’impressione è che ci sia qualche problema nell’inizializzazione del suono. Potremmo stare qui a configurare ALSA e montare la scheda audio del dispositivo fisico sul container, ma è un’attività sistemistica che lasciamo per esercizio (è la scusa per dire che non ci sono riuscito). Conviene per il momento far partire Frozen Bubble con il suono disabilitato, anche perché così il capo non mi sente durante i test dello script. Per ottenere questo risultato, aggiungiamo all’ultima istruzione del Dockerfile.V1 il parametro –no-sound.
FROM ubuntu:16.04
RUN apt-get update
RUN apt-get install -y frozen-bubble
CMD /usr/games/frozen-bubble –no-sound
Ricompiliamo e rilanciamo il container che si avvia e possiamo finalmente goderci la prima partita! (Ops! Capo in arrivo! Dov’è il tasto Boss?)
Graficamente, possiamo rappresentare la soluzione nel modo seguente
Al termine della meritata partita ripristiniamo la sicurezza del server con il comando
xhost –
E possiamo verificare che il container non parte più.
Ci sono delle possibili varianti, nel senso che non siamo obbligati ad abilitare proprio il mondo… ma tutte si basano sempre sul permettere ad altri l’accesso ai nostri dispositivi, che potrebbero essere catturati ed usati da remoto
Vediamo quindi soluzioni più sicure.
Avvio con utente condiviso
L’idea consiste nel fatto che vi è un parallelismo fra gli utenti della macchina fisica e quelli del container Docker, parallelismo sfruttabile facendo girare i processi con lo stesso user ID da entrambe le parti. Così facendo, l’utente del container disporrà dei permessi richiesti senza dover necessariamente rendere la nostra macchina meno sicura.
Detto in altre parole, se ho un utente con id 1000 sul container e un altro con lo stesso id sulla macchina fisica, l’utente che gira sul container avrà gli stessi diritti dell’analogo utente sulla macchina fisica quando richiederà operazioni che richiedono permessi sulla macchina fisica. Ad esempio, se l’utente con id 1000 della macchina fisica ha accesso all’hardware definito dalla variabile DISPLAY, questo accesso sarà consentito all’utente con id 1000 del container.
A questo scopo, dobbiamo per prima cosa identificare i nostri uID e gID tramite il comando
id
L’output sarà una cosa del tipo
uid=1000(maurino)
gid=1000(maurino)
gruppi=1000(maurino),10(wheel),982(docker)
contesto=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
che ci conferma che l’utente maurino ha uid e gid uguali a 1000.
A questo punto modifico il mio Dockerfile per creare un utente con gli stessi id, ed usarlo come utente di default:
Il nuovo Dockerfile, che chiameremo Dockerfile.V2 è il seguente (in rosso le aggiunte)
FROM ubuntu:16.04
RUN apt-get update
RUN apt-get install -y frozen-bubble
RUN groupadd -g 1000 maurino
RUN useradd -d /home/maurino -s /bin/bash -m maurino -u 1000 -g 1000
USER maurino
ENV HOME /home/maurino
CMD /usr/games/frozen-bubble –no-sound
Compiliamo e lanciamo il programma
docker build -t guiv2 -f Dockerfile.V2 .
docker run –env=”DISPLAY” –volume=”/tmp/.X11-unix:/tmp/.X11-unix:rw” guiv2
e verifichiamo che funziona. Questa seconda versione è più sicura, il rovescio della medaglia è che occorre personalizzare l’immagine con l’utente desiderato, oppure creare un utente extra sulla macchina che lancia il container?
Accesso con le proprie credenziali
Una terza soluzione è quella di utilizzare le proprie credenziali per accedere al server X, “montando” i propri files di configurazione in sostituzione di quelli del container:
docker run -it –user=$USER –env=”DISPLAY”
–volume=”/etc/group:/etc/group:ro”
–volume=”/etc/passwd:/etc/passwd:ro”
–volume=”/etc/shadow:/etc/shadow:ro”
–volume=”/etc/sudoers.d:/etc/sudoers.d:ro”
–volume=”/tmp/.X11-unix:/tmp/.X11-unix:rw”
guiv2
Questa configurazione è più sicura e più flessibile, perché non mi impone di creare l’utente del container in un certo modo, ma utilizza l’utente della macchina host.
Conclusione
Abbiamo visto tre modi diversi per separare il client ed il server X, nel caso in cui il client giri su un container. L’idea di base consiste nel condividere il socket di comunicazione e le variabili che descrivono l’hardware da utilizzare. La soluzione “as is” ha gravi implicazioni di sicurezza, che possono essere mitigate fissando l’utente autorizzato ad accedere alle risorse sul container, oppure montando sul container i propri files di configurazione.
La soluzione preferibile sarebbe quella di veicolare il protocollo X tramite SSH, ma si tratta di un’attività di configurazione complessa e che poco aggiungerebbe alle nostre conoscenze di Docker, per cui non la tratteremo.
Approfondimenti e note
Alcuni link utili, a partire dai quali è stato assemblato questo tutorial
Protocollo X-Windows
https://en.wikipedia.org/wiki/X_Window_System_protocols_and_architecture
Cartella /tmp/.X11-unix/
https://unix.stackexchange.com/questions/196677/what-is-tmp-x11-unix
Variabile $DISPLAY
https://askubuntu.com/questions/432255/what-is-the-display-environment-variable
Comando xhost
https://www.computerhope.com/unix/xhost.htm
SSH Server per Windows con terminale
https://mobaxterm.mobatek.net/