Automatyczne reverse proxy dla farmy Docker Swarm

Gdy już wkroczymy, cali na biało, w krainę automatycznych, autoskalujących się mikroserwisów które miały być dla nas ziemią obiecaną, podobnie jak w prawdziwym świecie, okaże się, że czyhają tam na nas problemy które mogą wybuchnąć w każdej chwili… Wyobraźcie sobie, taką sytuację – macie aplikację składającą się z jakiś baz danych, repozytoriów plików czy cokolwiek tam chcecie. Oczywiście macie tam również kontenery aplikacyjne z Waszym wypieszczonym programem który pozwala na wystawianie danych z rzeczonych baz poprzez cukierkowe, słodziutkie GUI. Na końcu stoi natomiast load balancer, który pilnuje, że jeżeli któryś z kontenerów zemrze to on zawsze będzie mógł pakować ruch do tego który się ostał.

Mija kilka miesięcy, aplikacja działa, kontenerki sprawują się wyśmienicie. Jednakże nadchodzi dzień sądu i Twoja firma wypuszcza nowy produkt, przychodzi okres przedświąteczny, nieważne,  a usery zaczynają walić do Was drzwiami i oknami. Jesteś przygotowany na tą okazje, zakładasz spodnie rurki, przyczesujesz włos na lewo i na najnowszym iPadzie skalujesz swoją aplikację poprzez powołanie kolejnych kontenerów. Jednak ku swojemu ogromnemu zdziwieniu stwierdzasz, że coś poszło nie tak, zapomniałeś o load-balancerach, które nie wiedzą że właśnie ktoś powołał dodatkowe serwery aplikacyjne, a one bezmyślnie wciąż ładują ruch do dwóch pierwotnych. Jak to naprawić?

Inny przypadek to promocja jakiegoś pre-prod na live. Naszą najnowszą instancję z wersją 2.0 aplikacji chcemy najpierw posadowić na farmie kontenerów, gruntownie przetestować , a następnie (gdy przejdzie wszystkie testy) puścić na produkcję. Dobrze też gdyby można było jednocześnie trzymać dla bezpieczeństwa poprzednią wersję (1.9) jeszcze przez jakiś czas. W każdej chwili chcemy też mieć możliwość szybkiego powrotu do 1.9 gdyby przypadkiem wersja 2.0 okazała się zbyt nowoczesna i przez to np. nie za bardzo stabilna. Ogólnie ideałem dla nas byłby model posiadania dwóch domen „stary.sklep.pl” i „nowy.sklep.pl”, a jeszcze lepiej kolejnych N wersji.

W celu swobodnego żonglowania środowiskami na farmach dockerowych wykorzystamy mechanizm automatycznych reverse-proxy zespawany z sygnałami Docker Events. Jeśli zapniemy się do Dockera (lub całego Swarma) z poleceniem “events” to np. przy wyłączaniu jakiegoś kontenera otrzymujemy dosyć obfite komunikaty. W poniższym przykładzie zapiąłem się akurat do Swarma ale to nie robi różnicy, identycznie wygląda to gdy zapniemy się do pojedynczego węzła z silnikiem Dockera:

root@gmbUbuntu11:~# docker -H tcp://10.3.0.7:5000 events

[stop jakiegoś kontenera...]

2016-04-07T07:18:24.023437507Z container kill c8c2a66e02e25a3c654cbdc80146081956d8d3f490def2414e71c06f3726ff0a (node.addr=10.3.0.9:2376, node.id=2BQM:VUX7:FDYU:7VU2:3HCL:IXJ4:ZGWT:BKHG:RBWS:RG7T:VSLL:YQ6A, node.ip=10.3.0.9, node.name=gmbUbuntu13, com.docker.swarm.constraints=["node==gmbUbuntu13"], com.docker.swarm.id=452a1b314c58bf26dbb741a40f8d54dba6309becd43b1e447d31f3d27bd08a6f, image=ubuntu_z_apache_ex8080, name=apa_exp_05)

Przy użyciu tegoż właśnie mechanizmu sterowane są te wszystkie zajebiste obrazy nginx-rev-proxy, haproxy itd. Metod na automatyczne rev-proxy jest wiele, ale omówię dziś poniższą, gdyż wydaje mi się najlepsza:

W tym przykładzie stosowany jest image jwilder/nginx-proxy. Najpierw na węźle który ma być bramą tcp/80 ze świata do naszej infrastruktury (u mnie jest to GmbUbuntu09) startujemy nginx reverse proxy z opcją wykrywania zmian i auto-generacją-nginx-conf. Klaster użyty w przykładzie jest sterowany Swarmem znajdującym się na 10.3.0.7 na porcie 5000. Uruchomienie tego w środowisku TLS-consul-swarm z siecią typu VxLan czyli “overlay network” o nazwie “ov01” wygląda następująco:

export  DOCKER_TLS_VERIFY="1"
export DOCKER_CERT_PATH=/root/.docker/
export DOCKER_HOST=tcp://10.3.0.7:5000

docker run -ti  --net=ov01 --name nginx_docker_rev_proxy01 -v $DOCKER_CERT_PATH:$DOCKER_CERT_PATH -p "80:80"  -e "constraint:node==GmbUbuntu09" --env DOCKER_HOST  --env DOCKER_CERT_PATH --env DOCKER_TLS_VERIFY    jwilder/nginx-proxy

Po wywołaniu powyższego faktycznie okazuje się że nasz nowy nginx-super-proxy słucha na docker events i nawet coś wypisuje na stdoutput po dodaniu jakiegoś kontenera:

dockergen.1 | 2016/04/07 08:37:25 Received event start for container 59e4da71a795

dockergen.1 | 2016/04/07 08:37:25 Contents of /etc/nginx/conf.d/default.conf did not change. Skipping notification 'nginx -s reload'

Aby wymusić automatyczną zmianę configu rev-proxy należy uruchamiać kontenery aplikacyjne w następujący sposób (kluczowe jest tu dodanie przełącznika z VIRTUAL_HOST_:

docker -H tcp://10.3.0.7:5000 run -e VIRTUAL_HOST=foo.bar.com  -tdi -P --name=apa_exp_11 --net=ov01 ubuntu_z_apache_ex8080

Warto zauważyć że klasyczne wywołania niewiele nam się przydadzą (tzn. żadne z poniższych nie spowoduje zmian w konfigu nginx-proxy):

docker -H tcp://10.3.0.7:5000 run -tdi -p 8080:80 --name=ngi01 --net=ov01 nginx

docker -H tcp://10.3.0.7:5000 run  -tdi -P --name=apa_exp_11 --net=ov01 ubuntu_z_apache_ex8080

docker -H tcp://10.3.0.7:5000 run  -tdi  --name=apa_exp_13 --net=ov01 ubuntu_z_apache_ex8080

Tak czy siak po powołaniu kontenera foo.bar.com moduł rev-proxy samoczynnie dopisał (w środku kontenera z rev-proxy) do konfigu nginxa następujące linijki (pogrubione):

# If we receive X-Forwarded-Proto, pass it through; otherwise, pass along the
# scheme used to connect to this server

map $http_x_forwarded_proto $proxy_x_forwarded_proto {
    default $http_x_forwarded_proto;
    ''      $scheme;
}

# If we receive Upgrade, set Connection to "upgrade"; otherwise, delete any
# Connection header that may have been passed to this server

map $http_upgrade $proxy_connection {
    default upgrade;
    '' close;
}

gzip_types text/plain text/css application/javascript application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;

log_format vhost '$host $remote_addr - $remote_user [$time_local] ''"$request" $status $body_bytes_sent ''"$http_referer" "$http_user_agent"';

access_log off;

# HTTP 1.1 support

proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $proxy_connection;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;

server {
    server_name _;
    listen 80;
    access_log /var/log/nginx/access.log vhost;
    return 503;
}

upstream foo.bar.com {
        # gmbUbuntu13/apa_exp_11
        server 10.3.0.9:32769;
}

server {
        server_name foo.bar.com;
        listen 80 ;
        access_log /var/log/nginx/access.log vhost;
        location / {
                proxy_pass http://foo.bar.com;
        }
}

Zważając na to, że cały LAB jest na jakiejś chmurze mapuję port 80 maszyny na której działa nginx-rev-proxy (np. via Endpoints) na świat zewnętrzny na port 80. Po dokonaniu tego i po połączeniu się “z domu” na foo.bar.com dostajemy stronę WWW właściwą dla tego kontenera. Oczywiście aby się to wszystko wydarzyło z lokalizacji “w domu” musimy dodać foo.bar do /etc/hosts . W świecie komercyjnym zakładam że mamy wykupione nasze domeny i wtedy już nie trzeba wykonywać tego karkołomnego etapu J

A teraz sprawdźmy sobie czy faktycznie niezbędna jest flaga EXPOSE.  Tworzymy testowo dwa obrazy – 01ubuntu_apache_bez_expose i 02ubuntu_apache_z_expose. Pogrubioną czcionką jest zaznaczona oczywiście różnica między nimi w Dockerfile:

FROM ubuntu:14.04
EXPOSE 80
RUN apt-get -y update && apt-get -y install apache2
CMD apachectl start && hostname >  /var/www/html/index.html && /bin/bash

--
FROM ubuntu:14.04
RUN apt-get -y update && apt-get -y install apache2
CMD apachectl start && hostname >  /var/www/html/index.html && /bin/bash
--

Jak widać po dockerfajlach nasze kontenery będą miały pewną cechę – na porcie 80 będą serwowały wybitnie biznesowy kontent – czyli swój hostname.

Odpalamy 2 kontenery:

docker -H tcp://10.3.0.7:5000 run -e VIRTUAL_HOST=loo.bar.com -P --name=loo -tdi --net=ov01 02ubuntu_apache_z_expose

docker -H tcp://10.3.0.7:5000 run -e VIRTUAL_HOST=joo.bar.com -P --name=joo -tdi --net=ov01 01ubuntu_apache_bez_expose

Drugi docker run nie spowodował dodania wpisu do konfigu nginx na proxy – jak więc widać EXPOSE  jest konieczny, a jego obecność objawia się pięknym logiem z rev-nginx-proxy:

dockergen.1 | 2016/04/08 14:06:14 Received event start for container 302eb12480ac

dockergen.1 | 2016/04/08 14:06:15 Generated '/etc/nginx/conf.d/default.conf' from 13 containers

dockergen.1 | 2016/04/08 14:06:15 Running 'nginx -s reload'

dockergen.1 | 2016/04/08 14:06:15 Error running notify command: nginx -s reload, exit status 1

Kolejne kontenery webowe można startować następująco:

docker -H tcp://10.3.0.7:5000 run -e VIRTUAL_HOST=goo.bar.com -P  -tdi --net=ov01 nginx

docker -H tcp://10.3.0.7:5000 run -e VIRTUAL_HOST=boo.bar.com -P --name=boo  -tdi --net=ov01 nginx

Lub nieco sobie uprościć tę czynność:

WEB_HOST=b.pl;

docker -H tcp://10.3.0.7:5000 run -e VIRTUAL_HOST=$WEB_HOST -P --name=$WEB_HOST -h $WEB_HOST -tdi  02ubuntu_apache_z_expose

Albo dodając nowy następny kontener – tym razem do sieci overlay:

WEB_HOST=d.pl ;

docker -H tcp://10.3.0.7:5000 run -e VIRTUAL_HOST=$WEB_HOST -P --name=$WEB_HOST -h $WEB_HOST --net=ov01 -tdi  02ubuntu_apache_z_expose

curl -H 'Host: d.pl 10.3.0.6

Podczas testów wygodnie jest zapiąć się do kontenera z docker-reverse-proxy poprzez docker logs, gdyż jwilder tak go przyjemnie zrobił że kontener ten wszystko co ma do powiedzenia rzuca na stdout:

docker -H tcp://10.3.0.7:5000 logs -f $(docker -H tcp://10.3.0.7:5000 ps | grep jwilder | awk '{print $1}')

… i paczcie państwo, widać już, że wszystko ładnie wyświetla się na ekranie. Jedyny problem jaki widzę na dzień dzisiejszy to to że obraz jwilder/proxy nie obsługuje za bardzo overlay networks. Z grubsza chodzi o to że do konfigu nginxa wrzucane są wpisy z normalną siecią z hardware-landu zamiast z adresacją z sieci overlay. Dla niewiernych dowód:

root@KONTENER_REV_PROXY:/app# cat /etc/nginx/conf.d/default.conf  | grep server | grep -v "{" | grep -v server_name

server 10.3.0.8:32777;
server 10.3.0.9:32776;
server 10.3.0.9:32773;
server 10.3.0.8:32774;
server 10.3.0.8:32778;
server 10.3.0.9:32775;

Pozostaje zaczekać aż jwilder poprawi swój obraz lub ktoś inny zrobi forka lub (jak to w świecie dockera) jutro powstanie 125 kolejnych obrazów podobnych i robiących to samo ale zupełnie inaczej.

Reasumując. Automatyczne reverse proxy sterowane eventami z dockera które samoczynnie się rekonfiguruje i dodaje kolejne domeny to całkiem fajny mechanizm. Mając go w podręcznym dev-opsowym arsenale można łatwo wyobrazić sobie potencjalne zastosowania… Jednak gdyby ktoś nie umiał samodzielnie wymyśleć to na przykład:

  • promocje środowisk z łańcucha Dev->Test->Q->preProd->Live
  • kolejne wersje np. naszego super wydajnego API restowego.
  • kolejne iteracje naszego sklepu internetowego dla kolejnych klientów lub dla kolejnego segmentu rynku w którym jesteśmy przyszłym potentatem(karma.dla.psa.pl, karma.dla.kota.pl, karma.dla.pancernika.pl)

Oczywiście jeśli zaczynamy stosować konteneryzację (zwłaszcza na produkcji) to warto wziąć na warsztat tematy bezpieczeństwa, wysokiej dostępności i skalowalności – ale to już będzie tematem następnych wpisów.