Docker Swarm e constraint in un mondo reale

di Andrea Zani, in Memo,

Continuo dal post precedente e da questo. Rileggo e cerco di fare autocritica. GRANDI parole avevo usato per descrivere quanto offerto da Docker sia con che senza Swarm. Gli elogi si sprecavano descrivendo con esempi funzionanti quanto fosse possibile. In effetti, come si può non dubitare della bontà di Docker (Swarm) quando ti accorgi che puoi distribuire servizi e altro con semplici comandi da terminale? Come si può non rimanere a bocca aperta(?!), durante le demo, nel vedere come i servizi vengono installati ed eseguiti e come essi, una volta che un host per causa accidentali esce dalla rete, si prenda carico e in piena autonomia riprenda il servizio perso e lo installi su un altro host?

Tutto questo è bello solo se si vive nel mondo delle demo, il mondo reale è un'altra cosa. Sia nel mondo del cloud che quello reale di una semplice web farm, ci sono macchine predisposte a questo o quel servizio (prestazioni CPU, capacità di memoria, capienza e tipo dei dischi). Nulla da eccepire contro la scalabilità di Docker Swarm, ma se ci sono due macchine che fanno da web server e una delle tue dev'essere messa offline per manutenzione, perché Docker si deve permettere di installare lo stesso servizio sulla macchina dove già gira l'altra istanza del web server? O, peggio, perché dovrebbe installare il web server su una macchina su cui vogliamo che giri un database? - Nelle configurazioni reali delle macchine devo fare in modo che alcune, dove girano servizi delicati, come il database, non siano raggiungibile direttamente dall'esterno e altre restrizioni simili.

In questo post voglio proprio trattare questi punti e come si può configurare con una migliore personalizzazione Docker Swarm e, perché no, mettere in luce altre problematiche. Ripartiamo dall'inizio: normalmente si hanno necessità di prestazione e permessi tra le varie macchine dipendentemente dal loro utilizzo. Come scritto sopra, una macchina per il database facilmente avrà bisogno di capienti dischi e restrizioni al limite del paranoico per l'accesso dalla rete; la macchina che esporrà servizi web in internet (un reverse proxy come NGINX), non avrà di certo necessità di dischi capienti, e il minimo di permessi per poter poi distribuire i servizi web presenti nelle altre macchine della rete, e così via... Come si ottiene tutto questo con Docker (Swarm)?

Innanzitutto si possono definire delle label a livello di singola macchina (host). Questo ci permette di filtrare l'assegnazione dei container a macchine predisposte. In questo mio esempio farò in modo di avere più host a cui assegnerò diversi servizi:

  • Nginx
  • Due istante per delle web.api (le stessi viste nei post precedenti)
  • Un ulteriore servizio che sarà utilizzato dalle web api precedenti per simulare una chiamata interna

Innanzitutto ho scritto una semplice web api che ritorna la data e l'ora attuale nel fuso orario UTC. Il codice è disponibile qui. Il tutto si riduce ad un singolo controller:

using System;
using Microsoft.AspNetCore.Mvc;

namespace MVC5ForLinuxTest2.Controllers
{
    [Route("api/[controller]")]
    public class DatetimeUTCController : Controller
    {
        // GET: api/values
        [HttpGet]
        public string Get()
        {
            return DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss");
        }
    }
}

Richiamandolo direttamente con http://localhost:5001/datetimeutc, la risposta sarà:

2016-12-10 12:26:29

Come scritto sopra, questa API simulerà una richiesta interna (non volevo incasinare gli esempi con un database reale), e all'API vista più volte (codice sorgente qui), ho aggiunto un controller SystemInfoUTCController che richiede il DateTime all'API prima vista. Codice per questo controller:

    public class SystemInfoUTCController : Controller
    {
        private readonly ISystemInfo _systemInfo;
        private readonly IHttpHelper _httpHelper;
        private readonly AppSettings _appSettings;

        public SystemInfoUTCController(ISystemInfo systemInfo, IHttpHelper httpHelper, IOptions<AppSettings> appSettings)
        {
            _systemInfo = systemInfo;
            _httpHelper = httpHelper;
            _appSettings = appSettings.Value;
        }

        [HttpGet]
        public async Task<DTOSystemInfoUTC[]> Get()
        {
            DateTime datetimeValue = new DateTime(1970, 1, 1);
            XElement value = await _httpHelper.GetHttpApi(_appSettings.DateTimeUrl);
            var content = value.XPathSelectElement(".");
            if (content != null && !string.IsNullOrEmpty(content.Value))
            {
                datetimeValue = DateTime.Parse(content.Value);
            }

            var obj = new DTOSystemInfoUTC();
            obj.Guid = _systemInfo.Guid;
            obj.DateTimeUTC = datetimeValue;

            return new DTOSystemInfoUTC[] {
                obj
            };
        }
    }

