Ekosystem Docker’a to był jeden z największych punktów TODO na mojej liście rzeczy do nauczenia się, na które nigdy nie było czasu. W końcu przyszedł taki czas, że pojawiła się potrzeba i przy okazji udało się ogarnąć co nieco z tej tajemnej wiedzy (ale dość dobrze udokumentowanej w sieci).
Poprzedni serwer dedykowany, jaki obsługiwał firmę cloudover.io był poszatkowany wirtualnymi maszynami, na mniejsze hosty, obsługujące poszczególne fragmenty infrastruktury. Z różnych względów (głównie bezpieczeństwa) osobno uruchomione były instancje dla:
- reverse proxy
- bazy danych
- hostingu stron www
- demo chmury CoreCluster
- Paczkomat – budowanie paczek deb oprogramowania CoreCluster
- VPN
- Testowa infrastruktura libdinemic
i czasami, w zależności od potrzeb – kilka innych. Wynajmowany serwer dedykowany był dość mocny, aby to wszystko udźwignąć, zwłaszcza jeśli chodzi o zapotrzebowanie na pamięć RAM. CPU, przy odpowiedniej alokacji rdzeni nie było aż tak wysycane i całość dość dobrze działała, nawet przy tej ilości zadań przydzielonej jednemu serwerowi.
Jedynym problemem była ekonomia – większość serwisów była wykorzystywana sporadycznie, a ciągłym obciążeniem mogły się pochwalić tylko dwa dotyczące stron – hosting i baza danych. Utrzymanie takiego serwera na to wszystko trochę mijało się z celem i można go było zastąpić czymś mniejszym. Mniejsze serwisy, które mogły się nie zmieścić, miały zostać przeniesione na własny serwer budowania i demo.
Przejście na chmurę publiczną niestety nie wchodziło w grę ze względu na koszty, które byłyby dużo większe (przeczytaj – porównanie kosztów hostingu), a utrzymanie hostingu u siebie też nie jest zbyt dobrym pomysłem.
W co się przenieść?
Pomysł padł na małe serwery udostępniane przez OVH, pod szyldem Kimsufi. Za około 20zł miesięcznie można wynająć serwer z 4 rdzeniami, 2GB ramu i 500GB dyskiem twardym. To ostatnie przesądziło ostatecznie nad wyborem właśnie fizycznego serwera. Najtańsze wirtualne maszyny, udostępniane przez OVH są nieco tańsze, ale posiadają jedynie kilkadziesiąt GB dyski twarde, co nie wystarczało, a dokupienie większej ilości przestrzeni niestety wiąże się z dodatkowymi kosztami.
Problemem została oczywiście migracja – jak do maszyny z 2GB ramu wcisnąć 6 wirtualnych maszyn, po 0.5-2GB i zostawić jeszcze trochę na system? Co prawda wirtualizator KVM udostępnia dość mało popularny mechanizm KSM, który umożliwia oszczędne wykorzystanie stron pamięci RAM używanych przez wirtualki, jednak to i tak było za mało.
W dużym skrócie, KSM pozwala wykorzystać te same strony pamięci w wielu wirtualnych maszynach. Oczywiście muszą być w nich takie same dane. Taka sytuacja może się zdarzyć, gdy uruchomimy kilka wirtualnych maszyn z jednego obrazu (lub podlinkowanej kopii dysku). Każda z nich ładuje do pamięci te same fragmenty. KVM potrafi rozpoznać takie obszary i odpowiednio połączyć mapowania różnych maszyn na jeden obszar pamięci, co może znacznie obniżyć wykorzystanie ramu (wg. wikipedii – 52 windowsy XP z 1GB ram, na hoście posiadającym jedynie 16GB ramu).
Samo to jednak nie wystarczyłoby w tak małym serwerze i nie udałoby się prawdopodobnie uruchomić tylu wirtualek na raz. Jeśli więc nie wirtualizacja, to co? Dockery.
Migrowanie infrastruktury
Z racji dość sceptycznego nastawienia do wykorzystania dockerów jako zamiennika wirtualizacji nie byłem zbyt zachwycony rozpoczynając z nimi pracę (moim zdaniem im więcej punktów styku z systemem gospodarza, tym gorzej). Poza tym trzeba było przemigrować wszystkie aplikacje, najlepiej zachowując konfigurację wirtualnych sieci.
Produkcyjne, publiczne chmury wykorzystując dockery, ale dopiero na warstwie wirtualizacji (Google Cloud, Amazon). U mnie nie było ryzyka wpuszczania osób trzecich do infrastruktury, więc jedna bolączka uruchamiania dockerów bezpośrednio na gospodarzu odpadła.
Na pierwszy ogień poszło przygotowanie reverse proxy, wpuszczającego ruch do wewnętrznej “infrastruktury”. Najprościej nginx można uruchomić poleceniem:
docker run --name nginx-test -d nginx:latest
Powyższe polecenie przygotuje obraz z oficjalnego repozytorium dockera i uruchomi go. Można go później zobaczyć wpisując:
docker ps --all
Po krótkim googlaniu udało się skleić jedną z pierwszych użytecznych komend dockera i dopasować do mojej konfiguracji:
docker run --name nginx-proxy -p 80:80 -p 443:443 \ -v /configs/nginx:/etc/nginx \ -d nginx:latest
Powyższe polecenie uruchamia serwer Nginx i wystawia jego dwa porty: 80 i 443 (parametr -p). Dodatkowo montuje lokalny katalog /config/nginx w /etc/nginx na kontenerze (parametr -v). Dzięki temu można łatwo zmieniać i backupować konfigurację kontenera, nie wchodząc do niego. Pierwsze koty za płoty – uruchomienie tego serwera zwykle zajmowało kilka chwil (nie wliczając przygotowania samej konfiguracji). Teraz skróciło się do jednej chwili 🙂
Kolejnym krokiem było przygotowanie dwóch sieci: jednej prywatnej, do dostępu do bazy danych i drugiej pseudo publicznej, do dostępu do innych serwisów i usług. Miało to swój odpowiednik w poprzedniej konfiguracji, gdzie odpowiednie sieci były uruchamiane przez Libvirt’a. Podobnie jak w przypadku Nginxa, sieci też udało się uruchomić jednym prostym poleceniem:
docker network create --driver=bridge --subnet=10.0.10.0/24 isolated docker network create --driver=bridge --subnet=10.0.20.0/24 services
I o ile łącząc się z gospodarzem, na port 80 trafiałem bezpośrednio do kontenera (przez przekierowanie jego portów), to komunikacja pomiędzy kontenerami miała być nieco bardziej rozbudowana. Dzięki wykorzystaniu sieci dockera udało się przenieść całą konfiguracje prawie bez zmian.
Jak to wpłynęło na serwer Nginxa? Finalnie jedno konfiguracja w porównaniu z wersją z wirtualek prawie nie uległa zmianie. Do jego zadań po za reverse proxy dołączyło hostowanie statycznych stron www i finalnie z małego polecenia urósł taki potworek:
docker run --name nginx-proxy --network=services --ip 10.0.20.3 -p 80:80 -p 443:443 \ -v /config/nginx:/etc/nginx \ -v /www/updates.cloudover.org:/srv/updates.cloudover.org \ -v /www/packages.cloudover.org:/srv/packages.cloudover.org \ ... -d nginx:latest
Część konfiguracji jest wycięta – nie wszystkim trzeba się dzielić w sieci 🙂 Reverse proxy potrzebuje tylko dostępu do sieci serwisowej – stąd parametr –ip oraz –network, który określa jak wpiąć kontener w daną sieć.
Samo reverse proxy to nie wszystko
Miałem już punkt wejściowy do infrastruktury, trzeba było dołożyć tylko infrastrukturę. Ze stronami w PHP poszło dość łatwo – są gotowe kontenery, rozwijane przez różnych ludzi, głównie na bazie dystrybucji Alpine Linux. Tak, takie coś istnieje, o czym się dowiedziałem przy okazji nauki dockera. Jest to dość mały system, opracowany właśnie na potrzeby kontenerów, z dość dużym naciskiem na bezpieczeństwo.
O ile gotowe pojemniki na strony php były gotowe, to pozostało jeszcze przygotować pojemniki na strony z Django, co jest nieco większym wyzwaniem. Podobnie jak z proxy, cała konfiguracja Nginx, uWSGI oraz samej aplikacji Django była trzymana poza pojemnikiem. W kontenerze trzeba było doinstalować pakiety takie jak serwer nginx, uwsgi i kilka innych rzeczy potrzebnych dla tej aplikacji.
W normalnym systemie, instalowanym na gołym Debianie prawdopodobnie skończyłoby się to jednym lub kilkoma skryptami, które robiłyby całą magię. Nieco bardziej doświadczeni zrobiliby to Ansiblem. W światku dockera mamy możliwość przygotowania całego obrazu dystrybucji od zera lub bazowanie na istniejącym już obrazie. W ruch poszedł jedyny, słuszny edytor pico i powstał taki Dockerfile, w katalogu webserver-nginx:
FROM ubuntu:16.04 MAINTAINER Dockerfiles RUN apt-get update RUN apt-get upgrade --yes RUN apt-get install --yes \ supervisor \ python \ ... uwsgi \ uwsgi-plugin-python \ nginx ... RUN pip install requests COPY uwsgi_params /etc/nginx/uwsgi_params COPY uwsgi-application.ini /etc/uwsgi/application.ini COPY nginx-application.conf /etc/nginx/sites-available/default COPY supervisor-app.conf /etc/supervisor/conf.d/ RUN echo "daemon off;" >> /etc/nginx/nginx.conf RUN mkdir /var/lib/uwsgi RUN chown www-data /var/lib/uwsgi CMD ["supervisord", "-n"]
Plik Dockerfile to instrukcja dla Dockera jak zbudować obraz. Na początek bierzemy gotowy obraz Ubuntu 16.04 LTS (polecenie FROM) i wykonujemy kolejne polecenia (RUN …). Każde wykonanie RUN w Dockerfile tworzy nową warstwę nad bazowym obrazem. Oznacza to, że jeśli będziemy mieć 10 obrazów bazujących na ubuntu:16.04, to bazowy obraz będzie pobrany tylko raz. Wszelkie zmiany względem bazowego obrazu są zapisywane na osobnych, przejściowych obrazach. Po pierwsze daje to nam oszczędność miejsca – podobnie jak wykorzystanie obrazów QCOW2 z backing file. Po drugie, dzięki temu zmiana jednej z linijek w Dockerfile powoduje wykonanie tylko kolejnych. Poprzednie wywołania RUN, które się nie zmieniły, nie są ponownie wykonywane. Ma to też swoje wady – trzeba pamiętać o aktualizacjach, które, nawet jeśli są w obrazie zawarte, to mogą się nie wykonać. Jeśli nie zmienimy nic przed apt-get update i upgrade, to te polecenia nie wykonają się, nawet podczas tworzenia nowego obrazu.
Powyższy plik instaluje co potrzeba w systemie i kopiuje pliki konfiguracyjne z katalogu, w którym znajduje się Dockerfile do systemu plików kontenera. Ostatnia komenda CMD jest wykorzystywana do uruchomienia kontenera, a dokładnie daemona, który ma działać wewnątrz. W większości przypadków będzie to supervisord, który zajmuje się uruchomieniem i monitorowaniem działania kontenerów. Ta usługa pełni rolę odpowiadającą systemowemu init – wewnątrz kontenera i jego przestrzeni procesów (o tym mam nadzieję przy okazji) supervisord będzie miał PID równy 1.
Konfiguracja supervisord
Aby poprawnie uruchomić coś wewnątrz kontenera, musimy jeszcze stworzyć plik konfiguracyjny dla supervisor. Przykładowa, najprostsza zawartość będzie następująca:
[program:app-uwsgi] command = /usr/bin/uwsgi --ini /etc/uwsgi/application.ini -s /var/lib/uwsgi/application.sock [program:nginx-app] command = /usr/sbin/nginx -c /etc/nginx/nginx.conf -g 'daemon off;'
Dwie sekcje [program] spowodują uruchomienie równolegle dwóch różnych procesów. Po więcej opcji konfiguracyjnych (których jest od groma) zachęcam do przeglądnięcia dokumentacji. Supervisor potrafi się dość elastycznie dopasowywać, włączając w to monitorowanie działania usług oraz grupowanie i porządkowanie ich wykonywania.
Jak zbudować kontener?
Jak wspomniałem wcześniej, każdy opis obrazu musi być osobnym katalogiem (np. nginx-webserver), w którym znajdzie się Dockerfile i wszystkie pliki potrzebne do zbudowania obrazu, dodawane przez COPY. Mając taką strukturę, wystarczy wpisać:
docker image build --tag webserver-nginx webserver-nginx/
Docker pobierze wymagany obraz bazowy, jego zależności i wykona po kolei polecenia z naszego Dockerfile. W razie problemów zostaniemy też poproszeni o naprawienie ich. Po chwili obraz powinien być dostępny na liście:
docker image list
Mając go już możemy uruchomić z niego kontener. Warto pamiętać, że wszystkie zmiany wykonywane przez kontener są zapisywane na kolejnej warstwie obrazu, tak aby nie modyfikować oryginalnego. Dla tego warto rzeczy takie jak logi lub konfiguracje trzymać w montowanym katalogu (parametr -v dla polecenia docker run).
Co dalej?
Samo utworzenie dockerów “z palca” jest dość dużym krokiem na przód, jeśli planujesz migrację na nie. Pozostaje jednak problem jak zbierać z nich logi (zapis do montowanego katalogu mimo wszystko jest mało elastyczny i skalowalny), jak monitorować ich pracę oraz jak reagować na ich awarie. Dobrym punktem wejścia są usługi takie jak ETCD lub Kubernetes, które potrafią obsłużyć część z powyższych zadań. Przy większych instalacjach dochodzi do tego jeszcze problem wykrywania migrowanych serwisów, które mogą się pojawić bądź gdzie, w naszym klastrze.
Jednak na sam początek, jeśli nie potrzebujesz działać od razu na klastrze i skalować go na X nodów, to nawet prosty skrypt bashowy budujący wszystko będzie wystarczający. Do czasu aż nie będziesz musiał do niego wrócić za rok 😉
Do powyższej listy należy oczywiście dołożyć rzeczy takie jak skopiowanie danych z poprzedniej instalacji i zabezpieczenie całej maszyny firewlalem i nie tylko.