Development

Ottimizzare la distribuzione su più data center usando il registro Docker

Abbiamo introdotto servizi di distribuzione come contenitori Docker per offrire un’interfaccia di distribuzione omogenea e standardizzata.

April 25 2016

USARE DOCKER PER DISTRIBUIRE SERVIZI IN PRODUZIONE

Infobip esegue più di 400 servizi su 6 diversi data center. Questi servizi vengono distribuiti dai team di distribuzione ad intervalli di pochi minuti, per un totale che prevede al momento 90 distribuzioni al giorno.

Abbiamo introdotto servizi di distribuzione come contenitori Docker per offrire un'interfaccia di distribuzione omogenea e standardizzata: i team possono impacchettare i loro servizi come immagini Docker e distribuirli allo stesso modo, indipendentemente dalla tecnologia o dal linguaggio utilizzato per crearli.

Sapendo bene che le immagini Docker possono essere notevolmente pesanti (centinaia di megabyte) abbiamo compreso da subito che sarebbe stata una sfida distribuire immagini Docker in modo efficiente su tutti i data center.

DISTRIBUIRE CON EFFICIENZA IMMAGINI DOCKER DEI SERVIZI SU TUTTI I DATA CENTER

Per distribuire un servizio impacchettato come immagine Docker come contenitore Docker su un data center, utilizziamo docker pull per scaricare immagini Docker su quella macchina ed eseguire il contenitore.

Archiviamo le immagini Docker delle nostre applicazioni in un registro Docker privato centrale. Come registro utilizziamo Artifactory, la scelta più ovvia, dato che lo usiamo già per archiviare tutti gli altri artefatti.

Potremmo eseguire docker pulldirettamente dall'archivio centrale Artifactory Docker per ogni distribuzione, ma il trasferimento della stessa immagine Docker su diverse macchine porterebbe a un notevole sovraccarico di dati sulla rete dei data center.

Ad esempio: l'immagine Docker di un'applicazione Infobip Java consiste di due livelli: un livello di base di circa 160MB (alpine linux + oracle jdk installati) e un livello di applicazione di circa 60MB (app Spring Java).