Questa API usa una classe esterna con l'interfaccia IHttpHelper, il cui codice è:

public class HttpHelper : IHttpHelper
{
    public async Task<XElement> GetHttpApi(string url)
    {
        using (var client = new HttpClient())
        {
            try
            {
                client.BaseAddress = new Uri(url);
                var response = await client.GetAsync("");
                response.EnsureSuccessStatusCode(); // Throw in not success

                var stringResponse = await response.Content.ReadAsStringAsync();

                var result = new XElement("Result", stringResponse);
                return result;
            }
            catch (HttpRequestException)
            {
                return new XElement("Error");
            }
        }
    }
}

Ora, richiamando questa API:

http://localhost:5000/api/systeminfoutc

Avremo come risposta:

[{"guid":"d5c3ea31-4049-48f3-bf53-152bd31f29dd","dateTimeUTC":"1970-01-01T00:00:00"}]
[{"guid":"d5c3ea31-4049-48f3-bf53-152bd31f29dd","dateTimeUTC":"2016-12-10T12:45:21"}]

1970-01-01 nel caso la seconda API datetimeutc non fosse attiva, o con la data se è tutto funzionante. Ottimo, ora basta creare come al solito le immagini per Docker (nel codice sorgente c'è il Dockerfile per la loro creazione, o si possono utilizzare quelle pubbliche che ho creato per questi esempi.

E' arrivato il momento di specificare quali macchine Docker Swarm dovrà utilizzare per i vari servizi. Innanzitutto è necessario creare una rete specifica per Docker Swarm:

docker network create --driver overlay mynet

Controllo che sia tutto corretto:

# docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
0f1edcc32683        bridge              bridge              local               
23e41e7b27e5        docker_gwbridge     bridge              local               
ff4d514a96e8        host                host                local               
d10zid5t65cb        ingress             overlay             swarm               
0x9abv9uqtgh        mynet               overlay             swarm               
46cd3ea3cb27        none                null                local

E arrivato il momento della configurazione degli host. Per questi esempi ho creato quattro macchine virtuali:

  • 192.168.0.15 osboxes1 web=true
  • 192.168.0.16 osboxes2 db=true
  • 192.168.0.17 osboxes3 web=true
  • 192.168.0.18 osboxes4 nginx=true

15 e 17 saranno per la web api systeiminfo e systeminfoutc, 16 per datetimeutc; la 18 la tratterò a breve. Per specificare quelle label ci sono vari modi in Docker, quello per me più comodo è modificando il file del servizio di Docker. Il file /lib/systemd/system/docker.service:

[Service]
Type=notify
# the default is not to use systemd for cgroups because the delegate issues still
# exists and systemd currently does not support the cgroup feature set required
# for containers run by docker
ExecStart=/usr/bin/dockerd -H fd://
ExecReload=/bin/kill -s HUP $MAINPID
...

E' sufficiente modificare la riga con ExecStart:

[Service]
Type=notify
# the default is not to use systemd for cgroups because the delegate issues still
# exists and systemd currently does not support the cgroup feature set required
# for containers run by docker
ExecStart=/usr/bin/dockerd -H fd:// --label web=true
ExecReload=/bin/kill -s HUP $MAINPID
...

Aggiunta la giusta dichiarazione delle label per ogni macchina, e riavviato i servizi di Docker, ora possiamo controllare che tutto funzioni con dei semplici comandi da terminale:

# docker node ls -f "label=web"
ID                           HOSTNAME  STATUS  AVAILABILITY  MANAGER STATUS
3ta2im9vlfgrbmsyupgdyvljl    osboxes3  Ready   Active        
83f6hk7nraat4ikews3tm9dgm *  osboxes1  Ready   Active        Leader

# docker node ls -f "label=db"
ID                         HOSTNAME  STATUS  AVAILABILITY  MANAGER STATUS
897zy6vpbxzrvaif7sfq2rhe0  osboxes2  Ready   Active

# docker node ls -f "label=nginx"
ID                         HOSTNAME  STATUS  AVAILABILITY  MANAGER STATUS
002iev7q6mgdor0zbo897noay  osboxes4  Ready   Active

Perfetto, ho il risultato che volevo. Avendo creato le immagini di Docker, ora posso iniziare la loro installazione sulle macchine che voglio io:

docker service create --replicas 1 --constraint engine.labels.db==true --name app1 -p 5001:5001 --network mynet sbraer/aspnetcorelinux:api2

Notare il parametro constraint e replicas. Se tutto è andato a buon fine:

# docker service ps app1
ID                         NAME    IMAGE                        NODE      DESIRED STATE  CURRENT STATE           ERROR
0x6nbwrahtd1x7x31exal4sb8  app1.1  sbraer/aspnetcorelinux:api2  osboxes2  Running        Starting 7 seconds ago

Ottimo, il servizio è attivo e funzionante sulla macchina predisposta.

# docker service ls
ID            NAME  REPLICAS  IMAGE                        COMMAND
7studfb313f7  app1  1/1       sbraer/aspnetcorelinux:api2

E con questo comando effettivamente vedo che è stata avviata solo una istanza di questa web api (ho usato il parametro --replicas 1). Questo ci riporta però al problema menzionato all'inizio di questo post. Nel caso della web api principale che dev'essere istallata su due macchine, che cosa succede se una delle due viene spenta? Docker Swarm installerò una sua copia sulla macchina disponibile (su nessun'altra che non abbia la stessa definizione della label e di constraint). Proviamo ad installarle:

docker service create --replicas 2 --constraint engine.labels.web==true --name app0 -p 5000:5000 --network mynet sbraer/aspnetcorelinux:api1

E se non sapessimo quante macchine abbiamo a disposizione per un servizio?

docker service create --replicas $(docker node ls -f "label=web" -q | wc -l) --constraint engine.labels.web==true --name app0 -p 5000:5000 --network mynet sbraer/aspnetcorelinux:api1

Questo facilita un po' le cose ma non risolve il problema principale. Per risolvere definitivamente il problema è sufficiente spulciare tra i parametri di Docker Swarm e trovare --mode global. Questo parametro installerà il container di Docker su tutte le macchine disponibile nella rete di Docker Swarm, ma con la clausola constraint lo farà solo sulle macchine predisposte:

docker service create --mode global --constraint engine.labels.web==true --name app0 -p 5000:5000 --network mynet sbraer/aspnetcorelinux:api1

In questo modo, Docker Swarm installerà una sola istanza di container per macchina e avremo anche due utili conseguenze: la prima è che se si spegne una macchina non istallerà doppioni inutili, la seconda, ben più importante e utile, è che, per esigenze di carico o altro, ci sarà sufficiente inserire in rete altre macchine con la label di configurazione che vogliamo, perché Docker Swarm istalli altri container del tutto indipendentemente. Ottima cosa!

Ecco cosa è accaduto con il comando precedente:

# docker service ps app0
ID                         NAME    IMAGE                        NODE      DESIRED STATE  CURRENT STATE            ERROR
9d0a7oms384cbli78a0vuwwre  app0.1  sbraer/aspnetcorelinux:api1  osboxes1  Running        Starting 36 seconds ago  
04y5c35orviamoogaetedjh1i  app0.2  sbraer/aspnetcorelinux:api1  osboxes3  Running        Starting 35 seconds ago

Ora ho avviato tutti i servizi principali, controllo che tutto funzioni su tutte le macchine:

# curl localhost:5000/api/systeminfo
[{"guid":"883bc3f9-f636-45f6-a05b-f91a09f95b13","dateTime":"2016-12-10T12:30:33.797293+00:00"}]

# curl localhost:5000/api/systeminfoutc
[{"guid":"d5c3ea31-4049-48f3-bf53-152bd31f29dd","dateTimeUTC":"2016-12-10T12:31:08"}]

Infine due parole sull'accessibilità dei servizi in Docker. Ci sono queste possibili casistiche:

  • Esterno a servizio Docker
  • Docker a servizio esterno
  • Docker a Docker

Il primo caso è quello usato finora: da un browser o da terminale richiamo un servizio esposto all'interno di un container di Docker; in questo caso è necessario che, quando è avviato, Docker esponga le porte di nostro interesse (5000 nel caso della API principale di questi esempi, 5001 quella per il DateTime UTC) e per richiamarlo è sufficiente usare l'IP di una qualsiasi macchina (se siamo in Docker Swarm e il container è avviato come servizio). Il secondo caso non è preso in considerazione nei miei esempi perché è il più semplice e non comporta alcun problema: se la nostra API avesse avuto bisogno di un database come SQL Server installato su un server esterno, la stringa di connessione sarebbe la classica e non ci sarebbero stati problemi. L'ultimo caso è il più complesso da comprendere all'inizio; le regole sono come quelle del primo caso ma l'utilizzo dell'IP comporterebbe dei problemi perché ogni container vive come se fosse in una macchina a sé; la soluzione più semplice è usare il suo name in modo che, nel caso sia un servizio distribuito via swarm non dovremo preoccuparci di controllare quale e se quella macchina con quel servizio è attiva. Nel file di configurazione della web API systeminfoutc ho definito l'URL della API da richiamare:

"AppSettings": {
    "DateTimeUrl": "http://app1:5001/api/DatetimeUTC"
  }

Ogni servizio avviato in Docker Swarm sarà visibile agli altri Container in esecuzione; per un controllo più granulare nulla ci vieta di create più reti in Docker con alcuni punti di accesso e condivisioni. In questo caso - lo ammetto - non ho trovato personalmente vantaggi dalle semplici prove fatte. Va be', taglio corto, siamo arrivati al punto che dovremo in qualche modo esporre la web api, e solo lei, su internet. E qui entra in tutto questo la macchina su cui installeremo NGINX. Per chi non lo conoscesse è un web server/reverse proxy molto conosciuto e utilizzato sul web per le sue prestazioni. La sua configurazione è semplice. Nel mio caso, per esporre le due API principali (che rispondono alla porta 5000) userò questo file di configurazione (dotnet.conf):

worker_processes 1;

events { worker_connections 1024; }

http {
    upstream web-app {
        server app0:5000;
    }

    server {
      listen 80;

      location / {
        proxy_pass http://web-app;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection keep-alive;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
      }
    }
}

Notare le definizione del servizio che risponde alla porta 5000: app0. Questo porta subito a capire che eseguirò NGINX da Docker. Inoltre creerò un container apposito con questo Dockerfile:

FROM nginx
COPY dotnet.conf /etc/nginx/nginx.conf
EXPOSE 80

Creata l'immagine ora potrò avviarla:

docker service create --mode global --network mynet -p 80:80 --name nginx --constraint engine.labels.nginx==true sbraer/nginx

Se tutto funziona, ora potrò richiamare l'API con:

# curl localhost/api/systeminfo
[{"guid":"d5c3ea31-4049-48f3-bf53-152bd31f29dd","dateTime":"2016-12-10T12:37:54.555623+00:00"}]
# curl localhost/api/systeminfoutc
[{"guid":"883bc3f9-f636-45f6-a05b-f91a09f95b13","dateTimeUTC":"2016-12-10T12:38:01"}]

Nel mondo reale ora si dovrebbe blindare la rete in modo che non sia accessibile dall'esterno se non per la porta 80 della macchina su cui gira NGINX e rifare la prova:

# curl http://192.168.0.18/api/systeminfo
[{"guid":"d5c3ea31-4049-48f3-bf53-152bd31f29dd","dateTime":"2016-12-10T12:39:52.582317+00:00"}]

Per prova, stoppiamo il servizio interno:

docker service rm app1

Nuovo test:

#curl http://192.168.0.18/api/systeminfoutc
[{"guid":"d5c3ea31-4049-48f3-bf53-152bd31f29dd","dateTimeUTC":"1970-01-01T00:00:00"}]

Come scritto prima per le modalità di accesso ai vari servizi da/a Docker, se avessimo voluto installare NGINX direttamente su una macchina, il file di configurazione avrebbe dovuto puntare direttamente a tutti gli IP che espongono la web API:

    upstream web-app {
        server 192.168.0.15:5000;
        server 192.168.0.17:5000;
    }

In questo caso aggiunte di altre macchine destinate a questo servizio comporterebbe la modifica manuale di questo file, cosa che non accadrebbe nel caso precedente.

Arrivato a questo punto vediamo alcuni punti importanti: innanzitutto, con la versione attuale di Docker Swarm (1.12 e 1.13) non è possibile distribuire immagini create in locale; è obbligatorio che le immagini sia prese da un hub ufficiale o meno (nei miei esempi ho caricato tutte le immagini di Docker nell'hub ufficiale). Per chi non volesse rendere pubbliche le proprie creazioni (per qualsiasi motivo) è possibile utilizzare hub che permettono il caricamento anche di immagini protette, oppure, più semplice, è possibile crearsi un registry server in casa senza problemi (qui la documentazione). Infine... che dire? Eh lo so, non ho ancora menzionato i problemi... Il problema più fastidioso? Quando si distribuisce su più macchine le istanze di un servizio, non è possibile avere dettagli sull'IP o un modo diretto per raggiungere una determinata istanza di quel servizio. Sembra da poco, ma è un problema grave perché il sistema fin qui descritto funziona alla perfezione su servizi che possono essere scalati indipendentemente l'uno dall'altro ma non per quei servizi che devono essere configurati e collegati. Che cosa voglio dire? Se volessi distribuire, come ho fatto per l'esempio qui sopra, un database su più macchine con Docker Swarm, come mi dovrei comportare se poi le volessi configurare per un cluster con replica?

Confesso di aver fatto molte prove in merito, ma con la versione attuale di Docker Swarm la soluzione automatica non esiste - o quel database o altro servizio è predisposto o ci sarà poco da fare. Se voglio forzatamente usare Docker Swarm, per ogni macchina in cluster per quel database dovrò creare immagini mirate con file di configurazione differenziato. La cosa funziona, ma dagli applicativi che necessitano l'accesso ci si dovrò accertare di avere stringhe di connessione che permettano la definizione di più server. La soluzione più umana che ho trovato, è usare singole istanze di Docker collegate come visto qui con Consul (ed ecco spiegato il motivo di quel post).

Ed è finito pure il 2016.

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese