Kubernetes i prywatny rejestr Dockera

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

Informatyka

Od niecałego miesiąca jestem szczęśliwym posiadaczem Malinowej Chmury, eksperymentalnego klastra obliczeniowego zbudowanego z pięciu Raspberry Pi:

Ilustracja
Malinowa Chmura

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:

Ilustracja
Terminologia 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:

  1. przekieruj wszystkie żądania HTTP ze ścieżką /foo/* do serwisu X,
  2. 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:

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:

  1. aby skonfigurować iptables przed jej wywołaniem,
  2. 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:

  1. na węźle administracyjnym (oblok01) w pliku /etc/resolv.conf wpisujemy adres IP zewnętrznego serwera DNS. Tutaj działa kube-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 pliku resolv.conf,
  2. sprawdzamy adres IP serwisu kube-dns przy pomocy polecenia kubectl describe service kube-dns --namespace=kube-system,
  3. 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:

  1. 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 ustaw containerPort na 5001,
    • otwórz registry-svc.yml i także zmień port na 5001.
  2. 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
  3. 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 ustaw containerPort ORAZ hostPort na 5000. Mimo iż ten drugi parametr nie działa, musi być podany, aby plik się poprawnie wczytał.

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.

-- Tomasz Jędrzejewski

Autor zdjęcia nagłówkowego: Tom Driggers, CC-BY-2.0

Komentarze (0)

Skomentuj

Od 3 do 40 znaków.

Wymagany, nie będzie publikowany.

Odpowiedz na pytanie.

Edycja Podgląd

Od 10 do 8000 znaków.

Wszystkie komentarze są moderowane i muszą być zatwierdzone przed publikacją.