Per distribuire la stessa app su 10 diverse macchine dello stesso data center, nel migliore dei casi dovremmo trasferire tra i data center 10x60MB di dati (dall'archivio centrale alle macchine nei data center remoti). E questo scenario presuppone che la macchina di arrivo abbia già l'immagine Docker Java di base.

Un tale sovraccarico della rete può essere minimizzato usando i registri Docker dei data center locali come cache proxy dell'archivio Docker centrale privato.

Se nello stesso scenario (10 macchine) usiamo il registro Docker come cache proxy, è necessario trasferire tra i data center solo il livello di applicazione Docker da 60MB. Le altre 9 macchine otterranno il livello dell'app dalla cache del registro locale (presupponendo che le 10 macchine siano distribuite in modo sequenziale, come nel nostro caso).

In alternativa, potremmo creare in qualche modo un flusso personalizzato di esportazione-trasferimento-cache-importazione dell'immagine Docker, ma abbiamo deciso di provare il registro open source Docker, che ha integrata la funzionalità di agire da proxy e da cache.

PORTARE IL REGISTRO DOCKER A FARE UN GIRO

Eseguire un registro Docker privato è semplice e immediato, come descritto di seguito: https://docs.Docker.com/registry.

Neanche configurare un registro Docker in modo che agisca da cache proxy è complicato, ed esiste una guida per farlo, così siamo riusciti a far partire rapidamente il registry mirror Docker in modalità "Pull through cache" solamente per scoprire se non funziona come avevamo sperato :(

Come spiegato nel documento, al momento non è possibile eseguire il mirroring di un altro registro privato:

Dato che l'idea di risparmiare larghezza di banda e ridurre il tempo di distribuzione ci era piaciuta molto, eravamo determinati a trovare una soluzione all'assenza di questa funzione nel registro Docker.

RIMBOCCARSI LE MANICHE

Artifactory espone l'API Docker per dialogare con ciasciun archivio Docker che ospita. Per avere i tag disponibili delle immagini busybox nell'archivio locale Docker Artifactory, ad esempio, possiamo inviare questa richiesta:

$ curl -u mzagar http://artifactory:8081/artifactory/api/docker/docker-local/v2/busybox/tags/list
Enter host password for user 'mzagar':
{
  "name" : "busybox",
  "tags" : [ "latest" ]
}

Dato che il registro Docker al momento può solo eseguire il mirroring dell'Hub Docker pubblico centrale, abbiamo pensato di intercettare ogni richiesta HTTP inviata dal mirror del registro Docker all'URL del registro Docker remoto e riscriverla in modo che si adattasse all'URL del nostro API Docker Artifactory.

In sostanza, volevamo che il mirror del registro Docker pensasse di parlare con l'Hub Docker centrale, mentre invece stava dialogando con il nostro archivio Docker Artifactory.

LA MAGIA DI HAPROXY

Per riscrivere la richiesta HTTP inviata dal registro Docker al registro centrale Docker abbiamo usato HAProxy, ovviamente eseguendolo come contenitore Docker.

Questa configurazione HAProxy spiega come eseguire la riscrittura:

haproxy.cfg

global
  log 127.0.0.1 local0
  log 127.0.0.1 local1 notice
 
defaults
  log global
  mode http
  option httplog
  option dontlognull
  option forwardfor
  timeout connect 5000ms
  timeout client 60000ms
  timeout server 60000ms
  stats uri /admin?stats
 
frontend docker
  bind *:80
  mode http
  default_backend artifactory

backend artifactory
  reqirep ^([^\ ]*)\ /v2/(.*) \1\ /artifactory/api/docker/docker-local/v2/\2
  http-request add-header Authorization Basic\ %[env(BASIC_AUTH_PASSWORD)]
  server artifactory ${ARTIFACTORY_IP}:${ARTIFACTORY_PORT} check

Ogni richiesta /v2/* inviata dal mirror Docker viene riscritta su /artifactory/api/docker/docker-local/v2/* e inviata al nostro server Artifactory.

Utilizziamo variabili ambientali per specificare l'IP e la porta Artifactory e aggiungere header fissi di autorizzazione e autenticarci come utenti Artifactory validi.

Questa configurazione espone anche le statistiche HAProxy per front end e back end, dandoci così modo di verificare che Artifactory sia live e accessibile e di conoscere la quantità di traffico generata dal mirror.

FARE IL FILO AL REGISTRO DOCKER

Ora dovevamo fare in modo che il registro Docker dialogasse con HAProxy, che avrebbe poi instradato la richiesta HTTP nel modo e verso la destinazione scelta da noi. Per farlo abbiamo usato docker-compose. Ecco l'aspetto dal file completo docker-compose.yml:

docker-compose.yml

haproxy:
  image: haproxy:latest
  container_name: hap
  restart: always
  ports:
    - 80:80
  volumes:
    - ${WORK}/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg
  environment:
    - ARTIFACTORY_IP=${ARTIFACTORY_IP}
    - ARTIFACTORY_PORT=${ARTIFACTORY_PORT}
    - BASIC_AUTH_PASSWORD=${BASIC_AUTH_PASSWORD}
  log_driver: "json-file"
  log_opt:
    max-size: "10m"
    max-file: "10"

mirror:
  image: registry:2.4.0
  container_name: registry
  restart: always
  ports:
    - 5000:5000
  volumes:
    - ${WORK}/docker-registry:/var/lib/registry
    - ${REGISTRY_CERTIFICATE_FOLDER}:/certs
  environment:
    - REGISTRY_HTTP_TLS_CERTIFICATE=${REGISTRY_HTTP_TLS_CERTIFICATE}
    - REGISTRY_HTTP_TLS_KEY=${REGISTRY_HTTP_TLS_KEY}
  command: serve /var/lib/registry/config.yml
  links:
    - haproxy:haproxy
  log_driver: "json-file"
  log_opt:
    max-size: "10m"
    max-file: "10"

Usiamo molte variabili ambientali per essere flessibili al momento di avviare la coppia di applicazioni. Compose si aspetta che haproxy.cfg in una cartella HAProxy che il registro Docker config.yml sia nella cartella docker-registry relativa alla cartella WORK specificata.

Utilizzando un semplice script bash possiamo far avviare docker-compose con tutte le variabili ambientali necessarie:

run-mirror.sh

#!/bin/sh

export WORK=`pwd`
export ARTIFACTORY_IP='10.10.10.10'
export ARTIFACTORY_PORT='8081'
export REGISTRY_CERTIFICATE_FOLDER='/etc/ssl/example'
export REGISTRY_HTTP_TLS_CERTIFICATE='/certs/cert.pem'
export REGISTRY_HTTP_TLS_KEY='/certs/cert.pem'
export BASIC_AUTH_PASSWORD='basicauthbase64encodedstring=='

docker-compose up --force-recreate

Dopo aver avviato docker-compose, il nostro mirror è disponibile su mirror.ib-ci.com:5000 - per prima cosa verifichiamo i contenuti:

$ ./run-mirror.sh

$ curl https://mirror.example.com:5000/v2/_catalog
{"repositories":[]}

Come ci aspettavamo, la cache del mirror è vuota. Ora estraiamo l'immagine busybox dall'archivio Docker centrale e misuriamo quanto tempo occorre per scaricare l'immagine:

$ time docker pull mirror.example.com:5000/busybox
Using default tag: latest
latest: Pulling from busybox
9d7588d3c063: Pull complete
a3ed95caeb02: Pull complete
Digest: sha256:000409ca75cd0b754155d790402405fdc35f051af1917ae35a9f4d96ec06ae50
Status: Downloaded newer image for mirror.example.com:5000/busybox:latest
real	0m 3.66s
user	0m 0.02s
sys	0m 0.00s

Il primo download dell'immagine ha richiesto circa 3,5 secondi.

Come vediamo, l'immagine busybox è ora archiviata nella cache del mirror Docker:

$ curl https://mirror.example.com:5000/v2/_catalog
{"repositories":["busybox"]}

Rimuoviamo l'immagine busybox locale ed estraiamola nuovamente: ci aspettiamo che la seconda estrazione venga eseguita più velocemente della prima, dato che l'immagine è nella cache e il mirror non deve recuperarla dall'archivio centrale:

$ docker rmi mirror.example.com:5000/busybox
Untagged: mirror.example.com:5000/busybox:latest
Deleted: sha256:a84c36ecc374f680d00a625d1f0ba52426a536775ee7277f21728369dc42499b
Deleted: sha256:1a879e2f481d67c4537144f80f5f6d776542c7d3a0bd7721fdf6aa1ec024af24
Deleted: sha256:a193ed10c686545c776af2bb8cfe20d3e5badf5c936fbf0e8f389d769018a3f9

$ time docker pull mirror.example.com:5000/busybox
Using default tag: latest
latest: Pulling from busybox
9d7588d3c063: Pull complete
a3ed95caeb02: Pull complete
Digest: sha256:000409ca75cd0b754155d790402405fdc35f051af1917ae35a9f4d96ec06ae50
Status: Downloaded newer image for mirror.example.com:5000/busybox:latest
real	0m 0.28s
user	0m 0.01s
sys	0m 0.00s

Evviva! Dato che l'immagine era già nella cache locale del registro, la seconda estrazione ha richiesto solamente 0,28 secondi!

CONCLUSIONI

Siamo riusciti a configurare istanze cache del registro mirror Docker locale in ciascun data center remoto per eseguire il mirroring del registro Docker Artifactory privato centrale, e ottimizzato la quantità di dati da trasferire tra i data center per distribuire le nostre applicazioni.

Il processo potrebbe essere ancora ottimizzato notevolmente, ma l'obiettivo principale è già stato raggiunto: abbiamo ottimizzato la distribuzione di un'immagine Docker su data center remoti in un modo relativamente semplice e trasparente.

Continueremo a migliorare il processo di distribuzione Docker ogni giorno per offrire ai nostri team di sviluppo un'esperienza di distribuzione priva di problemi.

(Mario Zagar, Senior Software Architect / Division Lead)