Kubernetes i prywatny rejestr Dockera
Kontynuacja przygody z Malinową Chmurą: pokazuję, jak odpalić prywatny rejestr Dockera na Kubernetesie i opublikować tam swoją przykładową aplikację.
wtorek, 29 sierpnia 2017
Od niecałego miesiąca jestem szczęśliwym posiadaczem Malinowej Chmury, eksperymentalnego klastra obliczeniowego zbudowanego z pięciu Raspberry Pi:
Obszarem moich zainteresowań są mikroserwisy oraz wirtualizacja, dlatego za cel obrałem sobie postawienie Kubernetesa, zbudowanie obrazu Dockera prostej aplikacji w Javie i odpalenie tegoż obrazu na klastrze. Ten prosty, wydawałoby się, cel wyrwał mi z życiorysu jakieś 30 godzin, jestem za to mądrzejszy o cenne doświadczenie, którym pragnę się dzisiaj podzielić.
Kubernetes, Docker i wirtualizacja
Wirtualizacja kojarzy się nam głównie z maszynami wirtualnymi (osobiście wolę termin maszyna urojona :)), jednak nie jest to jedyny sposób na uruchomienie aplikacji w izolowanym środowisku. Kilka systemów operacyjnych już od dawna oferuje możliwość wirtualizacji na poziomie jądra systemu operacyjnego. Pomysł polega na tym, aby nie tworzyć maszyny urojonej dla każdej naszej aplikacji, lecz aby wrzucić je wszystkie na jedną maszynę i kazać systemowi uruchomić je w izolacji. W systemach Solaris gotowe rozwiązanie istnieje już od wielu lat (co najmniej dekada) pod nazwą zon. Jądro Linuksa również od dawna posiada niezbędne mechanizmy - sami korzystaliśmy z nich razem z kolegą gdzieś około 2009/2010 roku podczas tworzenia automatycznej testerki zadań programistycznych dla naszej uczelni. Przez długi czas nie istniało jedynie narzędzie, które zbierałoby je w całość i robiło z nich coś użytecznego. Sytuację zmieniło dopiero pojawienie się projektu Docker.
W Dockerze aplikacje uruchamiane są w kontenerach z tzw. obrazów. Kontener można porównać do izolowanej klatki w obrębie jednego systemu, która nie tylko posiada własny system plików, własny interfejs sieciowy, ale także ograniczenia na zużycie zasobów (pamięć, CPU). Z kolei obraz zawiera wszystkie niezbędne pliki aplikacji oraz ustawienia, które muszą się w kontenerze znaleźć. Genialnym posunięciem twórców było stworzenie publicznego rejestru obrazów aplikacji, wzorowanego na idei GitHuba. Gdy każemy Dockerowi odpalić kontener dla aplikacji X, musimy podać nazwę obrazu. Docker połączy się z rejestrem, ściągnie go i uruchomi.
Dużym ograniczeniem Dockera jeszcze do niedawna (do wersji 1.12) było to, że całe środowisko ograniczone było do jednej maszyny - innymi słowy, projekt nie oferował narzędzi do zbudowania klastra z kilku komputerów. Obecnie taka funkcjonalność już istnieje i kryje się pod nazwą Docker Swarm, jednak w międzyczasie pojawił się zupełnie inny, niezależny projekt: Kubernetes. Jest to stworzony przez Google'a system do zarządzania kontenerami w środowisku rozproszonym. Architektura projektu pozwala na używanie go z dowolną technologią kontenerów, w tym oczywiście z Dockerem. I idealnie nadaje się do postawienia na Malinowej Chmurze.
Zanim zaczniemy, zapoznajmy się z podstawową terminologią Kubernetesa:
Najniższy poziom architektury to węzły. Każdy węzeł to jedna maszyna, rzeczywista bądź urojona, z własnym systemem operacyjnym. Na węźle zainstalowany jest Docker oraz narzędzia Kubernetesa. Docker odpowiada za uruchamianie kontenerów, które z kolei Kubernetes grupuje w pody. Pod to najmniejsza jednostka organizacyjna Kubernetesa, która składa się z jednego lub więcej kontenerów. Ważne jest to, że wszystkie kontenery zawsze znajdują się na tym samym węźle oraz współdzielą zasoby. W szczególności, pod ma jeden, wspólny dla wszystkich kontenerów adres IP. Grupę identycznych podów znajdujących się na różnych maszynach możemy połączyć w serwis. Serwis posiada swój własny adres IP oraz nazwę DNS, a Kubernetes zapewnia mechanizmy równoważenia obciążenia.
Do grupowania wszystkich obiektów Kubernetesa wykorzystywany jest mechanizm etykiet. Przykładowo, aby połączyć grupę podów w serwis, musimy nadać im etykietę, a następnie w konfiguracji serwisu użyć ją jako kryterium grupowania. Spostrzegawczy czytelnicy zapewne zwrócili uwagę, że w pewnym miejscu użyłem zwrotu "identyczne pody". Klaster stawiamy m.in. po to, aby zapewnić sobie odporność na awarię. Stworzywszy jakąś aplikację, uruchamiamy kilka jej instancji na różnych węzłach. W świecie Kubernetesa oznacza to, że uruchamiamy grupę podów z identycznego obrazu oraz z identyczną konfiguracją startową. Oczywiście nie musimy tego robić ręcznie, bowiem mamy do dyspozycji kolejne narzędzie w postaci kontrolerów, które zrobią to za nas (i nie tylko to).
Ostatnim terminem jest tzw. ingress. To jest chyba najbardziej abstrakcyjna rzecz do wyjaśnienia. Na potrzeby podów i serwisów Kubernetes tworzy wirtualną sieć lokalną, przy pomocy której wszystko może się ze sobą porozumiewać. Nasze serwisy nie są widoczne na zewnątrz klastra, dopóki sobie tego nie zażyczymy. Jednak udostępniając je, również nie chcielibyśmy ujawniać szczegółów dotyczących wewnętrznej architektury sieciowej naszego systemu. Chodzi tu nawet o ukrycie podstawowych informacji takich, jak to, na jakim porcie nasłuchuje określony serwis. W naszym klastrze możemy zainstalować sobie specjalny serwis zwany ingress controller, który pełni dwie role:
- równoważenie obciążenia,
- delegowanie połączeń z Internetu do wnętrza klastra.
Pojedynczy ingres to zestaw reguł dla tego kontrolera mówiący, co zrobić z określonym rodzajem ruchu. Jeśli mamy dwa serwisy X oraz Y gadające po protokole HTTP, to możemy dla każdego z nich zdefiniować po jednym ingresie:
- przekieruj wszystkie żądania HTTP ze ścieżką
/foo/*
do serwisu X, - przekieruj wszystkie żądania HTTP ze ścieżką
/bar/*
do serwisu Y.
Instalacja Kubernetesa na ARM
Raspberry Pi używa procesorów o architekturze ARM. Zarówno Linux, jak i Docker, jak i Kubernetes posiadają wsparcie dla tej architektury, z zastrzeżeniem, że musimy w naszym klastrze także używać obrazów ARM-owych. Do budowy klastra najlepiej jest wykorzystać system HypriotOS, który jest klonem Raspbiana zoptymalizowanym pod uruchamianie Dockera. Istnieją dwa główne poradniki, które opisują proces instalacji:
- Getting started with Docker on your ARM device
- Setup Kubernetes on a Raspberry Pi cluster - the official way
Po namyśle zdecydowałem się nie tworzyć własnej instrukcji i jest ku temu bardzo dobry powód. Kubernetes rozwija się bardzo szybko i nie byłbym w stanie nadążyć z jej aktualizowaniem. Lepiej powierzyć to zadanie ludziom, którzy siedzą w tym na 100%, zwłaszcza że pod wspomnianymi adresami znajdziemy też mnóstwo komentarzy z rozwiązaniami różnych problemów, na które można się natknąć. Zamiast tego, chciałbym się podzielić informacjami o kilku pułapkach, na których straciłem najwięcej czasu, oraz tym, jak je ominąć.
Mój klaster zawiera pięć maszynek nazwanych od oblok01
do oblok05
. Pierwszy z nich pełni rolę węzła administracyjnego, na którym odpalone są serwisy Kubernetesa. Tak, to nie błąd. Prawie wszystkie usługi Kubernetesa są kontenerami połączonymi w pody i serwisy zarządzane przez Kubernetesa :). Dzięki temu ich stawianie i konfiguracja wygląda niemal dokładnie tak samo, jak w przypadku produkcyjnych aplikacji.
Pułapka #1: iptables
Do przekierowywania ruchu pomiędzy siecią wirtualną, a węzłami służy iptables
. Tworzeniem odpowiednich reguł zarządza automatycznie pod kube-proxy
uruchomiony na każdym węźle. Jednocześnie, od Dockera 1.13 zmieniły się domyślne reguły iptables
i aby wszystko działało, po starcie każdego węzła należy wykonać każdorazowo:
$ sudo iptables -A FORWARD -i cni0 -j ACCEPT
$ sudo iptables -A FORWARD -o cni0 -j ACCEPT
Nie wolno nam tej konfiguracji niestety zapisać tak, by odtwarzała się przy starcie, gdyż w przeciwnym razie nie wstanie kube-proxy
, a za nim cały Kubernetes. Dojście do tego zajęło mi dobrych kilka godzin. Póki co nie wymyśliłem, jak to elegancko obejść i po prostu odpalam powyższe komendy ręcznie po uruchomieniu klastra.
Pułapka #2: kubeadm init
kubeadm
to stosunkowo nowa aplikacja administracyjna, której celem jest uproszczenie wstępnej konfiguracji Kubernetesa. W pierwszych wersjach cały klaster stawiało się ręcznie, tworząc krok po kroku poszczególne serwisy. Niestety, ma ona póki co status wersji alfa i potrafi spłatać psikusy. W przypadku wersji Kubernetesa 1.7 trzeba pamiętać o dwóch rzeczach:
- aby skonfigurować
iptables
przed jej wywołaniem, - aby podczas dodawania węzłów
oblok02 ... oblok05
dodać przełącznik--skip-preflight-checks
, gdyż w tych wstępnych weryfikatorach znajduje się krzak, który uniemożliwi nam dołączenie się.
Konfiguracja węzła administracyjnego może trwać nawet kilkanaście minut. W pewnym momencie proces zawisa na długo na komunikacie waiting for control plane to become ready, jednak powinien po kilku minutach pójść dalej. Jeśli tak się nie dzieje, oznacza to, że serwisy Kubernetesa nie mogą z jakiegoś powodu się podnieść.
Pułapka #3: uprawnienia
Przed instalacją musimy poprawić konfigurację usługi kubelet.service
- na każdym węźle otwieramy plik `/etc/systemd/system/kubelet.service.d/10-kubeadm.conf
i zmieniamy atrybuty User
oraz Group
tak, aby Kubernetes startował z prawami roota.
Pułapka #4: kontrola dostępu
Po instalacji Kubernetesa musimy wykonać dwie dodatkowe komendy, które nie są wymienione w poradniku (zmiany od wersji 1.6):
$ kubectl create -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel-rbac.yml
$ kubectl create -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml
Ponieważ pracujemy na architekturze ARM, w przypadku drugiego pliku należy go najpierw pobrać i zamienić w nazwach obrazów Dockera amd64
na arm
.
Źródło: github.com/kubernetes/kubernetes/issues/44029
Pułapka #5: DNS
O tym, że nie działa mi rozwiązywanie nazw serwisów w obrębie klastra, zorientowałem się dopiero po pewnym czasie. Problem jest bardzo łatwy do naprawienia:
- na węźle administracyjnym (
oblok01
) w pliku/etc/resolv.conf
wpisujemy adres IP zewnętrznego serwera DNS. Tutaj działakube-dns
i chodzi o to, aby nieznane zapytania DNS przekierowywać na zewnątrz. Możliwe jest też wymuszenie na tym podzie używania wskazanego przez nas plikuresolv.conf
, - sprawdzamy adres IP serwisu
kube-dns
przy pomocy poleceniakubectl describe service kube-dns --namespace=kube-system
, - na wszystkich pozostałych węzłach w pliku
/etc/resolv.conf
podajemy odczytany adres IP.
W ten sposób węzły robocze do rozwiązywania nazw będą używać serwisu kube-dns
chodzącego sobie na pierwszym obłoku. Rozwiąże on nazwy wszystkich serwisów, a zapytania o domeny przekieruje do Internetu. Poprawnie działający DNS jest niezbędny, aby później uruchomić rejestr Dockera.
Uruchomienie prywatnego rejestru Dockera
W podstawowej wersji nasz klaster będzie ściągał obrazy z rejestru publicznego. Dla mnie jednak było to niewystarczające, bowiem chciałem, aby w trakcie pisania eksperymentalnych aplikacji nie musieć ich wysyłać w świat, ale trzymać lokalnie. Zasada działania rejestru w klastrze jest prosta - chcemy mieć jedno miejsce, gdzie trzymamy obrazy, które jest widoczne dla każdego węzła. Na przeszkodzie stoją nam zabezpieczenia Dockera, który domyślnie odmawia łączenia się z rejestrami, które nie są schowane za SSL-em. W sieci lokalnej i bez domeny o SSL-u możemy zapomnieć, jednak jest pewna sztuczka. Otóż wyjątek jest zrobiony dla adresu localhost
. To, co musimy zrobić, to odpalić jedną instancję rejestru na porcie X oraz pięć instancji kube-registry-proxy
słuchających na adresie localhost
węzła i przekierowujących cały ruch do właściwego rejestru :). Skonfigurowałem to na podstawie poniższej oficjalnej instrukcji, jednak z kilkoma zmianami:
Po pierwsze, użyłem obrazów na architekturę ARM:
kubernetesonarm/kube-registry-proxy-arm:0.4
budry/registry-arm:latest
Gdy uporamy się z obrazami, czeka na nas przykra niespodzianka. Do otwarcia portu na węźle konfiguracja używa atrybutu hostPort
, który... jest ignorowany w sieciach wirtualnych zbudowanych w oparciu o rozwiązanie CNI (a z niego korzystamy i nie mamy za bardzo innego wyboru). Okazuje się, że aby dodać jego obsługę, twórcy Kubernetesa musieli przepisać bardzo dużą partię kodu i na dzień dzisiejszy jeszcze prace nie są zakończone. W międzyczasie zastosowałem interesujące obejście, które jest trochę brzydsze, ale działa i do celów eksperymentalnych w zupełności wystarcza. Polega ono na tym, żeby dać podom kube-registry-proxy
dostęp do wszystkich interfejsów sieciowych węzła. Aby je zrealizować, musimy zmodyfikować podane w instrukcji pliki konfiguracyjne przed ich zainstalowaniem:
- odpal
kube-registry
na porcie 5001:- otwórz
registry-rc.yml
- znajdź sekcję ze zmiennymi środowiskowymi i ustaw
REGISTRY_HTTP_ADDR
na 5001, - nieco niżej, w sekcji
ports
także ustawcontainerPort
na 5001, - otwórz
registry-svc.yml
i także zmień port na 5001.
- otwórz
- daj podom
kube-registry-proxy
pełny dostęp do interfejsów sieciowych węzła:- otwórz
registry-daemon-set.yml
, - w sekcji
spec
dodaj na samym początku flagęhostNetwork: true
- otwórz
- skomunikuj proxy z rejestrem i odpal proxy na porcie 5000:
- pozostań w
registry-daemon-set.yml
, - znajdź zmienną środowiskową
REGISTRY_PORT
i zmień jej wartość na 5001. Nie ruszaj nazwy domenowej rejestru, - poniżej, w sekcji
ports
ustawcontainerPort
ORAZhostPort
na 5000. Mimo iż ten drugi parametr nie działa, musi być podany, aby plik się poprawnie wczytał.
- pozostań w
Gotowe. Zaczekajmy, aż Kubernetes skończy tworzyć zasoby i spróbujmy na każdym węźle połączyć się z rejestrem, wykonując następujące polecenie i sprawdzając czy dostaniemy pustą odpowiedź:
$ curl http://localhost:5000
Próbujemy opublikować aplikację Javy
Ostatnim krokiem jest zbudowanie aplikacji Javy i publikacja jej obrazu w rejestrze. Nie jest to aż takie trudne; musimy tylko pamiętać, że proces budowania obrazu musi odbywać się na jednym z węzłów, gdyż nasz komputer do programowania najprawdopodobniej nie będzie maszyną z procesorem ARM.
Przygotowanie Dockera
Aby klaster mógł służyć do budowania obrazów ARM, musimy na jednym z węzłów otworzyć Docker Remote API na świat. Utwórzmy (jako root
) plik /etc/systemd/system/docker-tcp.socket
:
[Unit]
Description=Docker Socket for the API
[Socket]
ListenStream=2375
BindIPv6Only=both
Service=docker.service
[Install]
WantedBy=sockets.target
Następnie aktywujemy go:
# systemctl enable /etc/systemd/system/docker-tcp.socket
# systemctl start /etc/systemd/system/docker-tcp.socket
Od tego momentu Docker na jednym z węzłów potrafi przyjmować komendy z zewnątrz.
Tworzenie obrazu Dockera
Do budowania aplikacji Javy używam Gradle'a, do którego istnieje ciekawa wtyczka dodająca obsługę Dockera. Oto, co trzeba dopisać do pliku build.gradle
:
buildscript {
...
dependencies {
classspath 'com.bmuschko:gradle-docker-plugin:3.1.0'
}
}
...
docker {
url = 'tcp://192.168.1.124:3275' // adres jednego z naszych oblokow
javaApplication {
baseImage = 'hypriot/rpi-java'
maintainer = 'Ja <ja@example.com>'
ports = [5050]
tag = 'localhost:5000/zyxist/moj-obraz'
}
}
Wtyczka składa się z niskopoziomowej części dającej nam większą kontrolę nad procesem budowania oraz z wysokopoziomowego API, które wiele rzeczy robi za nas. W powyższym przykładzie użyłem tego drugiego rozwiązania. Jedyne, co musiałem podać, to bazowy obraz dla Javy ARM przygotowany przez ekipę Hypriota, podstawowe informacje identyfikacyjne oraz pełną nazwę gotowego obrazu. Mogę teraz odpalić polecenie:
$ gradle :dockerPushImage
Po chwili obraz znajdzie się w rejestrze, a ja będę mógł uruchomić go w moim klastrze. I o to chodziło.
Podsumowanie
Zainstalowanie Kubernetesa zajęło mi znacznie więcej czasu niż planowałem. Wynikało to z głównie z mojej niewiedzy. Było to moje pierwsze zetknięcie z tym systemem i do rozwiązania każdego problemu musiałem dochodzić metodą prób i błędów. Jednak ostatecznie bardzo dużo się nauczyłem i mogę z czystym sumieniem powiedzieć, że rozumiem, co się dzieje pod spodem, a o to przecież chodziło. Szczególnie jestem dumny z opracowania obejścia na problem z brakiem obsługi atrybutu hostPort
, gdyż wymyśliłem je samodzielnie. Sam Kubernetes zrobił na mnie wrażenie przemyślaną architekturą. Jednocześnie widać, że projekt się wciąż dynamicznie rozwija i za kilka miesięcy pewne rzeczy będą pewnie wyglądać już inaczej (przy poszukiwaniu informacji trzeba zwracać uwagę, jak dawno dany artykuł czy komentarz został opublikowany :)). Czytając dyskusje na Githubie czułem, że stoi za nim mocna ekipa, której zależy na zrobieniu czegoś fajnego i która wokół ma dużą społeczność.
Nie wiem jeszcze, w jakim kierunku pójdą dokładnie moje eksperymenty z Malinową Chmurą, jednak mogę zapewnić, że nie jest to ostatni wpis na jej temat.
zobacz inne wpisy w temacie
Komentarze (0)