Kubernetes. Tworzenie natywnych aplikacji - Stefan Schimanski

You might also like

Download as pdf or txt
Download as pdf or txt
You are on page 1of 284

Stefan Schimanski

Kubernetes
Tworzenie natywnych
aplikacji działających w
chmurze
Tytuł oryginału: Programming Kubernetes: Developing Cloud-Native Applications

Tłumaczenie: Tomasz Walczak


ISBN: 978-83-283-6406-6

© 2020 Helion SA
Authorized Polish translation of the English edition of Programming Kubernetes ISBN
9781492047100 © 2019 Michael Hausenblas and Stefan Schimanski
This translation is published and sold by permission of O’Reilly Media, Inc., which owns or
controls all rights to publish and sell the same.
All rights reserved. No part of this book may be reproduced or transmitted in any form or by
any means, electronic or mechanical, including photocopying, recording or by any information
storage retrieval system, without permission from the Publisher.
Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu
niniejszej publikacji w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii metodą
kserograficzną, fotograficzną, a także kopiowanie książki na nośniku filmowym, magnetycznym
lub innym powoduje naruszenie praw autorskich niniejszej publikacji.
Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź towarowymi
ich właścicieli.
Autor oraz Helion SA dołożyli wszelkich starań, by zawarte w tej książce informacje były
kompletne i rzetelne. Nie biorą jednak żadnej odpowiedzialności ani za ich wykorzystanie, ani
za związane z tym ewentualne naruszenie praw patentowych lub autorskich. Autor oraz Helion
SA nie ponoszą również żadnej odpowiedzialności za ewentualne szkody wynikłe z
wykorzystania informacji zawartych w książce.
HELION SA
ul. Kościuszki 1c, 44-100 GLIWICE
tel. 32 231 22 19, 32 230 98 63
e-mail: helion@helion.pl
WWW: http://helion.pl (księgarnia internetowa, katalog książek)
Drogi Czytelniku!

Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres


http://helion.pl/user/opinie/kubert

Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję.


Printed in Poland.

Poleć książkę
Kup w wersji papierowej
Oceń książkę

Księgarnia internetowa
Lubię to! » nasza społeczność
Opinie na temat książki Kubernetes.
Tworzenie natywnych aplikacji
działających w chmurze
Książka Kubernetes. Tworzenie natywnych aplikacji działających w chmurze uzupełnia lukę w
ekosystemie związanym z platformą Kubernetes. Dostępnych jest wiele książek i dokumentacja
na temat uruchamiania klastrów w tej platformie, jednak wciąż pracujemy nad
wyeliminowaniem braków w obszarze programowania w Kubernetesie. Ta książka to bardzo
potrzebny i dobrze napisany podręcznik poświęcony programowaniu z użyciem i dla platformy
Kubernetes.
— Bryan Liles, starszy inżynier, VMware
Chciałbym, aby istniała taka książka, gdy zaczynałem pisać kontrolery dla platformy
Kubernetes. Zawiera ona obszerne i szczegółowe omówienie interfejsu programowania i
działania platformy Kubernetes, a także pisania stabilnego oprogramowania.
— Michael Gasch, architekt platformy aplikacji w zespole dyrektora technicznego w VMware
Jest to lektura obowiązkowa, jeśli chcesz rozszerzać Kubernetesa.
— Dimitris-Ilias Gkanatsios, Technical Evangelist, Microsoft Grecja
Rozszerzanie Kubernetesa to jedyny sposób na instalowanie złożonych aplikacji i zarządzanie
cyklem ich życia. W tej książce pokazane jest, jak tworzyć własne zasoby w Kubernetesie i jak
rozszerzać API Kubernetesa.
— Ahmed Belgana, inżynier ds. chmury, SAP
Przedmowa
Witaj w książce Kubernetes. Tworzenie natywnych aplikacji działających w chmurze.
Dziękujemy, że zdecydowałeś się spędzić z nami trochę czasu. Zanim przejdziemy do
szczegółów, warto szybko opisać kilka zagadnień porządkowych i organizacyjnych. Wyjaśniamy
tu też, po co napisaliśmy tę książkę.

Dla kogo przeznaczona jest ta książka?


Jesteś programistą, który zamierza tworzyć natywne aplikacje dla chmury, albo pełnisz rolę
AppOps lub administratora przestrzeni nazw i chcesz w maksymalnym stopniu wykorzystać
możliwości Kubernetesa. Standardowe ustawienia już Ci nie wystarczają i możliwe, że poznałeś
punkty rozszerzeń (http://bit.ly/2XmoeKF). To dobrze. Trafiłeś pod właściwy adres.

Po co napisaliśmy tę książkę?
Obaj od początku 2015 r. bierzemy udział w rozwijaniu Kubernetesa, piszemy na temat tej
platformy, uczymy o niej i korzystamy z niej. Opracowaliśmy narzędzia i aplikacje dla
Kubernetesa, a także kilkakrotnie prowadziliśmy warsztaty na temat programowania dla tej
platformy i z jej użyciem. W pewnym momencie stwierdziliśmy: „Dlaczego nie napisać książki?”.
Dzięki temu jeszcze więcej osób mogłoby uczyć się — jednocześnie i we własnym tempie —
programować z użyciem platformy Kubernetes. A więc jesteśmy. Mamy nadzieję, że lektura tej
książki przyniesie Ci tyle samo przyjemności co nam jej pisanie.

Ekosystem
W ogólnym ujęciu ekosystem Kubernetesa jest jeszcze we wczesnej fazie rozwoju. Choć na
początku 2018 r. Kubernetes zyskał reputację standardowego narzędzia do zarządzania
kontenerami (i cyklem ich życia), nadal potrzebne są dobre praktyki pisania aplikacji
natywnych. Podstawowe cegiełki, takie jak klient client-go (http://bit.ly/2L5cUMu),
niestandardowe zasoby i natywne języki programowania dla chmury, są już gotowe. Jednak
duża część informacji ma postać wiedzy plemiennej rozproszonej po umysłach ludzi oraz
tysiącach kanałów w komunikatorze Slack i odpowiedzi w serwisie StackOverflow.

W czasie, gdy powstaje ta książka, najnowszą stabilną wersją jest Kubernetes


1.15. Skompilowane przykłady powinny działać także w starszych wersjach (aż
do 1.12), jednak kod oparty jest na nowszych edycjach bibliotek, powiązanych
z wersją 1.14. Niektóre zaawansowane definicje CRD wymagają do działania
klastrów z wersjami 1.13 lub 1.14, a opisane w rozdziale 9. konwersje definicji
CRD — nawet wersji 1.15. Jeśli nie masz dostępu do wystarczająco nowego
klastra, gorąco zalecamy użycie narzędzia Minikube (http://bit.ly/2WT3k1l) lub
kind (https://kind.sigs.k8s.io) w lokalnej stacji roboczej.

Technologie, które powinieneś znać


Jest to książka dla średnio zaawansowanych użytkowników i wymaga podstawowej wiedzy z
zakresu programowania i administracji systemami. Zanim zagłębisz się w lekturze, przydatne
może być odświeżenie informacji z następujących obszarów:

Zarządzanie pakietami
Narzędzia w tej książce często mają wiele zależności, które musisz uwzględnić, instalując
określone pakiety. Dlatego musisz znać system zarządzania pakietami używany w Twoim
komputerze. Może to być apt w systemach Ubuntu i Debian, yum w systemach CentOS i
RHEL, a port lub brew w systemach macOS. Niezależnie od używanego systemu upewnij
się, że wiesz, jak instalować, aktualizować i usuwać pakiety.
System Git

Git stał się standardowym narzędziem do kontroli wersji w środowisku rozproszonym. Jeśli
znasz już systemy CVS i SVN, ale nie używałeś Gita, powinieneś zapoznać się z tym
[1]
ostatnim. Dobrym punktem wyjścia będzie książka Version Control with Git Jona
Loeligera i Matthew McCullougha (wydawnictwo O’Reilly). Poza systemem Git znakomitym
narzędziem, które pomoże Ci tworzyć własne hostowane repozytoria, jest serwis GitHub
(http://github.com). Aby zapoznać się z serwisem GitHub, sprawdź oferowane w nim
szkolenia (https://services.github.com) i interaktywny samouczek (http://try.github.io).
Język Go

Kubernetes jest napisany w języku Go (http://golang.org). W ostatnim latach Go stał się


nowym językiem programowania wybieranym przez wiele startupów i w licznych otwartych
projektach z obszaru systemów. W tej książce nie będziemy uczyć Cię języka Go, ale
pokazujemy, jak programować w Kubernetesie z użyciem tego języka. Możesz poznać Go za
pomocą różnych materiałów — od internetowej dokumentacji w witrynie języka Go
(https://golang.org/doc), przez wpisy na blogach i wykłady, po liczne książki.

Konwencje stosowane w tej książce


W tej książce używane są następujące konwencje typograficzne:

Kursywa

Oznacza nowe pojęcia, adresy URL, adresy e-mail, nazwy plików i rozszerzenia plików.

Czcionka o stałej szerokości


Używana w listingach, a także w tekście do wyróżniania elementów programów, takich jak
nazwy zmiennych i funkcji, bazy danych, typy danych, zmienne środowiskowe, instrukcje i
słowa kluczowe. Używana także dla instrukcji i danych wyjściowych wiersza poleceń.

Pogrubiona czcionka o stałej szerokości

Stosowana dla instrukcji i innego tekstu, który powinien zostać wpisany bez modyfikacji
przez użytkownika.
Kursywa o stałej szerokości
Przeznaczona dla tekstu, który należy zastąpić wartościami podanymi przez użytkownika
lub wynikającymi z kontekstu.

Ta ikona oznacza wskazówkę lub sugestię.

Ta ikona oznacza ogólną uwagę.

Ta ikona informuje o ostrzeżeniu lub przestrodze.

Korzystanie z przykładowego kodu


Książka ta ma pomóc Ci w wykonywaniu zadań. Używany tu przykładowy kod znajdziesz w
repozytorium tej książki w serwisie GitHub (https://github.com/programming-kubernetes).

Jeśli w książce znajduje się przykładowy kod, zwykle możesz wykorzystać go we własnych
programach i dokumentacji. Nie musisz prosić nas o pozwolenie, chyba że kopiujesz duże
części kodu. Na przykład napisanie programu z wykorzystaniem kilku fragmentów kodu z tej
książki nie wymaga pozwolenia. Jednak wymaga go sprzedaż lub dystrybucja płyty CD-ROM z
przykładami z książek wydawnictwa O’Reilly. Udzielenie odpowiedzi za pomocą cytatu z tekstu i
przykładowego kodu z książki nie wymaga pozwolenia, natomiast jest ono niezbędne przy
umieszczaniu dużych fragmentów kodu w dokumentacji produktów.

Będzie nam miło, gdy podasz tę książkę jako źródło informacji, nie jest to jednak konieczne.
Przy podawaniu źródła zwykle określa się tytuł, autora, wydawnictwo i numer ISBN. Oto
przykład: Kubernetes. Tworzenie natywnych aplikacji działających w chmurze, Michael
Hausenblas i Stefan Schimanski, O’Reilly, prawa autorskie 2019 Michael Hausenblas i Stefan
Schimanski.

Jeśli sądzisz, że planowany przez Ciebie sposób wykorzystania kodu wykracza poza zasady
dozwolonego użytku lub przedstawione tu uprawnienia, skontaktuj się z wydawnictwem
O’Reilly. Jego adres to permissions@oreilly.com.

Pliki manifestów Kubernetesa, przykładowy kod i inne skrypty używane w tej książce są
dostępne w serwisie GitHub (https://github.com/programming-kubernetes). Możesz sklonować
dostępne tam repozytoria, przejść do odpowiedniego rozdziału i receptury oraz zastosować
gotowy kod.
Podziękowania
Wielkie podziękowania składamy społeczności skupionej wokół Kubernetesa za opracowanie tak
fantastycznego oprogramowania i za bycie wspaniałymi ludźmi — otwartymi, uprzejmymi i
zawsze gotowymi do niesienia pomocy. Ponadto jesteśmy bardzo wdzięczni recenzentom
technicznym. Oto oni: Ahmed Belgana, Michael Gasch, Dimitris Gkanatsios, Mingding Han, Jess
Males, Max Neunhöffer, Ewout Prangsma i Adrien Trouillaud. Każdy z nich przekazał nam
bardzo cenne i praktyczne informacje oraz sprawił, że książka stała się bardziej zrozumiała i
przydatna dla czytelników. Dziękujemy za wysiłek i poświęcony czas.

Michael chce wyrazić swoją dogłębną wdzięczność swojej cudownej i wspierającej rodzinie:
niezwykle inteligentnej i zabawnej żonie, Anneliese, dzieciom: Saphirze, Ranji i Iannisowi, a
także psu Snoopy'emu, który jeszcze nie do końca przestał być szczeniakiem.

Stefan chce podziękować żonie, Clelii, za niezwykłe wsparcie i zachęty, gdy znów „pracował
nad książką”. Bez niej ta książka by nie powstała. Jeśli w tekście znajdziesz literówki, ich
dumnymi autorami są z dużym prawdopodobieństwem dwa koty: Nino i Kira.

Na zakończenie nie mniej istotne podziękowania obaj autorzy składają zespołowi z


wydawnictwa OʼReilly, a przede wszystkim Virginii Wilson za przeprowadzenie przez proces
pisania książki i dbanie o to, by tekst był dostarczany na czas oraz miał oczekiwaną jakość.

1 Wyd. polskie: Kontrola wersji z systemem Git, Helion, 2014 — przyp. tłum.
Rozdział 1. Wprowadzenie
Programowanie w Kubernetesie może oznaczać dla różnych osób co innego. W tym rozdziale
najpierw określimy zakres i tematykę tej książki. Ponadto przedstawimy założenia dotyczące
używanego środowiska i wyjaśnimy, czego będziesz potrzebować, aby optymalnie wykorzystać
tę książkę. Zdefiniujemy, co rozumiemy przez programowanie dla Kubernetesa, czym są
natywne aplikacje dla Kubernetesa, a także — posługując się konkretnym przykładem — jakie
są ich cechy. Omówimy podstawy kontrolerów i operatorów oraz wyjaśnimy, jak działa
sterowana zdarzeniami warstwa kontroli w Kubernetesie. Gotowy? Zaczynajmy.

Czym jest programowanie dla


Kubernetesa?
Zakładamy, że masz dostęp do działającego klastra z Kubernetesem, np. do usługi Amazon EKS,
Microsoft AKS, Google GKE lub jednego z rozwiązań z rodziny OpenShift.

Sporo czasu przeznaczysz na programowanie w środowisku lokalnym — na


swoim laptopie lub komputerze stacjonarnym. W takim modelu klaster
Kubernetesa działa lokalnie, a nie w chmurze lub w centrum danych. Gdy
programujesz lokalnie, możesz korzystać z różnych narzędzi. W zależności od
używanego systemu operacyjnego i innych preferencji możesz zdecydować się
na jedno (lub na kilka) z wymienionych narzędzi, aby uruchamiać Kubernetesa
lokalnie: kind (https://kind.sigs.k8s.io), k3d (http://bit.ly/2Ja1LaH) lub Docker
[1]
Desktop (https://dockr.ly/2PTJVLL) .

Ponadto zakładamy, że programujesz w języku Go i masz doświadczenie w pracy z nim lub


przynajmniej podstawową wiedzę na jego temat. Jeśli nie spełniasz któregoś z wymienionych
warunków, teraz jest dobry czas na to, aby się doszkolić. Jeśli chodzi o język Go, polecamy
książki The Go Programming Language (https://www.gopl.io) autorstwa Alana A.A. Donovana i
Briana W. Kernighana (Addison-Wesley) oraz Concurrency in Go (http://bit.ly/2tdCt5j) autorstwa
Katherine Cox-Buday (O’Reilly). Aby zapoznać się z Kubernetesem, sprawdź jedną lub kilka z
następujących pozycji:

Kubernetes in Action, Marko Lukša (Manning);


Kubernetes: Up and Running, wydanie drugie (https://oreil.ly/2SaANU4) Kelsey
Hightower i inni (O’Reilly);
Cloud Native DevOps with Kubernetes (https://oreil.ly/2BaE1iq), John Arundel i Justin
Domingus (O’Reilly);
Managing Kubernetes (https://oreil.ly/2wtHcAm), Brendan Burns i Craig Tracey
(O’Reilly);
Kubernetes Cookbook (http://bit.ly/2FTgJzk), Sébastien Goasguen i Michael Hausenblas
(O’Reilly).

Dlaczego skupiamy się na programowaniu dla Kubernetesa w języku Go?


Przydatna może być tu analogia: Unix został napisany w języku C, a jeśli ktoś
chciał pisać aplikacje lub narzędzia dla Uniksa, domyślnie używał właśnie C.
Ponadto gdy ktoś chciał rozszerzać i dostosowywać Uniksa do własnych
potrzeb, to nawet jeśli korzystał z języka innego niż C, musiał przynajmniej
umieć czytać kod w C.
Kubernetes i wiele powiązanych technologii natywnych działających w chmurze
(od środowisk uruchomieniowych dla kontenerów po narzędzia do
monitorowania takie jak Prometheus) jest napisanych w Go. Uważamy, że
większość aplikacji natywnych będzie oparta na języku Go, dlatego skupiamy
się na nim w tej książce. Jeśli wolisz korzystać z innych języków, obserwuj
repozytorium kubernetes-client w serwisie GitHub (http://bit.ly/2xfSrfT). W
przyszłości może się tam znaleźć klient napisany w Twoim ulubionym języku
programowania.

W tej książce „programowanie dla Kubernetesa” oznacza pisanie natywnych aplikacji dla
platformy Kubernetes, które bezpośrednio komunikują się z serwerem API, kierując zapytania o
stan zasobów i/lub aktualizując go. Nie mamy tu na myśli uruchamiania gotowych aplikacji
takich jak WordPress, Rocket Chat lub Twój ulubiony system CRM dla przedsiębiorstw (takie
narzędzia czasem są nazywane aplikacjami COTS — ang. commercially available off-the-shelf).
Ponadto w rozdziale 7. skupiamy się nie na kwestiach operacyjnych, ale głównie na etapie
pisania i testowania kodu. Tak więc w skrócie można stwierdzić, że ta książka dotyczy pisania
natywnych aplikacji działających w chmurze. Rysunek 1.1 może pomóc Ci lepiej to zrozumieć.

Rysunek 1.1. Różne rodzaje aplikacji działających w Kubernetesie

Widać tu, że dostępne są różne rodzaje rozwiązań:

1. Możesz użyć aplikacji COTS, np. Rocket Chat, i uruchomić ją w Kubernetesie. Sama
aplikacja nie wie, że działa w Kubernetesie, i zwykle nie musi tego wiedzieć. To
Kubernetes kontroluje cykl życia aplikacji — znajduje węzeł, gdzie aplikacja jest
uruchamiana, pobiera obraz, uruchamia kontenery, sprawdza stan, montuje woluminy itd.
2. Możesz uruchomić w Kubernetesie niestandardową aplikację, którą napisałeś od podstaw.
Rozwijając ją, mogłeś myśleć o uruchamianiu jej w Kubernetesie, ale nie jest to konieczne.
Sytuacja wygląda tu podobnie jak dla aplikacji COTS.
3. W tej książce skupiamy się na natywnych aplikacjach dla chmury lub Kubernetesa, które
są w pełni świadome tego, że działają w Kubernetesie, a także wykorzystują w jakimś
stopniu API oraz zasoby Kubernetesa.

Warto ponieść koszty programowania z użyciem API Kubernetesa. Po pierwsze zyskujesz


przenośność, ponieważ aplikacja może działać w dowolnym środowisku (od lokalnych instalacji
po chmury oferowane przez publicznych dostawców). Po drugie możesz korzystać z
przejrzystych deklaratywnych mechanizmów dostępnych w Kubernetesie.

Przejdźmy teraz do konkretnego przykładu.

Przykład wprowadzający
Aby zademonstrować możliwości natywnych aplikacji dla Kubernetesa, załóżmy, że chcesz
zaimplementować narzędzie at, pozwalające zaplanować wykonanie polecenia w określonym
czasie (http://bit.ly/2L4VqzU).

Nowe narzędzie nazwiemy cnat (http://bit.ly/2RpHhON) (od ang. cloud-native at). Będzie ono
działać w następujący sposób: załóżmy, że chcesz wykonać polecenie echo „Natywne
aplikacje dla Kubernetesa są super!” o 2:00 w nocy 3 lipca 2020 r. W narzędziu cnat
można to będzie zrobić tak:

$ cat cnat-rocks-example.yaml

apiVersion: cnat.programming-kubernetes.info/v1alpha1

kind: At
metadata:

name: cnrex

spec:

schedule: “2019-07-03T02:00:00Z”

containers:
- name: shell

image: centos:7

command:

- “bin/bash”
- “-c”

- echo “Natywne aplikacje dla Kubernetesa są super!”

$ kubectl apply -f cnat-rocks-example.yaml


cnat.programming-kubernetes.info/cnrex created

Na zapleczu używane są tu następujące komponenty:

Niestandardowy zasób at.programming-kubernetes.info/cnrex reprezentujący


harmonogram.
Kontroler wykonujący zaplanowane polecenie w odpowiednim czasie.
Ponadto przydatna byłaby wtyczka kubectl zapewniająca interfejs CLI i umożliwiająca prostą
obsługę poleceń takich jak kubectl at „02:00 Jul 3” echo „Natywne aplikacje dla
Kubernetesa są super!”. Nie napiszemy jej w tej książce, jednak potrzebne instrukcje
znajdziesz w dokumentacji Kubernetesa (http://bit.ly/2J1dPuN).

W książce będziemy używać tego przykładu do omawiania aspektów Kubernetesa, jego


wewnętrznych mechanizmów i rozszerzania go.
W bardziej zaawansowanych przykładach z rozdziałów 8. i 9. będziemy symulować działanie
pizzerii z reprezentującymi pizzę i dodatki obiektami w klastrze. Szczegółowe informacje
znajdziesz w podrozdziale „Przykład — pizzeria”.

Wzorce rozszerzania
Kubernetes to dający duże możliwości i z natury rozszerzalny system. Istnieje wiele sposobów
na modyfikowanie i/lub rozszerzanie Kubernetesa, na przykład używanie plików
konfiguracyjnych i opcji (http://bit.ly/2KteqbA) dla komponentów kontrolnych takich jak
kubelet lub serwer API Kubernetesa, a także stosowanie wielu zdefiniowanych punktów
rozszerzeń, którymi są:

Tak zwani dostawcy chmury (http://bit.ly/2FpHInw), w przeszłości będący wewnętrznym


elementem menedżera kontrolera. W wersji 1.11 Kubernetes umożliwia rozwój
zewnętrznych dostawców, udostępniając niestandardowy proces cloud-controller-
manager do integracji rozwiązań z chmurą (http://bit.ly/2WWlcxk). Dostawca chmury
umożliwia używanie narzędzi specyficznych dla danego dostawcy, np. równoważników
obciążenia lub maszyn wirtualnych.
Binarne wtyczki dla narzędzia kubelet do obsługi: sieci (http://bit.ly/2L1tPzm), urządzeń
takich jak karty graficzne (http://bit.ly/2XthLgM), pamięci (http://bit.ly/2x7Unaa) i
środowisk uruchomieniowych dla kontenerów (http://bit.ly/2Zzh1Eq).
Binarne wtyczki dla narzędzia kubectl (http://bit.ly/2FmH7mu).
Rozszerzenia do obsługi dostępu w serwerze API, np. dynamiczna kontrola dostępu z
użyciem webhooków (http://bit.ly/2DwR2Y3; zob. rozdział 9.).
Niestandardowe zasoby (zob. rozdział 4.) i niestandardowe kontrolery; zob. następny
podrozdział.
Niestandardowe serwery API (zob. rozdział 8.).
Rozszerzenia programu szeregującego, np. używanie webhooków (http://bit.ly/2xcg4FL)
do implementowania własnych mechanizmów szeregowania.
Uwierzytelnianie (http://bit.ly/2Oh6DPS) z użyciem webhooków.

W tej książce koncentrujemy się na niestandardowych zasobach, kontrolerach, webhookach i


niestandardowych serwerach API, a także na wzorcach rozszerzania Kubernetesa
(http://bit.ly/2L2SJ1C). Jeśli interesują Cię inne punkty rozszerzeń, np. wtyczki związane z
pamięcią lub siecią, zapoznaj się z oficjalną dokumentacją (http://bit.ly/2Y0L1J9).

Po zapoznaniu się z podstawami wzorców rozszerzeń Kubernetesa i zakresem tej książki pora
przejść do istoty mechanizmów kontroli Kubernetesa i ich rozszerzania.

Kontrolery i operatory
W tym podrozdziale poznasz kontrolery i operatory Kubernetesa oraz dowiesz się, jak działają.
Zgodnie ze słowniczkiem pojęć związanych z Kubernetesem (http://bit.ly/2IWGlxz) kontroler
zawiera pętlę sterowania. Obserwuje współdzielony stan klastra za pomocą serwera API i
wprowadza zmiany, próbując przejść od bieżącego stanu do stanu docelowego.

Przed przejściem do mechanizmów kontrolerów warto zdefiniować terminologię:

Kontrolery mogą operować podstawowymi zasobami, takimi jak instalacje lub usługi,
które zwykle są częścią menedżera kontrolera Kubernetesa (http://bit.ly/2WUAEVy) w
warstwie kontroli. Mogą też obserwować niestandardowe zasoby zdefiniowane przez
użytkownika i operować nimi.
Operatory to kontrolery obejmujące wiedzę operacyjną, np. z zakresu zarządzania cyklem
życia aplikacji, a także niestandardowe zasoby (zdefiniowane w rozdziale 4.).

Naturalne jest to, że ponieważ drugie z tych pojęć jest oparte na pierwszym, najpierw omówimy
kontrolery, a następnie opiszemy bardziej wyspecjalizowane operatory.

Pętla sterowania
Pętla sterowania na ogólnym poziomie wygląda tak:

1. Wczytywanie stanu zasobów, najlepiej w modelu sterowanym zdarzeniami (z użyciem


czujek — ang. watches — co opisujemy w rozdziale 3.). Szczegółowe informacje znajdziesz
w punktach „Zdarzenia” i „Wyzwalacze sterowane zmianami i sterowane poziomem”.
2. Zmiana stanu obiektów w klastrze lub poza klastrem (np. uruchomienie poda, utworzenie
sieciowego punktu końcowego lub skierowanie zapytania do API chmury). Zobacz punkt
„Modyfikowanie świata zewnętrznego lub obiektów w klastrze”.
3. Aktualizowanie za pomocą serwera API statusu zasobu z kroku 1. w systemie etcd.
Zobacz punkt „Współbieżność optymistyczna”.
4. Powtórzenie cyklu (powrót do kroku 1.).

Niezależnie od tego, jak skomplikowany lub prosty jest kontroler, trzy wymienione tu kroki
(wczytywanie stanu zasobów > modyfikowanie świata > aktualizowanie stanu zasobów)
pozostają takie same. Przyjrzyjmy się bliżej temu, jak te kroki są realizowane w kontrolerze w
Kubernetesie. Pętla sterowania jest pokazana na rysunku 1.2, gdzie widoczne są typowe
komponenty. Pośrodku znajduje się główna pętla kontrolera. Ta główna pętla działa nieustannie
w procesie kontrolera. Z kolei proces działa zwykle w podzie w klastrze.
Rysunek 1.2. Pętla sterowania w Kubernetesie

Jeśli chodzi o architekturę, kontroler zwykle używa następujących struktur danych (opisanych
szczegółowo w rozdziale 3.).

Informatory

Informatory obserwują oczekiwany stan zasobów w skalowalny i stabilny sposób. Obsługują


też mechanizm ponownej synchronizacji (zob. punkt „Informatory i buforowanie”), który
wymusza okresowe uzgadnianie stanu. Są często używane do upewniania się, że stan
klastra i zakładany stan zapisany w buforze nie różnią się między sobą (np. z powodu
błędów lub problemów z siecią).

Kolejki zadań

Kolejka zadań to komponent, który może być używany przez mechanizm obsługi zdarzeń do
obsługi kolejkowania zmian stanu i do ponawiania prób. W narzędziu client-go ten
mechanizm jest dostępny w pakiecie workqueue (http://bit.ly/2x7zyeK; zob. punkt „Kolejka
zadań”). Zasoby można ponownie umieszczać w kolejce po wystąpieniu błędów w trakcie
aktualizowania świata zewnętrznego lub zapisywania stanu (kroki 2. i 3. w pętli) lub gdy z
innych powodów trzeba ponownie przetworzyć zasób po upływie jakiegoś czasu.

Bardziej formalne omówienie Kubernetes jako deklaratywnego silnika i narzędzia do zmieniania


stanu znajdziesz w tekście „The Mechanics of Kubernetes” (http://bit.ly/2IV2lcb) autorstwa
Andrew Chena i Dominika Tornowa.

Przyjrzyjmy się teraz dokładniej pętli sterowania. Zaczniemy od sterowanej zdarzeniami


architektury Kubernetesa.

Zdarzenia
Warstwa kontroli w Kubernetesie jest w dużym stopniu oparta na zdarzeniach i zasadzie
luźnego powiązania komponentów. W innych systemach rozproszonych do uruchamiania
operacji są używane wywołania RPC. W Kubernetesie jest inaczej. Kontrolery Kubernetesa
obserwują zmiany w obiektach Kubernetesa na serwerze API: operacje dodawania,
aktualizowania i usuwania. Gdy wystąpi takie zdarzenie, kontroler wykonuje logikę biznesową.
Na przykład aby uruchomić pod w instalacji, potrzebne jest współdziałanie wielu kontrolerów i
innych komponentów z warstwy kontroli:

1. Kontroler instalacji (w menedżerze kube-controller-manager) zauważa (za pomocą


informatora instalacji), że użytkownik tworzy instalację. Kontroler tworzy wtedy za
pomocą logiki biznesowej zbiór replik.
2. Kontroler zbioru replik (także w menedżerze kube-controller-manager) zauważa (za
pomocą informatora zbioru replik) nowy zbiór replik i uruchamia swoją logikę biznesową,
która tworzy obiekt poda.
3. Program szeregujący (w programie binarnym kube-scheduler), który sam też jest
kontrolerem, zauważa pod (za pomocą informatora poda) z pustym polem spec.nodeName.
Logika biznesowa programu szeregującego umieszcza pod w kolejce.
4. W tym czasie kubelet (kolejny kontroler) zauważa nowy pod (za pomocą informatora
poda). Jednak pole spec.nodeName nowego poda jest puste, dlatego nie pasuje do nazwy
węzła z kontrolera kubelet. Kontroler ignoruje więc pod i wraca do trybu uśpienia (do
czasu wystąpienia następnego zdarzenia).
5. Program szeregujący pobiera pod z kolejki zadań i szereguje go do wykonania w węźle,
który ma wystarczającą ilość wolnych zasobów. W tym celu aktualizuje pole
spec.nodeName w podzie i zapisuje pod na serwerze API.
6. Kontroler kubelet znów jest wybudzany w wyniku zdarzenia aktualizacji poda. Ponownie
porównuje wtedy pole spec.nodeName z własną nazwą. Jeśli nazwy pasują, kontroler
kubelet uruchamia kontenery poda i informuje serwer API o włączeniu tych kontenerów,
zapisując to w stanie poda.
7. Kontroler zestawu replik wykrywa zmodyfikowany pod, ale nie musi nic z tym robić.
8. Ostatecznie pod kończy działanie. Kontroler kubelet to wykrywa, pobiera obiekt poda z
serwera API, ustawia warunek „zakończono” w stanie poda i zapisuje pod z powrotem na
serwerze API.
9. Kontroler zbioru replik wykrywa pod, który zakończył działanie, i uznaje, że dany pod
musi zostać zastąpiony.
10. I tak dalej.

Widać tu, że liczne niezależne pętle sterowania komunikują się wyłącznie dzięki modyfikacjom
obiektów na serwerze API i zdarzeń wywoływanych przez te zmiany za pomocą informatorów.
Zdarzenia są wysyłane z serwera API do informatorów w kontrolerach przy użyciu czujek (zob.
punkt „Czujki”), a dokładnie zdarzeń czujek przesyłanych połączeniami strumieniowymi.
Większość tych operacji jest niewidoczna dla użytkowników. Nawet mechanizm audytu serwera
API nie uwidacznia takich zdarzeń. Widoczne są tylko aktualizacje obiektów. Jednak kontrolery
często korzystają z danych z dziennika, gdy mają reagować na zdarzenia.
Jeśli chcesz dowiedzieć się więcej na temat zdarzeń, zapoznaj się z artykułem „Events, the DNA
of Kubernetes” (http://bit.ly/2MZwbl6) z bloga Michaela Gascha. Znajdziesz tam więcej
informacji i przykładów.

Zdarzenia czujek a obiekty zdarzeń


Zdarzenia czujek i obiekty typu Event z najwyższego poziomu są w Kubernetesie dwoma
różnymi rzeczami:
Zdarzenia czujek są przesyłane strumieniowymi połączeniami HTTP między serwerem
API a kontrolerami na potrzeby informatorów.
Obiekty typu Event z najwyższego poziomu to zasoby takie jak pody, instalacje lub
usługi mające specjalną właściwość — ich czas życia to godzina, a następnie są
automatycznie usuwane z systemu etcd.
Obiekty typu Event to widoczny dla użytkowników mechanizm rejestrowania zdarzeń.
Liczne kontrolery tworzą takie zdarzenia, aby informować użytkowników o różnych
aspektach logiki biznesowej. Na przykład kubelet informuje o zdarzeniach dotyczących
cyklu życia podów (np. o uruchomieniu, ponownym uruchomieniu i zamknięciu
kontenera).
Za pomocą narzędzia kubectl można wyświetlić drugą kategorię zdarzeń zachodzących
w klastrze. Poniższe polecenie pozwala zobaczyć, co dzieje się w przestrzeni nazw kube-
system:

$ kubectl -n kube-system get events


LAST SEEN FIRST SEEN COUNT NAME
KIND

3m 3m 1 kube-controller-manager-
master.15932b6faba8e5ad Pod
3m 3m 1 kube-apiserver-master.15932b6fa3f3fbbc
Pod
3m 3m 1 etcd-master.15932b6fa8a9a776
Pod


2m 3m 2 weave-net-7nvnf.15932b73e61f5bc6
Pod
2m 3m 2 weave-net-7nvnf.15932b73efeec0b3
Pod

2m 3m 2 weave-net-7nvnf.15932b73e8f7d318
Pod

Wyzwalacze sterowane zmianami i sterowane


poziomem
Przyjrzyjmy się teraz na bardziej abstrakcyjnym poziomie temu, jak można ustrukturyzować
logikę biznesową implementowaną w kontrolerach i dlaczego w Kubernetesie zdecydowano się
stosować zdarzenia (np. zmiany stanu) do sterowania logiką.
Istnieją dwa podstawowe sposoby wykrywania zmian stanu (czyli samych zdarzeń):

Wyzwalacze sterowane zmianami


Mechanizm obsługi jest wyzwalany w momencie, gdy zachodzi zmiana stanu — np. z
nieuruchomionego poda na uruchomiony pod.

Wyzwalacze sterowane poziomem


Stan jest sprawdzany w regularnych odstępach czasu i jeśli spełnione są określone warunki
(np. pod działa), wyzwalany jest mechanizm obsługi.
Drugie z tych podejść jest formą odpytywania. Technika ta nie skaluje się dobrze wraz ze
wzrostem liczby obiektów, a opóźnienie w kontrolerach wykrywających zmiany zależy od
odstępów między operacjami odpytywania i szybkości odpowiadania przez serwer API. Gdy
używanych jest wiele asynchronicznych kontrolerów, co opisano w punkcie „Zdarzenia”,
powstaje system, który z dużym opóźnieniem reaguje na żądania użytkowników.
Gdy używanych jest wiele obiektów, znacznie wydajniejsza jest pierwsza z tych technik.
Opóźnienie zależy wtedy głównie od liczby wątków roboczych w kontrolerach przetwarzających
zdarzenia. Dlatego Kubernetes jest oparty na zdarzeniach (czyli używane są wyzwalacze
sterowane zmianami).
W warstwie kontroli w Kubernetesie liczne komponenty modyfikują obiekty na serwerze API, a
każda zmiana prowadzi do zdarzenia (czyli zmiany). Takie komponenty są nazywane źródłami
zdarzeń lub generatorami zdarzeń. W kontrolerach istotne jest konsumowanie tych zdarzeń,
czyli to, kiedy i jak reagować na zdarzenia (z użyciem informatorów).
W systemach rozproszonych równolegle działa wiele jednostek, a zdarzenia nadchodzą
asynchronicznie w dowolnej kolejności. Gdy logika kontrolera zawiera błędy, gdy używana jest
nieprawidłowa maszyna stanowa lub gdy nastąpi awaria zewnętrznej usługi, łatwo jest utracić
zdarzenia (w tym sensie, że nie zostają one w pełni przetworzone). Dlatego trzeba dobrze
przyjrzeć się sposobom radzenia sobie z błędami.

Na rysunku 1.3 pokazane są trzy różne strategie:

1. Przykład logiki sterowanej tylko zmianami, gdzie utracona może zostać druga zmiana
stanu.
2. Przykład logiki wyzwalanej zmianami, gdzie jednak przy przetwarzaniu zdarzenia zawsze
pobierany jest najnowszy stan (czyli poziom). Oznacza to, że logika jest wyzwalana
zmianami, ale sterowana poziomem.
3. Przykład logiki wyzwalanej zmianami i sterowanej poziomem z dodatkową
resynchronizacją.

Rysunek 1.3. Opcje wyzwalania (sterowane zmianami i sterowane poziomem)


Strategia nr 1 nie radzi sobie dobrze z pominiętymi zdarzeniami. Zdarzenia mogą zostać
pominięte z powodu utraty zdarzeń w uszkodzonej sieci, błędów w samym kontrolerze lub
przestoju API w zewnętrznej chmurze. Wyobraź sobie, że kontroler zbioru replik zastępuje pody
tylko po zakończeniu przez nie pracy. Pominięcie zdarzeń może oznaczać, że w zbiorze replik
zawsze będzie uruchamiana mniejsza liczba podów, ponieważ nigdy nie jest uzgadniany pełny
stan.
Strategia nr 2 pozwala po takich problemach przywrócić stan, gdy odebrane zostanie następne
zdarzenie. Wynika to z tego, że logika jest oparta na najnowszym stanie klastra. Kontroler
zbioru replik zawsze porównuje wtedy określoną liczbę replik z liczbą podów uruchomionych w
klastrze. Po utracie zdarzenia wszystkie brakujące pody zostaną zastąpione po otrzymaniu
następnej aktualizacji podów.

W strategii nr 3 dodana została ciągła resynchronizacja (np. co pięć minut). Jeśli nie zostaną
zgłoszone nowe zdarzenia dotyczące podów, system uzgodni stan przynajmniej co pięć minut,
nawet jeśli aplikacja działa bardzo stabilnie i nie generuje wielu zdarzeń dotyczących podów.

Z powodu problemów związanych z wyzwalaczami sterowanymi tylko zmianami w kontrolerach


Kubernetesa zwykle stosowana jest strategia nr 3.

Jeśli chcesz dowiedzieć się czegoś więcej na temat źródeł wyzwalaczy oraz powodów
zastosowania w Kubernetesie wyzwalaczy opartych na poziomach i uzgadniania stanu, zapoznaj
się z artykułem „Level Triggering and Reconciliation in Kubernetes” (http://bit.ly/2FmLLAW)
Jamesa Bowesa.

To kończy omawianie różnych abstrakcyjnych sposobów wykrywania zewnętrznych zmian i


reagowania na nie. Następny krok w pętli sterowania z rysunku 1.2 dotyczy modyfikowania
obiektów w klastrze lub zmieniania świata zewnętrznego zgodnie ze specyfikacją. Zajmijmy się
tym teraz.

Modyfikowanie świata zewnętrznego lub obiektów w


klastrze
W tej fazie kontroler zmienia stan nadzorowanych obiektów. Na przykład kontroler ReplicaSet
w menedżerze kontrolera (http://bit.ly/2WUAEVy) nadzoruje pody. Po każdym zdarzeniu
(wyzwalanie zmianami) kontroler wykrywa bieżący stan nadzorowanych podów i porównuje go
z oczekiwanym stanem (sterowanie poziomem).
Ponieważ proces zmiany stanu zasobów może być specyficzny dla domeny lub dla zadania,
trudno jest coś doradzać w tym obszarze. Zamiast tego należy przyjrzeć się wprowadzonemu
wcześniej kontrolerowi ReplicaSet. Takie kontrolery są używane w instalacjach, a podstawowe
zadanie takiego kontrolera to utrzymywanie zdefiniowanej przez użytkownika liczby
identycznych replik podów. Oznacza to, że gdy liczba podów jest mniejsza od podanej (np. z
powodu awarii poda lub zwiększenia liczby replik), kontroler uruchamia nowe pody. Jeśli jednak
podów jest zbyt wiele, kontroler zamyka niektóre z nich. Cała logika biznesowa kontrolera jest
dostępna w pakiecie replica_set.go (http://bit.ly/2L4eKxa), a poniższy fragment kodu w Go
dotyczy zmian stanu (kod został zmodyfikowany pod kątem przejrzystości):

// Funkcja manageReplicas sprawdza i aktualizuje repliki z danego zbioru


ReplicaSet.
// Funkcja NIE modyfikuje kolekcji <filteredPods>.

// Błąd w trakcie tworzenia lub usuwania podów skutkuje ponownym


zakolejkowaniem danego zbioru replik.

func (rsc *ReplicaSetController) manageReplicas(


filteredPods []*v1.Pod, rs *apps.ReplicaSet,
) error {

diff := len(filteredPods) - int(*(rs.Spec.Replicas))


rsKey, err := controller.KeyFunc(rs)
if err != nil {

utilruntime.HandleError(
fmt.Errorf(“Nie można pobrać klucza dla %v %#v: %v”, rsc.Kind,
rs, err),
)

return nil
}
if diff < 0 {

diff *= -1
if diff > rsc.burstReplicas {

diff = rsc.burstReplicas
}

rsc.expectations.ExpectCreations(rsKey, diff)
klog.V(2).Infof(“Za mało replik dla %v %s/%s, potrzebnych %d,
tworzenie %d”,

rsc.Kind, rs.Namespace, rs.Name, *(rs.Spec.Replicas), diff,


)

successfulCreations, err := slowStartBatch(


diff,

controller.SlowStartInitialBatchSize,
func() error {
ref := metav1.NewControllerRef(rs, rsc.GroupVersionKind)

err := rsc.podControl.CreatePodsWithControllerRef(
rs.Namespace, &rs.Spec.Template, rs, ref,

)
if err != nil && errors.IsTimeout(err) {

return nil
}

return err
},
)

if skippedPods := diff - successfulCreations; skippedPods > 0 {


klog.V(2).Infof(“Niepowodzenie przy powolnym rozruchu. Pominięto
“ +
“tworzenie %d podów, zmniejszanie oczekiwań dla %v %v/%v”,

skippedPods, rsc.Kind, rs.Namespace, rs.Name,


)

for i := 0; i < skippedPods; i++ {


rsc.expectations.CreationObserved(rsKey)

}
}

return err
} else if diff > 0 {
if diff > rsc.burstReplicas {

diff = rsc.burstReplicas
}

klog.V(2).Infof(“Zbyt wiele replik dla %v %s/%s, potrzebnych %d,


usuwanie %d”,
rsc.Kind, rs.Namespace, rs.Name, *(rs.Spec.Replicas), diff,

podsToDelete := getPodsToDelete(filteredPods, diff)


rsc.expectations.ExpectDeletions(rsKey, getPodKeys(podsToDelete))

errCh := make(chan error, diff)


var wg sync.WaitGroup

wg.Add(diff)
for _, pod := range podsToDelete {
go func(targetPod *v1.Pod) {

defer wg.Done()
if err := rsc.podControl.DeletePod(

rs.Namespace,
targetPod.Name,

rs,
); err != nil {

podKey := controller.PodKey(targetPod)
klog.V(2).Infof(“Nieudane usuwanie %v, zmniejszanie “ +
“oczekiwań dla %v %s/%s”,
podKey, rsc.Kind, rs.Namespace, rs.Name,

)
rsc.expectations.DeletionObserved(rsKey, podKey)

errCh <- err


}
}(pod)

}
wg.Wait()

select {

case err := <-errCh:


if err != nil {

return err
}
default:

}
}

return nil
}

Widać tu, że w wierszu diff := len(filteredPods) - int(*(rs.Spec.Replicas)) kontroler


oblicza różnicę między specyfikacją a bieżącym stanem, a następnie na podstawie wyniku
realizuje dwa scenariusze:

diff < 0: replik jest za mało, trzeba utworzyć dodatkowe pody.


diff > 0: replik jest za dużo, trzeba usunąć pody.

W funkcji getPodsToDelete zaimplementowana jest strategia wyboru podów, których usunięcie


będzie najmniej szkodliwe.

Jednak zmiana stanu zasobu nie musi oznaczać, że dany zasób jest częścią klastra Kubernetesa.
Kontroler może więc modyfikować stan zasobów zlokalizowanych poza Kubernetesem, np. w
usłudze przechowywania danych w chmurze. Na przykład narzędzie AWS Service Operator
(http://bit.ly/2ItJcif) pozwala zarządzać zasobami w usłudze AWS. Między innymi możliwe jest
zarządzanie komorami S3. Oznacza to, że kontroler S3 nadzoruje zasób (komorę S3), która
istnieje poza Kubernetesem, a zmiany stanu odzwierciedlają konkretne etapy cyklu życia:
komora S3 jest tworzona i w określonym momencie usuwana.

To powinno Cię przekonać, że niestandardowy kontroler pozwala zarządzać nie tylko


podstawowymi zasobami (takimi jak pody) i niestandardowymi zasobami (takimi jak
przykładowe narzędzie cnat), ale nawet przeprowadzać obliczenia lub przechowywać zasoby
poza Kubernetesem. Dzięki temu kontrolery są bardzo elastycznymi i oferującymi duże
możliwości mechanizmami integracji rozwiązań oraz zapewniają jednolity sposób używania
zasobów w różnych platformach i środowiskach.
Współbieżność optymistyczna
W punkcie „Pętla sterowania” opisano w kroku 3., że kontroler (po zaktualizowaniu zgodnie ze
specyfikacją obiektów klastra i/lub świata zewnętrznego) zapisuje wyniki w stanie zasobu, który
wyzwolił uruchomienie kontrolera w kroku 1.
Ten i niemal dowolny inny zapis (także w kroku 2.) może zakończyć się niepowodzeniem. W
systemie rozproszonym kontroler jest zwykle tylko jedną z wielu jednostek aktualizujących
zasoby. Współbieżne zapisy mogą się nie powieść z powodu konfliktów zapisu.
[2]
Aby lepiej zrozumieć, co się nie powiodło, warto przyjrzeć się rysunkowi 1.4 .

Rysunek 1.4. Architektury szeregowania w systemach rozproszonych

W źródłowym tekście architektura równoległego programu szeregującego z systemu Omega


została zdefiniowana tak:
Nasze rozwiązanie to nowa architektura równoległego programu szeregującego oparta na
współdzielonym stanie i wykorzystująca optymistyczną kontrolę współbieżności bez blokad,
aby uzyskać zarówno rozszerzalność implementacji, jak i skalowalność wydajności. Ta
architektura jest używana w Omedze — systemie zarządzania klastrami nowej generacji
opracowanym w firmie Google.
Choć w Kubernetesie wykorzystano wiele cech i lekcji z systemu Borg (http://bit.ly/2XNSv5p),
ten konkretny transakcyjny mechanizm z warstwy kontroli pochodzi z Omegi. W celu
wykonywania współbieżnych operacji bez blokad serwer API Kubernetesa używa
współbieżności optymistycznej.

W skrócie oznacza to, że jeśli (i kiedy) serwer API wykryje próbę współbieżnego zapisu, odrzuca
późniejszą z dwóch operacji zapisu. Następnie to klient (kontroler, program szeregujący,
narzędzie kubectl itd.) musi obsłużyć konflikt i ewentualnie ponowić próbę operacji zapisu.
Poniżej zilustrowano działanie optymistycznej współbieżności w Kubernetesie:
var err error
for retries := 0; retries < 10; retries++ {
foo, err = client.Get(“foo”, metav1.GetOptions{})
if err != nil {
break

<aktualizowanie-świata-i-inne>

_, err = client.Update(foo)

if err != nil && errors.IsConflict(err) {


continue
} else if err != nil {
break

}
}
W tym kodzie pokazana jest pętla z ponawianiem prób, która w każdej iteracji pobiera
najnowszy obiekt foo, a następnie próbuje zaktualizować świat zewnętrzny i stan obiektu foo
zgodnie ze specyfikacją tego obiektu. Zmiany przed wywołaniem Update są wprowadzane w
trybie optymistycznym.

Obiekt foo zwracany przez wywołanie client.Get zawiera wersję zasobu (część zagnieżdżonej
struktury ObjectMeta; szczegóły znajdziesz w punkcie „ObjectMeta”), informującą system etcd
związany z operacją zapisu z wywołania client.Update, że inna jednostka w klastrze w
międzyczasie zapisała obiekt foo. Jeśli taki inny zapis miał miejsce, w pętli z ponawianiem prób
występuje błąd konfliktu wersji zasobu. To oznacza, że logika optymistycznej współbieżności
zawiodła. Wywołanie client.Update jest więc optymistyczne.

Wersja zasobu to para klucz-wartość z systemu etcd. Wersją zasobu dla


każdego obiektu jest łańcuch znaków z Kubernetesa zawierający liczbę
całkowitą. Ta liczba całkowita pochodzi bezpośrednio z systemu etcd. Ten
system zawiera licznik zwiększany za każdym razem, gdy modyfikowana jest
powiązana z kluczem wartość (przechowująca zserializowany obiekt).
W kodzie API wersja zasobu jest (dość konsekwentnie) obsługiwana jak zwykły,
ale uporządkowany w określony sposób łańcuch znaków. To, że ten łańcuch
znaków zawiera liczby całkowite, jest jedynie szczegółem implementacji
obecnie używanego backendu systemu etcd.

Przyjrzyj się konkretnemu przykładowi. Wyobraź sobie, że Twój klient nie jest jedyną jednostką
w klastrze modyfikującą pod. Istnieje też inna jednostka, kubelet, która nieustannie modyfikuje
jakieś pola, ponieważ kontener stale ulega awarii. Twój kontroler wczytuje najnowszy stan
poda:

kind: Pod
metadata:
name: foo
resourceVersion: 57
spec:
...

status:
...
Teraz załóżmy, że kontroler potrzebuje kilku sekund na wprowadzenie zmian w świecie
zewnętrznym. Po siedmiu sekundach kontroler próbuje zaktualizować wczytany pod — np.
zapisuje w nim adnotacje. W tym czasie kubelet wykrywa restart innego kontenera i
aktualizuje stan poda, aby to odzwierciedlić. Oznacza to, że wartość pola resourceVersion
rośnie do 58.
Obiekt przesłany przez kontroler w żądaniu aktualizacji ma pole resourceVersion: 57. Serwer
API próbuje ustawić w systemie etcd klucz dla poda o tej wartości. System etcd wykrywa
niedopasowanie wersji zasobu i zgłasza konflikt wersji 57 i 58. Aktualizacja kończy się
niepowodzeniem.
Podsumowanie tego przykładu jest takie, że programista kontrolera odpowiada za
implementacją strategii ponawiania prób i uwzględniania niepowodzenia optymistycznych
operacji. Nigdy nie wiadomo, jakie jeszcze jednostki modyfikują stan. Mogą to być inne
kontrolery lub kontrolery podstawowe, np. kontroler instalacji.
Oto istota tego fragmentu: błędy spowodowane konfliktami są w kontrolerach czymś zupełnie
normalnym. Zawsze powinieneś się ich spodziewać i obsługiwać je w kontrolowany sposób.
Należy zwrócić uwagę na to, że współbieżność optymistyczna doskonale współdziała z logiką
opartą na poziomach, ponieważ dzięki temu można bez dodatkowych zabiegów ponownie
uruchomić pętlę sterowania (zob. punkt „Wyzwalacze sterowane zmianami i sterowane
poziomem”). W następnym przebiegu pętla automatycznie wycofa optymistyczne zmiany
wprowadzone w nieudanej optymistycznej próbie i spróbuje wprowadzić najnowszy stan w
systemie.
Przejdźmy teraz do konkretnego rodzaju niestandardowych kontrolerów (powiązanego z
niestandardowymi zasobami) — do operatorów.

Operatory
Operatory to mechanizm Kubernetesa wprowadzony przez firmę CoreOS w 2016 r. W swoim
przełomowym wpisie na blogu „Introducing Operators: Putting Operational Knowledge into
Software” (http://bit.ly/2ZC4Rui) Brandon Philips, dyrektor techniczny firmy CoreOS,
zdefiniował operatory tak:
Inżynier SRE to osoba, która operuje aplikacją, pisząc oprogramowanie. Jest to inżynier
(programista), który wie, jak pisać oprogramowanie w dziedzinie powiązanej z konkretną
aplikacją. W wynikowym oprogramowaniu wbudowana jest wiedza z dziedziny działania
aplikacji.
[…]
Tę nową kategorię oprogramowania nazywamy operatorami. Operator to specyficzny dla
aplikacji kontroler, który rozszerza API Kubernetesa, aby tworzyć, konfigurować i
obsługiwać instancje złożonych aplikacji stanowych na rzecz użytkownika Kubernetesa.
Operator jest oparty na podstawowych mechanizmach Kubernetesa, zasobach i
kontrolerach, ale uwzględnia dziedzinę lub wiedzę specyficzną dla aplikacji, aby
automatyzować powtarzalne zadania.
W tej książce używamy operatorów zgodnie z opisem Philipsa. W bardziej formalnym ujęciu
operatory muszą spełniać trzy warunki (zob. też rysunek 1.5):

Rysunek 1.5. Mechanizm operatora

Istnieje specyficzna dla dziedziny wiedza operacyjna, którą chcesz zautomatyzować.


Najlepsze praktyki związane z tą wiedzą operacyjną są znane i można je opisać. Na
przykład w operatorze Cassandra ta wiedza dotyczy tego, kiedy i jak równoważyć węzły, a
w operatorze systemu service mesh — jak tworzyć trasy.
Jednostki udostępniane razem z operatorem to:
Zestaw definicji CRD reprezentujących schemat specyficzny dla dziedziny, a także
niestandardowych zasobów zgodnych z definicjami CRD, które na poziomie instancji
reprezentują daną dziedzinę.
Niestandardowy kontroler nadzorujący niestandardowe zasoby, a czasem także
zasoby podstawowe. Niestandardowy kontroler może np. uruchamiać pody.

Operatory przeszły długą drogę (http://bit.ly/2x5TSNw) od koncepcji i prototypów w 2016 r. do


uruchomienia na początku 2019 r. serwisu OperatorHub.io (https://operatorhub.io) przez firmę
Red Hat (która w 2018 r. przejęła CoreOS i rozwija omawiany mechanizm). Na rysunku 1.6
pokazany jest zrzut tego serwisu z połowy 2019 r. Dostępnych jest na nim 17 operatorów
gotowych do użycia.
Rysunek 1.6. Zrzut serwisu OperatorHub.io

Podsumowanie
W pierwszym rozdziale zdefiniowaliśmy zakres książki i to, czego od Ciebie oczekujemy.
Wyjaśniliśmy, co oznacza tu programowanie dla Kubernetesa. Zdefiniowaliśmy też, czym są
natywne aplikacje dla Kubernetesa. W ramach przygotowań do dalszych przykładów
przedstawiliśmy też ogólne wprowadzenie do kontrolerów i operatorów.
Teraz gdy wiesz już, czego możesz oczekiwać od tej książki i jakie korzyści może przynieść Ci
jej lektura, pora przejść do szczegółów. W następnym rozdziale przyjrzymy się API Kubernetesa,
wewnętrznym mechanizmom serwera API, a także interakcjom z tym API z użyciem narzędzi
wiersza poleceń takich jak curl.

1 Więcej na ten temat dowiesz się z tekstu Megan OʼKeefe: „A Kubernetes Developer Workflow
for MacOS” (http://bit.ly/2WXfzu1), Medium, 24 stycznia 2019, a także z artykułu z bloga
Aleksa Ellisa: „Be KinD to yourself” (http://bit.ly/2XkK9C1), 14 grudnia 2018.
2 Źródło: „Omega: Flexible, Scalable Schedulers for Large Compute Clusters”
(http://bit.ly/2PjYZ59), Malte Schwarzkopf i inni, Google AI, 2013.
Rozdział 2. Podstawy API
Kubernetesa
W tym rozdziale omawiamy podstawy API Kubernetesa. Między innymi szczegółowo opisujemy
wewnętrzne mechanizmy serwera API, samo API, a także interakcje z API z poziomu wiersza
poleceń. Przedstawiamy też zagadnienia związane z API Kubernetesa, np. zasoby i rodzaje (ang.
kinds), a także grupy i wersjonowanie.

Serwer API
Kubernetes obejmuje zestaw węzłów (maszyn w klastrze) pełniących różne role. Ilustruje to
rysunek 2.1. Warstwa kontrolna w węźle nadrzędnym składa się z serwera API, menedżera
kontrolera i programu szeregującego. Serwer API to centralna jednostka zarządzająca i jedyny
komponent, który bezpośrednio komunikuje się z systemem etcd odpowiadającym za
składowanie danych w środowisku rozproszonym.

Rysunek 2.1. Ogólny obraz architektury Kubernetesa


Oto podstawowe zadanie serwera API:

Obsługa API Kubernetesa. To API jest używane wewnątrz klastra przez komponenty
nadrzędne, węzły robocze i natywne aplikacje dla Kubernetesa. Z API korzystają też
zewnętrzne klienty, takie jak kubectl.
Pełnienie roli pośrednika dla komponentów klastra (takich jak panel kontrolny
Kubernetesa), strumieniowe przesyłanie dzienników, obsługa portów i udostępnianie sesji
kubectl exec.

Obsługa API polega na:

Odczycie stanu: pobieraniu pojedynczych obiektów, wyświetlaniu ich i strumieniowym


przesyłaniu zmian.
Operowaniu stanem: tworzeniu, aktualizowaniu i usuwaniu obiektów.

Stan jest utrwalany przez system etcd.


Centralną jednostką Kubernetesa jest serwer API. Jak działa taki serwer? Najpierw
potraktujemy go jak czarną skrzynkę i przyjrzymy się jego interfejsowi HTTP. Dalej przejdziemy
do wewnętrznych mechanizmów serwera API.

Interfejs HTTP serwera API


Z perspektywy klienta serwer API udostępnia interfejs API HTTP REST z danymi w formacie
JSON lub protobuf (http://bit.ly/1HhFC5L) (format protobuf jest używany — ze względu na
wydajność — głównie do komunikacji wewnątrz klastra).
Interfejs HTTP serwera API obsługuje żądania HTTP dotyczące zapytań o zasoby Kubernetesa
lub operacji na nich. Używane są do tego następujące metody HTTP (https://mzl.la/2WX21hL):

Metoda HTTP GET służy do pobierania danych z określonego zasobu (np. danego poda) lub
z kolekcji zasobów (np. wszystkich podów z danej przestrzeni nazw).
Metoda HTTP POST jest przeznaczona do tworzenia zasobów, np. usług lub instalacji.
Metoda HTTP PUT służy do aktualizowania istniejących zasobów, np. do zmiany obrazu
kontenera w podzie.
Metoda HTTP PATCH wprowadza częściowe aktualizacje istniejących zasobów. Zapoznaj
się z punktem „Use a JSON merge patch to update a Deployment” (http://bit.ly/2Xpbi6I) w
dokumentacji Kubernetesa, aby dowiedzieć się więcej na temat dostępnych strategii i
skutków korzystania z nich.
Metoda HTTP DELETE służy do usuwania zasobów w nieodwracalny sposób.

Jeśli przyjrzysz się np. dokumentacji API Kubernetesa 1.14 (http://bit.ly/2IVevBG), znajdziesz
przykłady używania różnych metod HTTP. Na przykład aby wyświetlić pody z bieżącej
przestrzeni nazw (odpowiednik polecenia CLI kubectl -n PRZESTRZEŃ_NAZW get pods), należy
przesłać żądanie GET /api/v1/namespaces/PRZESTRZEŃ_NAZW/pods (rysunek 2.2).
Rysunek 2.2. Używanie interfejsu HTTP serwera API: wyświetlanie podów z określonej przestrzeni
nazw

Wprowadzenie do wywoływania interfejsu HTTP serwera API w programach w języku Go


zawiera punkt „Biblioteka klienta”.

Terminologia związana z API


Przed przejściem do omawiania API warto najpierw zdefiniować pojęcia używane w kontekście
serwera API Kubernetesa:

Rodzaj (ang. kind)

Rodzaj jednostek. Każdy obiekt ma pole Kind (w formacie JSON zapisywane małą literą,
kind, w języku Go zapisywane jako Kind), informujące klienta takiego jak kubectl, że
obiekt reprezentuje np. pod. Są trzy kategorie rodzajów:

Obiekty reprezentują trwałą jednostkę w systemie, np. Pod lub Endpoints. Obiekty mają
nazwy, a wiele obiektów znajduje się w przestrzeniach nazw.
Listy, czyli kolekcje obejmujące jednostki jednego lub kilku rodzajów. Listy mają
ograniczony zbiór standardowych metadanych. Przykładowe listy to PodList i NodeList.
W odpowiedzi na wywołanie kubectl get pods dostajesz właśnie listy.
Rodzaje o specjalnym przeznaczeniu, używane dla określonych operacji na obiektach i dla
jednostek nietrwałych (takich jak /binding lub /scale). Na potrzeby wykrywania
informacji Kubernetes używa rodzajów APIGroup i APIResource. Dla wyników
oznaczających błąd używany jest rodzaj Status.

W programach dla Kubernetesa rodzaj bezpośrednio odpowiada typowi z języka Go. Dlatego
rodzaje, podobnie jak typy w języku Go, mają nazwy w liczbie pojedynczej rozpoczynające się
wielką literą.

Grupa API
Jest to kolekcja logicznie powiązanych rodzajów. Na przykład wszystkie obiekty wsadowe,
np. Job lub ScheduledJob, znajdują się w grupie API obiektów wsadowych.
Wersja
Każda grupa API może mieć (i zwykle ma) wiele wersji. Na przykład grupa najpierw
pojawia się w wersji v1alpha1, następnie jest promowana do wersji v1beta1, a ostatecznie
do wersji v1. Obiekt utworzony w jednej wersji (np. v1beta1) można pobrać w każdej z
obsługiwanych wersji. Serwer API przeprowadza bezstratną konwersję, aby zwracać
obiekty w żądanej wersji. Z perspektywy użytkownika klastra wersje to różne reprezentacje
tych samych obiektów.

Nie można powiedzieć, że „jeden obiekt w klastrze jest w wersji v1, a inny w
wersji v1beta1”. Zamiast tego każdy obiekt można zwrócić jako jego
reprezentację w wersji v1 lub v1beta1, zgodnie z żądaniem użytkownika
klastra.

Zasób

Zwykle zapisywany jest w liczbie mnogiej małą literą (np. pods) i oznacza zestaw punktów
końcowych HTTP (ścieżek) udostępniający operacje CRUD (ang. create, read, update,
delete, czyli tworzenie, wczytywanie, aktualizowanie, usuwanie) dotyczące obiektów
określonego typu w systemie. Często używane ścieżki to:

Ścieżka do katalogu głównego, np. …/pods, wyświetlająca wszystkie instancje danego


typu.
Ścieżka do poszczególnych nazwanych zasobów, np. …/pods/nginx.

Każdy punkt końcowy zwykle zwraca i pobiera jednostki jednego rodzaju (PodList w
pierwszym przypadku i Pod w drugim). Jednak w innych sytuacjach (np. po błędach)
zwracany jest obiekt rodzaju Status.

Oprócz zasobów podstawowych z punktami końcowymi dla wszystkich operacji CRUD


istnieją też zasoby z dodatkowymi punktami końcowymi do wykonywania określonych
operacji (np. …/pod/nginx/port-forward, …/pod/nginx/exec lub …/pod/nginx/logs). Są to
podzasoby (zob. punkt „Podzasoby”). Zamiast protokołu REST zwykle udostępniają one
niestandardowe protokoły, np. jakiegoś rodzaju połączenie strumieniowe z użyciem gniazd
WebSocket lub imperatywne interfejsy API.

Zasoby i rodzaje są czasem mylone. Zwróć jednak uwagę na wyraźną różnicę:


Zasoby odpowiadają ścieżkom HTTP.
Rodzaje to typy obiektów zwracanych i przyjmowanych przez punkty końcowe
oraz utrwalanych w systemie etcd.

Zasoby zawsze są powiązane z grupą API i wersją (razem tworzą identyfikator GVR — ang.
GroupVersionResource). Identyfikator GVR jednoznacznie definiuje ścieżkę HTTP. Konkretna
ścieżka, np. w przestrzeni nazw default, to /apis/batch/v1/namespaces/default/jobs. Na
rysunku 2.3 pokazany jest przykładowy identyfikator GVR dla zasobu Job z określonej
przestrzeni nazw.
Rysunek 2.3. API Kubernetesa — identyfikator GVR

W odróżnieniu od przykładowego identyfikatora GVR jobs zasoby z poziomu klastra, np. węzły i
przestrzenie nazw, nie mają w ścieżce członu $PRZESTRZEŃNAZW. Na przykład identyfikator GVR
dla zasobu nodes może wyglądać tak: /api/v1/nodes. Warto zauważyć, że przestrzenie nazw
pojawiają się w ścieżkach HTTP innych zasobów, jednak same też są zasobem (dostępnym za
pomocą ścieżki /api/v1/namespaces).

Podobnie jak zasoby mają identyfikatory GVR, tak wszystkie rodzaje mają określoną grupę API
oraz wersję i są identyfikowane za pomocą identyfikatorów GVK (ang. GroupVersionKind).

Współistnienie — rodzaje dostępne w wielu grupach API


Rodzaje o tej samej nazwie mogą występować jednocześnie nie tylko w różnych
wersjach, ale też w różnych grupach API. Na przykład rodzaj Deployment pojawił się jako
rodzaj alfa w grupie extensions, a ostatecznie został promowany do stabilnej wersji w
jego własnej grupie — apps.k8s.io. Taki scenariusz to współistnienie (ang.
cohabitation). Choć to podejście nie jest w Kubernetesie często stosowane, istnieją różne
rodzaje tego typu:
Ingress i NetworkPolicy w grupach extensions i networking.k8s.io.
Deployment, DeamonSet i ReplicaSet w grupach extensions i apps.
Event w grupie rodzajów podstawowych i w events.k8s.io.

Identyfikatory GVK i GVR są powiązane ze sobą. Identyfikatory GVK są udostępniane na


podstawie ścieżek HTTP określanych na bazie identyfikatorów GVR. Proces odwzorowywania
identyfikatorów GVK na identyfikatory GVR to odwzorowywanie REST. W punkcie
„Odwzorowania REST” poznasz obiekty RESTMapper, które przeprowadzają odwzorowywanie
REST w języku Go.

W kontekście globalnym przestrzeń zasobów API tworzy logicznie drzewo, w którym węzły
najwyższego poziomu to /api, /apis i niehierarchiczne punkty końcowe takie jak /healthz lub
/metrics. Przykładową grafikę ilustrującą przestrzeń zasobów API znajdziesz na rysunku 2.4.
Warto zauważyć, że kształt tej przestrzeni i ścieżki zależą od wersji Kubernetesa (choć z
upływem lat następuje stabilizacja).
Rysunek 2.4. Przykładowa przestrzeń API Kubernetesa

Wersjonowanie API w Kubernetesie


Aby umożliwić rozszerzalność, Kubernetes obsługuje wiele wersji API w różnych ścieżkach API
(np. /api/v1 lub /apis/extensions/v1beta1). Różne wersje API związane są z różnym poziomem
stabilności i wsparcia technicznego:

Poziom alfa (np. v1alpha1) jest zwykle domyślnie nieaktywny. Obsługa funkcji może
zostać wyłączona w dowolnym momencie bez ostrzeżenia. Takie wersje należy stosować
tylko w tymczasowych klastrach testowych.
Poziom beta (np. v2beta3) jest domyślnie aktywny, co oznacza, że kod jest dobrze
przetestowany. Jednak znaczenie obiektów może się zmienić, tak że staną się one
niekompatybilne z obiektami z późniejszych wersji beta i z wersji stabilnych.
Poziom stabilny (wersja ogólnie dostępna, np. v1) jest używany dla udostępnianego
oprogramowania przez wiele kolejnych wersji.

Przyjrzyj się teraz budowie przestrzeni API HTTP. Na najwyższym poziomie występuje podział
na grupę podstawową (wszystko w ścieżce /api/v1) i grupy nazwane w ścieżkach w formacie
/apis/$NAZWA/$WERSJA.

Grupa podstawowa jest z przyczyn historycznych zlokalizowana w węźle


/api/v1, a nie — jak można oczekiwać — w węźle /apis/core/v1. Grupa
podstawowa istniała jeszcze przed wprowadzeniem grup API.

Istnieje też trzeci typ ścieżek HTTP udostępnianych przez serwer API. Nie są one ściśle
powiązane z zasobami i dotyczą jednostek dostępnych na poziomie klastra. Te ścieżki to np.
/metrics, /logs i /healthz. Oprócz tego serwer API obsługuje czujki. Oznacza to, że zamiast
odpytywać zasoby w ustalonych odstępach czasu, można dodać do określonych żądań człon ?
watch=true, a serwer API przejdzie w tryb używania czujek (http://bit.ly/2x5PnTl).

Deklaratywne zarządzanie stanem


W większości obiektów API obowiązuje rozróżnienie na specyfikację oczekiwanego stanu
zasobu i statusu obiektu w danym momencie. Specyfikacja to kompletny opis oczekiwanego
stanu zasobu, zwykle utrwalony w stabilnym magazynie danych (przeważnie w systemie etcd).

Dlaczego napisane jest „przeważnie w systemie etcd”? No cóż, dostępne są


różne dystrybucje i wersje Kubernetesa, np. k3s (https://k3s.io) lub AKS
Microsoftu, w których system etcd został zastąpiony innym narzędziem. Jest to
możliwe dzięki modułowej architekturze warstwy kontroli Kubernetesa.

Warto dokładniej opisać różnice między specyfikacją (oczekiwanym stanem) a statusem


(aktualnym stanem) na serwerze API.

Specyfikacja opisuje oczekiwany stan zasobu, czyli coś, co należy podać za pomocą narzędzia
wiersza poleceń (np. kubectl) lub programowo w kodzie w języku Go. Status określa
zaobserwowany, aktualny stan zasobu i zależy od warstwy kontroli w postaci komponentów
podstawowych (takich jak menedżer kontrolera) lub własnego niestandardowego kontrolera
(zob. punkt „Kontrolery i operatory”). Na przykład w instalacji można określić, że chcesz, aby
zawsze działało 20 replik aplikacji. Kontroler instalacji (komponent menedżera kontrolera w
warstwie kontroli) wczytuje podaną przez Ciebie specyfikację instalacji i tworzy zbiór replik,
który następnie odpowiada za zarządzanie replikami — tworzy określoną liczbę podów, co
ostatecznie (z użyciem narzędzia kubelet) skutkuje uruchomieniem kontenerów w węzłach
roboczych. Jeśli któraś z replik ulegnie awarii, kontroler instalacji poinformuje o tym,
zmieniając status. Jest to deklaratywne zarządzanie stanem. Użytkownik deklaruje oczekiwany
stan, a Kubernetes zajmuje się pozostałymi zadaniami.
Z deklaratywnym zarządzaniem stanem w praktyce zapoznasz się w następnym podrozdziale,
gdy zaczniemy eksplorować API za pomocą wiersza poleceń.

Używanie API w wierszu poleceń


W tym podrozdziale użyjemy narzędzi kubectl i curl, aby zademonstrować używanie API
Kubernetesa. Jeśli nie znasz tych narzędzi wiersza poleceń, teraz jest dobry czas, by je
zainstalować i wypróbować.
Na początek przyjrzyj się oczekiwanemu i zaobserwowanemu stanowi zasobu. Użyjesz do tego
komponentu warstwy kontroli, który jest dostępny w prawie każdym klastrze. Jest to wtyczka
CoreDNS (w starszych wersjach Kubernetesa używane było narzędzie kube-dns) z przestrzeni
nazw kube-system (dane wyjściowe zostały mocno zmodyfikowane, aby wyróżnić istotne
fragmenty):

$ kubectl -n kube-system get deploy/coredns -o=yaml


apiVersion: apps/v1

kind: Deployment
metadata:
name: coredns
namespace: kube-system
...
spec:

template:
spec:

containers:
- name: coredns

image: 602401143452.dkr.ecr.us-east-
2.amazonaws.com/eks/coredns:v1.2.2
...

status:
replicas: 2

conditions:
- type: Available

status: “True”
lastUpdateTime: “2019-04-01T16:42:10Z”
...

W tym poleceniu kubectl widać, że w sekcji spec instalacji zdefiniowane są aspekty takie jak
używany obraz kontenera i liczba równolegle uruchamianych replik. Z kolei z sekcji status
można się dowiedzieć, ile replik działa w danym momencie.

Do wykonywania instrukcji w wierszu poleceń w pozostałej części rozdziału używane będą


operacje wsadowe. Zacznij od wykonania w terminalu następującej instrukcji:

$ kubectl proxy --port=8080


Starting to serve on 127.0.0.1:8080
To polecenie ustawia dla API Kubernetesa pośrednika w postaci maszyny lokalnej, a także
określa sposób uwierzytelniania i autoryzacji. Dzięki temu można bezpośrednio zgłaszać
żądania za pomocą protokołu HTTP i otrzymywać w odpowiedzi dane w formacie JSON. Zrób to
— uruchom drugą sesję terminala, w której skierujesz zapytania o wersję v1:

$ curl http://127.0.0.1:8080/apis/batch/v1
{

“kind”: “APIResourceList”,
“apiVersion”: “v1”,

“groupVersion”: “batch/v1”,
“resources”: [
{

“name”: “jobs”,
“singularName”: “”,
“namespaced”: true,

“kind”: “Job”,
“verbs”: [
“create”,

“delete”,
“deletecollection”,

“get”,
“list”,

“patch”,
“update”,

“watch”
],
“categories”: [

“all”
]

},
{

“name”: “jobs/status”,
“singularName”: “”,

“namespaced”: true,
“kind”: “Job”,
“verbs”: [

“get”,
“patch”,

“update”
]

}
]

Nie musisz używać narzędzia curl i polecenia kubectl proxy, aby uzyskać
bezpośredni dostęp do API Kubernetesa za pomocą HTTP. Zamiast tego możesz
zastosować polecenie kubectl get --raw. Zastąp np. instrukcję curl
http://127.0.0.1:8080/apis/batch/v1 poleceniem kubectl get --raw
/apis/batch/v1.
Porównaj to z wersją v1beta1. Zauważ, że możesz pobrać listę obsługiwanych wersji dla grupy
API batch, używając ścieżki http://127.0.0.1:8080/apis/batch. A oto informacje o wersji
v1beta1:

$ curl http://127.0.0.1:8080/apis/batch/v1beta1
{
“kind”: “APIResourceList”,

“apiVersion”: “v1”,
“groupVersion”: “batch/v1beta1”,

“resources”: [
{

“name”: “cronjobs”,
“singularName”: “”,

“namespaced”: true,
“kind”: “CronJob”,
“verbs”: [

“create”,
“delete”,

“deletecollection”,
“get”,

“list”,
“patch”,

“update”,
“watch”
],

“shortNames”: [
“cj”

],
“categories”: [

“all”
]

},
{
“name”: “cronjobs/status”,

“singularName”: “”,
“namespaced”: true,
“kind”: “CronJob”,

“verbs”: [
“get”,
“patch”,

“update”
]

}
]

}
Widać tu, że wersja v1beta1 zawiera zasób cronjobs rodzaju CronJob. W czasie, gdy powstaje
ta książka, zadania crona nie zostały jeszcze promowane do wersji v1.

Jeśli chcesz wiedzieć, jakie zasoby API są obsługiwane w Twoim klastrze, a także poznać ich
rodzaje, ustalić, czy znajdują się w przestrzeni nazw, oraz poznać ich skrócone nazwy (głównie
do użytku z narzędziem kubectl w wierszu poleceń), możesz posłużyć się następującą
instrukcją:
$ kubectl api-resources

NAME SHORTNAMES APIGROUP NAMESPACED KIND


bindings true Binding
componentstatuses cs false ComponentStatus

configmaps cm true ConfigMap


endpoints ep true Endpoints

events ev true Event


limitranges limits true LimitRange

namespaces ns false Namespace


nodes no false Node

persistentvolumeclaims pvc true


PersistentVolumeClaim
persistentvolumes pv false PersistentVolume

pods po true Pod


podtemplates true PodTemplate

replicationcontrollers rc true
ReplicationController
resourcequotas quota true ResourceQuota

secrets true Secret


serviceaccounts sa true ServiceAccount

services svc true Service


controllerrevisions apps true ControllerRevision
daemonsets ds apps true DaemonSet
deployments deploy apps true Deployment

...
Poniżej pokazane jest powiązane polecenie, które może być bardzo przydatne przy ustalaniu
różnych wersji zasobu obsługiwanych w danym klastrze:

$ kubectl api-versions
admissionregistration.k8s.io/v1beta1

apiextensions.k8s.io/v1beta1
apiregistration.k8s.io/v1

apiregistration.k8s.io/v1beta1
appmesh.k8s.aws/v1alpha1
appmesh.k8s.aws/v1beta1

apps/v1
apps/v1beta1

apps/v1beta2
authentication.k8s.io/v1

authentication.k8s.io/v1beta1
authorization.k8s.io/v1

authorization.k8s.io/v1beta1
autoscaling/v1
autoscaling/v2beta1

autoscaling/v2beta2
batch/v1

batch/v1beta1
certificates.k8s.io/v1beta1

coordination.k8s.io/v1beta1
crd.k8s.amazonaws.com/v1alpha1

events.k8s.io/v1beta1
extensions/v1beta1
networking.k8s.io/v1

policy/v1beta1
rbac.authorization.k8s.io/v1

rbac.authorization.k8s.io/v1beta1
scheduling.k8s.io/v1beta1

storage.k8s.io/v1
storage.k8s.io/v1beta1

v1

W jaki sposób serwer API przetwarza


żądania?
Poznałeś już interfejs HTTP służący do komunikacji z zewnętrznymi jednostkami. Teraz pora
skupić się na wewnętrznych mechanizmach serwera API. Na rysunku 2.5 pokazany jest ogólny
przegląd przetwarzania żądań na serwerze API.

Rysunek 2.5. Przetwarzanie żądań przez serwer API Kubernetesa

Co się dzieje, gdy żądanie HTTP trafia do API Kubernetesa? Na ogólnym poziomie zachodzą
następujące interakcje:

1. Żądanie HTTP jest przetwarzane przez łańcuch filtrów zarejestrowanych w funkcji


DefaultBuildHandlerChain(). Ten łańcuch jest zdefiniowany w pliku
k8s.io/apiserver/pkg/server/config.go (http://bit.ly/2x9t27e) i omówiony dalej w tekście. W
łańcuchu stosowana jest seria operacji filtrowania przesyłanego żądania. Żądanie może
przejść przez filtr, co powoduje dołączenie do kontekstu odpowiednich informacji —
ctx.RequestInfo, gdzie ctx to kontekst (https://golang.org/pkg/context) używany w Go,
np. uwierzytelniony użytkownik. Jeśli żądanie nie przejdzie przez filtr, zwraca odpowiedni
kod odpowiedzi HTTP z powodem niepowodzenia (np. odpowiedź 401,
https://httpstatuses.com/401, oznacza nieudane uwierzytelnianie użytkownika).
2. Następnie, w zależności od używanej ścieżki HTTP, multiplekser z pliku
k8s.io/apiserver/pkg/server/handler.go (http://bit.ly/2WUd0c6) przekierowuje żądanie
HTTP do odpowiedniej metody obsługi żądań.
3. Dla każdej grupy API zarejestrowane są funkcja obsługi żądań (zob.
k8s.io/apiserver/pkg/endpoints/groupversion.go — http://bit.ly/2IvvSKA i
k8s.io/apiserver/pkg/endpoints/installer.go — http://bit.ly/2Y1eySV). Funkcja obsługi
żądań przyjmuje żądanie HTTP i kontekst (np. konto i uprawnienia dostępu), a także
pobiera i zapisuje żądane obiekty z systemu etcd.

Przyjrzyj się teraz łańcuchowi filtrów tworzonemu przez metodę DefaultBuildHandlerChain()


w pliku server/config.go (http://bit.ly/2LWUUnQ). Zobacz, co dzieje się w każdym z tych filtrów:
func DefaultBuildHandlerChain(apiHandler http.Handler, c *Config)
http.Handler {
h := WithAuthorization(apiHandler, c.Authorization.Authorizer,
c.Serializer)
h = WithMaxInFlightLimit(h, c.MaxRequestsInFlight,
c.MaxMutatingRequestsInFlight, c.LongRunningFunc)

h = WithImpersonation(h, c.Authorization.Authorizer, c.Serializer)


h = WithAudit(h, c.AuditBackend, c.AuditPolicyChecker, LongRunningFunc)
...
h = WithAuthentication(h, c.Authentication.Authenticator, failed, ...)
h = WithCORS(h, c.CorsAllowedOriginList, nil, nil, nil, “true”)

h = WithTimeoutForNonLongRunningRequests(h, LongRunningFunc,
RequestTimeout)
h = WithWaitGroup(h, c.LongRunningFunc, c.HandlerChainWaitGroup)
h = WithRequestInfo(h, c.RequestInfoResolver)

h = WithPanicRecovery(h)
return h
}
Wszystkie potrzebne pakiety znajdują się w katalogu k8s.io/apiserver/pkg
(http://bit.ly/2LUzTdx). Oto opis wybranych metod:

WithPanicRecovery()
Odpowiada za przywracanie stanu i rejestrowanie paniki. Zdefiniowana w pliku
server/filters/wrap.go (http://bit.ly/2N0zfNB).
WithRequestInfo()
Dołącza wartość typu RequestInfo do kontekstu. Zdefiniowana w pliku
endpoints/filters/requestinfo.go (http://bit.ly/2KvKjQH).

WithWaitGroup()
Dodaje wszystkie krótkie żądania do grupy oczekujących. Używana do kontrolowanego
zamykania programu. Zdefiniowana w pliku server/filters/waitgroup.go
(http://bit.ly/2ItnsD6).
WithTimeoutForNonLongRunningRequests()

Zgłasza przekroczenie limitu czasu krótkotrwałych żądań (jest to większość żądań GET, PUT,
POST i DELETE), różniących się od długotrwałych, np. związanych z czujkami i pośrednikami.
Zdefiniowana w pliku server/filters/timeout.go (http://bit.ly/2KrKk8r).
WithCORS()
Zapewnia implementację mechanizmu CORS (https://enable-cors.org). CORS (ang. cross-
origin resource sharing) to mechanizm umożliwiający kodowi w JavaScripcie
zagnieżdżonemu na stronie HTML zgłaszanie żądań XMLHttpRequest do domen innych niż
źródłowa domena tego kodu. Ta metoda jest zdefiniowana w pliku server/filters/cors.go
(http://bit.ly/2L2A6uJ).
WithAuthentication()
Próbuje uwierzytelnić dane żądanie (jako użytkownik ludzki lub maszyna) i zapisuje
informacje o użytkowniku w podanym kontekście. Po powodzeniu z żądania usuwany jest
nagłówek HTTP Authorization. Jeśli uwierzytelnianie się nie powiedzie, zwracany jest kod
HTTP 401. Metoda jest zdefiniowana w pliku endpoints/filters/authentication.go
(http://bit.ly/2Fjzr4b).
WithAudit()
Dodaje do funkcji obsługi żądania informacje z dziennika kontroli dla wszystkich
przychodzących żądań. Wpisy z dziennika kontroli obejmują informacje takie jak źródłowy
adres IP żądania, użytkownik wywołujący operację i przestrzeń nazw żądania. Metoda
zdefiniowana jest w pliku admission/audit.go (http://bit.ly/2XpQN9U).
WithImpersonation()

Obsługuje podawanie się za innego użytkownika, sprawdzając żądania dotyczące próby


zmiany użytkownika (podobne do polecenia sudo). Zdefiniowana jest w pliku
endpoints/filters/impersonation.go (http://bit.ly/2L2UETP).
WithMaxInFlightLimit()
Ogranicza liczbę aktualnie przetwarzanych żądań. Zdefiniowana w pliku
server/filters/maxinflight.go (http://bit.ly/2IY4unl).
WithAuthorization()
Sprawdza uprawnienia, wywołując moduły uwierzytelniania, i przekazuje wszystkie
uwierzytelnione żądania do multipleksera, który kieruje żądanie do odpowiedniej funkcji
obsługi. Jeśli użytkownik nie ma wystarczających uprawnień, metoda zwraca kod HTTP
403. W Kubernetesie obecnie używana jest kontrola dostępu oparta na rolach. Metoda
zdefiniowana jest w pliku endpoints/filters/authorization.go (http://bit.ly/31M2NSA).
Po przejściu ogólnego łańcucha funkcji obsługi żądań (pierwsze pole na rysunku 2.5)
rozpoczyna się właściwe przetwarzanie żądania (wykonywana jest logika funkcji obsługi
żądania):

Żądania ścieżek /, /version, /apis, /healthz i innych API nie-REST są obsługiwane


bezpośrednio.
Żądania zasobów REST trafiają do potoku obejmującego:

Kontrolę dostępu
Przychodzące obiekty przechodzą przez łańcuch kontroli dostępu. Obejmuje on ok. 20
[1]
różnych wtyczek . Każda wtyczka może być używana na etapie modyfikacji (trzecie
pole na rysunku 2.5), na etapie sprawdzania poprawności (czwarte pole na rysunku)
lub na obu tych etapach.
Na etapie modyfikacji zmieniana może być treść przychodzącego żądania. Na przykład
polityka pobierania obrazów w zależności od konfiguracji procesu kontroli dostępu ma
wartość Always, IfNotPresent lub Never.
Drugi etap kontroli dostępu służy wyłącznie do sprawdzania poprawności. Sprawdzane
są np. ustawienia zabezpieczeń w podach lub dostępność przestrzeni nazw przed
utworzeniem obiektów w tej przestrzeni.
Sprawdzanie poprawności
Przychodzące obiekty są sprawdzane za pomocą rozbudowanej logiki dostępnej dla
wszystkich typów obiektów z systemu. Sprawdzane są np. formaty łańcuchów znaków
(aby mieć pewność, że w nazwach usług używane są tylko poprawne znaki zgodne z
nazwami DNS) lub to, czy wszystkie nazwy kontenerów w podzie są unikatowe.

Operacje CRUD z wykorzystaniem systemu etcd


Tu zaimplementowane są różne operacje opisane w punkcie „Interfejs HTTP serwera
API”. Na przykład aktualizacja wymaga wczytania obiektu z systemu etcd,
sprawdzenia, czy żaden inny użytkownik nie zmodyfikował obiektu (w modelu z punktu
„Współbieżność optymistyczna”), i — jeśli takich modyfikacji nie odnotowano —
zapisania obiektu żądania w systemie etcd.
Wszystkie te etapy są szczegółowo opisane w dalszych rozdziałach, np.:
Niestandardowe zasoby

Sprawdzanie poprawności w punkcie „Sprawdzanie poprawności niestandardowych


zasobów”, kontrola dostępu w punkcie „Webhooki związane z kontrolą dostępu”, a ogólne
operacje CRUD w rozdziale 4.
Natywne zasoby w języku Go
Sprawdzanie poprawności w punkcie „Sprawdzanie poprawności”, kontrola dostępu w
punkcie „Kontrola dostępu”, a implementacja operacji CRUD w punkcie „Rejestr i
strategie”.

Podsumowanie
W tym rozdziale najpierw opisaliśmy serwer API Kubernetesa jako czarną skrzynkę i
przyjrzeliśmy się jego interfejsowi HTTP. Dalej dowiedziałeś się, jak komunikować się z tą
czarną skrzynką za pomocą wiersza poleceń. Na koniec otworzyliśmy tę czarną skrzynkę i
zbadaliśmy jej wewnętrzne mechanizmy. Na tym etapie powinieneś już wiedzieć, jak działają
wewnętrzne mechanizmy serwera API i jak komunikować się z nim za pomocą narzędzia
wiersza poleceń kubectl, aby badać zasoby i operować nimi.

Teraz pora przejść od ręcznych interakcji w wierszu poleceń do programowego dostępu do


serwera API z użyciem języka Go. Poznaj klienta client-go — podstawę „biblioteki
standardowej” Kubernetesa.

1 W klastrze Kubernetesa 1.14 są to (w podanej tu kolejności): AlwaysAdmit,


NamespaceAutoProvision, NamespaceLifecycle, NamespaceExists, SecurityContextDeny,
LimitPodHardAntiAffinityTopology, PodPreset, LimitRanger, ServiceAccount,
NodeRestriction, TaintNodesByCondition, AlwaysPullImages, ImagePolicyWebhook,
PodSecurityPolicy, PodNodeSelector, Priority, DefaultTolerationSeconds,
PodTolerationRestriction, DenyEscalatingExec, DenyExecOnPrivileged, EventRateLimit,
ExtendedResourceToleration, PersistentVolumeLabel, DefaultStorageClass,
StorageObjectInUseProtection, OwnerReferencesPermissionEnforcement,
PersistentVolumeClaimResize, MutatingAdmissionWebhook, ValidatingAdmissionWebhook,
ResourceQuota i AlwaysDeny.
Rozdział 3. Podstawy klienta client-go
Teraz skupimy się na interfejsie programowania Kubernetesa w języku Go. Dowiesz się, jak używać w
Kubernetesie API znanych typów natywnych takich jak pody, usługi i instalacje. W dalszych rozdziałach
rozwiniemy omówienie tych technik o typy definiowane przez użytkownika. Jednak tu najpierw skoncentrujemy
się na wszystkich obiektach API dostępnych w każdym klastrze Kubernetesa.

Repozytoria
Projekt Kubernetes obejmuje w repozytorium kubernetes w serwisie GitHub wiele repozytoriów systemu Git
dostępnych do użytku przez niezależnych programistów. Musisz zaimportować je do projektu, używając aliasu
domeny k8s.io/… (zamiast nazwy github.com/kubernetes/…). W kolejnych punktach omawiamy najważniejsze z
dostępnych repozytoriów.

Biblioteka klienta
Interfejs programowania Kubernetesa w języku Go składa się przede wszystkim z biblioteki k8s.io/client-go (aby
zachować zwięzłość, dalej jest ona nazywana client-go). Jest to typowa biblioteka klienta usług sieciowych
obsługująca wszystkie typy API oficjalnie dostępne w Kubernetesie. Można jej używać do wykonywania
standardowych operacji REST:

CREATE,
GET,
LIST,
UPDATE,
DELETE,
PATCH.

Wszystkie te operacje REST są zaimplementowane z użyciem interfejsu HTTP serwera API (zob. „Interfejs HTTP
serwera API”). Dostępna jest też operacja Watch, przeznaczona specjalnie dla API podobnych do API
Kubernetesa. Jest ona jedną z głównych różnic w porównaniu z innymi interfejsami API.

Biblioteka client-go (http://bit.ly/2RryyLM) jest dostępna w serwisie GitHub (zob. rysunek 3.1) i używana w
kodzie w języku Go za pomocą pakietu k8s.io/client-go. Jest udostępniana równolegle z wersjami Kubernetesa —
dla każdej wersji 1.x.y Kubernetesa istnieje wersja biblioteki client-go z analogicznym oznaczeniem
kubernetes-1.x.y.
Rysunek 3.1. Repozytorium client-go w serwisie GitHub

Ponadto istnieje schemat wersjonowania semantycznego. Na przykład wersja 9.0.0 biblioteki client-go jest
powiązana z wersją 1.12 Kubernetesa, wersja 10.0.0 biblioteki client-go jest powiązana z wersją 1.13
Kubernetesa itd. W przyszłości mogą pojawić się bardziej szczegółowo numerowane podwersje. Obok kodu
klienckiego dla obiektów API Kubernetesa biblioteka client-go zawiera też rozbudowany ogólny kod biblioteki.
Jest on używany także dla obiektów API zdefiniowanych przez użytkownika (zob. rozdział 4.). Na rysunku 3.1
pokazana jest lista dostępnych pakietów.
Choć wszystkie pakiety mają swoje zastosowania, większość kodu komunikująca się z API Kubernetesa używa
pakietu tools/clientcmd/ do konfigurowania klienta na podstawie pliku kubeconfig i pakietu kubernetes/ na
potrzeby samych klientów API Kubernetesa. Potrzebny kod zostanie przedstawiony wkrótce. Najpierw jednak
zakończmy ten krótki przegląd, omawiając inne ważne repozytoria i pakiety.

Typy w API Kubernetesa


Zobaczyłeś już, że biblioteka client-go zawiera interfejsy klienckie. Typy języka Go powiązane z API
Kubernetesa, np. z obiektami takimi pody, usługi i instalacje, są umieszczone w odrębnym repozytorium
(http://bit.ly/2ZA6dWH). W kodzie w Go można ich używać za pomocą biblioteki k8s.io/api.
Pody są częścią dawnej grupy API (często nazywaną grupą podstawową, ang. core) z wersji v1. Dlatego typ Pod
w Go znajduje się w pakiecie k8s.io/api/core/v1. To samo dotyczy innych typów z API Kubernetesa. Na rysunku
3.2 pokazana jest lista pakietów. Większość z nich odpowiada grupom API z Kubernetesa i ich wersjom.

Rysunek 3.2. Repozytorium API w serwisie GitHub

Typy języka Go są umieszczone w plikach types.go (np. k8s.io/api/core/v1/types.go). Ponadto istnieją też inne
pliki. Większość z nich jest automatycznie tworzona przez generator kodu.

Repozytorium API Machinery


Istnieje też trzecie, nie mniej ważne repozytorium — API Machinery (http://bit.ly/2xAZiR2). W Go jest ono
dostępne jako pakiet k8s.io/apimachinery. Znajdują się tam wszystkie ogólne cegiełki do implementowania API
podobnych do API Kubernetesa. Repozytorium API Machinery dotyczy nie tylko zarządzania kontenerami. Można
je wykorzystać np. do utworzenia API dla sklepu internetowego lub w innej dziedzinie biznesowej.
W Go w kodzie natywnym dla Kubernetesa zetkniesz się z wieloma pakietami z repozytorium API Machinery.
Jednym z ważnych pakietów tego typu jest k8s.io/apimachinery/pkg/apis/meta/v1. Zawiera on wiele ogólnych
typów API, np. ObjectMeta, TypeMeta, GetOptions i ListOptions (zob. rysunek 3.3).
Rysunek 3.3. Repozytorium API Machinery w serwisie GitHub

Tworzenie i używanie klientów


Znasz już wszystkie cegiełki potrzebne do utworzenia obiektu klienckiego dla Kubernetesa. Oznacza to, że
możesz uzyskać dostęp do zasobów z klastra Kubernetesa. Jeśli masz dostęp klastra w swoim lokalnym
środowisku (wymaga to skonfigurowania narzędzia kubectl i danych uwierzytelniających), poniższy kod pozwoli
Ci używać klienta client-go w projekcie w języku Go:

import (
metav1 “k8s.io/apimachinery/pkg/apis/meta/v1”

“k8s.io/client-go/tools/clientcmd”
“k8s.io/client-go/kubernetes”

kubeconfig = flag.String(“kubeconfig”, “~/.kube/config”, “kubeconfig file”)


flag.Parse()

config, err := clientcmd.BuildConfigFromFlags(“”, *kubeconfig)


clientset, err := kubernetes.NewForConfig(config)

pod, err := clientset.CoreV1().Pods(“book”).Get(“example”, metav1.GetOptions{})

Ten kod importuje pakiet meta/v1, aby uzyskać dostęp do metody metav1.GetOptions. Ponadto importowany
jest pakiet clientcmd z biblioteki client-go, aby móc wczytywać i przetwarzać plik kubeconfig (z konfiguracją
klienta obejmującą nazwę serwera, dane uwierzytelniające itd.). Następnie importowany jest pakiet kubernetes
z biblioteki client-go ze zbiorami klientów dla zasobów Kubernetesa.

Plik kubeconfig domyślnie znajduje się w katalogu .kube/config w katalogu głównym użytkownika. Z tego miejsca
narzędzie kubectl pobiera dane uwierzytelniające dla klastrów Kubernetesa.
Plik kubeconfig jest wczytywany i przetwarzany za pomocą metody clientcmd.BuildConfigFromFlags. W tym
kodzie pomijamy obowiązkową obsługę błędów, jednak zmienna err standardowo zawierałaby np. błąd składni,
gdyby plik kubeconfig miał niewłaściwą składnię. Ponieważ w kodzie w Go błędy składni są częste, należałoby
odpowiednio go wykrywać:
config, err := clientcmd.BuildConfigFromFlags(“”, *kubeconfig)

if err != nil {
fmt.Printf(“Nie można wczytać pliku kubeconfig: %v\n”, err

os.Exit(1)
}

Za pomocą funkcji clientcmd.BuildConfigFromFlags można pobrać wartość typu rest.Config (ten typ
dostępny jest w pakiecie k8s.io/client-go/rest). Ta wartość jest przekazywana do funkcji
kubernetes.NewForConfig, aby utworzyć zbiór klientów Kubernetesa. Nazwa zbiór klientów wynika z tego, że
obejmuje on różne klienty dla wszystkich natywnych zasobów Kubernetesa.

Gdy w podzie w klastrze uruchamiasz plik binarny, narzędzie kubelet automatycznie montuje konto usługi w
kontenerze, używając ścieżki /var/run/secrets/kubernetes.io/serviceaccount. To konto jest używane zamiast
wspomnianego wcześniej pliku kubeconfig i może zostać łatwo przekształcone w wartość typu rest.Config za
pomocą metody rest.InClusterConfig(). Często spotykane jest połączenie wywołań rest.InClusterConfig()
i clientcmd.BuildConfigFromFlags() oraz korzystania ze zmiennej środowiskowej KUBECONFIG:
config, err := rest.InClusterConfig()

if err != nil {
// Rozwiązanie rezerwowe — użycie pliku kubeconfig.

kubeconfig := filepath.Join(“~”, “.kube”, “config”)


if envvar := os.Getenv(“KUBECONFIG”); len(envvar) >0 {

kubeconfig = envvar
}

config, err = clientcmd.BuildConfigFromFlags(“”, kubeconfig)


if err != nil {

fmt.Printf(“Nie można wczytać pliku kubeconfig: %v\n”, err

os.Exit(1)
}

}
W kolejnym przykładzie pobierana jest grupa podstawowa z wersji v1 (wywołanie clientset.Corev1()), a
następnie kod uzyskuje dostęp do poda „example” z przestrzeni nazw „book”:
pod, err := clientset.CoreV1().Pods(“book”).Get(“example”, metav1.GetOptions{})

Warto zauważyć, że tylko ostatnie wywołanie, Get, rzeczywiście wymaga dostępu do serwera. Wywołania CoreV1
i Pods jedynie pobierają klienta i ustawiają przestrzeń nazw na potrzeby późniejszego wywołania Get (często
stosowana nazwa tej techniki to wzorzec budowniczy; tu służy ona do budowania żądania).
Wywołanie Get przesyła żądanie HTTP GET ścieżki /api/v1/namespaces/book/pods/example z serwera zapisanego
w pliku kubeconfig. Jeśli serwer API Kubernetesa zwróci kod HTTP 200, w treści odpowiedzi znajdą się
zakodowane obiekty podów — albo w formacie JSON (jest do domyślny format przesyłania danych w bibliotece
client-go), albo w formacie protobuf.

Możesz włączyć format protobuf dla klientów natywnych zasobów Kubernetesa, modyfikując
konfigurację REST przed utworzeniem klienta na jej podstawie:
cfg, err := clientcmd.BuildConfigFromFlags(“”, *kubeconfig)

cfg.AcceptContentTypes = “application/vnd.kubernetes.protobuf,
application/json”

cfg.ContentType = “application/vnd.kubernetes.protobuf”
clientset, err := kubernetes.NewForConfig(cfg)

Warto zauważyć, że omawiane w rozdziale 4. niestandardowe zasoby nie obsługują formatu


protobuf.

Wersjonowanie i kompatybilność
API Kubernetesa są wersjonowane. W poprzednim punkcie pokazane zostało, że pody znajdują się w wersji v1
grupy podstawowej. Grupa podstawowa obecnie istnieje w tylko jednej wersji. Istnieją jednak także inne grupy.
Na przykład grupa apps jest dostępna (w czasie, gdy powstaje ta książka) w wersjach v1, v1beta2 i v1beta1.
Jeśli przyjrzysz się pakietowi k8s.io/api/apps (http://bit.ly/2L1Nyio), znajdziesz w nim wszystkie obiekty API z
tych wersji. W pakiecie k8s.io/client-go/kubernetes/typed/apps (http://bit.ly/2x45Uab) znajdują się implementacje
klientów dla wszystkich tych wersji.

Wszystkie te wersje dotyczą tylko kodu klienckiego. Nie mówią nic o klastrze Kubernetesa ani o serwerze API.
Użycie klienta wymagającego wersji grupy API, której serwer API nie obsługuje, zakończy się niepowodzeniem.
Klienty mają zapisaną na stałe wersję, a autor aplikacji musi wybrać odpowiednią wersję grupy API, aby móc
komunikować się z używanym klastrem. W punkcie „Wersje API i gwarancje kompatybilności” dowiesz się więcej
na temat gwarancji kompatybilności grup API.
Drugi aspekt kompatybilności dotyczy metamechanizmów serwera API, z którym komunikuje się biblioteka
client-go. Dostępne są m.in. struktury z opcjami dla operacji CRUD, np. CreateOptions, GetOptions,
UpdateOptions i DeleteOptions. Innym ważnym typem jest ObjectMeta (opisany szczegółowo w punkcie
„ObjectMeta”), używany w każdym rodzaju. Wszystkie takie typy są często rozszerzane o nowe mechanizmy
(nazywane czasem mechanizmami API Machinery). W dokumentacji pól tych typów w Go w komentarzach
określone jest, czy dany mechanizm jest dostępny w wersji alfa, czy w wersji beta. Obowiązują tu te same
gwarancje kompatybilności API co dla innych pól API.

W następnym przykładzie zdefiniowana jest struktura DeleteOptions z pakietu


k8s.io/apimachinery/pkg/apis/meta/v1/types.go (http://bit.ly/2MZ9flL):

// Strukturę DeleteOptions można przekazać w momencie usuwania obiektu API.


type DeleteOptions struct {

TypeMeta `json:”,inline”`

GracePeriodSeconds *int64 `json:”gracePeriodSeconds,omitempty”`


Preconditions *Preconditions `json:”preconditions,omitempty”`

OrphanDependents *bool `json:”orphanDependents,omitempty”`

PropagationPolicy *DeletionPropagation `json:”propagationPolicy,omitempty”`

// Gdy dyrektywa dryRun jest używana, informuje, że modyfikacji nie należy utrwalać.
// Błędna lub nierozpoznana dyrektywa dryRun skutkuje błędem

// i zaprzestaniem dalszego przetwarzania żądania.


// Oto poprawne wartości:

// - All: wszystkie etapy przebiegu próbnego będą przetwarzane


// +optional

DryRun []string `json:”dryRun,omitempty” protobuf:”bytes,5,rep,name=dryRun”`


}

Ostatnie pole, DryRun, zostało dodane w Kubernetesie 1.12 w wersji alfa i w Kubernetesie 1.13 w wersji beta
(domyślnie włączone). Nie jest rozpoznawane przez serwery API w starszych wersjach. W zależności od
mechanizmu przekazanie nierozpoznanej opcji może zostać zignorowane, a nawet skutkować odrzuceniem
żądania. Dlatego ważne jest, aby używać wersji biblioteki client-go, która nie odbiega zanadto od wersji
klastra.

Informacje o tym, w jakich wersjach dostępne są poszczególne pola, zawierają pliki źródłowe z
biblioteki k8s.io/api. Na przykład w Kubernetesie 1.13 znajdziesz je w gałęzi release-1.13
(http://bit.ly/2Yrhjgq). Pola w wersji alfa są opisane w ten sposób.
Dostępna jest też wygodniejsza w użyciu generowana dokumentacja API (http://bit.ly/2YrfiB2).
Zawiera ona jednak te same informacje co biblioteka k8s.io/api.
Warto też wspomnieć, że wiele mechanizmów w wersjach alfa i beta jest powiązanych z
bramkami (http://bit.ly/2RP5nmi; oto pierwotne źródło: http://bit.ly/2FPZPTT). Informacje o
mechanizmach można śledzić dzięki zgłoszeniom na stronie http://bit.ly/2YuHYcd.

Formalnie gwarantowana tabela obsługi różnych wersji biblioteki client-go w różnych klastrach jest
publikowana w pliku README tej biblioteki (http://bit.ly/2RryyLM). Zobacz tabelę 3.1.

✓: wersje biblioteki client-go i Kubernetesa mają te same mechanizmy i te same wersje grup API.
+: biblioteka client-go ma mechanizmy lub wersje grup API, które mogą być niedostępne w klastrze
Kubernetesa. Może to wynikać z dodania funkcji do biblioteki client-go lub usunięcia dawnych,
przestarzałych mechanizmów z Kubernetesa. Jednak wszystkie wspólne elementy (czyli większość API)
będą działać poprawnie.
–: wiadomo, że biblioteka client-go jest niekompatybilna z klastrami Kubernetesa w danej wersji.
Tabela 3.1. Kompatybilność biblioteki client-go z wersjami Kubernetesa

Kubernetes Kubernetes Kubernetes Kubernetes Kubernetes Kubernetes Kubernetes


1.9 1.10 1.11 1.12 1.13 1.14 1.15

client-
go ✓ +– +– +– +– +– +–
6.0

client-
go +– ✓ +– +– +– +– +–
7.0

client-
go +– +– ✓ +– +– +– +–
8.0

client-
go +– +– +– ✓ +– +– +–
9.0

client-
go +– +– +– +– ✓ +– +–
10.0

client-
go +– +– +– +– +– ✓ +–
11.0

client-
go +– +– +– +– +– +– ✓
12.0

client-
go +– +– +– +– +– +– +–
HEAD

Wniosek z tabeli 3.1 jest taki, że biblioteka client-go jest obsługiwana w powiązanej z nią wersji klastra. Gdy
wersje nie pasują do siebie, programiści muszą starannie rozważyć, które mechanizmy i które grupy API można
stosować oraz czy są one obsługiwane w wersji klastra, z jaką komunikuje się aplikacja.
W tabeli 3.1 wymienione są wersje biblioteki client-go. W punkcie „Biblioteka klienta” pokrótce opisano, że w
bibliotece client-go formalnie używane jest wersjonowanie semantyczne — każde zwiększenie numeru
podwersji Kubernetesa (człon 13 w wersji 1.13.2) powoduje zwiększenie numeru wersji tej biblioteki. Dla
Kubernetesa 1.4 udostępniono bibliotekę client-go 1.0, a w czasie, gdy powstaje ta książka, dostępna jest
biblioteka client-go 12.0 udostępniona dla Kubernetesa 1.15.
Wersjonowanie semantyczne dotyczy tylko biblioteki client-go, a nie repozytorium API Machinery lub
repozytorium API. Dla tych ostatnich używane są wersje Kubernetesa, co ilustruje rysunek 3.4. W punkcie
„Vendoring” opisujemy, co to oznacza, jeśli chcesz zastosować vendoring i umieścić biblioteki k8s.io/client-go,
k8s.io/apimachinery oraz k8s.io/api w swoim projekcie.

Rysunek 3.4. Wersjonowanie biblioteki client-go

Wersje API i gwarancje kompatybilności


W poprzednim punkcie opisaliśmy, że wybór odpowiednich wersji grup API może być niezwykle istotny, jeśli Twój
kod ma działać w różnych wersjach klastrów. W Kubernetesie wersjonowanie dotyczy wszystkich grup API.
Używany jest typowy dla Kubernetesa schemat wersjonowania, który obejmuje wersje alfa, beta i powszechnie
dostępne (ang. general availability — GA).
Oto stosowany wzorzec:

v1alpha1, v1alpha2, v2alpha1 itd. to wersje alfa uznawane za niestabilne. To oznacza, że:
mogą zostać usunięte lub zmodyfikowane w dowolnym momencie bez zważania na kompatybilność;
dane mogą zostać usunięte lub zagubione albo stać się niedostępne po zmianie wersji Kubernetesa;
często są domyślnie wyłączone, jeśli administrator nie włączy ich ręcznie.
v1beta1, v1beta2, v2beta1 itd. to wersje beta. Są one krokiem na drodze do stabilności, co oznacza, że:
będą istniały w przynajmniej jeszcze jednej wersji Kubernetesa równolegle do powiązanej stabilnej
wersji API;
zwykle nie będą modyfikowane w sposób naruszający kompatybilność, jednak nie jest to bezwzględnie
gwarantowane;
obiekty przechowywane w wersji beta nie zostaną usunięte i nie staną się niedostępne;
wersje beta są często domyślnie włączone w klastrach, jednak może to zależeć od dystrybucji
Kubernetesa i dostawcy chmury.
v1, v2 itd. to stabilne, powszechnie dostępne API, które:
pozostaną dostępne;
będą kompatybilne.

W Kubernetesie obok tych ogólnych reguł obowiązuje formalna polityka wycofywania


komponentów (http://bit.ly/2FOrKU8). Więcej informacji o tym, które komponenty API są
uznawane za kompatybilne, znajdziesz na stronach społeczności Kubernetesa w serwisie GitHub
(http://bit.ly/2XKPWAX).

Obok wersji grup API trzeba uwzględnić dwie inne ważne kwestie:

Wersje grup API dotyczą zasobów API jako całych jednostek, np. formatu podów lub usług. Obok wersji
grup API zasoby API mogą mieć pojedyncze pola wersjonowane niezależnie od grup. Na przykład pola w
stabilnych API mogą być oznaczone jako pola w wersji alfa w wewnątrzwierszowej dokumentacji w kodzie
w języku Go. Dla takich pól obowiązują te same reguły co opisane wcześniej zasady dla grup API. Oto
przykłady:
Pole w wersji alfa w stabilnym API może w dowolnym momencie stać się niekompatybilne,
spowodować utratę danych lub zostać usunięte. Na przykład pole ObjectMeta.Initializers, które
nigdy nie zostało promowane poza wersję alfa, zostanie w bliskiej przyszłości usunięte (w wersji 1.14
zostało uznane za wycofywane):
// WYCOFYWANE — initializers to pole w wersji alfa i zostanie

// usunięte w wersji 1.15.


Initializers *Initializers `json:”initializers,omitempty”
Pola w wersji alfa są zwykle domyślnie wyłączone i muszą zostać włączone za pomocą bramki serwera
API:
type JobSpec struct {
...

// To pole jest dostępne w wersji alfa i jest uwzględniane tylko przez serwery
// z włączonym mechanizmem TTLAfterFinished.
TTLSecondsAfterFinished *int32 `json:”ttlSecondsAfterFinished,omitempty”
}

Serwer API może w inny sposób traktować poszczególne pola. Jeśli bramka mechanizmu powiązana z
danym polem alfa jest wyłączona, niektóre takie pola są odrzucane, a inne — ignorowane. Jest to
udokumentowane w opisie pola (zob. pole TTLSecondsAfterFinished w poprzednim przykładzie).
Ponadto w dostępie do API istotne są wersje grup API. Serwer API przeprowadza „w locie” konwersję
między różnymi wersjami tego samego zasobu. Oznacza to, że możesz uzyskać dostęp do obiektów
utworzonych w jednej wersji (np. v1beta1) w każdej innej obsługiwanej wersji (np. v1) bez konieczności
wykonywania dodatkowych operacji w aplikacji. Jest to bardzo wygodne, jeśli chcesz pisać aplikacje zgodne
ze starszymi lub nowszymi wersjami oprogramowania.
Każdy obiekt zapisany systemie etcd jest przechowywany w określonej wersji. Domyślnie jest to
przechowywana wersja danego zasobu. Choć przechowywana wersja może się zmieniać w zależności
od wersji Kubernetesa, to w czasie, gdy powstaje ta książka, obiekt zapisany w systemie etcd nie jest
automatycznie aktualizowany. Dlatego administrator klastra musi zadbać o migrację w trakcie
aktualizowania klastrów Kubernetesa, zanim obsługa starszych wersji zostanie zarzucona. Nie istnieje
generyczny mechanizm migracji, a przebieg migracji zależy od dystrybucji Kubernetesa.
Jednak dla autora aplikacji tego rodzaju operacyjne zadania w ogóle nie powinny mieć znaczenia.
Konwersja „w locie” gwarantuje, że aplikacja będzie miała jednolity obraz obiektów klastra. Aplikacja
nie wykryje nawet, jaka przechowywana wersja jest używana. Przechowywane wersje są niewidoczne
dla kodu pisanego w Go.

Obiekty Kubernetesa w Go
W punkcie „Tworzenie i używanie klientów” zobaczyłeś, jak utworzyć klienta dla grupy podstawowej, aby
uzyskać dostęp do podów z klastra Kubernetesa. Teraz przyjrzymy się bliżej temu, czym jest pod (a także każdy
inny zasób Kubernetesa) w świecie języka Go.

[1]
Zasoby Kubernetesa (a precyzyjniej — obiekty) to instancje określonego rodzaju udostępniane przez serwer
API w formie struktur. W zależności od rodzaju dostępne są oczywiście różne pola. Jednak budowa wszystkich
zasobów jest taka sama.
Jeśli chodzi o system typów, obiekty Kubernetesa implementują interfejs runtime.Object języka Go. Ten
interfejs pochodzi z pakietu k8s.io/apimachinery/pkg/runtime i jest bardzo prosty:
// Interfejs Object musi być obsługiwany przez wszystkie typy API zarejestrowane w
schemacie.
// Ponieważ obiekty schematu mają być serializowane na potrzeby przesyłu,
// interfejs Object musi umożliwiać jednostkom serializującym podanie

// rodzaju, wersji i grupy obiektu. W sytuacjach, gdy obiekt nie ma być serializowany,
// można zwracać akcesor niewykonujący operacji.
type Object interface {
GetObjectKind() schema.ObjectKind

DeepCopyObject() Object
}
W tym kodzie schema.ObjectKind (z pakietu k8s.io/apimachinery/pkg/runtime/schema) to następny prosty
interfejs:
// Wszystkie serializowane obiekty ze schematu kodują informacje o swoim typie.

// Ten interfejs jest używany w serializacji do podawania informacji o typie


// ze schematu w serializowanej wersji obiektu. W obiektach, których nie można
// serializować lub które mają unikatowe wymogi, ten interfejs może nie wykonywać operacji.
type ObjectKind interface {

// SetGroupVersionKind ustawia lub zeruje oczekiwany serializowany rodzaj


// obiektu. Przekazanie rodzaju nil powinno zerować aktualne ustawienia.
SetGroupVersionKind(kind GroupVersionKind)
// GroupVersionKind zwraca grupę, wersję i rodzaj przechowywanego obiektu

// (lub nil, jeśli dany obiekt nie udostępnia lub nie zawiera pól z tymi danymi).
GroupVersionKind() GroupVersionKind
}
Oznacza to, że obiekt Kubernetes jest w Go strukturą danych, która:

może zwracać oraz ustawiać identyfikator GroupVersionKind,


umożliwia głębokie kopiowanie.

Głęboka kopia to sklonowana wersja struktury danych, która nie współdzieli żadnej pamięci z pierwotnym
obiektem. Jest ona używana, gdy kod ma modyfikować obiekt bez wpływu na jego pierwotną wersją. W
poświęconym generowaniu kodu punkcie „Znaczniki globalne” szczegółowo opisano implementację głębokiego
kopiowania w Kubernetesie.

W prostych słowach można stwierdzić, że obiekt przechowuje swój typ i umożliwia klonowanie.

TypeMeta
Choć runtime.Object jest tylko interfejsem, warto wiedzieć, jak jest zaimplementowany. Obiekty Kubernetesa z
biblioteki k8s.io/api implementują getter i setter wartości typu schema.ObjectKind poprzez zagnieżdżenie
struktury metav1.TypeMeta z pakietu k8s.io/apimachinery/meta/v1:
// TypeMeta opisuje jeden obiekt w odpowiedzi lub żądaniu w API.
// Zawiera łańcuchy znaków reprezentujące typ obiektu i wersję schematu API.

// W wersjonowanych lub utrwalanych strukturach należy wewnętrznie stosować typ TypeMeta.


//
// +k8s:deepcopy-gen=false
type TypeMeta struct {

// Kind to wartość typu string określająca zasób REST reprezentowany przez ten obiekt.
// Serwery mogą wywnioskować tę wartość na podstawie punktu końcowego,
// do którego klient kieruje żądania.
// Aktualizowanie jest niedozwolone.

// Stosowana jest NotacjaWielbłądzia.


// +optional
Kind string `json:”kind,omitempty” protobuf:”bytes,1,opt,name=kind”`

// APIVersion określa wersjonowany schemat tej reprezentacji obiektu.

// Serwery powinny przekształcać rozpoznane schematy na najnowszą


// wewnętrzną wartość i mogą odrzucać nierozpoznane wartości.
// +optional
APIVersion string `json:”apiVersion,omitempty”`

}
Gdy używana jest ta struktura, deklaracja poda w Go wygląda tak:
// Pod to kolekcja kontenerów, które można uruchamiać w hoście. Ten zasób jest
// tworzony przez klienty i szeregowany do wykonania w hostach.

type Pod struct {


metav1.TypeMeta `json:”,inline”`
// Standardowe metadane obiektu.

// +optional
metav1.ObjectMeta `json:”metadata,omitempty”`

// Specyfikacja oczekiwanego działania poda.

// +optional
Spec PodSpec `json:”spec,omitempty”`

// Najnowszy zaobserwowany status poda.

// Te dane mogą być nieaktualne.


// Uzupełniany przez system.
// Tylko do odczytu.
// +optional

Status PodStatus `json:”status,omitempty”`


}
Widać tu, że struktura typu TypeMeta jest zagnieżdżana. Ponadto dla typu poda używane są znaczniki w formacie
JSON, informujące, że struktura typu TypeMeta jest używana wewnętrznie.

W Go znacznik „,inline” w koderach i dekoderach dla formatu JSON jest nadmiarowy.


Zagnieżdżone struktury są automatycznie dodawane wewnętrznie.
Inaczej jest w koderach i dekoderach dla formatu YAML (go-yaml/yaml; http://bit.ly/2ZuPZy2),
używanych w bardzo wczesnych wersjach Kubernetesa równolegle z dekoderami JSON. Znacznik
inline to spadek po tamtych czasach (http://bit.ly/2IUGwcC), a obecnie pełni głównie funkcję
dokumentacyjną i nie wpływa na działanie programu.
Serializatory dla formatu YAML z pakietu k8s.io/apimachinery/pkg/runtime/serializer/yaml
używają funkcji serializowania i deserializowania z pakietu sigs.k8s.io/yaml. Te funkcje kodują i
dekodują dane w formacie YAML z użyciem interfejsu interface{} oraz używają pośredniego
formatu JSON do kodowania i dekodowania struktur API języka Go.

[2]
Ten kod odpowiada reprezentacji poda w formacie YAML, znanej wszystkim użytkownikom Kubernetesa :
apiVersion: v1
kind: Pod
metadata:
namespace: default
name: example
spec:

containers:
- name: hello
image: debian:latest
command:

- /bin/sh
args:
- -c
- echo “Witaj, świecie”; sleep 10000

Wersja jest zapisana w polu TypeMeta.APIVersion, a rodzaj — w polu TypeMeta.Kind.

Grupa podstawowa z przyczyn historycznych różni się od innych grup


Pody i liczne inne typy bardzo wcześnie dodane do Kubernetesa należą do grupy podstawowej,
reprezentowanej przez pusty łańcuch znaków. Dlatego pole apiVersion ma tu wartość „v1”.
Później do Kubernetesa dodano grupy API, a z przodu pola apiVersion dołączona została nazwa grupy
wraz z ukośnikiem. Dla grupy apps pole to ma wartość apps/v1. Dlatego nazwa apiVersion jest nieco
myląca — obejmuje ona nazwę grupy API i wersję. Wynika to z przyczyn historycznych, ponieważ pole
apiVersion zostało zdefiniowane, gdy istniała tylko grupa podstawowa (i nie istniały żadne inne grupy
API).

Na zapleczu: zależności między typami języka Go, pakietami, rodzajami i nazwami grup
Możesz się zastanawiać, skąd klient zna rodzaj i grupę API, którymi ma uzupełnić pole typu TypeMeta. Jest
to tylko pozornie proste pytanie:
Wydaje się, że rodzajem jest tylko nazwa typu z języka Go, którą można pobrać z obiektu za pomocą
mechanizmu refleksji. Zwykle jest to prawdą (w ok. 99% sytuacji), jednak zdarzają się wyjątki. W
rozdziale 4. poznasz niestandardowe zasoby, dla których ta zasada nie obowiązuje.
Wydaje się, że grupa to tylko nazwa pakietu z języka Go (typy z grupy API apps są zadeklarowane w
pakiecie k8s.io/api/apps). Często jest to prawdą, ale nie zawsze. Dla grupy podstawowej nazwą jest pusty
łańcuch znaków. Ponadto typy z grupy rbac.authorization.k8s.io znajdują się w pakiecie
k8s.io/api/rbac, a nie w k8s.io/api/rbac.authorization.k8s.io.
Właściwa odpowiedź na pytanie o to, jak uzupełnić pole typu TypeMeta, wymaga uwzględnienia
schematów opisanych szczegółowo w punkcie „Schematy”.

Gdy będziesz uruchamiać przykłady z punktu „Tworzenie i używania klientów”, aby pobrać pod z klastra,
zauważ, że obiekt poda zwrócony przez klienta nie ma ustawionego rodzaju ani wersji. W aplikacjach opartych
na bibliotece client-go obowiązuje zwyczaj, że te pola w obiektach w pamięci są puste; zostają one uzupełnione
dopiero wtedy, gdy wartości są serializowane do formatu JSON lub protobuf. Operację tę wykonuje
automatycznie klient (a bardziej precyzyjnie — mechanizm serializowania uwzględniający wersje).
Tak więc aplikacje oparte na bibliotece client-go sprawdzają typy z języka Go, aby ustalić, jaki obiekt jest
używany. W innych platformach (np. w Operator SDK; zob. punkt „Operator SDK”) może to wyglądać inaczej.

ObjectMeta
Obok pola typu TypeMeta większość obiektów z najwyższego poziomu ma też pole typu metav1.ObjectMeta,
także z pakietu k8s.io/apimachinery/pkg/meta/v1:

type ObjectMeta struct {


Name string `json:”name,omitempty”`
Namespace string `json:”namespace,omitempty”`
UID types.UID `json:”uid,omitempty”`
ResourceVersion string `json:”resourceVersion,omitempty”`
CreationTimestamp Time `json:”creationTimestamp,omitempty”`

DeletionTimestamp *Time `json:”deletionTimestamp,omitempty”`


Labels map[string]string `json:”labels,omitempty”`
Annotations map[string]string `json:”annotations,omitempty”`
...

}
W formatach JSON i YAML te pola należą do kategorii metadata. Na przykład dla opisywanego wcześniej poda
pole typu metav1.ObjectMeta zawiera następujące dane:
metadata:
namespace: default

name: example
Zwykle pole to obejmuje wszystkie informacje z poziomu meta, np. nazwę, przestrzeń nazw, wersję zasobów (nie
należy mylić jej z wersją grupy API), kilka znaczników czasu, a także znane etykiety i adnotacje. Dokładne
omówienie pól z typu ObjectMeta znajdziesz w punkcie „Budowa typu”.

Wersje zasobów zostały opisane wcześniej w punkcie „Współbieżność optymistyczna”. W kodzie używającym
biblioteki client-go wersja zasobu prawie nigdy nie jest wczytywana ani zapisywana. Jest to jednak jedno z pól
w Kubernetesie, dzięki którym cały system może działać. Pole resourceVersion należy do typu ObjectMeta,
ponieważ każdy obiekt z zagnieżdżoną wartością tego typu odpowiada kluczowi z systemu etcd, z którego
pochodzi wartość pola resourceVersion.

Sekcje spec i status


Ponadto prawie każdy obiekt z najwyższego poziomu obejmuje sekcje spec i status. Ta konwencja wynika z
deklaratywnego charakteru API Kubernetesa. Sekcja spec określa oczekiwania użytkownika, a sekcja status
informuje o wyniku realizacji tych oczekiwań (i zwykle jest uzupełniana przez kontroler). Szczegółowe
omówienie kontrolerów w Kubernetesie zawiera punkt „Kontrolery i operatory”.

W systemie jest tylko kilka wyjątków od konwencji związanej ze stosowaniem sekcji spec i status. Nie używa się
ich np. dla punktów końcowych w grupie podstawowej lub typów związanych z kontrolą dostępu opartą na rolach
(np. dla typu ClusterRole).

Zbiory klientów
We wprowadzającym przykładzie z punktu „Tworzenie i używanie klientów” pokazaliśmy, że wywołanie
kubernetes.NewForConfig(config) zwraca zbiór klientów. Zbiór klientów zapewnia dostęp do klientów dla
różnych grup API i zasobów. Wywołanie kubernetes.NewForConfig(config) z pakietu k8s.io/client-
go/kubernetes daje dostęp do wszystkich grup API i zasobów zdefiniowanych w bibliotece k8s.io/api, czyli do
prawie całego zbioru zasobów udostępnianych przez serwer API Kubernetesa; kilka wyjątków to typy
APIServices (reprezentuje zagregowane serwery API) i CustomResourceDefinition (zob. rozdział 4.).
W rozdziale 5. wyjaśniamy, jak zbiory klientów są generowane na podstawie typów API (z biblioteki k8s.io/api w
tym scenariuszu). W niezależnych projektach, gdzie występują niestandardowe API, używane są nie tylko zbiory
klientów z Kubernetesa. Jednak wszystkie zbiory klientów mają coś wspólnego — konfigurację w formacie REST,
zwracaną m.in. przez wywołanie clientcmd.BuildConfigFromFlags(„”, *kubeconfig) (tak jak w przykładzie).

Główny interfejs zbioru klientów z pakietu k8s.io/client-go/kubernetes/typed przeznaczony dla natywnych


zasobów Kubernetesa wygląda tak:

type Interface interface {

Discovery() discovery.DiscoveryInterface
AppsV1() appsv1.AppsV1Interface

AppsV1beta1() appsv1beta1.AppsV1beta1Interface

AppsV1beta2() appsv1beta2.AppsV1beta2Interface
AuthenticationV1() authenticationv1.AuthenticationV1Interface

AuthenticationV1beta1() authenticationv1beta1.AuthenticationV1beta1Interface

AuthorizationV1() authorizationv1.AuthorizationV1Interface
AuthorizationV1beta1() authorizationv1beta1.AuthorizationV1beta1Interface

...
}

W przeszłości ten interfejs obejmował niewersjonowane metody, np. Apps() appsv1.AppsV1Interface. Jednak w
powiązanej z Kubernetesem 1.14 bibliotece client-go 11.0 zostały one uznane za wycofywane. Już
wspominaliśmy, że dobrą praktyką jest jednoznaczne określanie wersji grupy API używanej w aplikacji.
Każdy zbiór klientów zapewnia dostęp do klient odpowiedzialnego za wykrywanie informacji (jest on używany w
obiektach typu RESTMapper; zob. punkty „Odwzorowania REST” i „Używanie API w wierszu poleceń”).

Wersjonowane klienty a dawne klienty wewnętrzne


W przeszłości w Kubernetesie stosowane były klienty wewnętrzne. Używano w nich uogólnionej wersji
obiektów przechowywanych w pamięci (nazywanej „wewnętrzną”), którą przekształcano na przesyłaną
wersję (i w drugą stronę).
Celem było oddzielenie kodu kontrolera od używanej wersji API i umożliwienie przełączenia się na inną
wersję za pomocą zmiany jednego wiersza kodu. Jednak w praktyce olbrzymi wzrost złożoności przy
implementowaniu konwersji i ilość informacji, jakie odpowiedzialny za konwersję kod musiał mieć na temat
semantyki obiektów, doprowadziły do stwierdzenia, że nie warto stosować tego wzorca.
Ponadto nigdy nie wprowadzono automatycznych negocjacji między klientem a serwerem API. Nawet gdy
używane były wewnętrzne typy i klienty, w kontrolerach na stałe zapisana była określona wersja
komponentów. Dlatego kontrolery używające typów wewnętrznych nie były bardziej kompatybilne w
sytuacji niezgodności wersji klienta i serwera niż kontrolery korzystające z wersjonowanych typów API.
W nowszych wersjach Kubernetesa zmodyfikowano duże fragmenty kodu, aby całkowicie wyeliminować
wersje wewnętrzne. Obecnie nie ma już wewnętrznych wersji w bibliotece k8s.io/api ani wewnętrznych
klientów w bibliotece k8s.io/client-go.

Każda metoda o nazwie GrupaWersja (np. AppsV1beta1) pozwala uzyskać zasoby z danej grupy API. Oto
przykład:
type AppsV1beta1Interface interface {

RESTClient() rest.Interface

ControllerRevisionsGetter
DeploymentsGetter

StatefulSetsGetter
}

RESTClient zwraca generycznego klienta REST, a każdemu zasobowi odpowiada jeden interfejs:

// Interfejs DeploymentsGetter obejmuje metodę zwracającą wartość typu DeploymentInterface.


// Klient dla grupy powinien implementować ten interfejs.

type DeploymentsGetter interface {

Deployments(namespace string) DeploymentInterface


}

// Interfejs DeploymentInterface obejmuje metody do pracy z zasobami typu Deployment.


type DeploymentInterface interface {

Create(*v1beta1.Deployment) (*v1beta1.Deployment, error)


Update(*v1beta1.Deployment) (*v1beta1.Deployment, error)

UpdateStatus(*v1beta1.Deployment) (*v1beta1.Deployment, error)


Delete(name string, options *v1.DeleteOptions) error

DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error


Get(name string, options v1.GetOptions) (*v1beta1.Deployment, error)

List(opts v1.ListOptions) (*v1beta1.DeploymentList, error)

Watch(opts v1.ListOptions) (watch.Interface, error)


Patch(name string, pt types.PatchType, data []byte, subresources ...string)

(result *v1beta1.Deployment, err error)

DeploymentExpansion
}

W zależności od zasięgu zasobu — na poziomie klastra lub przestrzeni nazw — akcesor (tu jest nim
DeploymentGetter) może, ale nie musi przyjmować argumentu namespace.

Typ DeploymentInterface daje dostęp do wszystkich operacji obsługiwanych przez zasób. Większość tych
operacji jest oczywista. Dalej opisano te, które wymagają dodatkowych objaśnień.

Podzasoby status — UpdateStatus


W instalacjach występują podzasoby status. To oznacza, że UpdateStatus używa dodatkowego punktu
końcowego HTTP z przyrostkiem /status. Aktualizacje kierowane do punktu końcowego
/apis/apps/v1beta1/namespaces/ns/deployments/name mogą modyfikować tylko specyfikację instalacji, a punkt
końcowy /apis/apps/v1beta1/namespaces/ns/deployments/name/status służy tylko do zmiany statusu obiektu. Jest
to przydatne, gdy chcesz ustawić inne uprawnienia dla aktualizacji specyfikacji (przeprowadzanych przez
człowieka) i aktualizacji statusu (wykonywanych przez kontroler).

Domyślnie metoda UpdateStatus() jest generowana przez narzędzie client-gen (zob. punkt „Znaczniki dla
generatora client-gen”). Istnienie tej metody nie gwarantuje, że dany zasób obsługuje podzasoby. To zastrzeżenie
jest ważne w kontekście pracy z zasobami CRD w punkcie „Podzasoby”.

Wyświetlanie i usuwanie obiektów


Funkcja DeleteCollection umożliwia jednoczesne usunięcie wielu obiektów z przestrzeni nazw. Parametr
ListOptions pozwala za pomocą selektora pól lub selektora etykiet zdefiniować usuwane obiekty:
type ListOptions struct {

...

// Selektor ograniczający listę zwracanych obiektów na podstawie ich etykiet.

// Wartość domyślna pobiera wszystkie obiekty.


// +optional

LabelSelector string `json:”labelSelector,omitempty”`

// Selektor ograniczający listę zwracanych obiektów na podstawie pól.


// Wartość domyślna pobiera wszystkie obiekty.

// +optional
FieldSelector string `json:”fieldSelector,omitempty”`

...
}

Czujki
Typ Watch zapewnia interfejs ze zdarzeniami dotyczącymi wszystkich zmian (dodania, usuwania i modyfikacji) w
obiektach. Zwracana wartość typu watch.Interface z pakietu k8s.io/apimachinery/pkg/watch wygląda tak:

// Interfejs Interface może być implementowany w każdym typie,

// który potrafi obserwować i zgłaszać zmiany.


type Interface interface {

// Kończy obserwowanie zmian. Zamyka kanał zwrócony przez funkcję ResultChan().

// Zwalnia zasoby używane przez czujkę.


Stop()

// Zwraca kanał, do którego kierowane będą wszystkie zdarzenia. Jeśli wystąpi błąd lub

// wywołana zostanie funkcja Stop(), kanał zostanie zamknięty. Wtedy czujka

// powinna zwolnić wszystkie zasoby.


ResultChan() <-chan Event

Wynikowy kanał z interfejsu watch zwraca zdarzenia trzech typów:


// Typ EventType definiuje możliwe typy zdarzeń.

type EventType string

const (

Added EventType = “ADDED”


Modified EventType = “MODIFIED”

Deleted EventType = “DELETED”

Error EventType = “ERROR”


)

// Typ Event reprezentuje jedno zdarzenie w obserwowanym zasobie.


// +k8s:deepcopy-gen=true

type Event struct {


Type EventType

// Obiekt to:
// * jeśli Type ma wartość Added lub Modified: nowy stan obiektu.

// * jeśli Type to Deleted: stan obiektu tuż przed usunięciem.

// * jeśli Type to Error: zalecany jest typ *api.Status; w niektórych


// scenariuszach sensowne mogą być też inne typy.

Object runtime.Object
}

Choć kuszące jest bezpośrednie używanie tego interfejsu, jest to odradzane. W zamian zaleca się używanie
informatorów (zob. punkt „Informatory i buforowanie”). Informatory łączą pokazany interfejs zdarzeń z buforem
umożliwiającym indeksowane wyszukiwanie. Jest to zdecydowanie najczęstszy sposób używania czujek. Na
zapleczu informatory najpierw wywołują funkcję List dla klienta, aby uzyskać zbiór wszystkich obiektów (jako
punkt wyjścia do utworzenia bufora), a następnie obserwują te obiekty, by aktualizować bufor. Informatory we
właściwy sposób obsługują błędy — przywracają stan po awariach sieci lub innych problemach z klastrem.
Rozszerzanie klientów
DeploymentExpansion to pusty interfejs. Służy do dodawania niestandardowych operacji klientów, jednak
obecnie jest bardzo rzadko używany w Kubernetesie. Zamiast tego stosowany jest generator klientów, który
pozwala dodawać niestandardowe metody w deklaratywny sposób (zob. punkt „Znaczniki dla generatora client-
gen”).
Warto zauważyć, że metody z typu DeploymentInterface ani nie oczekują poprawnych informacji w polach Kind
i APIVersion z typu TypeMeta, ani nie ustawiają wartości tych pól w wywołaniach Get() i List() (zob. też punkt
„TypeMeta”). Te pola są zapełniane rzeczywistymi wartościami tylko w trakcie przesyłu danych.

Opcje klientów
Warto przyjrzeć się różnym opcjom, jakie można ustawić w trakcie tworzenia zbioru klientów. Z uwagi przed
punktem „Wersjonowanie i kompatybilność” dowiedziałeś się, że obiekty natywnych typów Kubernetesa można
przesyłać w formacie protobuf. Ten format jest bardziej wydajny niż JSON (zarówno jeśli chodzi o pamięć, jak i
ze względu na obciążenie procesora w kliencie i na serwerze), dlatego zaleca się stosowanie go.

W trakcie debugowania i w celu poprawy czytelności wskaźników często warto rozróżniać klienty korzystające z
serwera API. W tym celu można ustawić pole agenta użytkownika w konfiguracji REST. Wartość domyślna to
binary/version (os/arch) kubernetes/commit. Na przykład narzędzie kubectl ma agenta użytkownika
kubectl/v1.14.0 (darwin/amd64) kubernetes/d654b49. Jeśli ten wzorzec jest niewystarczający, można go
dostosować do potrzeb:

cfg, err := clientcmd.BuildConfigFromFlags(“”, *kubeconfig)


cfg.AcceptContentTypes = “application/vnd.kubernetes.protobuf,application/json”

cfg.UserAgent = fmt.Sprintf(

“book-example/v1.0 (%s/%s) kubernetes/v1.0”,


runtime.GOOS, runtime.GOARCH

clientset, err := kubernetes.NewForConfig(cfg)


Inne wartości często zastępowane w konfiguracji REST dotyczą ograniczania liczby zapytań i limitów czasu po
stronie klienta:
// W konfiguracji przechowywane są standardowe atrybuty, które można

// przekazać do klienta Kubernetesa w trakcie inicjalizacji.


type Config struct {

...

// QPS oznacza maksymalną liczbę żądań na sekundę z danego klienta do

// jednostki nadrzędnej. Jeśli podane jest 0, tworzony klient

// RESTClient używa wartości DefaultQPS: 5.


QPS float32

// Maksymalny skokowy wzrost liczby żądań.

// Jeśli podane jest 0, tworzony klient RESTClient używa wartości DefaultBurst: 10.

Burst int

// Maksymalny czas oczekiwania na obsługę żądania.

// Zero oznacza brak limitu czasu oczekiwania.


Timeout time.Duration
...

Wartość domyślna pola QPS to 5 (żądań na sekundę), a wartość domyślna pola burst — 10.
Dla limitu czasu oczekiwania nie ma wartości domyślnej (przynajmniej nie w konfiguracji REST klienta.
Domyślnie serwer API Kubernetesa stosuje limit czasu oczekiwania 60 sekund dla wszystkich żądań, które nie są
długotrwałe. Długotrwałe żądania mogą pochodzić od czujek lub być kierowane do podzasobów takich jak /exec,
/portforward lub /proxy.

Kontrolowane kończenie pracy i odporność na błędy połączeń


Żądania dzielimy na długotrwałe i niedługotrwałe. Czujki zgłaszają długotrwałe żądania, natomiast GET,
LIST, UPDATE i podobne żądania są niedługotrwałe. Także wiele podzasobów (np. do strumieniowania
dzienników, wykonywania poleceń i przekierowywania portów) używa długotrwałych żądań.
Gdy serwer API Kubernetesa jest restartowany (np. na potrzeby aktualizacji), oczekuje 60 sekund w
ramach kontrolowanego kończenia pracy. W tym czasie realizuje niedługotrwałe żądania, po czym kończy
pracę. Zakończenie pracy powoduje zamknięcie długotrwałych żądań (np. aktywnych połączeń z
czujkami).
Dla niedługotrwałych żądań i tak obowiązuje ograniczenie 60 sekund (po czym następuje przekroczenie
limitu czasu). Dlatego z perspektywy klienta taki sposób kończenia pracy jest kontrolowany.
Kod aplikacji zawsze powinien być przygotowany na to, że żądanie nie zostanie obsłużone, i reagować
wtedy w sposób, który nie kończy pracy programu. W świecie systemów rozproszonych błędy połączeń są
czymś normalnym, czym nie trzeba się przejmować. Trzeba jednak poświęcić dużo uwagi starannej
obsłudze błędów i przywracaniu stanu po ich wystąpieniu.
Obsługa błędów jest ważna przede wszystkim w czujkach. Czujki działają przez długi czas i w każdym
momencie może w nich wystąpić błąd. Informatory (opisane w następnym punkcie) zapewniają odporną
na awarię implementację kodu związanego z czujkami i kontrolowaną obsługę błędów — po zerwaniu
połączenia przywracają stan, nawiązując nowe połączenie. Kod aplikacji zwykle nawet tego nie zauważa.

Informatory i buforowanie
Interfejs klienta opisany w punkcie „Zbiory klientów” obejmuje operację Watch, udostępniającą interfejs zdarzeń
reagujący na zmiany (dodawanie, usuwanie i aktualizowanie obiektów). Informator zapewnia wysokopoziomowy
interfejs programowania związany z najczęstszym zastosowaniem czujek: obsługą bufora w pamięci i szybkim,
indeksowanym wyszukiwaniem obiektów w pamięci na podstawie nazw lub innych właściwości.
Kontroler, który komunikuje się z serwerem API za każdym razem, gdy potrzebuje obiektu, powoduje wysokie
obciążenie w systemie. Rozwiązaniem tego problemu są informatory korzystające z bufora w pamięci.
Informatory potrafią też reagować na modyfikacje obiektów w czasie bliskim rzeczywistemu i nie wymagają
żądań związanych z odpytywaniem.
Na rysunku 3.5 pokazany jest wzorzec informatora. Informatory:

pobierają dane wejściowe z serwera API jako zdarzenia;


udostępniają przypominający klienta interfejs Lister do pobierania i wyświetlania obiektów z bufora z
pamięci;
rejestrują funkcje obsługi zdarzeń dodawania, usuwania i aktualizowania obiektów;
implementują bufor w pamięci, używając magazynu danych.
Rysunek 3.5. Informatory

Informatory udostępniają też zaawansowaną obsługę błędów. Gdy długotrwałe połączenie z czujką zostanie
zerwane, przywracają stan, zgłaszając inne żądanie do czujki. Kontynuują obsługę strumienia zdarzeń bez utraty
żadnych z nich. Jeśli przestój jest długi, a serwer API traci zdarzenia, ponieważ system etcd usunął je z bazy
przed udanym przetworzeniem nowego żądania do czujki, informator ponownie zgłasza żądanie pobrania (ang.
relist) wszystkich obiektów.

Obok ponownego żądania pobrania obowiązuje konfigurowalny okres resynchronizacji służący do uzgodnienia
zawartości bufora w pamięci z logiką biznesową. Po każdym upływie tego czasu dla wszystkich obiektów
wywoływane są zarejestrowane funkcje obsługi zdarzeń. Zwykle stosowane wartości są podawane w minutach
(np. 10 lub 30 minut).

Resynchronizacja odbywa się w całości w pamięci i nie powoduje przesłania żądania na serwer.
W przeszłości było inaczej, jednak zmieniono używaną technikę (http://bit.ly/2FmeMge),
ponieważ obsługa błędów w mechanizmie czujek została usprawniona w wystarczającym
stopniu, aby ponowne żądanie pobrania obiektów stało się zbędne.

Wszystkie te zaawansowane i sprawdzone w praktyce mechanizmy obsługi błędów są dobrym powodem do tego,
by używać informatorów, zamiast tworzyć niestandardowy kod i bezpośrednio używać metody Watch() klienta.
Informatory są stosowane w wielu miejscach w samym Kubernetesie i są jednym z głównych rozwiązań
architektury API Kubernetesa.
Wprawdzie zaleca się stosowanie informatorów zamiast odpytywania, niemniej i tak powodują one obciążenie
serwera API. Jeden program binarny powinien tworzyć tylko jednego informatora dla danego identyfikatora GVR.
Aby ułatwić współdzielenie informatorów, można je tworzyć za pomocą fabryki współdzielonych informatorów.

Fabryka współdzielonych informatorów umożliwia współdzielenie w aplikacji informatorów dla tego samego
zasobu. Oznacza to, że w różnych pętlach sterowania można używać na zapleczu tego samego połączenia z
czujką z serwera API. Na przykład kube-controller-manager (jeden z głównych komponentów klastra
Kubernetesa; zob. punkt „Serwer API”) ma wysoką, dwucyfrową liczbę kontrolerów. Jednak dla każdego zasobu
(np. dla podów) w procesie działa tylko jeden informator.

Zawsze używaj fabryki współdzielonych informatorów do ich tworzenia. Nie próbuj ręcznie
generować informatorów. Dodatkowe koszty korzystania z fabryki są bardzo małe, a złożony
kontroler binarny, który nie używa informatorów współdzielonych, zapewne będzie nawiązywał
wiele połączeń z czujkami dotyczących tego samego zasobu.

Jeśli zaczniesz od konfiguracji REST (zob. punkt „Tworzenie i używanie klientów”), łatwo utworzysz fabrykę
współdzielonych informatorów dla zbioru klientów. Informatory dla standardowych zasobów Kubernetesa są
generowane przez generator kodu i udostępniane w pakiecie k8s.io/client-go/informers jako część biblioteki
client-go:

import (

...
“k8s.io/client-go/informers”

)
...

clientset, err := kubernetes.NewForConfig(config)


informerFactory := informers.NewSharedInformerFactory(clientset, time.Second*30)

podInformer := informerFactory.Core().V1().Pods()

podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(new interface{}) {...},

UpdateFunc: func(old, new interface{}) {...},


DeleteFunc: func(obj interface{}) {...},

})

informerFactory.Start(wait.NeverStop)
informerFactory.WaitForCacheSync(wait.NeverStop)

pod, err := podInformer.Lister().Pods(“programming-kubernetes”).Get(“client-go”)

W przykładzie pokazane jest, jak pobrać współdzielony informator dla podów.


Widać tu, że informatory umożliwiają dodawanie funkcji obsługi zdarzeń dla trzech operacji: dodawania,
aktualizowania i usuwania. Zwykle te zdarzenia służą do uruchamiania logiki biznesowej kontrolera, czyli do
ponownego przetwarzania określonych obiektów (zob. punkt „Kontrolery i operatory”). Często funkcje obsługi
zdarzeń jedynie dodają zmodyfikowany obiekt do kolejki zadań.

Dodatkowe funkcje obsługi zdarzeń i wewnętrzna logika aktualizacji magazynu danych


Nie pomyl wspomnianych tu funkcji obsługi zdarzeń z logiką aktualizacji magazynu danych
przechowywanego w pamięci w informatorze (i dostępnego za pomocą listera w ostatnim wierszu
przykładowego kodu). Informator zawsze aktualizuje magazyn danych, jednak dodatkowe funkcje obsługi
zdarzeń są opcjonalne i przeznaczone do użytku przez jednostkę korzystającą z danego informatora.

Warto też zauważyć, że można dodać wiele funkcji obsługi zdarzeń. Sama fabryka współdzielonych informatorów
istnieje tylko dlatego, że tak często jest potrzebna w plikach binarnych kontrolera z wieloma pętlami sterowania,
z których każda instaluje funkcje obsługi zdarzeń dodające obiekty do własnej kolejki zadań.

Po zarejestrowaniu funkcji obsługi zdarzeń trzeba uruchomić fabrykę współdzielonych informatorów. Na


zapleczu używane są procedury języka Go kierujące odpowiednie wywołania do serwera API. Metoda Start (z
kanałem stopu służącym do kontrolowania cyklu życia) uruchamia te procedury języka Go, a metoda
WaitForCacheSync() powoduje, że kod oczekuje na pierwsze ukończone wywołania List skierowane do
klientów. Jeśli logika kontrolera wymaga, by bufor był zapełniony, wywołanie WaitForCacheSync jest niezbędne.

Interfejs zdarzeń w czujkach na zapleczu powoduje zwykle pewne opóźnienia. W systemie, dla którego
zastosowano odpowiednie planowanie wydajności, te opóźnienia nie są duże. Oczywiście dobrą praktyką jest
pomiar opóźnień z użyciem wskaźników. Jednak opóźnienia i tak występują, dlatego logikę aplikacji trzeba tak
zaprojektować, aby nie szkodziły one działaniu kodu.

Opóźnienie w informatorach może prowadzić do „wyścigu” między zmianami, jakie kontroler


wprowadza na serwerze API bezpośrednio za pomocą biblioteki client-go, a stanem świata
znanym informatorom.
Jeśli kontroler zmodyfikuje obiekt, informator z tego samego procesu musi czekać na zgłoszenie
odpowiedniego zdarzenia, po czym aktualizuje przechowywany w pamięci magazyn danych. Ten
proces wymaga czasu, dlatego inny wyzwalacz może uruchomić nową pętlę roboczą kontrolera,
zanim wcześniejsza zmiana stanie się widoczna.

Okres 30 sekund między operacjami resynchronizacji w pokazanym przykładzie powoduje przesłanie do


zarejestrowanej funkcji UpdateFunc kompletnego zestawu zdarzeń, dzięki czemu logika kontrolera może
uzgodnić stan ze stanem serwera API. Sprawdzenie wartości pola ObjectMeta.resourceVersion pozwala
odróżnić rzeczywistą aktualizację od resynchronizacji.

To, jaki okres między operacjami resynchronizacji będzie właściwy, zależy od sytuacji. Na
przykład 30 sekund to dość krótki czas. W wielu sytuacjach dobrym wyborem jest kilka minut (a
nawet do 30). W najgorszym scenariuszu oznacza to, że naprawienie usterki w kodzie (np. utraty
sygnału z powodu nieprawidłowej obsługi błędów) za pomocą uzgadniania stanu zajmie 30
minut.

Warto też zauważyć, że ostatni wiersz w przykładowym kodzie, wywołanie Get(„client-go”), wymaga tylko
dostępu do pamięci (a nie do serwera API). Obiekty w magazynie danych w pamięci nie mogą być bezpośrednio
modyfikowane. Zamiast tego trzeba użyć zbioru klientów, aby uzyskać dostęp do zasobów na potrzeby zapisu.
Informator otrzymuje wtedy zdarzenia z serwera API i aktualizuje przechowywany w pamięci stan.

Nigdy nie modyfikuj obiektów należących do informatorów


Trzeba pamiętać, że każdy obiekt przekazany z listerów do funkcji obsługi zdarzeń należy do informatorów.
Jeśli zmodyfikujesz taki obiekt, ryzykujesz spowodowanie w aplikacji trudnych do zdiagnozowania
problemów ze spójnością bufora. Przed modyfikacją obiektu zawsze wykonuj głęboką kopię (zob. punkt
„Obiekty Kubernetesa w Go”).
Przed zmodyfikowaniem obiektu zawsze zastanów się, do jakiej jednostki należy ten obiekt i jakie
struktury danych zawiera. Oto ogólne reguły:
Informatory i listery są właścicielami zwracanych obiektów. Dlatego odbiorcy muszą wykonywać głęboką
kopię przed modyfikacją obiektu.
Klienty zwracają nowe obiekty należące do jednostki wywołującej.
W wyniku konwersji zwracany jest współdzielony obiekt. Jeśli jednostka wywołująca jest właścicielem
obiektu wejściowego, nie jest właścicielem obiektu wyjściowego.

Konstruktor informatora NewSharedInformerFactory z przykładowego kodu buforuje w magazynie danych


wszystkie obiekty reprezentujące dany zasób z wszystkich przestrzeni nazw. Jeśli w danej aplikacji jest to za
dużo, istnieje też inny konstruktor, zapewniający większą swobodę:

// NewFilteredSharedInformerFactory tworzy nową instancję typu

// sharedInformerFactory. Dla listerów uzyskanych z tej instancji


// używane są te same filtry, które podano tutaj.

func NewFilteredSharedInformerFactory(

client versioned.Interface, defaultResync time.Duration,


namespace string,

tweakListOptions internalinterfaces.TweakListOptionsFunc

) SharedInformerFactor

type TweakListOptionsFunc func(*v1.ListOptions)


Ten konstruktor pozwala podać przestrzeń nazw i przekazać funkcję TweakListOptionsFunc, która może
modyfikować strukturę ListOptions używaną do zwracania i obserwowania obiektów za pomocą wywołań List i
Watch w kliencie. Za pomocą tej funkcji można np. określić selektory etykiet lub selektory pól.

Informatory są jedną z cegiełek do tworzenia kontrolerów. W rozdziale 6. zobaczysz, jak wygląda typowy
kontroler oparty na bibliotece client-go. Obok klientów i informatorów trzecią podstawową cegiełką jest tu
kolejka zadań. Przyjrzyjmy się jej teraz.

Kolejka zadań
Kolejka zadań jest strukturą danych. Można dodawać do niej elementy i pobierać je z niej w kolejności
zdefiniowanej w kolejce. Formalnie struktura tego rodzaju jest nazywana kolejką priorytetową. Biblioteka
client-go na potrzeby tworzenia kontrolerów udostępnia rozbudowaną implementację takich kolejek w pakiecie
k8s.io/client-go/util/workqueue (http://bit.ly/2IV0JPz).

Ściśle rzecz biorąc, ten pakiet udostępnia kilka wersji kolejek przeznaczony do różnych celów. Interfejs bazowy
implementowany we wszystkich tych wersjach wygląda tak:

type Interface interface {

Add(item interface{})
Len() int

Get() (item interface{}, shutdown bool)

Done(item interface{})
ShutDown()

ShuttingDown() bool
}

Tu funkcja Add(item) dodaje element, funkcja Len() zwraca długość kolejki, a Get() zwraca element o
najwyższym priorytecie (i blokuje operacje do czasu, gdy element stanie się dostępny). Każdy element zwrócony
przez funkcję Get() wymaga wywołania Done(item) po zakończeniu przetwarzania elementu przez kontroler. W
trakcie przetwarzania elementu kolejne wywołania Add(item) powodują jedynie oznaczenie elementu jako
zmodyfikowanego, po czym zostaje on ponownie dodany po wywołaniu Done(item).

Ten generyczny interfejs jest typem bazowym dla następujących typów kolejek:

DelayingInterface, który pozwala dodać element w późniejszym czasie. Ułatwia to ponowne


zakolejkowanie elementów po awarii bez ciągłego ponawiania żądań:

type DelayingInterface interface {


Interface

// AddAfter dodaje element do kolejki zadań po


// upływie czasu określonego w parametrze duration.

AddAfter(item interface{}, duration time.Duration)

}
RateLimitingInterface ogranicza częstotliwość dodawania elementów do kolejki; jest to interfejs
pochodny od DelayingInterface:
type RateLimitingInterface interface {

DelayingInterface

// AddRateLimited dodaje element do kolejki zadań,

// jeśli ogranicznik częstotliwości na to zezwoli.


AddRateLimited(item interface{})

// Forget informuje, że zakończono ponawianie prób dla danego elementu. Nie ma


// znaczenia, czy przyczyną są ciągłe niepowodzenia, czy sukces — ogranicznik

// częstotliwości przestaje śledzić ten element. Ta funkcja zeruje tylko


ogranicznik

// częstotliwości; nadal trzeba wywołać funkcję Done dla kolejki.

Forget(item interface{})

// NumRequeues zwraca liczbę ponownych operacji umieszczenia elementu w kolejce.

NumRequeues(item interface{}) int


}

Najciekawsza jest tu metoda Forget(item). Zeruje ona mechanizm wydłużania odczekiwania dla danego
elementu. Zwykle jest wywoływana po udanym przetworzeniu elementu.

Algorytm ograniczania częstotliwości kolejkowania można przekazać do konstruktora


NewRateLimitingQueue. W omawianym pakiecie zdefiniowanych jest kilka ograniczników częstotliwości,
np. BucketRateLimiter, ItemExponentialFailureRateLimiter, ItemFastSlowRateLimiter i
MaxOfRateLimiter. Więcej szczegółów znajdziesz w dokumentacji pakietu. Większość kontrolerów używa
funkcji DefaultControllerRateLimiter() *RateLimiter o następujących parametrach:

wykładnicze wydłużanie odczekiwania z wartością początkową 5 ms rosnącą do 1000 sekund, po


każdym błędzie opóźnienie jest podwajane;
maksymalna częstotliwość kolejkowania 10 elementów na sekundę ze skokowymi wzrostami do 100
elementów.
W niektórych sytuacjach warto zmodyfikować te wartości. Maksymalne opóźnienie 1000 sekund na element to w
niektórych kontrolerach bardzo długi czas.

Repozytorium API Machinery — szczegóły


W repozytorium API Machinery zaimplementowane są podstawy systemu typów Kubernetesa. Czym jednak jest
ten system typów? Czym jest sam typ?

W terminologii związanej z API Machinery nie występuje pojęcie typ. Zamiast niego używane jest określenie
rodzaj.

Rodzaje
Rodzaje są podzielone na grupy API i wersjonowane, co opisaliśmy już w punkcie „Terminologia związana z API”.
Dlatego ważnym pojęciem w repozytorium API Machinery są identyfikatory GVK (ang. GroupVersionKind).

W Go każdy identyfikator GVK odpowiada jednemu typowi języka Go. Z kolei typ z języka Go może odpowiadać
kilku identyfikatorom GVK.

Rodzaje formalnie nie są powiązane jeden do jednego ze ścieżkami HTTP. Wiele rodzajów ma punkty końcowe
HTTP REST używane do dostępu do obiektów danego rodzaju. Istnieją jednak rodzaje bez żadnego punktu
końcowego HTTP (np. używany do wywoływania webhooków admission.k8s.io/v1beta1.AdmissionReview;
http://bit.ly/2XJXBQD). Ponadto niektóre rodzaje są zwracane przez wiele punktów końcowych (m.in.
meta.k8s.io/v1.Status, http://bit.ly/31Ktjvz, zwracany przez wszystkie punkty końcowe w celu informowania o
statusie, np. o błędach, gdy wywołanie nie zwraca innych obiektów).

Zwyczajowo rodzaje są zapisywane w NotacjiWielbłądziej (http://bit.ly/31IqMSC) i liczbie pojedynczej. W


niektórych sytuacjach jest jednak inaczej. Nazwy rodzajów związanych z zasobami CRD muszą mieć postać
ścieżki DNS (RFC 1035).

Zasoby
Obok rodzajów występują też zasoby, co opisaliśmy w punkcie „Terminologia związana z API”. Zasoby też są
grupowane i wersjonowane oraz mają identyfikatory GVR (ang. GroupVersionResource).

Każdy identyfikator GVR odpowiada jednej (bazowej) ścieżce HTTP. Identyfikatory GVR służą do identyfikowania
punktów końcowych REST w API Kubernetesa. Na przykład identyfikator GVR apps/v1.deployments jest
powiązany z punktem końcowym /apis/apps/v1/namespaces/przestrzeńnazw/ deployments.

Biblioteki klientów wykorzystują to powiązanie do tworzenia ścieżek HTTP na potrzeby dostępu do zasobów o
określonych identyfikatorach GVR.

Nazwy zasobów zwyczajowo są zapisywane małą literą i w liczbie mnogiej (zwykle odpowiadają liczbie mnogiej
nazwy powiązanego rodzaju). Te nazwy muszą być zgodne z formatem ścieżek DNS (RFC 1035). Nie jest to
zaskoczeniem, ponieważ zasoby bezpośrednio odpowiadają ścieżkom HTTP.

Zasoby o zasięgu przestrzeni nazw lub klastra


Aby ustalić ścieżkę HTTP, musisz wiedzieć, czy identyfikator GVR ma zasięg przestrzeni nazw, czy zasięg
klastra. Na przykład instalacje mają zasięg przestrzeni nazw, dlatego częścią ich ścieżki HTTP jest dana
przestrzeń nazw. Inne identyfikatory GVR, np. rbac.authorization.k8s.io/v1.clusterroles, mają zasięg
klastra, a dostęp do ról w klastrze można uzyskać za pomocą ścieżki
apis/rbac.authorization.k8s.io/v1/clusterroles.

Odwzorowania REST
Odwzorowanie z identyfikatora GVK na identyfikator GVR jest nazywane odwzorowaniem REST.
RESTMapper to interfejs z języka Go (http://bit.ly/2Y7wYS8) umożliwiający zażądanie identyfikatora GVR na
podstawie identyfikatora GVK:
RESTMapping(gk schema.GroupKind, versions ...string) (*RESTMapping, error)

Typ RESTMapping wygląda tak:


type RESTMapping struct {
// Resource to identyfikator GVR określający lokalizację danego punktu końcowego.

Resource schema.GroupVersionResource.

// GroupVersionKind określa format danych przekazywanych do tego punktu końcowego.

GroupVersionKind schema.GroupVersionKind

// Scope zawiera informacje potrzebne do obsługi

// zasobów REST występujących w hierarchii danego zasobu.


Scope RESTScope

RESTMapper udostępnia szereg funkcji pomocniczych:


// KindFor przyjmuje fragment nazwy zasobu i zwraca jedną pasującą wartość.

// Dopasowanie kilku wartości skutkuje zwróceniem błędu.


KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error)

// KindsFor przyjmuje fragment nazwy zasobu i zwraca listę potencjalnych


// rodzajów w kolejności zgodnej z priorytetami.

KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error)

// ResourceFor przyjmuje fragment nazwy zasobu i zwraca jedną pasującą wartość.

// Dopasowanie kilku wartości skutkuje zwróceniem błędu.

ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error)

// ResourcesFor przyjmuje fragment nazwy zasobu i zwraca listę potencjalnych


// zasobów w kolejności zgodnej z priorytetami.

ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error)

// RESTMappings zwraca wszystkie odwzorowania zasobów dla podanej pary grupa-rodzaj,

// jeśli wyszukiwanie nie dotyczy wersji. Gdy podana jest wersja, funkcja zwraca

// preferowane odwzorowanie zasobu dla danej wersji.


RESTMappings(gk schema.GroupKind, versions ...string) ([]*RESTMapping, error)

We fragmentach identyfikatora GVR nie wszystkie pola są podane. Załóżmy, że wpisałeś polecenie kubectl get
pods. Brakuje w nim grupy i wersji. Jednak RESTMapper, mając wystarczające informacje, nadal może
odwzorować to polecenie na rodzaj v1 Pods.
We wcześniejszym przykładzie instalacji RESTMapper, który zna dane instalacje (wkrótce dowiesz się, co to
oznacza), odwzoruje nazwę apps/v1.Deployment (http://bit.ly/2IujaLU) na zasób apps/v1.deployments o zasięgu
przestrzeni nazw.

Istnieje wiele różnych implementacji interfejsu RESTMapper. Najważniejsza z nich przeznaczona dla aplikacji
klienckich to oparty na wykrywaniu informacji typ DeferredDiscoveryRESTMapper (http://bit.ly/2XroxUq) z
pakietu k8s.io/client-go/restmapper. Używa ona mechanizmu wykrywania informacji z serwera API Kubernetesa
do dynamicznego tworzenia odwzorowań REST. Działa także dla zasobów innych niż podstawowe (np. dla
zasobów niestandardowych).
Schemat
Ostatnim pojęciem, które należy zaprezentować w związku z systemem typów Kubernetesa, jest schemat
(http://bit.ly/2N1PGJB). Schematy są dostępne w pakiecie k8s.io/apimachinery/pkg/runtime.

Schemat łączy świat języka Go z niezależnym od implementacji światem identyfikatorów GVK. Głównym
zadaniem schematu jest odwzorowywanie typów z języka Go na możliwe identyfikatory GVK:
func (s *Scheme) ObjectKinds(obj Object) ([]schema.GroupVersionKind, bool, error)

W punkcie „Obiekty Kubernetesa w Go” opisaliśmy, że obiekt może zwrócić grupę i rodzaj za pomocą metody
GetObjectKind() schema.ObjectKind. Jednak grupa i rodzaj zwykle nie są podane, dlatego metoda ta jest
prawie nieprzydatna do identyfikowania obiektów.
Zamiast tego schemat określa dla danego obiektu typ z Go, używając mechanizmu refleksji, i odwzorowuje ten
typ na zarejestrowane dla niego identyfikatory GVK. Aby ta technika działała, typy z Go muszą oczywiście zostać
zarejestrowane w schemacie:

scheme.AddKnownTypes(schema.GroupVersionKind{“”, “v1”, “Pod”}, &Pod{})


Schemat jest używany nie tylko do rejestrowania typów z Go i powiązanych z nimi identyfikatorów GVK, ale też
do zapisywania list funkcji konwersji i defaulterów (rysunek 3.6). Konwersje i defaultery są opisane szczegółowo
w rozdziale 8. Schemat jest też źródłem danych do implementowania koderów i dekoderów.

Rysunek 3.6. Schemat łączący typy danych z języka Go z identyfikatorami GVK, konwersjami i defaulterami

Dla typów podstawowych Kubernetesa dostępny jest predefiniowany schemat w zbiorze klientów client-go
(http://bit.ly/2FkXDn2) w pakiecie k8s.io/client-go/kubernetes/scheme. Wstępnie zarejestrowane są w nim
wszystkie typy podstawowe. Każdy zbiór klientów wygenerowany przez generator kodu z narzędzia client-gen
(zob. rozdział 5.) zawiera pakiet pomocniczy scheme z wszystkimi typami z wszystkich grup i wersji z danego
zbioru klientów.
Omówienie schematu kończy przegląd zagadnień związanych z repozytorium API Machinery. Jeśli masz
zapamiętać tylko jedną rzecz związaną z tymi zagadnieniami, niech będzie to rysunek 3.7.
Rysunek 3.7. API Machinery w pigułce — od typów języka Go przez identyfikatory GVK i GVR do ścieżek HTTP

Vendoring
W tym rozdziale zobaczyłeś, że biblioteki k8s.io/client-go, k8s.io/api i k8s.io/apimachinery są podstawą w
programowaniu dla Kubernetesa w języku Go. W Go stosowana jest technika vendoringu do dołączania tych
bibliotek w repozytorium z kodem źródłowym niezależnych aplikacji.

Technika vendoringu wciąż jest rozwijana przez społeczność użytkowników języka Go. W czasie, gdy powstaje ta
książka, popularnych jest kilka narzędzi do vendoringu, np. godeps, dep i glide. Jednocześnie w Go 1.12
wprowadzono obsługę modułów języka Go, które w przyszłości zapewne staną się standardową metodą
vendoringu w społeczności użytkowników Go. Mechanizm ten nie jest jednak jeszcze gotowy do zastosowania w
ekosystemie Kubernetesa.
Obecnie w większości projektów używane są narzędzia dep i glide. W samym Kubernetesie w projekcie
github.com/kubernetes/kubernetes w pracach nad wersją 1.15 zdecydowano się zastosować moduły języka Go.
Poniższy opis jest adekwatny dla wszystkich narzędzi do vendoringu.

W repozytoriach k8s.io/∗ źródłem informacji o obsługiwanych wersjach zależności jest plik Godeps/Godeps.json.
Należy podkreślić, że wybór zależności niewymienionych w tym pliku może sprawić, iż biblioteka nie będzie
działać.

W punkcie „Biblioteka klienta” znajdziesz więcej informacji na temat publikowanych znaczników z bibliotek
k8s.io/client-go, k8s.io/api i k8s.io/apimachinery oraz kompatybilności między tymi znacznikami.

glide
W projektach, gdzie używane jest narzędzie glide, można wczytywać plik Godeps/Godeps.json przy każdej
zmianie zależności. Okazało się, że jest to dość stabilne rozwiązanie. Programista musi tylko zadeklarować
właściwą wersję biblioteki k8s.io/client-go, a glide wybierze odpowiednią wersję bibliotek k8s.io/apimachinery,
k8s.io/api i innych zależności.
W niektórych projektach w serwisie GitHub plik glide.yaml wygląda tak:

package: github.com/book/example

import:

- package: k8s.io/client-go

version: v10.0.0
...
Wtedy polecenie glide install -v spowoduje pobranie biblioteki k8s.io/client-go i zależności do lokalnego
pakietu vendor/. Opcja -v powoduje tu usunięcie katalogów vendor/ z dodawanych bibliotek. Jest to potrzebne do
naszych celów.

Jeśli aktualizujesz projekt do nowej wersji biblioteki client-go i w tym celu zmodyfikujesz plik glide.yaml,
polecenie glide update -v pobierze odpowiednie nowe wersje zależności.

dep
Narzędzie dep jest uważane za bardziej rozbudowane i zaawansowane od narzędzia glide. Przez długi czas
uznawano je za następcę narzędzia glide w ekosystemie Kubernetesa i wydawało się, że dep stanie się tym
najważniejszym narzędziem do vendoringu w języku Go. W czasie, gdy powstaje ta książka, przyszłość nie jest
jeszcze jasna, ale wydaje się, że należy ona do modułów języka Go.

Przy korzystaniu z biblioteki client-go trzeba mieć świadomość kilku ograniczeń narzędzia dep:

dep wczytuje plik Godeps/Godeps.json w pierwszym przebiegu instrukcji dep init,


dep nie wczytuje pliku Godeps/Godeps.json w późniejszych wywołaniach dep ensure -update.

To oznacza, że wybór zależności dla biblioteki client-go będzie prawdopodobnie błędny po zaktualizowaniu
wersji tej biblioteki w pliku Godep.toml. Jest to niekorzystne, ponieważ programista musi bezpośrednio (i zwykle
ręcznie) zadeklarować wszystkie zależności.
Oto działający i spójny plik Godep.toml:

[[constraint]]

name = “k8s.io/api”

version = “kubernetes-1.13.0”

[[constraint]]

name = “k8s.io/apimachinery”

version = “kubernetes-1.13.0”

[[constraint]]

name = “k8s.io/client-go”
version = “10.0.0”

[prune]

go-tests = true

unused-packages = true

# Poniższe sekcje override są niezbędne, aby

# wymusić użycie danej wersji, choć kod nie

# importuje pakietów bezpośrednio.

[[override]]

name = “k8s.io/api”

version = “kubernetes-1.13.0”

[[override]]

name = “k8s.io/apimachinery”
version = “kubernetes-1.13.0”

[[override]]

name = “k8s.io/client-go”

version = “10.0.0”

W pliku Gopkg.toml nie tylko zadeklarowane są bezpośrednio wersje repozytoriów


k8s.io/apimachinery i k8s.io/api, ale też znajdują się zmieniające ustawienia sekcje override. Te
sekcje są konieczne w sytuacjach, gdy projekt jest uruchamiany bez bezpośredniego
importowania pakietów z tych dwóch repozytoriów. W takim scenariuszu brak sekcji override
sprawi, że dep zignoruje dyrektywy constraint z początku pliku, a programista od początku
otrzyma nieprawidłowe zależności.
Nawet pokazany tu plik Gopkg.toml nie jest technicznie poprawny, ponieważ jest niekompletny
— nie zawiera deklaracji zależności dla wszystkich innych bibliotek wymaganych przez bibliotekę
client-go. W przeszłości jedna z powiązanych bibliotek uniemożliwiła kompilację biblioteki
client-go. Dlatego jeśli używasz narzędzia dep do zarządzania zależnościami, przygotuj się na
podobne sytuacje.

Moduły języka Go
Moduły języka Go są przyszłością zarządzania zależnościami w tym języku. Ich wstępna obsługa została
wprowadzona w Go 1.11 (http://bit.ly/2FmBp3Y), a stabilna wersja pojawiła się w Go 1.12. Po ustawieniu
zmiennej środowiskowej GO111MODULE=on dla modułów języka Go działają różne polecenia, np. go run i go get.
W Go 1.13 będzie to ustawienie domyślne.
Moduły języka Go są oparte na pliku go.mod z katalogu głównego projektu. Oto fragment pliku go.mod z
projektu github.com/programming-kubernetes/pizza-apiserver z rozdziału 8.:

module github.com/programming-kubernetes/pizza-apiserver

require (
...

k8s.io/api v0.0.0-20190222213804-5cb15d344471 // Pośrednio

k8s.io/apimachinery v0.0.0-20190221213512-86fb29eff628

k8s.io/apiserver v0.0.0-20190319190228-a4358799e4fe

k8s.io/client-go v2.0.0-alpha.0.0.20190307161346-7621a5ebb88b+incompatible
k8s.io/klog v0.2.1-0.20190311220638-291f19f84ceb

k8s.io/kube-openapi v0.0.0-20190320154901-c59034cc13d5 // Pośrednio

k8s.io/utils v0.0.0-20190308190857-21c4ce38f2a7 // Pośrednio

sigs.k8s.io/yaml v1.1.0 // Pośrednio

W bibliotece client-go v11.0.0 (odpowiada ona Kubernetesowi 1.14) i w starszych jej wersjach nie ma
bezpośredniej obsługi modułów języka Go. Można jednak używać takich modułów razem z bibliotekami
Kubernetesa, co pokazano w poprzednim przykładzie.

Jeśli jednak w bibliotece client-go lub w innych repozytoriach Kubernetesa nie ma pliku go.mod (jest tak
przynajmniej do wersji 1.15 Kubernetesa), trzeba ręcznie wybrać odpowiednie wersje modułów. Oznacza to, że
będziesz potrzebować kompletnej listy wszystkich zależności pasujących do wersji zależności wymienionych w
pliku Godeps/Godeps.json z biblioteki client-go.

Zwróć też uwagę na mało czytelne wersje z poprzedniego przykładu. Są to pseudowersje uzyskane na podstawie
istniejących znaczników (jeśli nie ma znaczników, jako przedrostek używana jest wersja v0.0.0). Co gorsza, w
pliku można podać wersje ze znacznikami, ale polecenia modułów języka Go po następnym ich wywołaniu
zastąpią wersje pseudowersjami.
W bibliotece client-go v12.0.0 (powiązanej z Kubernetesem 1.15) udostępniany jest plik go.mod, a
wycofywana jest obsługa wszystkich pozostałych narzędzi do vendoringu (zob. dokument opisujący to
rozwiązanie — http://bit.ly/2IZ9MPg). Udostępniany plik go.mod obejmuje wszystkie zależności, a w pliku
go.mod w projekcie nie trzeba już ręcznie wymieniać wszystkich pośrednio potrzebnych zależności. W przyszłych
wersjach schemat tworzenia znaczników może zostać zmodyfikowany, aby wyeliminować okropne pseudowersje i
zastąpić je poprawnymi znacznikami wersji semantycznych. Jednak w czasie, gdy powstaje ta książka, takie
rozwiązanie nie zostało jeszcze w pełni zaimplementowane i nie podjęto też decyzji o jego wprowadzeniu.

Podsumowanie
W tym rozdziale skoncentrowaliśmy się na interfejsie programowania Kubernetesa w języku Go. Omówiliśmy
dostęp do API Kubernetesa znanych typów podstawowych, reprezentujących obiekty API dostępne w każdym
klastrze Kubernetesa.

To kończy omówienie podstaw API Kubernetesa i reprezentacji tego API w języku Go. Jesteś gotów do przejścia
do niestandardowych zasobów, które są jedną z podstaw działania operatorów.

1 Zobacz punkt „Terminologia związana z API”.

2 Polecenie kubectl explain pod pozwala skierować do serwera API zapytanie o schemat obiektu (z
dokumentacją pól włącznie).
Rozdział 4. Używanie
niestandardowych zasobów
W tym rozdziale przedstawimy niestandardowe zasoby. Jest to jeden z głównych mechanizmów
rozszerzania używanych w ekosystemie Kubernetesa.
Niestandardowe zasoby są używane w małych, wewnętrznych obiektach konfiguracyjnych i nie
są powiązane z logiką kontrolera. Definiuje się je czysto deklaratywnie. Jednak odgrywają one
podstawową rolę w wielu rozbudowanych projektach programistycznych opartych na
Kubernetesie, których autorzy chcą zapewnić interfejs podobny do natywnego API
Kubernetesa. Dotyczy to np. systemów service mesh takich jak Istio, Linkerd 2.0 i AWS App
Mesh, które wszystkie są oparte na niestandardowych zasobach.
Pamiętasz punkt „Przykład wprowadzający” z rozdziału 1.? Podstawą tego przykładu jest
niestandardowy zasób o następującej postaci:
apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At
metadata:

name: example-at
spec:
schedule: “2019-07-03T02:00:00Z”
status:
phase: “pending”
Niestandardowe zasoby są dostępne w każdym klastrze Kubernetesa od wersji 1.7. Są
zapisywane w tej samej instancji systemu etcd co główne zasoby API Kubernetes i
udostępniane przez ten sam serwer API Kubernetesa. Na rysunku 4.1 pokazane jest, że żądania
niestandardowych zasobów są kierowane do serwera apiextensions-apiserver, który
udostępnia zasoby tworzone za pomocą definicji CRD, jeśli nie są to:

zasoby obsługiwane przez zagregowane serwery API (zob. rozdział 8.),


natywne zasoby Kubernetesa.
Rysunek 4.1. Serwer API Extensions w serwerze API Kubernetesa

Definicja CRD sama jest zasobem Kubernetesa. Opisuje niestandardowe zasoby dostępne w
klastrze. W tym przykładzie definicja CRD niestandardowego zasobu wygląda tak:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition

metadata:
name: ats.cnat.programming-kubernetes.info
spec:

group: cnat.programming-kubernetes.info

names:

kind: At

listKind: AtList
plural: ats

singular: at

scope: Namespaced

subresources:
status: {}

version: v1alpha1

versions:

- name: v1alpha1
served: true
storage: true
Nazwa definicji CRD (tu jest to ats.cnat.programming-kubernetes.info) to nazwa zasobu w
liczbie mnogiej z dołączoną nazwą grupy. Ta definicja tworzy niestandardowy zasób rodzaju At
w grupie API cnat.programming-kubernetes.info jako zasób ats z przestrzeni nazw.

Jeśli ta definicja CRD zostanie użyta w klastrze, narzędzie kubectl automatycznie wykryje
zasób, a użytkownik będzie mógł uzyskać do niego dostęp w następujący sposób:

$ kubectl get ats

NAME CREATED AT

ats.cnat.programming-kubernetes.info 2019-04-01T14:03:33Z

Wykrywanie informacji
Na zapleczu narzędzie kubectl używa wykrywania informacji z serwera API, aby poznać nowe
zasoby. Przyjrzyjmy się teraz dokładnie procesowi wykrywania.

Po zwiększeniu poziomu szczegółowości komunikatów z narzędzia kubectl możesz zobaczyć, w


jaki sposób uzyskuje ono informacje o nowych typach zasobów:

$ kubectl get ats -v=7

... GET https://XXX.eks.amazonaws.com/apis/cnat.programming-kubernetes.info/


v1alpha1/namespaces/cnat/ats?limit=500

... Request Headers:

... Accept:
application/json;as=Table;v=v1beta1;g=meta.k8s.io,application/json

User-Agent: kubectl/v1.14.0 (darwin/amd64) kubernetes/641856d


... Response Status: 200 OK in 607 milliseconds

NAME AGE

example-at 43s

Oto szczegółowy opis etapów wykrywania informacji:

1. Początkowo kubectl nie zna typu ats.


2. Dlatego kubectl wysyła do serwera API żądanie danych o wszystkich istniejących
grupach API. Używa do tego punktu końcowego do wykrywania informacji — /apis.
3. Następnie kubectl żąda od serwera API zasobów z wszystkich istniejących grup API.
Używa do tego punktów końcowych do wykrywania informacji o grupach — /apis/wersja
grupy.
4. Potem kubectl przekształca dany typ, ats, na trzy elementy:
grupę (tu cnat.programming-kubernetes.info),
wersję (tu v1alpha1),
zasób (tu ats).

Punkty końcowe używane do wykrywania informacji zapewniają wszystkie dane potrzebne do


wykonania przekształceń z ostatniego etapu:

$ http localhost:8080/apis/
{
“groups”: [{

“name”: “at.cnat.programming-kubernetes.info”,

“preferredVersion”: {

“groupVersion”: “cnat.programming-kubernetes.info/v1”,

“version”: “v1alpha1“
},

“versions”: [{

“groupVersion”: “cnat.programming-kubernetes.info/v1alpha1”,

“version”: “v1alpha1”

}]

}, ...]
}

$ http localhost:8080/apis/cnat.programming-kubernetes.info/v1alpha1

“apiVersion”: “v1”,

“groupVersion”: “cnat.programming-kubernetes.info/v1alpha1”,

“kind”: “APIResourceList”,

“resources”: [{

“kind”: “At”,
“name”: “ats”,

“namespaced”: true,

“verbs”: [“create”, “delete”, “deletecollection”,

“get”, “list”, “patch”, “update”, “watch”

}, ...]

Cały ten proces jest zaimplementowany w obiekcie RESTMapper odpowiedzialnym za


wykrywanie informacji. Ta bardzo często stosowana odmiana obiektów RESTMapper została też
opisana w punkcie „Odwzorowania REST”.

Interfejs CLI narzędzia kubectl przechowuje też bufor z typami zasobów w


pliku ~/.kubectl, dzięki czemu nie trzeba ponownie pobierać informacji za
pomocą procesu ich wykrywania przy każdym dostępie do systemu. Ten bufor
jest unieważniany co 10 minut. Dlatego zmiana w definicji CRD może stać się
widoczna w interfejsie CLI danego użytkownika nawet po 10 minutach.
Definicje typów
Przyjrzyjmy się teraz dokładnie definicjom CRD i dostępnym mechanizmom. W przykładzie
dotyczącym narzędzia cnat definicje CRD są zasobami Kubernetesa w grupie API
apiextensions.k8s.io/v1beta1 udostępnianymi przez serwer API apiextensions-apiserver
w procesie serwera API Kubernetesa.

Schemat tej definicji CRD wygląda tak:

apiVersion: apiextensions.k8s.io/v1beta1

kind: CustomResourceDefinition

metadata:

name: nazwa

spec:
group: nazwa grupy

version: nazwa wersji

names:

kind: nazwa pisana wielką literą

plural: nazwa w liczbie mnogiej pisana małą literą

singular: nazwa w liczbie poj. pisana małą literą # Domyślnie rodzaj


pisany małą literą

shortNames: lista łańcuchów znaków z krótkimi nazwami # Opcjonalne

listKind: rodzaj listy pisany wielka literą # Domyślnie rodzajList

categories: lista kategorii, do których należy zasób, np. “all” #


Opcjonalne

validation: # Opcjonalne
openAPIV3Schema: schemat OpenAPI # Opcjonalne

subresources: # Opcjonalne

status: {} # Włączanie podzasobu status (opcjonalne)

scale: # Opcjonalne

specReplicasPath: ścieżka w formacie JSON do liczby replik ze


specyfikacji
niestandardowego zasobu

statusReplicasPath: ścieżka w formacie JSON do liczby replik ze statusu

niestandardowego zasobu

labelSelectorPath: ścieżka w formacie JSON do pola


Scale.Status.Selector
z zasobu rodzaju Scale

versions: # Domyślnie pole Spec.Version


- name: nazwa wersji

served: Wartość logiczna określająca, czy dana wersja jest udostępniana

przez serwer API # Domyślnie false

storage: Wartość logiczna określająca, czy dana wersja służy

do składowania obiektu
- ...

Wiele z tych pól jest opcjonalnych lub ma wartość domyślną. Te pola zostaną opisane
szczegółowo w następnych punktach.

Po utworzeniu obiektu z definicją CRD serwer apiextensions-apiserver z serwera kube-


apiserver sprawdzi nazwy i określi, czy nie powodują konfliktów z innymi zasobami i czy są
spójne. Po chwili serwer poinformuje o wyniku testów w statusie definicji CRD. Oto przykład:

apiVersion: apiextensions.k8s.io/v1beta1

kind: CustomResourceDefinition
metadata:

name: ats.cnat.programming-kubernetes.info

spec:

group: cnat.programming-kubernetes.info

names:

kind: At

listKind: AtList

plural: ats

singular: at
scope: Namespaced

subresources:

status: {}

validation:

openAPIV3Schema:

type: object

properties:

apiVersion:

type: string
kind:

type: string

metadata:

type: object

spec:
properties:

schedule:

type: string

type: object

status:
type: object

version: v1alpha1

versions:

- name: v1alpha1

served: true

storage: true

status:
acceptedNames:

kind: At
listKind: AtList

plural: ats
singular: at

conditions:
- lastTransitionTime: “2019-03-17T09:44:21Z”
message: no conflicts found

reason: NoConflicts
status: “True”

type: NamesAccepted
- lastTransitionTime: null

message: the initial names have been accepted


reason: InitialNamesAccepted

status: “True”
type: Established
storedVersions:

- v1alpha1
Widać tu, że jeśli pole z nazwą jest puste, w specyfikacji używane są wartości domyślne, a
nazwy w statusie zostają opisane jako zaakceptowane. Ponadto ustawiane są następujące
warunki:

NamesAccepted informuje, czy dane nazwy ze specyfikacji są spójne i wolne od konfliktów.


Established określa, czy serwer API udostępnia dany zasób pod nazwami z pola
status.acceptedNames.
Warto zauważyć, że wartość niektórych pól można zmienić długo po utworzeniu definicji CRD.
Możesz np. dodawać krótkie nazwy lub kolumny. Wtedy definicja CRD może zostać ustalona (i
będzie udostępniana z dawnymi nazwami), ale nazwy ze specyfikacji spowodują konflikty.
Dlatego warunek NamesAccepted będzie miał wartość false, a nazwy ze specyfikacji będą
różne od zaakceptowanych nazw.

Zaawansowane mechanizmy
niestandardowych zasobów
W tym podrozdziale opisane są zaawansowane mechanizmy niestandardowych zasobów, np.
sprawdzanie poprawności i podzasoby.

Sprawdzanie poprawności niestandardowych


zasobów
Serwer API może sprawdzać poprawność niestandardowych zasobów w trakcie ich tworzenia i
aktualizowania. Odbywa się to na podstawie schematu OpenAPI v3 (http://bit.ly/2RqtN5i),
podanego w polach validation w specyfikacji CRD.
Gdy żądanie tworzy lub modyfikuje niestandardowy zasób, utworzony obiekt w formacie JSON
jest sprawdzany na podstawie specyfikacji. Błędy powodują zwrócenie użytkownikowi kodu
odpowiedzi 400 HTTP z polem, które spowodowało konflikt. Na rysunku 4.2 pokazane jest, że
sprawdzanie poprawności ma miejsce w funkcji obsługi żądań na serwerze apiextensions-
apiserver.
Rysunek 4.2. Etap sprawdzania poprawności w stosie mechanizmów obsługi żądań na serwerze
apiextensions-apiserver

Bardziej skomplikowany proces sprawdzania poprawności można zaimplementować za pomocą


webhooków do kontroli dostępu, które sprawdzają poprawność. W takiej implementacji
używany jest język programowania kompletny w sensie Turinga. Na rysunku 4.2 widać, że takie
webhooki są wywoływane bezpośrednio po opisanym w tym podrozdziale sprawdzaniu
poprawności opartym na OpenAPI. W punkcie „Webhooki do kontroli dostępu” zobaczysz, jak
takie webhooki są implementowane i instalowane. Przyjrzysz się tam sprawdzaniu poprawności
z uwzględnieniem innych zasobów, znacznie wykraczającym poza sprawdzanie poprawności ze
specyfikacji OpenAPI v3. Na szczęście w wielu scenariuszach wystarczają schematy ze
specyfikacji OpenAPI v3.
Język schematu OpenAPI jest oparty na standardzie JSON Schema (http://bit.ly/2J7aIT7), gdzie
do zapisu schematu używany jest format JSON/YAML. Oto przykład:

type: object
properties:

apiVersion:
type: string

kind:
type: string

metadata:
type: object
spec:

type: object
properties:

schedule:
type: string

pattern: “^\d{4}-([0]\d|1[0-2])-([0-2]\d|3[01])...”
command:

type: string
required:
- schedule

- command
status:

type: object
properties:

phase:
type: string

required:
- metadata
- apiVersion

- kind
- spec
[1]
W tym schemacie określone jest, że wartość to obiekt w formacie JSON . Oznacza to, że
używana jest mapa łańcuchów znaków, a nie wycinek lub wartość taka jak liczba. Ponadto
obiekt ten ma (oprócz właściwości metadata, kind i apiVersion, pośrednio zdefiniowanych dla
niestandardowych zasobów) dwie dodatkowe właściwości: spec i status.

Każda z tych właściwości też jest obiektem w formacie JSON. Wymagane pola właściwości spec
to łańcuchy znaków schedule i command. Pole schedule musi pasować do wzorca dat w
formacie ISO (przedstawionego tu za pomocą wyrażenia regularnego). Opcjonalna właściwość
status zawiera pole tekstowe phase.

Schematy OpenAPI v3, ich kompletność i przyszłość


Schematy OpenAPI v3 były kiedyś opcjonalne w definicjach CRD. Do Kubernetesa 1.14
używano ich tylko do sprawdzania poprawności po stronie serwera. Gdy są używane w
tym celu, mogą być niekompletne (nie trzeba w nich podawać wszystkich pól).
Od Kubernetesa 1.15 schematy CRD będą publikowane w ramach specyfikacji OpenAPI
serwera API Kubernetesa. Ma to służyć głównie do sprawdzania poprawności po stronie
klienta przez narzędzie kubectl. W tym procesie zgłaszane są zastrzeżenia co do
nieznanych pól. Na przykład gdy użytkownik wpisze w obiekcie wyrażenie foo:bar, a w
schemacie OpenAPI nie ma definicji foo, narzędzie kubectl odrzuci taki obiekt. Dlatego
dobrą praktyką jest przekazywanie kompletnych schematów OpenAPI.
W przyszłości instancje niestandardowych zasobów będą okrajane
(http://bit.ly/2WY8lKY). To oznacza, że — podobnie jak w natywnych zasobach
Kubernetesa, np. w podach — nieznane (nieopisane w specyfikacji) pola nie będą
utrwalane. Jest to ważne nie tylko ze względu na spójność danych, ale też z powodu
bezpieczeństwa. Jest to następny powód, dla którego schematy OpenAPI dla definicji
CRD powinny być kompletne.
Wszystkie potrzebne informacje znajdziesz w dokumentacji schematów OpenAPI v3
(http://bit.ly/2RqtN5i).

Ręczne tworzenie schematów OpenAPI może być żmudne. Na szczęście trwają prace nad tym,
aby znacznie uprościć ten proces dzięki generowaniu kodu. W projekcie Kubebuilder (zob.
punkt „Kubebuilder”) opracowano narzędzie crd-gen. Jest ono dostępne w repozytorium
sigs.k8s.io/controller-tools (http://bit.ly/2J00kvi) i rozbudowywane krok po kroku, aby było
przydatne także w innych kontekstach. Generator crd-schema-gen (http://bit.ly/31N0eQf) to
fork narzędzia crd-gen rozwijany w tym kierunku.

Kategorie i krótkie nazwy


Niestandardowe zasoby (podobnie jak zasoby natywne) mogą mieć długie nazwy. Sprawdzają
się one świetnie na poziomie API, ale ich wpisywanie w interfejsie CLI jest żmudne.
Niestandardowe zasoby mogą też mieć krótkie nazwy, tak jak natywny zasób daemonsets, do
którego zapytania można kierować za pomocą polecenia kubectl get ds. Te krótkie nazwy to
aliasy, a każdy zasób może mieć ich dowolną liczbę.
Aby wyświetlić wszystkie dostępne krótkie nazwy, użyj polecenia kubectl api-resources:

$ kubectl api-resources
NAME SHORTNAMES APIGROUP NAMESPACED KIND
bindings true Binding

componentstatuses cs false ComponentStatus


configmaps cm true ConfigMap
endpoints ep true Endpoints

events ev true Event


limitranges limits true LimitRange

namespaces ns false Namespace


nodes no false Node

persistentvolumeclaims pvc true


PersistentVolumeClaim
persistentvolumes pv false PersistentVolume

pods po true Pod


statefulsets sts apps true StatefulSet

...
Narzędzie kubectl poznaje krótkie nazwy za pomocą wykrywania informacji (zob. punkt
„Wykrywanie informacji”). Oto przykład:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition

metadata:
name: ats.cnat.programming-kubernetes.info

spec:
...

shortNames:
- at
Następnie za pomocą polecenia kubectl get at można wyświetlić wszystkie niestandardowe
zasoby cnat z danej przestrzeni nazw.
Niestandardowe zasoby (podobnie jak wszystkie inne) mogą należeć do kategorii. Najczęściej
używana jest kategoria all, np. w poleceniu kubectl get all, które wyświetla wszystkie
dostępne dla użytkownika zasoby klastra, np. pody i usługi.
Niestandardowe zasoby zdefiniowane w klastrze można dodać do istniejącej lub nowej
kategorii, używając pola categories:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition

metadata:
name: ats.cnat.programming-kubernetes.info

spec:
...
categories:
- all

Przy tych ustawieniach polecenie kubectl get all wyświetli także niestandardowy zasób cnat
z danej przestrzeni nazw.

Wyświetlane kolumny
Narzędzie kubectl z interfejsu CLI używa wyświetlania po stronie serwera, aby generować
dane wyjściowe polecenia kubectl get. To oznacza, że kieruje do serwera API zapytania o
wyświetlane kolumny i wartości z każdego wiersza.
Także niestandardowe zasoby obsługują wyświetlanie kolumn po stronie serwera. Używane jest
tu pole additionalPrinterColumns. Człon additional (czyli dodatkowe) wynika z tego, że
pierwsza kolumna zawsze zawiera nazwę obiektu. Dodatkowe kolumny są definiowane w
następujący sposób:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition

metadata:
name: ats.cnat.programming-kubernetes.info
spec:

additionalPrinterColumns: (optional)
- name: nazwa kolumn dla kubectl

type: typ OpenAPI kolumny


format: format OpenAPI kolumny (opcjonalne)

description: czytelny dla człowieka opis kolumny (opcjonalne)


priority: liczba całkowita; obecnie kubectl obsługuje tylko wartość zero

JSONPath: ścieżka JSON do wyświetlanej wartości z niestandardowego zasobu


Pole name to nazwa kolumny. Pole type to typ OpenAPI zdefiniowany w sekcji z typami w
specyfikacji (http://bit.ly/2N0DSY4). Pole format (zdefiniowane w tym samym dokumencie) jest
opcjonalne i może być odpowiednio interpretowane przez narzędzie kubectl i inne klienty.
Pole description to opcjonalny, czytelny dla człowieka łańcuch znaków używany na potrzeby
dokumentacji. Pole priority kontroluje poziom szczegółowości wyświetlania danej kolumny
przez narzędzie kubectl. W czasie, gdy powstaje ta książka (wersja Kubernetes 1.14),
obsługiwane jest tylko zero, a wszystkie kolumny o wyższym priorytecie są ukrywane.
Pole JSONPath definiuje, jakie wartości mają być wyświetlane. Jest to prosta ścieżka JSON do
lokalizacji w niestandardowym zasobie. „Prosta” oznacza tu tyle, że obsługiwana jest składnia
dla pól obiektów, np. .spec.foo.bar, ale nie są dostępne bardziej skomplikowane ścieżki JSON,
które wymagają pobierania elementów tablic w pętli lub podobnych operacji.
Przykładową definicję CRD można więc rozbudować o pole additionalPrinterColumns:

additionalPrinterColumns: # (opcjonalne)
- name: schedule

type: string
JSONPath: .spec.schedule

- name: command
type: string

JSONPath: .spec.command
- name: phase
type: string

JSONPath: .status.phase
Teraz narzędzie kubectl wyświetli zasób cnat w następujący sposób:

$ kubectl get ats


NAME SCHEDULER COMMAND PHASE

foo 2019-07-03T02:00:00Z echo “Witaj, świecie” Pending


W dalszej kolejności przyjrzymy się podzasobom.

Podzasoby
O podzasobach pokrótce wspomnieliśmy w punkcie „Podzasoby status — UpdateStatus”.
Podzasoby to specjalne punkty końcowe HTTP, w których przyrostek jest dodawany do ścieżki
HTTP zwykłego zasobu. Na przykład standardowa ścieżka HTTP poda to
/api/v1/namespace/przestrzeńnazw/pods/nazwa. Pody mają wiele podzasobów, np. /logs,
/portforward, /exec i /status. Ścieżki HTTP takich podzasobów to:

/api/v1/namespace/przestrzeńnazw/pods/nazwa/logs
/api/v1/namespace/przestrzeńnazw/pods/nazwa/portforward
/api/v1/namespace/przestrzeńnazw/pods/nazwa/exec
/api/v1/namespace/przestrzeńnazw/pods/nazwa/status

Dla punktów końcowych podzasobów używany jest inny protokół niż dla punktu końcowego
głównego zasobu.

W czasie, gdy powstaje ta książka, niestandardowe zasoby obsługują dwa podzasoby: /scale i
/status. Oba są opcjonalne, co oznacza, że trzeba je bezpośrednio aktywować w definicji CRD.

Podzasób status
Podzasób /status służy do oddzielania podanej przez użytkownika specyfikacji instancji
niestandardowego zasobu od określanego przez kontroler statusu. Głównym celem tego
rozwiązania jest rozdział uprawnień:

użytkownik zwykle nie powinien zapisywać pól związanych ze statusem,


kontroler nie powinien zapisywać pól związanych ze specyfikacją.

Kontrola dostępu oparta na rolach nie pozwala podawać reguł na takim poziomie
szczegółowości. Reguły zawsze są podawane na poziomie zasobu. Podzasób /status rozwiązuje
ten problem, ponieważ zapewnia dwa punkty końcowe, które same są zasobami. Każdy z nich
można niezależnie kontrolować za pomocą reguł kontroli dostępu opartej na rolach. Ten
mechanizm można nazwać podziałem na status i specyfikację. Oto przykład zastosowania
reguły tylko dla podzasobu /status zasobu ats (nazwa „ats” odpowiada głównemu zasobowi):

apiVersion: rbac.authorization.k8s.io/v1
kind: Role

metadata: ...
rules:
- apiGroups: [“”]
resources: [“ats/status”]
verbs: [“update”, “patch”]

Zasoby (także niestandardowe), które mają podzasób /status, działają w nowy sposób. Dotyczy
to także punktu końcowego głównego zasobu.

W trakcie tworzenia (status jest wtedy zerowany) i modyfikowania zasobu ignorowane są


zmiany statusu głównego punktu końcowego HTTP.
Podobnie w punkcie końcowym podzasobu /status ignorowane są zmiany inne niż w
statusie. Operacja tworzenia dla punktu końcowego /status nie jest obsługiwana.
Gdy zmieni się coś poza polami metadata i status (dotyczy to przede wszystkim
modyfikacji w specyfikacji), dla punktu końcowego głównego zasobu zwiększana jest
wartość metadata.generation. Może to być wyzwalacz dla kontrolera, informujący, że
zmieniły się oczekiwania użytkownika.

Warto zauważyć, że w żądaniu aktualizacji przesyłane są zwykle zarówno pole spec, jak i
status, jednak w treści żądania można byłoby pominąć jedno z nich.

Zwróć też uwagę na to, że punkt końcowy /status ignoruje wszystko poza statusem, w tym
zmiany metadanych, np. etykiet i adnotacji.
Rozdział na status i specyfikację w niestandardowym zasobie można włączyć w następujący
sposób:
apiVersion: apiextensions.k8s.io/v1beta1

kind: CustomResourceDefinition
spec:
subresources:
status: {}
...

Zauważ, że do pola status w tym fragmencie w formacie YAML przypisany jest pusty obiekt. W
ten sposób można ustawić wartość pola, które nie ma innych właściwości. Jeśli użyjesz tylko
kodu:
subresources:
status:

to przy sprawdzaniu poprawności wystąpi błąd, ponieważ w formacie YAML pole status będzie
miało wartość null.

Włączenie rozdziału na status i specyfikację to zmiana powodująca


niekompatybilność API. Starsze kontrolery będą zapisywać dane w głównym
punkcie końcowym. Nie wykryją, że od momentu aktywacji rozdziału status
zawsze jest ignorowany. Podobnie do momentu aktywowania rozdziału nowy
kontroler nie będzie mógł zapisywać danych w nowym punkcie końcowym
/status.
Od Kubernetesa 1.13 podzasoby można konfigurować dla poszczególnych wersji. Pozwala to
dodać podzasób /status bez naruszania kompatybilności:
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
spec:

...
versions:
- name: v1alpha1
served: true

storage: true
- name: v1beta1
served: true
subresources:
status: {}

Ten kod aktywuje podzasób /status dla wersji v1beta1, ale już nie dla wersji v1alpha1.

Współbieżność optymistyczna (zob. punkt „Współbieżność optymistyczna”)


działa tu tak samo jak dla punktów końcowych głównych zasobów. Pola status
i spec mają wspólny licznik wersji zasobu, a aktualizacje punktu końcowego
/status mogą skutkować konfliktem spowodowanym zapisem głównego zasobu
(i w drugą stronę). Oznacza to, że w warstwie składowania danych nie ma
rozdziału na status i specyfikację.

Podzasób scale
Drugim podzasobem dostępnym dla niestandardowych zasobów jest /scale. Podzasób ten jest
[2]
(uzyskanym w wyniku projekcji) widokiem zasobu, pozwalającym wyświetlać i modyfikować
wartości tylko w replice. Jest to dobrze znany podzasób dla zasobów takich jak instalacje i
zbiory replik Kubernetesa, które — co oczywiste — można skalować w obie strony.
Polecenie kubectl scale wykorzystuje podzasób /scale. Na przykład poniższy kod modyfikuje
liczbę replik w danej instancji:

$ kubectl scale --replicas=3 Twój-niestandardowy-zasób -v=7


I0429 21:17:53.138353 66743 round_trippers.go:383] PUT
https://host/apis/group/v1/Twój-niestandardowy-zasób/scale

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
spec:
subresources:
scale:
specReplicasPath: .spec.replicas
statusReplicasPath: .status.replicas
labelSelectorPath: .status.labelSelector

...
Ten kod sprawia, że zaktualizowana liczba replik jest zapisywana w polu spec.replicas i
zwracana z tego pola w żądaniach GET.
Za pomocą podzasobu /status nie można zmodyfikować selektora etykiet (można go tylko
wczytać). Ten selektor ma zapewniać kontrolerowi informacje potrzebne do zliczania
określonych obiektów. Na przykład kontroler ReplicaSet zlicza pody zgodne z danym
selektorem.
Selektor etykiet jest opcjonalny. Jeśli działanie niestandardowego zasobu nie jest zgodne z
selektorami etykiet, nie podawaj ścieżki JSON dla takiego selektora.
W przykładowej instrukcji kubectl scale --replicas=3 ... wartość 3 jest zapisywana w polu
spec.replicas. Oczywiście można tu zastosować także inne proste ścieżki JSON.
Przykładowymi sensownymi nazwami pól mogłyby być spec.instances lub spec.size (zależy
to od kontekstu).

Całkowitoliczbowa ilość replik a kontroler tworzący i usuwający repliki


Tu opisywane są tylko odczyt i zapis w niestandardowym zasobie liczby całkowitej
określającej ilość replik. Semantyka związana z liczbą replik (np. tworzenie i usuwanie
samych replik) musi być zaimplementowana w niestandardowym kontrolerze (zob. punkt
„Kontrolery i operatory”).

Rodzaj obiektu wczytywanego lub zapisywanego w omawianym punkcie końcowym to Scale z


grupy API autoscaling/v1. Ten rodzaj wygląda tak:
type Scale struct {
metav1.TypeMeta `json:”,inline”`

// Standardowe metadane obiektu. Więcej informacji: https://git.k8s.io/


// community/contributors/devel/api-conventions.md#metadata.
// +optional
metav1.ObjectMeta `json:”metadata,omitempty”`

// Definiuje działanie skali. Więcej informacji:


https://git.k8s.io/community/
// contributors/devel/api-conventions.md#spec-and-status.
// +optional

Spec ScaleSpec `json:”spec,omitempty”`

// Aktualny status skali. Więcej informacji:


https://git.k8s.io/community/
// contributors/devel/api-conventions.md#spec-and-status. Tylko do
odczytu.
// +optional
Status ScaleStatus `json:”status,omitempty”`

// ScaleSpec opisuje atrybuty podzasobu scale.


type ScaleSpec struct {

// Oczekiwana liczba instancji skalowanego obiektu.


// +optional
Replicas int32 `json:”replicas,omitempty”`
}

// ScaleStatus reprezentuje aktualny status podzasobu scale.


type ScaleStatus struct {
// Obecna liczba instancji skalowanego obiektu.
Replicas int32 `json:”replicas”`

// Zapytanie o pody z określoną etykietą. Liczba tych podów ma odpowiadać


liczbie replik.
// Działa tak samo jak selektor etykiet, ale w formacie tekstowym, aby
uniknąć
// introspekcji po stronie klienta. Używany łańcuch znaków ma ten sam
format
// co w składni parametrów zapytań. Więcej informacji o selektorach
etykiet:
// http://kubernetes.io/docs/user-guide/labels#label-selectors.
// +optional

Selector string `json:”selector,omitempty”`


}
Instancja wygląda tak:
metadata:
name: nazwa-niestandardowego-zasobu

namespace: przestrzeń-nazw-niestandardowego-zasobu
uid: uid-niestandardowego-zasobu
resourceVersion: wersja-zasób-niestandardowego-zasobu
creationTimestamp: czas-utworzenia-niestandardowego-zasobu

spec:
replicas: 3

status:
replicas: 2
selector: “environment = production”
Współbieżność optymistyczna działa tak samo dla głównego zasobu i podzasobu /scale. Oznacza
to, że zapis głównego zasobu może powodować konflikty z zapisem podzasobu /scale i w drugą
stronę.

Niestandardowe zasoby z perspektywy


programisty
W języku Go dostęp do niestandardowych zasobów można uzyskać w różnych klientach. Tu
skoncentrujemy się na:

używaniu klienta dynamicznego client-go (zob. punkt „Klient dynamiczny”),


używaniu klienta typizowanego:
dostępnego w projekcie kubernetes-sigs/controller-runtime (http://bit.ly/2ZFtDKd) i
używanego w narzędziach Operator SDK i Kubebuilder (zob. punkt „Klient
controller-runtime z narzędzi Operator SDK i Kubebuilder”),
wygenerowanego przez narzędzie client-gen, np. takiego jak w pakiecie
k8s.io/client-go/kubernetes (zob. punkt „Klienty typizowane tworzone za pomocą
generatora client-gen”).

Wybór klienta zależy głównie od przeznaczenia pisanego kodu, a przede wszystkim od


złożoności implementowanej logiki i obowiązujących wymogów (np. konieczności użycia
dynamicznego klienta i obsługi identyfikatorów GVK nieznanych w czasie kompilacji).
Kolejne klienty z przedstawionej listy:

zapewniają coraz mniejsze możliwości obsługi nieznanych identyfikatorów GVK,


zapewniają coraz większe bezpieczeństwo ze względu na typy,
zapewniają coraz bardziej kompletny zestaw mechanizmów dostępnych w API
Kubernetesa.

Klient dynamiczny
Klient dynamiczny z pakietu k8s.io/client-go/dynamic (http://bit.ly/2Y6eeSK) nie uwzględnia
znanych identyfikatorów GVK. Nie używa nawet żadnych typów języka Go innych niż
unstructured.Unstructured (http://bit.ly/2WYZ6oS), który opakowuje funkcję json.Unmarshal i
jej dane wyjściowe.

Klient dynamiczny nie używa ani schematu, ani obiektów typu RESTMapper. To oznacza, że
programista musi ręcznie określić wszystkie informacje o typach, podając zasoby (zob. punkt
„Zasoby”) w postaci identyfikatora GVR:
schema.GroupVersionResource{
Group: “apps”,

Version: “v1”,
Resource: “deployments”,
}
Jeśli dostępna jest konfiguracja klienta REST („Tworzenie i używanie klientów”), dynamicznego
klienta można utworzyć w jednym wierszu:

client, err := NewForConfig(cfg)


Dostęp w modelu REST do danego identyfikatora GVR jest równie prosty:
client.Resource(gvr).
Namespace(namespace).Get(“foo”, metav1.GetOptions{})
To wywołanie zwraca instalację foo z danej przestrzeni nazw.

Musisz znać zasięg zasobu (z poziomu przestrzeni nazw lub klastra). Dla
zasobów z poziomu klastra wystarczy pominąć człon Namespace(namespace).

Dane wejściowe i wyjściowe w dynamicznym kliencie są typu *unstructured.Unstructured.


Są to więc obiekty, które zawierają tę samą strukturę, jaką funkcja json.Unmarshal zwraca po
unmarshallingu:

wartość typu map[string]interface{} dla obiektów,


wartość typu []interface{} dla tablic,
wartości typów string, bool, float64 lub int64 dla typów prostych.

Metoda UnstructuredContent() zapewnia dostęp do struktury danych w


nieustrukturyzowanym obiekcie (możesz też użyć pola Unstructured.Object). W tym samym
pakiecie dostępne są funkcje pomocnicze ułatwiające pobieranie pól i umożliwiające
operowanie obiektem. Oto przykład:
name, found, err := unstructured.NestedString(u.Object, “metadata”, “name”)
Ta funkcja zwraca nazwę instalacji. Tu jest to „foo”. Zmienna found przyjmuje wartość true,
jeśli pole zostało znalezione (i nie jest puste). Zmienna err informuje, czy typ istniejącego pola
jest nieoczekiwany (tu jest tak, gdy typem nie jest string). Dostępne są też generyczne funkcje
pomocnicze z głębokim kopiowaniem wyników i bez głębokiego kopiowania:
func NestedFieldCopy(obj map[string]interface{}, fields ...string)
(interface{}, bool, error)
func NestedFieldNoCopy(obj map[string]interface{}, fields ...string)
(interface{}, bool, error)

Dostępne są również typizowane wersje funkcji, które rzutują wartość na dany typ i zwracają
błąd, gdy taka operacja zakończy się niepowodzeniem:
func NestedBool(obj map[string]interface{}, fields ...string) (bool, bool,
error)
func NestedFloat64(obj map[string]interface{}, fields ...string)

(float64, bool, error)


func NestedInt64(obj map[string]interface{}, fields ...string) (int64, bool,
error)
func NestedStringSlice(obj map[string]interface{}, fields ...string)

([]string, bool, error)


func NestedSlice(obj map[string]interface{}, fields ...string)
([]interface{}, bool, error)
func NestedStringMap(obj map[string]interface{}, fields ...string)
(map[string]string, bool, error)

Ponadto istnieje generyczny setter:


func SetNestedField(obj, value, path...)
Dynamiczny klient jest używany w samym Kubernetesie dla generycznych kontrolerów, np. w
kontrolerze do odzyskiwania nieużytków, usuwającym obiekty, których obiekty nadrzędne
przestały istnieć. Kontroler do odzyskiwania nieużytków współdziała z wszystkimi zasobami z
systemu, dlatego często korzysta z dynamicznego klienta.

Klienty typizowane
Klienty typizowane nie używają generycznych struktur danych podobnych do
map[string]interface{}. Zamiast tego korzystają z rzeczywistych typów języka Go, które są
różne i specyficzne dla poszczególnych identyfikatorów GVK. Te klienty są znacznie łatwiejsze
w użyciu, znacznie zwiększają bezpieczeństwo ze względu na typ, a także poprawiają spójność i
czytelność kodu. Ich wadą jest to, że oferują mniejszą elastyczność, ponieważ przetwarzane
typy muszą być znane w czasie kompilacji, a klienty są generowane, co zwiększa złożoność.

Przed przejściem do dwóch implementacji klientów typizowanych przyjrzyj się reprezentacji


rodzajów w systemie typów języka Go (teorię, na której oparty jest system typów w
Kubernetesie, znajdziesz w punkcie „Repozytorium API Machinery — szczegóły”).

Budowa typu
Rodzaje są reprezentowane jako struktury języka Go. Zwykle struktura ma nazwę identyczną z
nazwą rodzaju (choć technicznie nie jest to wymagane) i jest umieszczona w pakiecie
odpowiadającym grupie i wersji z danego identyfikatora GVK. Często stosowaną konwencją jest
uwzględnianie identyfikatora GVK grupa/wersja.Rodzaj w pakiecie języka Go:

pkg/apis/grupa/wersja
i definiowanie struktury Rodzaj języka Go w pliku types.go.
Każdy typ języka Go odpowiadający identyfikatorowi GVK obejmuje strukturę TypeMeta z
pakietu k8s.io/apimachinery/pkg/apis/meta/v1 (http://bit.ly/2Y5HdWT). Struktura TypeMeta
zawiera tylko pola Kind i ApiVersion:

type TypeMeta struct {


// +optional
APIVersion string `json:”apiVersion,omitempty”
yaml:”apiVersion,omitempty”`
// +optional

Kind string `json:”kind,omitempty” yaml:”kind,omitempty”`


}
Ponadto każdy rodzaj z najwyższego poziomu — czyli mający własny punkt końcowy, a tym
samym przynajmniej jeden powiązany identyfikator GVR (zob. punkt „Odwzorowania REST”) —
musi przechowywać nazwę, przestrzeń nazw (w przypadku zasobów z poziomu przestrzeni
nazw) i dość długą listę innych pól z metadanymi. Wszystkie te elementy są zapisane w
strukturze ObjectMeta z pakietu k8s.io/apimachinery/pkg/apis/meta/v1 (http://bit.ly/2XSt8eo):
type ObjectMeta struct {
Name string `json:”name,omitempty”`

Namespace string `json:”namespace,omitempty”`


UID types.UID `json:”uid,omitempty”`
ResourceVersion string `json:”resourceVersion,omitempty”`
CreationTimestamp Time `json:”creationTimestamp,omitempty”`

DeletionTimestamp *Time `json:”deletionTimestamp,omitempty”`


Labels map[string]string `json:”labels,omitempty”`
Annotations map[string]string `json:”annotations,omitempty”`
...
}

Dostępnych jest też wiele dodatkowych pól. Gorąco zachęcamy do lektury rozbudowanej
dokumentacji internetowej (http://bit.ly/2IutNyh), ponieważ daje ona dobry obraz
podstawowych możliwości obiektów Kubernetesa.
Typy najwyższego poziomu z Kubernetesa (mające zagnieżdżone struktury TypeMeta i
ObjectMeta oraz, w tym kontekście, utrwalane w systemie etcd) wyglądają bardzo podobnie do
siebie w tym sensie, że zwykle mają pola spec i status. Przyjrzyj się przykładowej instalacji z
pliku k8s.io/kubernetes/apps/v1/types.go (http://bit.ly/2RroTFb):
type Deployment struct {
metav1.TypeMeta `json:”,inline”`
metav1.ObjectMeta `json:”metadata,omitempty”`

Spec DeploymentSpec `json:”spec,omitempty”`


Status DeploymentStatus `json:”status,omitempty”`
}
Choć typy używane w polach spec i status są w poszczególnych typach zupełnie inne, podział
na status i specyfikację jest w Kubernetesie standardowo używaną techniką, a nawet
konwencją, choć technicznie nie jest wymagany. Dlatego dobrą praktyką jest stosowanie takiej
struktury także w definicjach CRD. Co więcej, niektóre mechanizmy definicji CRD wymagają tej
struktury. Na przykład podzasób /status dla niestandardowych zasobów (zob. punkt „Podzasób
status”) po włączeniu zawsze używa podstruktury status instancji niestandardowego zasobu.
Nazwy tej podstruktury nie można zmienić.

Struktura pakietów języka Go


Opisaliśmy, że typy języka Go są zwyczajowo umieszczane w pliku types.go w pakiecie
pkg/apis/grupa/wersja. Warto zwrócić uwagę także na kilka innych plików. Niektóre z nich są
ręcznie pisane przez programistę, natomiast inne są generowane za pomocą generatorów kodu.
Szczegółowe informacje znajdziesz w rozdziale 5.
W pliku doc.go opisane jest przeznaczenie danego API. Znajdują się tu także znaczniki globalne
z poziomu pakietu używane do generowania kodu:
// Pakiet v1alpha1 zawiera grupę API cnat v1alpha1.
//

// +k8s:deepcopy-gen=package
// +groupName=cnat.programming-kubernetes.info
package v1alpha1
Plik register.go zawiera funkcje pomocnicze do rejestrowania w schemacie (zob. punkt
„Schemat”) typów języka Go dla niestandardowych zasobów:

package wersja

import (
metav1 “k8s.io/apimachinery/pkg/apis/meta/v1”

“k8s.io/apimachinery/pkg/runtime”
“k8s.io/apimachinery/pkg/runtime/schema”

group “repozytorium/pkg/apis/grupa”
)

// SchemeGroupVersion określa wersję grupy używaną do rejestrowania obiektów.


var SchemeGroupVersion = schema.GroupVersion{
Group: group.GroupName,

Version: “wersja”,
}

// Funkcja Kind przyjmuje rodzaj bez kwalifikatora i zwraca parę GroupKind


(rodzaj z kwalifikatorem w postaci grupy)

func Kind(kind string) schema.GroupKind {


return SchemeGroupVersion.WithKind(kind).GroupKind()
}

// Funkcja Resource przyjmuje zasób bez kwalifikatora i zwraca parę

// GroupResource (zasób z kwalifikatorem w postaci grupy)


func Resource(resource string) schema.GroupResource {
return SchemeGroupVersion.WithResource(resource).GroupResource()
}

var (

SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
AddToScheme = SchemeBuilder.AddToScheme
)

// Dodawanie listy znanych typów do schematu.


func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(SchemeGroupVersion,
&SomeKind{},
&SomeKindList{},

)
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
return nil
}

W pliku zz_generated.deepcopy.go zdefiniowane są metody do głębokiego kopiowania dla typów


najwyższego poziomu z języka Go powiązanych z niestandardowym zasobem (np. SomeKind i
SomeKindList w kodzie z wcześniejszego przykładu). Ponadto tworzenie głębokich kopii staje
się możliwe dla wszystkich podstruktur (np. tych używanych dla pól spec i status).
Ponieważ w przykładzie w pliku doc.go używany jest znacznik +k8s:deepcopy-gen=package,
generowanie metod do tworzenia głębokich kopii jest opcjonalne i domyślnie włączone.
Oznacza to, że metody DeepCopy są generowane w pakiecie dla każdego typu, dla którego nie
zrezygnowano z takich metod za pomocą znacznika +k8s:deepcopy-gen=false. Więcej
informacji o takich znacznikach znajdziesz w rozdziale 5., a przede wszystkim w punkcie
„Znaczniki dla generatora deepcopy-gen”.

Klienty typizowane tworzone za pomocą generatora client-gen


Gdy dostępny jest pakiet API pkg/apis/grupa/wersja, generator klientów client-gen tworzy
klienta typizowanego (szczegółowe informacje znajdziesz w rozdziale 5., a przede wszystkim w
punkcie „Znaczniki dla generatora client-gen”) i domyślnie umieszcza go w pakiecie
pkg/generated/clientset/versioned (w starszych wersjach generatora jest to pakiet
pkg/client/clientset/versioned). Precyzyjnie można powiedzieć, że wygenerowany obiekt
najwyższego poziomu jest zbiorem klientów. Uwzględniany jest tu zestaw grup API, wersji i
zasobów.
Plik najwyższego poziomu (http://bit.ly/2GdcikH) wygląda tak:
// Kod wygenerowany przez narzędzie client-gen. NIE MODYFIKOWAĆ.

package versioned
import (
discovery “k8s.io/client-go/discovery”

rest “k8s.io/client-go/rest”
flowcontrol “k8s.io/client-go/util/flowcontrol”

cnatv1alpha1 “.../cnat/cnat-client-go/pkg/generated/clientset/versioned/
)

type Interface interface {


Discovery() discovery.DiscoveryInterface
CnatV1alpha1() cnatv1alpha1.CnatV1alpha1Interface

// Typ Clientset obejmuje klienty z danej grupy. W wartości typu Clientset


// występuje dokładnie jedna wersja każdej grupy.
type Clientset struct {

*discovery.DiscoveryClient
cnatV1alpha1 *cnatv1alpha1.CnatV1alpha1Client
}

// Funkcja CnatV1alpha1 pobiera wartość typu CnatV1alpha1Client.

func (c *Clientset) CnatV1alpha1() cnatv1alpha1.CnatV1alpha1Interface {


return c.cnatV1alpha1
}

// Funkcja Discovery pobiera wartość typu DiscoveryClient.


func (c *Clientset) Discovery() discovery.DiscoveryInterface {
...
}

// Funkcja NewForConfig tworzy nową wartość typu Clientset na podstawie danej


konfiguracji.
func NewForConfig(c *rest.Config) (*Clientset, error) {
...

}
Zbiór klientów jest reprezentowany za pomocą interfejsu Interface i zapewnia dostęp do
interfejsu klienta grupy API dla każdej wersji. W przykładowym kodzie takim interfejsem jest
CnatV1alpha1Interface:
type CnatV1alpha1Interface interface {
RESTClient() rest.Interface
AtsGetter
}

// AtsGetter zawiera metodą zwracającą interfejs AtInterface.


// W kliencie grupy należy zaimplementować ten interfejs.
type AtsGetter interface {

Ats(namespace string) AtInterface


}

// Interfejs AtInterface zawiera metody służące do pracy z zasobami At.


type AtInterface interface {

Create(*v1alpha1.At) (*v1alpha1.At, error)


Update(*v1alpha1.At) (*v1alpha1.At, error)
UpdateStatus(*v1alpha1.At) (*v1alpha1.At, error)
Delete(name string, options *v1.DeleteOptions) error

DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions)


error
Get(name string, options v1.GetOptions) (*v1alpha1.At, error)
List(opts v1.ListOptions) (*v1alpha1.AtList, error)
Watch(opts v1.ListOptions) (watch.Interface, error)

Patch(name string, możliwość types.PatchType, data []byte, subresources


...string)
(result *v1alpha1.At, err error)
AtExpansion
}

Instancję zbioru klientów można utworzyć za pomocą funkcji pomocniczej NewForConfig.


Technika ta jest analogiczna jak w klientach dla podstawowych zasobów Kubernetesa opisanych
w punkcie „Tworzenie i używanie klientów”:
import (
metav1 “k8s.io/apimachinery/pkg/apis/meta/v1”

“k8s.io/client-go/tools/clientcmd”
client “github.com/.../cnat/cnat-client-
go/pkg/generated/clientset/versioned”
)

kubeconfig = flag.String(“kubeconfig”, “~/.kube/config”, “kubeconfig file”)

flag.Parse()
config, err := clientcmd.BuildConfigFromFlags(“”, *kubeconfig)
clientset, err := client.NewForConfig(config)

ats := clientset.CnatV1alpha1Interface().Ats(“default”)
book, err := ats.Get(“kubernetes-programming”, metav1.GetOptions{})
Widać tu, że mechanizmy generowania kodu umożliwiają zaprogramowanie logiki
niestandardowych zasobów w taki sam sposób jak dla podstawowych zasobów Kubernetesa.
Dostępne są także narzędzia wysokopoziomowe, np. informatory (zob. omówienie narzędzia
informer-gen w rozdziale 5.).

Klient controller-runtime z narzędzi Operator SDK i


Kubebuilder
Aby omówienie było kompletne, warto pokrótce przyjrzeć się trzeciemu klientowi, podanemu
jako druga możliwość w punkcie „Niestandardowe zasoby z perspektywy programisty”. Projekt
controller-runtime zapewnia podstawy dla opisanych w rozdziale 6. narzędzi do tworzenia
operatorów: Operator SDK i Kubebuilder. Projekt ten obejmuje klienta używającego typów
języka Go przedstawionych w punkcie „Budowa typu”.
Ten klient — w odróżnieniu od klienta wygenerowanego przez narzędzie client-gen z punktu
„Klient typizowany tworzony za pomocą narzędzia client-gen”, a podobnie jak klient z punktu
„Klient dynamiczny” — jest jedną instancją, która potrafi obsługiwać wszystkie rodzaje
zarejestrowane w danym schemacie.
Taki klient stosuje wykrywanie informacji z serwera API, aby odwzorować poszczególne rodzaje
na ścieżki HTTP. Warto zauważyć, że w rozdziale 6. znajdziesz szczegółowe omówienie
używania tego klienta w obu narzędziach związanych z operatorami.
Oto krótki przykład ilustrujący korzystanie z projektu controller-runtime:
import (
“flag”

corev1 “k8s.io/api/core/v1”
metav1 “k8s.io/apimachinery/pkg/apis/meta/v1”
“k8s.io/client-go/kubernetes/scheme”
“k8s.io/client-go/tools/clientcmd”

runtimeclient “sigs.k8s.io/controller-runtime/pkg/client”
)

kubeconfig = flag.String(“kubeconfig”, “~/.kube/config”, “kubeconfig file


path”)
flag.Parse()
config, err := clientcmd.BuildConfigFromFlags(“”, *kubeconfig)

cl, _ := runtimeclient.New(config, client.Options{


Scheme: scheme.Scheme,
})
podList := &corev1.PodList{}
err := cl.List(context.TODO(), client.InNamespace(“default”), podList)
Metoda List() obiektu klienta przyjmuje dowolny obiekt typu runtime.Object zarejestrowany
w danym schemacie. Tu schemat jest zapożyczony z biblioteki client-go i są w nim
zarejestrowane wszystkie standardowe rodzaje z Kubernetesa. Wewnętrznie klient używa
danego schematu do odwzorowania typu *corev1.PodList z języka Go na identyfikator GVK. W
drugim kroku metoda List() stosuje wykrywanie informacji, aby pobrać identyfikator GVR dla
podów, zwracany przez wywołanie schema.GroupVersionResource{„”, „v1”, „pods”}.
Dlatego kod używa ścieżki /api/v1/namespace/default/pods, aby pobrać listę podów z
przekazanej przestrzeni nazw.
Tę samą logikę można zastosować do niestandardowych zasobów. Główna różnica polega na
tym, że dla niestandardowych zasobów trzeba zastosować niestandardowy schemat zawierający
przekazany typ języka Go:
import (
“flag”

corev1 “k8s.io/api/core/v1”
metav1 “k8s.io/apimachinery/pkg/apis/meta/v1”

“k8s.io/client-go/kubernetes/scheme”
“k8s.io/client-go/tools/clientcmd”

runtimeclient “sigs.k8s.io/controller-runtime/pkg/client”
cnatv1alpha1 “github.com/.../cnat/cnat-
kubebuilder/pkg/apis/cnat/v1alpha1”
)

kubeconfig = flag.String(“kubeconfig”, “~/.kube/config”, “kubeconfig file”)

flag.Parse()
config, err := clientcmd.BuildConfigFromFlags(“”, *kubeconfig)
crScheme := runtime.NewScheme()
cnatv1alpha1.AddToScheme(crScheme)

cl, _ := runtimeclient.New(config, client.Options{


Scheme: crScheme,
})
list := &cnatv1alpha1.AtList{}
err := cl.List(context.TODO(), client.InNamespace(“default”), list)

Zauważ, że wywołanie List() w ogóle się nie zmienia.


Wyobraź sobie, że piszesz operator, który używa tego klienta i wymaga dostępu do wielu
różnych rodzajów. Klient typizowany (opisany w punkcie „Klienty typizowane tworzone za
pomocą generatora client-gen”) wymagałby przekazania do operatora wielu różnych klientów,
przez co kod przygotowujący środowisko stałby się skomplikowany. Z kolei pokazany tu klient
controller-runtime to jeden obiekt przeznaczony dla wszystkich rodzajów (pod warunkiem że
wszystkie one znajdują się w jednym schemacie).
Wszystkie trzy odmiany klientów mają swoje zastosowania oraz wady i zalety zależne od
kontekstu użytkowania. W generycznych kontrolerach, obsługujących nieznane obiekty, można
korzystać tylko z klientów dynamicznych. W kontrolerach, w których bezpieczeństwo ze
względu na typ pomaga zapewnić poprawność kodu, dobrym wyborem są generowane klienty.
Projekt Kubernetes jest rozwijany przez tak wielu programistów, że stabilność kodu jest bardzo
ważna — także w sytuacji, gdy jest rozszerzany i przepisywany przez liczne osoby. Jeśli ważne
są wygoda, szybkość pracy i minimalizacja kodu pomocniczego do przygotowywania
środowiska, dobrze sprawdzi się klient controller-runtime.

Podsumowanie
W tym rozdziale przedstawiliśmy niestandardowe zasoby — podstawowy mechanizm
rozszerzania używany w ekosystemie Kubernetesa. Na tym etapie powinieneś dobrze rozumieć
ich możliwości i ograniczenia, a także znać dostępne klienty.
Pora przejść do generowania kodu służącego do zarządzania takimi zasobami.

1 Nie pomyl tutaj obiektów Kubernetesa z obiektami w formacie JSON. Te ostatnie to inna
nazwa map z łańcuchami znaków używanych w formacie JSON i w OpenAPI.
2 „Uzyskany w wyniku projekcji” oznacza tu tyle, że obiekt scale jest projekcją głównego
zasobu (w tym sensie, że wyświetlane są tylko określone pola, a wszystko pozostałe jest
ukrywane).
Rozdział 5. Automatyzowanie
generowania kodu
Z tego rozdziału dowiesz się, jak w projektach w języku Go używać generatorów kodu dla
Kubernetesa, aby tworzyć niestandardowe zasoby w naturalny sposób. Generatory kodu są
używane w wielu miejscach w implementacji natywnych zasobów Kubernetesa. Tu użyjemy tych
samych generatorów.

Po co stosować generatory kodu?


Go zgodnie z projektem ma być prostym językiem. Nie ma w nim wysokopoziomowych ani
nawet opartych na metaprogramowaniu mechanizmów do zapisywania algorytmów dla różnych
typów danych w sposób generyczny (czyli niezależny od typu). „Styl języka Go” w tym obszarze
polega na używaniu zewnętrznych generatorów kodu.

Na bardzo wczesnym etapie rozwoju Kubernetesa trzeba było przerabiać coraz większe ilości
kodu wraz z dodawaniem do systemu nowych zasobów. Generatory znacznie uprościły
konserwację takiego kodu. Już dawno utworzono bibliotekę Gengo (http://bit.ly/2L9kwNJ), a
później na jej podstawie kolekcję generatorów k8s.io/code-generator (http://bit.ly/2Kw8I8U)
przeznaczonych do użytku w kodzie spoza Kubernetesa. Tych generatorów będziemy używać w
następnych podrozdziałach dla niestandardowych zasobów.

Wywoływanie generatorów
Generatory kodu są zwykle wywoływane w prawie ten sam sposób we wszystkich projektach
kontrolerów. Różne są tylko pakiety, nazwy grup i wersje API. Wywołanie skryptu k8s.io/code-
generator/generate-groups.sh lub skryptu powłoki Bash takiego jak hack/update-codegen.sh to
najłatwiejszy sposób na dodanie na poziomie systemu budowania generatora kodu dla typów
języka Go odpowiadających niestandardowym zasobom (zob. powiązane z książką repozytorium
w serwisie GitHub — http://bit.ly/2J0s2YL).

Warto zauważyć, że w niektórych projektach bezpośrednio wywoływane są pliki binarne


generatorów. Wynika to ze specjalnych wymogów, często obowiązujących z przyczyn
historycznych. Gdy chcesz utworzyć kontroler dla niestandardowych zasobów, znacznie łatwiej
jest wywołać skrypt generate-groups.sh z repozytorium k8s.io/code-generator:
$ vendor/k8s.io/code-generator/generate-groups.sh all \

github.com/programming-kubernetes/cnat/cnat-client-go/pkg/generated

github.com/programming-kubernetes/cnat/cnat-client-go/pkg/apis \
cnat:v1alpha1 \

--output-base “${GOPATH}/src” \
--go-header-file “hack/boilerplate.go.txt”
W tym kodzie all oznacza wywołanie wszystkich czterech standardowych generatorów kodu
dla niestandardowych zasobów:

deepcopy-gen

Generuje metody func (t *T) DeepCopy() *T i func (t *T) DeepCopyInto(*T).


client-gen

Tworzy typizowane zbiory klientów.


informer-gen

Tworzy dla niestandardowych zasobów informatory, które udostępniają oparty na


zdarzeniach interfejs do reagowania na modyfikacje niestandardowych zasobów na
serwerze.

lister-gen
Tworzy dla niestandardowych zasobów listery, które udostępniają warstwę bufora tylko do
odczytu na potrzeby żądań GET i LIST.

Dwa ostatnie z tych generatorów są podstawą do budowania kontrolerów (zob. punkt


„Kontrolery i operatory”). Cztery wymienione generatory kodu stanowią solidną podstawę do
tworzenia kompletnych kontrolerów gotowych do użycia w środowisku produkcyjnym. Takie
kontrolery korzystają z tych samych mechanizmów i pakietów, co wbudowane kontrolery
Kubernetesa.

W pakiecie k8s.io/code-generator istnieją też inne generatory, przydatne


głównie w innych kontekstach. Na przykład, jeśli tworzysz własny zagregowany
serwer API (zob. rozdział 8.), będziesz używać typów wewnętrznych razem z
typami wersjonowanymi i będziesz musiał zdefiniować funkcje uzupełniające
puste pola. Wtedy przydatne będą dwa poniższe generatory (dostęp do nich
uzyskasz za pomocą skryptu generate-internal-groups.sh z pakietu
k8s.io/code-generator; http://bit.ly/2L9kSE3):

conversion-gen

Tworzy funkcje do obsługi przekształceń między typami wewnętrznymi i


zewnętrznymi.

defaulter-gen
Odpowiada za ustawianie wartości domyślnych w wybranych polach.

Przyjrzyj się teraz dokładnie parametrom skryptu generate-groups.sh:

Drugi parametr to nazwa docelowego pakietu dla generowanych klientów, listerów i


informatorów.
Trzeci parametr to pakiet bazowy dla danej grupy API.
Czwarty parametr to lista rozdzielonych spacjami grup API i ich wersji.
Opcja --output-base jest przekazywana jako flaga do wszystkich generatorów, aby
zdefiniować katalog bazowy, w którym można znaleźć dane pakiety.
Opcja --go-header-file pozwala dodać do generowanego kodu nagłówki z prawami
autorskimi.

Niektóre generatory, np. deepcopy-gen, tworzą pliki bezpośrednio w pakietach grupy API. Te
pliki mają nazwy zgodne ze standardowym schematem (z przedrostkiem zz_generated.), dzięki
czemu łatwo jest je wykluczać w systemie kontroli wersji (np. za pomocą pliku .gitignore), choć
w większości projektów generowane pliki są jednak dodawane do takiego systemu, ponieważ
[1]
narzędzia języka Go związane z generatorami kodu nie są w pełni dopracowane .

Jeśli projekt jest zgodny ze wzorcem z pakietu k8s.io/sample-controller (sample-controller to


przykładowy projekt, w którym uwzględniono wzorce sprawdzone w wielu kontrolerach z
samego Kubernetesa; http://bit.ly/2UppsTN), proces generowania kodu rozpoczyna się od
polecenia:

$ hack/update-codegen.sh

To podejście jest stosowane dla narzędzia cnat w wersji sample-controller+client-go (zob.


punkt „Wzorowanie się na projekcie sample-controller”).

Zwykle oprócz skryptu hack/update-codegen.sh (http://bit.ly/2J0s2YL)


wywoływany jest też drugi skrypt, hack/verify-codegen.sh
(http://bit.ly/2IXUWsy).
Ten drugi skrypt wywołuje skrypt hack/update-codegen.sh i sprawdza, czy
coś się zmieniło, a następnie kończy pracę, zwracając niezerowy kod, jeśli
któryś z wygenerowanych plików jest nieaktualny.
Jest to bardzo przydatne w skryptach odpowiedzialnych za ciągłą integrację.
Jeśli programista przypadkowo zmodyfikuje pliki lub gdy pliki są nieaktualne,
skrypt do ciągłej integracji wykryje to i zgłosi.

Kontrolowanie generatorów za pomocą


znaczników
Choć niektórymi działaniami generatorów kodu można sterować za pomocą opisanych
wcześniej opcji wiersza poleceń (przede wszystkim można tak wskazywać przetwarzane
pakiety), znacznie więcej właściwości jest kontrolowanych z użyciem znaczników z plików
języka Go. Znacznik to specjalnie sformatowany komentarz w języku Go:

// +jakiś-znacznik
// +inny-znacznik=wartość

Istnieją dwa rodzaje znaczników:

globalne znaczniki nad wierszem package w pliku doc.go,


lokalne znaczniki nad deklaracją typu (np. nad definicją struktury).

W przypadku niektórych znaczników istotna jest lokalizacja komentarza.

Precyzyjnie przepisuj kod przykładów (w tym bloki komentarzy)


Część znaczników musi znajdować się w komentarzu bezpośrednio nad typem (lub nad
wierszem package, jeśli chodzi o znaczniki globalne), natomiast inne znaczniki muszą
być oddzielone od typu (lub wiersza package) przynajmniej jednym pustym wierszem.
Oto przykład:

// +znacznik-w-drugim-bloku-komentarza

// +znacznik-w-pierwszym-bloku-komentarza
type Foo struct {
}

Ten podział wynika z przyczyn historycznych. Generatory dokumentacji API w


Kubernetesie nie znały znaczników związanych z generowaniem kodu i eksportowały
tylko pierwszy blok komentarza. Dlatego znaczniki z tego bloku byłyby wyświetlane w
dokumentacji API w formacie HTML.
Logika przetwarzania znaczników dla generatorów kodu nie zawsze jest w pełni spójna, a
obsługa błędów w generatorach często jest daleka od ideału. Choć z każdą wersją
sytuacja się poprawia, pamiętaj, aby bardzo precyzyjnie przepisywać przykłady.
Znaczenie może mieć np. pusty wiersz.

Znaczniki globalne
Znaczniki globalne są zapisywane w pliku doc.go pakietu. Typowy plik
pkg/apis/grupa/wersja/doc.go wygląda tak:

// +k8s:deepcopy-gen=package

// Pakiet v1 to wersja v1alpha1 API.

// +groupName=cnat.programming-kubernetes.info

package v1alpha1

Pierwszy wiersz tego pliku nakazuje generatorowi deepcopy-gen domyślne tworzenie metod do
głębokiego kopiowania dla każdego typu z danego pakietu. Jeśli masz typy, w których głębokie
kopiowanie nie jest potrzebne, pożądane, a nawet możliwe, możesz wyłączyć tworzenie takich
metod za pomocą lokalnego znacznika // +k8s:deepcopy-gen=false. Jeżeli nie włączysz
generowania metod do głębokiego kopiowania na poziomie pakietu, będziesz musiał aktywować
proces generowania ich w każdym typie, w którym takie metody są potrzebne. Służy do tego
znacznik // +k8s:deepcopy-gen=true.

Drugi znacznik, // +groupName=example.com, definiuje w pełni kwalifikowaną nazwę grupy


API. Ten znacznik jest potrzebny, jeśli nazwa pakietu nadrzędnego w Go nie odpowiada nazwie
grupy.

Pokazany tu plik pochodzi z pliku pkg/apis/cnat/v1alpha1/doc.go z przykładu cnat client-go


(http://bit.ly/2L6M9ad; zob. punkt „Wzorowanie się na projekcie sample-controller”). W tym
przykładzie cnat to pakiet nadrzędny, jednak nazwa grupy to cnat.programming-
kubernetes.info.

Gdy używany jest znacznik // +groupName, generator klientów (zob. punkt „Klienty typizowane
tworzone za pomocą generatora client-gen”) wygeneruje klienta z użyciem poprawnej ścieżki
HTTP /apis/foo.project.example.com. Oprócz znacznika +groupName istnieje też znacznik
+groupGoName, definiujący niestandardowy identyfikator języka Go (dla nazw zmiennych i
typów), który będzie używany zamiast nazwy nadrzędnego pakietu. Generatory domyślnie
używają dla identyfikatorów nazwy nadrzędnego pakietu pisanej wielką literą. W naszym
przykładzie będzie to Cnat. Lepszym identyfikatorem byłoby CNAt (od ang. Cloud Native At). Za
pomocą znacznika // +groupGoName=CNAt można zastosować taki identyfikator zamiast Cnat
(choć w przykładzie nie wykorzystaliśmy tej możliwości i pozostawiliśmy identyfikator Cnat), a
wynik wygenerowany przez narzędzie client-gen będzie wyglądał wtedy tak:

type Interface interface {

Discovery() discovery.DiscoveryInterface
CNatV1() atv1alpha1.CNatV1alpha1Interface

Znaczniki lokalne
Znaczniki lokalne są zapisywane albo bezpośrednio nad typem API, albo w drugim bloku
komentarza nad nim. Oto główne typy z pliku types.go z przykładowego projektu cnat
(http://bit.ly/31QosJw):

// AtSpec definiuje oczekiwany stan obiektu At.

type AtSpec struct {

// Schedule to oczekiwany czas wykonania danego polecenia.

// Uwaga: używany jest tu czas w formacie UTC (https://www.utctime.net).

Schedule string `json:”schedule,omitempty”`


// Command to polecenie, które ma zostać wykonane w powłoce Bash

Command string `json:”command,omitempty”`

// Ważne: po zmodyfikowaniu tego pliku uruchom narzędzie „make”, aby


odtworzyć kod

// AtStatus definiuje zaobserwowany stan obiektu At.

type AtStatus struct {

// Phase reprezentuje status harmonogramu wykonywania polecenia. Do czasu

// wykonania polecenia status to PENDING, po wykonaniu status to DONE.

Phase string `json:”phase,omitempty”`

// Ważne: po zmodyfikowaniu tego pliku uruchom narzędzie „make”, aby


odtworzyć kod.

// +genclient

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// At uruchamia polecenie zgodnie z określonym harmonogramem.

type At struct {
metav1.TypeMeta `json:”,inline”`

metav1.ObjectMeta `json:”metadata,omitempty”`

Spec AtSpec `json:”spec,omitempty”`


Status AtStatus `json:”status,omitempty”`

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// AtList zawiera listę obiektów typu At

type AtList struct {

metav1.TypeMeta `json:”,inline”`

metav1.ListMeta `json:”metadata,omitempty”`

Items []At `json:”items”`

W dalszych punktach omówimy znaczniki z tego przykładu.

W tym przykładzie dokumentacja API znajduje się w pierwszym bloku


komentarza, a znaczniki są umieszczone w drugim bloku komentarza. Pomaga
to pominąć znaczniki w dokumentacji API, jeśli generujesz ją za pomocą
narzędzi pobierających komentarze go doc.

Znaczniki dla generatora deepcopy-gen


Generowanie metod do obsługi głębokiego kopiowania jest zwykle domyślnie włączone dla
wszystkich typów. Służy do tego globalny znacznik // +k8s:deepcopy-gen=package (zob.
punkt „Znaczniki globalne”). Lokalnie można zrezygnować z generowania takich metod. Jednak
w pokazanym pliku (i w całym pakiecie) wszystkie typy API wymagają metod do obsługi
głębokiego kopiowania. Dlatego nie trzeba lokalnie rezygnować z ich generowania.

Gdyby w pakiecie z typami API istniała struktura pomocnicza (zwykle jest to odradzane, aby
zachować przejrzystość pakietów z typami API), konieczne byłoby wyłączenie dla niej
generowania metod do obsługi głębokiego kopiowania. Oto przykład:

// +k8s:deepcopy-gen=false

//
// Helper to struktura pomocnicza, a nie typ API.

type Helper struct {

...

runtime.Object i DeepCopyObject
Istnieje specjalny znacznik związany z głębokim kopiowaniem, który wymaga dodatkowych
objaśnień:
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

W punkcie „Obiekty Kubernetesa w Go” zobaczyłeś, że w typie runtime.Object trzeba


zaimplementować metodę DeepCopyObject() runtime.Object. Wynika to z tego, że
generyczny kod w Kubernetesie musi mieć możliwość tworzenia głębokich kopii obiektów. Ta
metoda to umożliwia.

Tło historyczne
Przed wersją 1.8 Kubernetesa schemat (zob. punkt „Schemat”) przechowywał referencje
do specyficznych dla typów funkcji do obsługi głębokiego kopiowania. Używana wtedy
była implementacja głębokiego kopiowania oparta na refleksji. Oba te mechanizmy były
źródłami wielu skomplikowanych i trudnych do wykrycia błędów. Dlatego w Kubernetesie
przestawiono się na statyczne głębokie kopiowanie z użyciem metody DeepCopyObject z
interfejsu runtime.Object.

Metoda DeepCopyObject() nie robi nic oprócz wywoływania wygenerowanej metody DeepCopy.
Sygnatura tej ostatniej zależy od typu (DeepCopy() *T zależy od typu T). Sygnatura tej
pierwszej to zawsze DeepCopyObject() runtime.Object:
func (in *T) DeepCopyObject() runtime.Object {

if c := in.DeepCopy(); c != nil {
return c

} else {
return nil

}
}

Umieść lokalny znacznik // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/


runtime.Object nad typami API najwyższego poziomu, aby za pomocą generatora deepcopy-
gen wygenerować opisaną metodę. Jest to informacja dla generatora deepcopy-gen, aby
utworzyć metodę DeepCopyObject() dla typu runtime.Object.

W poprzednim przykładzie At i AtList to typy najwyższego poziomu, ponieważ


są używane jako wartości typu runtime.Object.
Możesz posługiwać się prostą regułą — typy najwyższego poziomu to te, w
których zagnieżdżona jest wartość typu metav1.TypeMeta.

Zdarza się, że także inne interfejsy wymagają głębokiego kopiowania. Załóżmy, że typ API ma
pole typu interfejsowego Foo:

type SomeAPIType struct {


Foo Foo `json:”foo”`

}
Wiesz już, że typy API muszą umożliwiać głębokie kopiowanie. Dlatego także pole Foo musi być
uwzględnione przy głębokim kopiowaniu. Jak zrobić to w generyczny sposób (bez rzutowania
typów), nie dodając metody DeepCopyFoo() Foo do interfejsu Foo?
type Foo interface {

...
DeepCopyFoo() Foo
}
W takiej sytuacji można posłużyć się omawianym znacznikiem:

// +k8s:deepcopy-gen:interfaces=<package>.Foo
type FooImplementation struct {

...
}

W kodzie źródłowym Kubernetesa ten znacznik jest używany także w kilku innych miejscach
poza typem runtime.Object:
// +k8s:deepcopy-gen:interfaces=…/pkg/registry/rbac/reconciliation.RuleOwner

// +k8s:deepcopy-
gen:interfaces=…/pkg/registry/rbac/reconciliation.RoleBinding

Znaczniki dla generatora client-gen


Ponadto istnieje zestaw znaczników do sterowania generatorem client-gen. Jeden z tych
znaczników poznałeś we wcześniejszym przykładzie dotyczącym typów At i AtList:
// +genclient

Ten znacznik informuje generator client-gen, że należy utworzyć klienta dla danego typu (ten
znacznik nigdy nie jest domyślnie włączony). Warto zauważyć, że nie musisz (a nawet nie
możesz) stosować go do typów List obiektów API.

W przykładowym projekcie cnat używany jest podzasób /status, a status zasobów


niestandardowych jest modyfikowany za pomocą metody UpdateStatus klienta (zob. punkt
„Podzasób status”). Istnieją jednak instancje niestandardowych zasobów, które nie przechowują
statusu lub w których nie jest stosowany podział na status i specyfikację. W takich sytuacjach
poniższy znacznik pozwala uniknąć generowania metody UpdateStatus():
// +genclient:noStatus

Jeśli nie użyjesz tego znacznika, client-gen automatycznie wygeneruje


metodę UpdateStatus(). Należy jednak zauważyć, że podział na status i
specyfikację działa tylko wtedy, jeśli podzasób /status jest włączony w
manifeście definicji CRD (zob. punkt „Podzasoby”).
Samo istnienie metody UpdateStatus() w kliencie nie wystarcza. Jej
wywołania bez wprowadzenia odpowiedniej zmiany w manifeście zakończą się
niepowodzeniem.

Generator klientów musi wybrać odpowiednią ścieżkę HTTP — z przestrzenią nazw lub bez niej.
Dla zasobów z poziomu klastra trzeba używać następującego znacznika:

// +genclient:nonNamespaced
Domyślnie generowany jest klient z poziomu przestrzeni nazw. Konieczne jest dopasowanie do
ustawienia zasięgu w manifeście definicji CRD. Dla klientów o specjalnym przeznaczeniu
przydatne może być też precyzyjne kontrolowanie tego, które metody HTTP są dostępne.
Można to zrobić za pomocą kilku znaczników. Oto przykład:

// +genclient:noVerbs
// +genclient:onlyVerbs=create,delete

// +genclient:skipVerbs=get,list,create,update,patch,delete,watch
// +genclient:method=Create,verb=create,
// result=k8s.io/apimachinery/pkg/apis/meta/v1.Status

Pierwsze trzy powinny być oczywiste. Ostatni wymaga jednak pewnych objaśnień.
Typ, nad którym znajduje się ten znacznik, będzie obsługiwał tylko operację tworzenia i nie
będzie zwracał samego typu API; zamiast tego zwracana będzie wartość typu metav1.Status.
W przypadku zasobów niestandardowych takie rozwiązanie nie ma sensu, jednak dla
napisanych w Go serwerów API udostępnianych przez użytkowników (zob. rozdział 8.) takie
zasoby mogą istnieć (i zdarza się to w praktyce).

Znacznik // +genclient:method= często stosuje się, aby dodać metodę do skalowania zasobu.
W punkcie „Podzasób scale” opisaliśmy, jak włączyć zasób /scale dla niestandardowych
zasobów. Znaczniki wymienione poniżej pozwalają utworzyć w klientach potrzebne metody:

// +genclient:method=GetScale,verb=get,subresource=scale,\
// result=k8s.io/api/autoscaling/v1.Scale

// +genclient:method=UpdateScale,verb=update,subresource=scale,\
//
input=k8s.io/api/autoscaling/v1.Scale,result=k8s.io/api/autoscaling/v1.Scale

Pierwszy z tych znaczników tworzy getter GetScale. Drugi pozwala wygenerować setter
UpdateScale.

Wszystkie podzasoby /scale zasobów niestandardowych przyjmują i zwracają


wartość typu Scale z grupy autoscaling/v1. W API Kubernetesa z przyczyn
historycznych istnieją zasoby, które używają innych typów.

Generatory informer-gen i lister-gen


Generatory informer-gen i lister-gen uwzględniają znacznik // +genclient generatora
client-gen. Nie wymagają one żadnej dodatkowej konfiguracji. Każdy typ, dla którego
włączone zostanie generowanie klienta, automatycznie otrzyma informatory i listery pasujące
do tego klienta (jeśli za pomocą skryptu k8s.io/code-generator/generate-group.sh wywołany
zostanie cały zestaw generatorów).
Dokumentacja generatorów z Kubernetesa pozostawia wiele do życzenia i z pewnością będzie z
czasem ulepszana. Więcej informacji na temat różnych generatorów można znaleźć w
przykładach w Kubernetesie, np. w repozytoriach k8s.io/api (http://bit.ly/2ZA6dWH) i OpenShift
API (http://bit.ly/2KxpKnc). W obu tych repozytoriach znajdziesz wiele zaawansowanych
przykładów zastosowania generatorów.

Nie wahaj się też zaglądać do kodu generatorów. Dokumentacja generatora deepcopy-gen jest
dostępna w pliku main.go (http://bit.ly/2x9HmN4), a dokumentację generatora client-gen
znajdziesz w dokumentacji dla programistów pracujących nad Kubernetesem
(http://bit.ly/2WYNlns). Obecnie nie istnieje dodatkowa dokumentacja dla generatorów
informer-gen i lister-gen, choć w pliku generate-groups.sh pokazane jest, jak wywoływać
każdy z nich (http://bit.ly/31MeSHp).
Podsumowanie
W tym rozdziale pokazaliśmy, jak używać generatorów kodu Kubernetesa dla niestandardowych
zasobów. Po omówieniu tego zagadnienia przejdziemy do narzędzi z wyższych poziomów
abstrakcji — do rozwiązań służących do tworzenia niestandardowych kontrolerów i operatorów,
które umożliwiają skupienie się na logice biznesowej.

1 Narzędzia języka Go nie uruchamiają automatycznie generowania kodu, gdy jest to


potrzebne, i nie umożliwiają definiowania zależności między plikami źródłowymi i
generowanymi.
Rozdział 6. Narzędzia służące
do tworzenia operatorów
W punkcie „Kontrolery i operatory” omówiliśmy na ogólnym poziomie niestandardowe
kontrolery i operatory, a w rozdziale 5. pokazaliśmy, jak korzystać z generatorów kodu
Kubernetesa, co jest dość niskopoziomowym sposobem tworzenia kodu. W tym rozdziale
szczegółowo omówimy trzy sposoby pisania niestandardowych kontrolerów i operatorów, a
także wspomnimy o kilku innych podejściach.

Zastosowanie jednego z rozwiązań przedstawionych w tym rozdziale powinno pomóc Ci uniknąć


pisania dużych ilości powtarzającego się kodu i skupić się na logice biznesowej zamiast na
szablonowym kodzie. Dzięki temu szybciej rozpoczniesz pracę i będziesz bardziej produktywny.

Operatory i narzędzia omawiane w tym rozdziale na razie (w połowie 2019 r.)


wciąż są szybko rozwijane. Choć dokładamy wszelkich starań, aby
przedstawiać aktualną wiedzę, niektóre polecenia i/lub dane wyjściowe mogą
ulec zmianie. Pamiętaj o tym i zawsze staraj się korzystać z najnowszych wersji
narzędzi. Zwracaj też uwagę na systemy ewidencji zgłoszeń, listy dyskusyjne i
kanały w komunikatorze Slack.

Choć w internecie dostępne są materiały porównujące omawiane tu rozwiązania


(http://bit.ly/2ZC5fZT), tu nie rekomendujemy żadnego konkretnego podejścia. Zachęcamy
jednak do samodzielnej oceny i porównania różnych rozwiązań oraz wyboru tego z nich, które
jest najlepiej dopasowane do organizacji i środowiska.

Czynności wstępne
W tym rozdziale podstawowym przykładem używanym do prezentacji różnych rozwiązań będzie
projekt cnat (polecenie at natywne dla chmury, opisane w punkcie „Przykład wprowadzający”).
Jeśli chcesz równolegle wykonywać opisywane tu czynności, zwróć uwagę na następujące
założenia:

1. Masz zainstalowaną i odpowiednio skonfigurowaną wersję 1.12 (lub nowszą) języka Go.
2. Posiadasz dostęp do klastra Kubernetesa w wersji 1.12 (lub nowszej) — albo lokalnie, np.
za pomocą narzędzia kind lub k3d, albo zdalnie, z użyciem wybranego dostawcy usług w
chmurze. Ponadto masz narzędzie kubectl skonfigurowane na potrzeby dostępu do tego
klastra.
3. Sklonowałeś nasze repozytorium GitHub (http://bit.ly/2N3R6U4) za pomocą polecenia git
clone. To repozytorium zawiera kompletny i działający kod źródłowy oraz niezbędne
polecenia pokazane w dalszych punktach. Warto zauważyć, że tu pokazujemy, jak
opracować rozwiązanie od podstaw. Jeśli chcesz zobaczyć same efekty prac, to zamiast
samodzielnie wykonywać omawiane kroki, możesz sklonować repozytorium i wywołać
tylko polecenia instalujące definicje CRD i niestandardowe zasoby oraz uruchamiające
niestandardowy kontroler.
Po omówieniu kwestii porządkowych pora przejść do pisania operatorów. W tym rozdziale
omówimy narzędzia sample-controller, Kubebuilder i Operator SDK.
Gotów poznać Go? Bierzmy się za niego (gra słów zamierzona).

Wzorowanie się na projekcie sample-


controller
Zacznijmy od zaimplementowania narzędzia cnat z użyciem kontrolera k8s.io/sample-controller
(http://bit.ly/2UppsTN), który bezpośrednio korzysta z biblioteki client-go
(http://bit.ly/2Yas9HK). Kontroler sample-controller używa generatora k8s.io/code-generator
(http://bit.ly/2Kw8I8U) do generowania typizowanego klienta, informatorów, listerów i funkcji
do obsługi głębokiego kopiowania. Gdy w niestandardowym kontrolerze zmienią się typy API,
np. w wyniku dodania nowego pola w niestandardowym zasobie, trzeba użyć skryptu update-
codegen.sh (zob. jego kod źródłowy w serwisie GitHub — http://bit.ly/2Fq3Td1), by ponownie
wygenerować wspomniane pliki z kodem źródłowym.

Może zwróciłeś uwagę na to, że jako bazowy adres URL w tej książce używany
jest człon k8s.io. Zaczęliśmy korzystać z niego w rozdziale 3. Warto
przypomnieć, że jest to alias nazwy kubernetes.io, a w kontekście zarządzania
pakietami języka Go nazwa ta odpowiada repozytorium
github.com/kubernetes. Człon k8s.io nie zapewnia automatycznych
przekierowań. Dlatego np. nazwa k8s.io/sample-controller oznacza, że
powinieneś użyć repozytorium github.com/kubernetes/sample-controller
(http://bit.ly/2UppsTN).

W porządku, zaimplementujmy operator do projektu cnat (http://bit.ly/2RpHhON), używając


biblioteki client-go i wzorując się na kontrolerze sample-controller. Możesz zajrzeć do
odpowiedniego katalogu w naszym repozytorium (http://bit.ly/2N3R6U4).

Przygotowania
Najpierw wywołaj polecenie go get k8s.io/sample-controller, aby pobrać kod źródłowy i
zależności do systemu. Powinieneś używać ścieżki $GOPATH/src/k8s.io/sample-\controller.

Jeśli zaczynasz od zera, skopiuj zawartość katalogu sample-controller do wybranego katalogu


(w naszym repozytorium jest to katalog cnat-client-go). Następnie wywołaj sekwencję
przedstawionych tu instrukcji, aby zbudować i uruchomić podstawowy kontroler (na razie
będzie on zawierał implementację domyślną, a nie logikę biznesową dla narzędzia cnat):

# Budowanie pliku binarnego niestandardowego kontrolera:

$ go build -o cnat-controller .

# Lokalne uruchamianie niestandardowego kontrolera:


$ ./cnat-controller -kubeconfig=$HOME/.kube/config

To polecenie uruchomi niestandardowy kontroler i będzie oczekiwać na zarejestrowanie


definicji CRD oraz utworzenie niestandardowego zasobu. Wykonaj teraz te zadania i zobacz, co
się stanie. W drugiej sesji terminala wpisz następujące polecenie:
$ kubectl apply -f artifacts/examples/crd.yaml
Upewnij się, że definicja CRD jest poprawnie zarejestrowana i dostępna:

$ kubectl get crds

NAME CREATED AT

foos.samplecontroller.k8s.io 2019-05-29T12:16:57Z
W zależności od używanej dystrybucji Kubernetesa możesz zobaczyć także wiele innych
definicji CRD. Jednak dostępna powinna być przynajmniej pozycja foos.samplecontroller.k8s.io.

Teraz utwórz przykładowy niestandardowy zasób foo.samplecontroller.k8s.io/example-foo i


sprawdź, czy kontroler wykonuje swoje zadania:

$ kubectl apply -f artifacts/examples/example-foo.yaml

foo.samplecontroller.k8s.io/example-foo created

$ kubectl get po,rs,deploy,foo

NAME READY STATUS RESTARTS


AGE

pod/example-foo-5b8c9679d8-xjhdf 1/1 Running 0


67s

NAME DESIRED CURRENT READY


AGE
replicaset.extensions/example-foo-5b8c9679d8 1 1 1
67s

NAME READY UP-TO-DATE AVAILABLE


AGE

deployment.extensions/example-foo 1/1 1 1
67s

NAME AGE
foo.samplecontroller.k8s.io/example-foo 67s

Pięknie, wszystko działa zgodnie z oczekiwaniami! Teraz możemy przejść do implementowania


logiki biznesowej dla narzędzia cnat.

Logika biznesowa
Aby rozpocząć implementowanie logiki biznesowej, najpierw zmień nazwę istniejącego katalogu
pkg/apis/samplecontroller na pkg/apis/cnat, a następnie utwórz własną definicję CRD i
niestandardowy zasób:
$ cat artifacts/examples/cnat-crd.yaml

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:

name: ats.cnat.programming-kubernetes.info

spec:

group: cnat.programming-kubernetes.info

version: v1alpha1
names:

kind: At

plural: ats

scope: Namespaced

$ cat artifacts/examples/cnat-example.yaml
apiVersion: cnat.programming-kubernetes.info/v1alpha1

kind: At

metadata:

labels:

controller-tools.k8s.io: “1.0”

name: example-at

spec:

schedule: “2019-04-12T10:12:00Z”

command: “echo SUPER”


Zauważ, że gdy zmienią się typy API (np. dodasz nowe pole do definicji CRD At), będziesz
musiał uruchomić skrypt update-codegen.sh:

$ ./hack/update-codegen.sh

To spowoduje automatyczne wygenerowanie następujących elementów:

pkg/apis/cnat/v1alpha1/zz_generated.deepcopy.go,
pkg/generated/∗.

W obszarze logiki biznesowej trzeba w operatorze zaimplementować dwie części:

W pliku types.go (http://bit.ly/31QosJw) zmodyfikuj strukturę AtSpec, aby znalazły się w


niej odpowiednie pola, np. schedule i command. Zauważ, że po każdej zmianie w tym
miejscu musisz uruchomić skrypt update-codegen.sh, aby ponownie wygenerować
powiązane pliki.
W pliku controller.go (http://bit.ly/31MM4OS) zmodyfikuj funkcje NewController() i
syncHandler(), a także dodaj funkcje pomocnicze, które m.in. tworzą pody i sprawdzają
czas zaplanowanego wykonywania operacji.

W pliku types.go zwróć uwagę na trzy stałe reprezentujące trzy fazy działania zasobu At. Do
czasu zaplanowanego wykonania operacji zasób znajduje się w stanie PENDING, do czasu
ukończenia operacji ten stan to RUNNING, a ostatecznie zasób przechodzi do stanu DONE:
// +genclient

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

const (
PhasePending = “PENDING”

PhaseRunning = “RUNNING”

PhaseDone = “DONE”

// AtSpec definiuje oczekiwany stan obiektu At.

type AtSpec struct {

// Schedule to czas, kiedy polecenie powinno zostać wykonane.

// Uwaga: używany jest tu czas w formacie UTC — https://www.utctime.net.


Schedule string `json:”schedule,omitempty”`

// Command to oczekiwane polecenie, wykonywane w powłoce Bash.

Command string `json:”command,omitempty”`

// AtStatus definiuje zaobserwowany stan obiektu At.

type AtStatus struct {

// Phase reprezentuje status harmonogramu. Do czasu wykonania operacji


status to

// PENDING, po wykonaniu operacji status to DONE.

Phase string `json:”phase,omitempty”`

Zwróć uwagę na bezpośrednie użycie znaczników związanych z procesem budowania


+k8s:deepcopy-gen:interfaces (zob. rozdział 5.), powodujących, że odpowiednie pliki
źródłowe są generowane automatycznie.

Teraz możemy przejść do implementowania logiki biznesowej niestandardowego kontrolera.


Zaimplementujemy w pliku controller.go przejścia między trzema fazami — PhasePending,
PhaseRunning i PhaseDone (http://bit.ly/31MM4OS).

W punkcie „Kolejka zadań” przedstawiliśmy i objaśniliśmy kolejkę zadań dostępną w bibliotece


client-go. Teraz możemy wykorzystać tę wiedzę. W funkcji processNextWorkItem() w pliku
controller.go (w wierszach od 176. do 186.; http://bit.ly/2WYDbyi) znajdziesz następujący
wygenerowany kod:

if when, err := c.syncHandler(key); err != nil {

c.workqueue.AddRateLimited(key)

return fmt.Errorf(“Błąd synchronizacji ‘%s’: %s, ponowne kolejkowanie”,


key, err.Error())

} else if when != time.Duration(0) {

c.workqueue.AddAfter(key, when)

} else {

// Jeśli błędy nie wystąpiły, można zapomnieć element (wywołać funkcję


Forget), aby

// do czasu następnej zmiany nie był on kolejkowany.

c.workqueue.Forget(obj)

Ten fragment pokazuje, jak nasza (jeszcze nieistniejąca) niestandardowa funkcja


syncHandler() będzie wywoływana. Omówienie tej funkcji znajdziesz dalej. Ten kod
uwzględnia trzy scenariusze:

1. Pierwsza gałąź w instrukcji if ponownie kolejkuje element za pomocą wywołania


AddRateLimited(), obsługując przejściowe błędy.
2. Druga gałąź, else if, ponownie kolejkuje element za pomocą wywołania AddAfter(), aby
uniknąć ponawiania wywołań pętli.
3. Ostatnia sytuacja, z bloku else, dotyczy udanego przetworzenia elementu i usuwania go z
kolejki za pomocą wywołania Forget().

Teraz gdy już dobrze rozumiesz ogólne działanie tego podejścia, pora przejść do logiki
biznesowej. Najważniejsza jest w niej funkcja syncHandler(), w której zaimplementowana
będzie logika biznesowa naszego niestandardowego kontrolera. Oto sygnatura tej funkcji:
// syncHandler porównuje aktualny stan z oczekiwanym i próbuje sprawić, aby

// były identyczne. Następnie aktualizuje pole Status w zasobie At na


podstawie

// bieżącego statusu zasobu. Zwraca czas oczekiwania do

// zaplanowanego wykonania operacji.

func (c *Controller) syncHandler(key string) (time.Duration, error) {


...

[1]
W funkcji syncHandler() zaimplementowane są następujące przejścia między stanami :
// Jeśli żaden etap nie jest ustawiony, domyślnie ustawiany jest status
początkowy — pending.

if instance.Status.Phase == “” {

instance.Status.Phase = cnatv1alpha1.PhasePending

// Oto kod do określania statusu: implementacja

// diagramu stanów PENDING -> RUNNING -> DONE.


switch instance.Status.Phase {

case cnatv1alpha1.PhasePending:

klog.Infof(“instancja %s: status=PENDING”, key)

// Dopóki nie wykonano jeszcze polecenia, trzeba sprawdzać, czy

// nie nadszedł czas na jego uruchomienie:


klog.Infof(“instancja %s: sprawdzanie harmonogramu %q”, key,
instance.Spec.Schedule)

// Sprawdzanie, czy nadszedł czas na wykonanie polecenia.

// Sprawdzanie odbywa się z dokładnością do dwóch sekund:

d, err := timeUntilSchedule(instance.Spec.Schedule)

if err != nil {
utilruntime.HandleError(fmt.Errorf(“błąd przetwarzania harmonogramu:
%v”, err))
// Błąd przy odczycie harmonogramu — ponowne zakolejkowanie żądania:

return time.Duration(0), err


}

klog.Infof(“instancja %s: zakończono przetwarzanie harmonogramu:


diff=%v”, key, d)
if d > 0 {

// Nie nadszedł jeszcze czas na wykonanie polecenia. Należy


// odczekać do zaplanowanego czasu.

return d, nil
}

klog.Infof(

“instancja %s: nadszedł czas! Gotowy do wykonania: %s”, key,


instance.Spec.Command,
)

instance.Status.Phase = cnatv1alpha1.PhaseRunning
case cnatv1alpha1.PhaseRunning:

klog.Infof(“instancja %s: status: RUNNING”, key)

pod := newPodForCR(instance)

// Ustawianie instancji rodzaju At jako właściciela i kontrolera.


owner := metav1.NewControllerRef(
instance, cnatv1alpha1.SchemeGroupVersion.
WithKind(“At”),
)

pod.ObjectMeta.OwnerReferences = append(pod.ObjectMeta.OwnerReferences,
*owner)

// Próba sprawdzenia, czy pod już istnieje. Jeśli nie istnieje (co jest
// oczekiwane), należy na podstawie specyfikacji utworzyć pod na potrzeby
zadania:
found, err := c.kubeClientset.CoreV1().Pods(pod.Namespace).

Get(pod.Name, metav1.GetOptions{})
if err != nil && errors.IsNotFound(err) {

found, err = c.kubeClientset.CoreV1().Pods(pod.Namespace).Create(pod)


if err != nil {
return time.Duration(0), err

}
klog.Infof(“instancja %s: uruchomiono pod: name=%s”, key, pod.Name)

} else if err != nil {


// Ponowne zakolejkowanie po błędzie.

return time.Duration(0), err


} else if found.Status.Phase == corev1.PodFailed ||

found.Status.Phase == corev1.PodSucceeded {
klog.Infof(
“instancja %s: kontener zakończył pracę: przyczyna=%q
komunikat=%q”,
key, found.Status.Reason, found.Status.Message,

)
instance.Status.Phase = cnatv1alpha1.PhaseDone
} else {

// Nie trzeba ponownie kolejkować polecenia, ponieważ stanie się to


// automatycznie po zmianie statusu.

return time.Duration(0), nil


}

case cnatv1alpha1.PhaseDone:
klog.Infof(“instancja %s: status: DONE”, key)

return time.Duration(0), nil


default:

klog.Infof(“instancja %s: NOP”)


return time.Duration(0), nil
}

// Aktualizacja instancji rodzaju At, przypisanie odpowiedniej fazy do


statusu:
_, err = c.cnatClientset.CnatV1alpha1().Ats(instance.Namespace).
UpdateStatus(instance)

if err != nil {
return time.Duration(0), err

// Bez ponownego kolejkowania. Należy ponownie uzgodnić stan, ponieważ


zmienił się
// albo pod, albo niestandardowy zasób.

return time.Duration(0), nil


Ponadto aby na ogólnym poziomie skonfigurować informatory i kontrolery, dodaj pokazaną tu
funkcję NewController():
// NewController zwraca nowy kontroler dla narzędzia cnat.
func NewController(

kubeClientset kubernetes.Interface,
cnatClientset clientset.Interface,

atInformer informers.AtInformer,
podInformer corev1informer.PodInformer) *Controller {

// Tworzenie obiektu do rozgłaszania zdarzeń.

// Dodawanie typów z kontrolera cnat-controller do domyślnego schematu


Kubernetesa, aby
// umożliwić rejestrowanie zdarzeń dotyczących tych typów.

utilruntime.Must(cnatscheme.AddToScheme(scheme.Scheme))
klog.V(4).Info(“Tworzenie obiektu do rozgłaszania zdarzeń”)

eventBroadcaster := record.NewBroadcaster()
eventBroadcaster.StartLogging(klog.Infof)
eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{

Interface: kubeClientset.CoreV1().Events(“”),
})
source := corev1.EventSource{Component: controllerAgentName}

recorder := eventBroadcaster.NewRecorder(scheme.Scheme, source)

rateLimiter := workqueue.DefaultControllerRateLimiter()

controller := &Controller{
kubeClientset: kubeClientset,

cnatClientset: cnatClientset,
atLister: atInformer.Lister(),

atsSynced: atInformer.Informer().HasSynced,
podLister: podInformer.Lister(),

podsSynced: podInformer.Informer().HasSynced,
workqueue: workqueue.NewNamedRateLimitingQueue(rateLimiter,
“Ats”),

recorder: recorder,
}

klog.Info(“Konfigurowanie funkcji obsługi zdarzeń”)


// Konfigurowanie funkcji obsługi zdarzeń dotyczących zmiany zasobów
rodzaju At.
atInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{

AddFunc: controller.enqueueAt,
UpdateFunc: func(old, new interface{}) {
controller.enqueueAt(new)

},
})

// Konfigurowanie funkcji obsługi zdarzeń dotyczących zmiany zasobów


rodzaju Pod.

podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: controller.enqueuePod,
UpdateFunc: func(old, new interface{}) {

controller.enqueuePod(new)
},

})
return controller

}
Potrzebne są też dwie funkcje pomocnicze, aby to rozwiązanie zadziałało. Jedna z nich oblicza
czas do momentu zaplanowanego uruchomienia polecenia:

func timeUntilSchedule(schedule string) (time.Duration, error) {


now := time.Now().UTC()

layout := “2006-01-02T15:04:05Z”
s, err := time.Parse(layout, schedule)

if err != nil {
return time.Duration(0), err

}
return s.Sub(now), nil
}

Druga tworzy pod z poleceniem do wykonania, używając obrazu kontenera busybox:


func newPodForCR(cr *cnatv1alpha1.At) *corev1.Pod {

labels := map[string]string{
“app”: cr.Name,

}
return &corev1.Pod{

ObjectMeta: metav1.ObjectMeta{
Name: cr.Name + “-pod”,
Namespace: cr.Namespace,

Labels: labels,
},

Spec: corev1.PodSpec{
Containers: []corev1.Container{

{
Name: “busybox”,

Image: “busybox”,
Command: strings.Split(cr.Spec.Command, “ “),
},

},
RestartPolicy: corev1.RestartPolicyOnFailure,

},
}

}
Te dwie funkcje pomocnicze i zaprezentowany tu podstawowy przepływ logiki biznesowej
wykorzystamy ponownie w funkcji syncHandler() w dalszej części rozdziału, dlatego
koniecznie dokładnie zapoznaj się z tymi funkcjami.

Warto zauważyć, że w zasobie At pod jest zasobem pomocniczym, a kontroler musi zadbać o
usunięcie podów (w przeciwnym razie występuje ryzyko powstania osieroconych podów).
Kontroler sample-controller jest dobrym narzędziem do nauczenia się, jak budować takie
komponenty. Jednak zwykle celem jest skoncentrowanie się na logice biznesowej, a nie na
szablonowym kodzie. W tym obszarze dostępne są dwa powiązane projekty: Kubebuilder i
Operator SDK. Przyjrzyjmy się każdemu z nich i temu, jak przy ich użyciu zaimplementować
narzędzie cnat.

Kubebuilder
Kubebuilder (http://bit.ly/2I8w9mz), którego właścicielem i opiekunem jest grupa SIG (ang.
Special Interest Group) API Machinery w społeczności skupionej wokół Kubernetesa, to zestaw
bibliotek umożliwiających łatwe i wydajne tworzenie operatorów. Najlepszym źródłem do
dokładnego zapoznania się z Kubebuilderem jest dostępna w internecie książka na temat tego
narzędzia (https://book.kubebuilder.io). Opisane są w niej komponenty Kubebuildera i sposoby
jego używania. Jednak tu skoncentrujemy się na tym, jak za pomocą Kubebuildera
zaimplementować operator dla naszego narzędzia cnat (http://bit.ly/2RpHhON; zob. też
odpowiedni katalog w repozytorium Git — http://bit.ly/2Iv6pAS).

Najpierw należy się upewnić, że zainstalowane są wszystkie zależności, czyli dep


(http://bit.ly/2x9Yrqq), kustomize (http://bit.ly/2Y3JeCV) (zob. punkt „Kustomize”) i sam
Kubebuilder (http://bit.ly/32pQmfu):

$ dep version
dep:
version : v0.5.1

build date : 2019-03-11


git hash : faa6189

go version : go1.12
go compiler : gc

platform : darwin/amd64
features : ImportDuringSolve=false

$ kustomize version
Version: {KustomizeVersion:v2.0.3
GitCommit:a6f65144121d1955266b0cd836ce954c04122dc8
BuildDate:2019-03-18T22:15:21+00:00 GoOs:darwin GoArch:amd64}

$ kubebuilder version

Version: version.Version{
KubeBuilderVersion:”1.0.8”,

KubernetesVendor:”1.13.1”,
GitCommit:”1adf50ed107f5042d7472ba5ab50d5e1d357169d”,

BuildDate:”2019-01-25T23:14:29Z”, GoOs:”unknown”, GoArch:”unknown”


}
Przeprowadzimy Cię przez proces pisania od podstaw operatora dla narzędzia cnat. Najpierw
utwórz katalog (w naszym repozytorium jest to cnat-kubebuilder), którego będziesz używał jako
katalogu bazowego we wszystkich dalszych poleceniach.

W czasie, gdy powstaje ta książka, wprowadzana jest nowa wersja


Kubebuildera (v2). Ponieważ na razie nie jest ona stabilna, przedstawiamy tu
polecenia i konfigurację dla (stabilnej) wersji v1 (https://book-
v1.book.kubebuilder.io).

Przygotowania
Aby utworzyć początkową wersję operatora cnat, użyjemy polecenia init (zauważ, że w
zależności od środowiska jego wykonywanie może zająć kilka minut):
$ kubebuilder init \

--domain programming-kubernetes.info \
--license apache2 \

--owner “Autorzy książki Programowanie dla Kubernetesa”


Run `dep ensure` to fetch dependencies (Recommended) [y/n]?

y
dep ensure
Running make...

make
go generate ./pkg/... ./cmd/...
go fmt ./pkg/... ./cmd/...

go vet ./pkg/... ./cmd/...


go run vendor/sigs.k8s.io/controller-tools/cmd/controller-gen/main.go all
CRD manifests generated under ‘config/crds’
RBAC manifests generated under ‘config/rbac’

go test ./pkg/... ./cmd/... -coverprofile cover.out


? github.com/mhausenblas/cnat-kubebuilder/pkg/apis [no test files]
? github.com/mhausenblas/cnat-kubebuilder/pkg/controller [no test
files]
? github.com/mhausenblas/cnat-kubebuilder/pkg/webhook [no test files]
? github.com/mhausenblas/cnat-kubebuilder/cmd/manager [no test files]

go build -o bin/manager github.com/mhausenblas/cnat-kubebuilder/cmd/manager


Wykonanie tego polecenia oznacza utworzenie przez Kubebuildera szkieletu operatora, czyli
wygenerowanie zestawu plików — od niestandardowego kontrolera po przykładową definicję
CRD. Katalog bazowy powinien wyglądać teraz podobnie jak poniżej (dla przejrzystości
pomijamy tu rozbudowany katalog vendor):
$ tree -I vendor

.
├── Dockerfile
├── Gopkg.lock
├── Gopkg.toml

├── Makefile
├── PROJECT
├── bin
│ └── manager
├── cmd

│ └── manager
│ └── main.go
├── config
│ ├── crds

│ ├── default
│ │ ├── kustomization.yaml
│ │ ├── manager_auth_proxy_patch.yaml
│ │ ├── manager_image_patch.yaml
│ │ └── manager_prometheus_metrics_patch.yaml

│ ├── manager
│ │ └── manager.yaml
│ └── rbac
│ ├── auth_proxy_role.yaml

│ ├── auth_proxy_role_binding.yaml
│ ├── auth_proxy_service.yaml
│ ├── rbac_role.yaml
│ └── rbac_role_binding.yaml
├── cover.out

├── hack
│ └── boilerplate.go.txt

└── pkg
├── apis
│ └── apis.go
├── controller

│ └── controller.go
└── webhook
└── webhook.go
13 directories, 22 files
Następnie należy utworzyć API (czyli niestandardowy kontroler), używając polecenia create
api. Wykonywanie tego polecenia powinno zająć mniej czasu, ale też może chwilę potrwać:
$ kubebuilder create api \
--group cnat \
--version v1alpha1 \
--kind At

Create Resource under pkg/apis [y/n]?


y
Create Controller under pkg/controller [y/n]?
y

Writing scaffold for you to edit...


pkg/apis/cnat/v1alpha1/at_types.go
pkg/apis/cnat/v1alpha1/at_types_test.go
pkg/controller/at/at_controller.go
pkg/controller/at/at_controller_test.go

Running make...
go generate ./pkg/... ./cmd/...
go fmt ./pkg/... ./cmd/...
go vet ./pkg/... ./cmd/...

go run vendor/sigs.k8s.io/controller-tools/cmd/controller-gen/main.go all


CRD manifests generated under ‘config/crds’
RBAC manifests generated under ‘config/rbac’
go test ./pkg/... ./cmd/... -coverprofile cover.out
? github.com/mhausenblas/cnat-kubebuilder/pkg/apis [no test files]

? github.com/mhausenblas/cnat-kubebuilder/pkg/apis/cnat [no test files]


ok github.com/mhausenblas/cnat-kubebuilder/pkg/apis/cnat/v1alpha1 9.011s
? github.com/mhausenblas/cnat-kubebuilder/pkg/controller [no test
files]
ok github.com/mhausenblas/cnat-kubebuilder/pkg/controller/at 8.740s
? github.com/mhausenblas/cnat-kubebuilder/pkg/webhook [no test files]

? github.com/mhausenblas/cnat-kubebuilder/cmd/manager [no test files]


go build -o bin/manager github.com/mhausenblas/cnat-kubebuilder/cmd/manager
Zobaczmy, co się zmieniło. Skoncentrujemy się na dwóch katalogach, w których pojawiły się
zmiany i dodatki:

$ tree config/ pkg/


config/
├── crds
│ └── cnat_v1alpha1_at.yaml
├── default

│ ├── kustomization.yaml
│ ├── manager_auth_proxy_patch.yaml
│ ├── manager_image_patch.yaml
│ └── manager_prometheus_metrics_patch.yaml

├── manager
│ └── manager.yaml
├── rbac
│ ├── auth_proxy_role.yaml
│ ├── auth_proxy_role_binding.yaml

│ ├── auth_proxy_service.yaml
│ ├── rbac_role.yaml
│ └── rbac_role_binding.yaml
└── samples

└── cnat_v1alpha1_at.yaml
pkg/
├── apis
│ ├── addtoscheme_cnat_v1alpha1.go
│ ├── apis.go

│ └── cnat
│ ├── group.go
│ └── v1alpha1
│ ├── at_types.go

│ ├
│ ├── at_types_test.go
│ ├── doc.go

│ ├── register.go
│ ├── v1alpha1_suite_test.go
│ └── zz_generated.deepcopy.go
├── controller

│ ├── add_at.go
│ ├── at
│ │ ├── at_controller.go
│ │ ├── at_controller_suite_test.go
│ │ └── at_controller_test.go

│ └── controller.go
└── webhook
└── webhook.go

11 directories, 27 files
Zwróć uwagę na pojawienie się pliku cnat_v1alpha1_at.yaml z definicją CRD w katalogu
config/crds/, a także pliku cnat_v1alpha1_at.yaml (tak, to ta sama nazwa; ten plik reprezentuje
przykładową instancję niestandardowego zasobu opartego na wspomnianej definicji CRD) w
katalogu config/samples/. Ponadto w katalogu pkg/ pojawiło się kilka nowych plików.
Najważniejsze z nich to apis/cnat/v1alpha1/at_types.go i controller/at/at_controller.go. Wkrótce
je zmodyfikujemy.

Następnie należy utworzyć w Kubernetesie specjalną przestrzeń nazw, cnat, i ustawić ją jako
domyślną przestrzeń nazw. Kontekst należy ustawić w następujący sposób (dobrą praktyką jest
używanie specjalnej przestrzeni nazw zamiast przestrzeni nazw default):
$ kubectl create ns cnat && \
kubectl config set-context $(kubectl config current-context) --
namespace=cnat

Definicję CRD zainstaluj tak:


$ make install
go run vendor/sigs.k8s.io/controller-tools/cmd/controller-gen/main.go all
CRD manifests generated under ‘config/crds’

RBAC manifests generated under ‘config/rbac’


kubectl apply -f config/crds
customresourcedefinition.apiextensions.k8s.io/ats.cnat.programming-
kubernetes.info
created

Teraz można lokalnie uruchomić operator:


$ make run

go generate ./pkg/... ./cmd/...


go fmt ./pkg/... ./cmd/...
go vet ./pkg/... ./cmd/...
go run ./cmd/manager/main.go

{“level”:”info”,”ts”:1559152740.0550249,”logger”:”entrypoint”,
“msg”:”setting up client for manager”}
{“level”:”info”,”ts”:1559152740.057556,”logger”:”entrypoint”,
“msg”:”setting up manager”}
{“level”:”info”,”ts”:1559152740.1396701,”logger”:”entrypoint”,

“msg”:”Registering Components.”}
{“level”:”info”,”ts”:1559152740.1397,”logger”:”entrypoint”,
“msg”:”setting up scheme”}
{“level”:”info”,”ts”:1559152740.139773,”logger”:”entrypoint”,

“msg”:”Setting up controller”}
{“level”:”info”,”ts”:1559152740.139831,”logger”:”kubebuilder.controller”,
“msg”:”Starting EventSource”,”controller”:”at-controller”,
“source”:”kind source: /, Kind=”}
{“level”:”info”,”ts”:1559152740.139929,”logger”:”kubebuilder.controller”,

“msg”:”Starting EventSource”,”controller”:”at-controller”,
“source”:”kind source: /, Kind=”}
{“level”:”info”,”ts”:1559152740.139971,”logger”:”entrypoint”,
“msg”:”setting up webhooks”}

{“level”:”info”,”ts”:1559152740.13998,”logger”:”entrypoint”,
“msg”:”Starting the Cmd.”}
{“level”:”info”,”ts”:1559152740.244628,”logger”:”kubebuilder.controller”,
“msg”:”Starting Controller”,”controller”:”at-controller”}
{“level”:”info”,”ts”:1559152740.344791,”logger”:”kubebuilder.controller”,

“msg”:”Starting workers”,”controller”:”at-controller”,”worker count”:1}


Pozostaw aktywną sesję terminala i w nowej sesji zainstaluj definicję CRD, sprawdź jej
poprawność i utwórz przykładowy niestandardowy zasób:
$ kubectl apply -f config/crds/cnat_v1alpha1_at.yaml
customresourcedefinition.apiextensions.k8s.io/ats.cnat.programming-
kubernetes.info
configured
$ kubectl get crds
NAME CREATED AT
ats.cnat.programming-kubernetes.info 2019-05-29T17:54:51Z

$ kubectl apply -f config/samples/cnat_v1alpha1_at.yaml


at.cnat.programming-kubernetes.info/at-sample created
Jeśli teraz przyjrzysz się danym wyjściowym w sesji, gdzie działa polecenie make run,
powinieneś zobaczyć następujące informacje:

...
{“level”:”info”,”ts”:1559153311.659829,”logger”:”controller”,
“msg”:”Creating Deployment”,”namespace”:”cnat”,”name”:”at-sample-
deployment”}
{“level”:”info”,”ts”:1559153311.678407,”logger”:”controller”,

“msg”:”Updating Deployment”,”namespace”:”cnat”,”name”:”at-sample-
deployment”}
{“level”:”info”,”ts”:1559153311.6839428,”logger”:”controller”,
“msg”:”Updating Deployment”,”namespace”:”cnat”,”name”:”at-sample-
deployment”}

{“level”:”info”,”ts”:1559153311.693443,”logger”:”controller”,
“msg”:”Updating Deployment”,”namespace”:”cnat”,”name”:”at-sample-
deployment”}
{“level”:”info”,”ts”:1559153311.7023401,”logger”:”controller”,
“msg”:”Updating Deployment”,”namespace”:”cnat”,”name”:”at-sample-
deployment”}
{“level”:”info”,”ts”:1559153332.986961,”logger”:”controller”,#
“msg”:”Updating Deployment”,”namespace”:”cnat”,”name”:”at-sample-
deployment”}
To informuje, że ogólna konfiguracja zakończyła się powodzeniem! Teraz, po przygotowaniu
szkieletu i udanym uruchomieniu operatora cnat, możemy przejść do podstawowego zadania:
implementowania logiki biznesowej narzędzia cnat za pomocą Kubebuildera.

Logika biznesowa
Na początek zmodyfikujemy pliki config/crds/cnat_v1alpha1_at.yaml (http://bit.ly/2N1jQNb) i
config/samples/cnat_v1alpha1_at.yaml (http://bit.ly/2Xs1F7c) zgodnie z definicjami CRD z
projektu cnat i wartościami niestandardowych zasobów. Ponownie użyjemy tu struktur z
punktu „Wzorowanie się na projekcie sample-controller”.

Jeśli chodzi o logikę biznesową, w operatorze trzeba zaimplementować dwa elementy:


W pliku pkg/apis/cnat/v1alpha1/at_types.go (http://bit.ly/31KNLfO) zmodyfikujemy
strukturę AtSpec, aby dodać odpowiednie pola, np. schedule i command. Zauważ, że po
każdej zmianie trzeba wywołać polecenie make, aby ponownie wygenerować powiązane
pliki. Kubebuilder korzysta z generatorów z Kubernetesa (opisanych w rozdziale 5.), a
także udostępnia własny zestaw generatorów (np. do generowania manifestów CRD).
W pliku pkg/controller/at/at_controller.go (http://bit.ly/2Iwormg) zmodyfikujemy metodę
Reconcile(request reconcile.Request), aby tworzyć pod w czasie zdefiniowanym w
polu Spec.Schedule.

Oto kod z pliku at_types.go:


const (

PhasePending = “PENDING”
PhaseRunning = “RUNNING”
PhaseDone = “DONE”
)

// AtSpec definiuje oczekiwany stan zasobu rodzaju At.


type AtSpec struct {
// Schedule to czas, kiedy polecenie ma zostać wykonane.
// Uwaga: używany jest tu czas w formacie UTC — https://www.utctime.net.

Schedule string `json:”schedule,omitempty”`


// Command to polecenie, które ma zostać wykonane (w powłoce Bash).
Command string `json:”command,omitempty”`

}
// AtStatus definiuje zaobserwowany status zasobu rodzaju At.
type AtStatus struct {
// Phase reprezentuje status harmonogramu. Do czasu wykonania polecenia
// status to PENDING, następnie status to DONE.

Phase string `json:”phase,omitempty”`


}
W pliku at_controller.go zaimplementujemy przejścia między trzema stanami: z PENDING do
RUNNING i z RUNNING do DONE:
func (r *ReconcileAt) Reconcile(req reconcile.Request) (reconcile.Result,
error) {
reqLogger := log.WithValues(“namespace”, req.Namespace, “at”, req.Name)
reqLogger.Info(“===uzgadnianie stanu At”)
// Pobieranie instancji rodzaju At.
instance := &cnatv1alpha1.At{}
err := r.Get(context.TODO(), req.NamespacedName, instance)
if err != nil {

if errors.IsNotFound(err) {
// Nie znaleziono obiektu żądania. Mógł zostać usunięty po
// żądaniu uzgodnienia stanu. Należy zwrócić sterowanie i nie
kolejkować ponownie żądania:

return reconcile.Result{}, nil


}
// Błąd odczytu obiektu. Należy ponownie zakolejkować żądanie:
return reconcile.Result{}, err
}

// Jeśli etap nie jest określony, domyślnie należy ustawić początkową


fazę — pending:
if instance.Status.Phase == “” {
instance.Status.Phase = cnatv1alpha1.PhasePending

}
// Pora utworzyć główny blok z sekcjami case i zaimplementować
// diagram stanów PENDING -> RUNNING -> DONE.
switch instance.Status.Phase {

case cnatv1alpha1.PhasePending:
reqLogger.Info(“Etap: PENDING”)
// Dopóki nie wykonano polecenia, trzeba sprawdzać, czy nie
// nadszedł czas na jego wykonanie:
reqLogger.Info(“Sprawdzanie harmonogramu”, “Target”,
instance.Spec.Schedule)
// Sprawdzanie, czy nadszedł już czas na wykonanie polecenia.
// Dokładność czasu to dwie sekundy:
d, err := timeUntilSchedule(instance.Spec.Schedule)
if err != nil {

reqLogger.Error(err, “Błąd przetwarzania harmonogramu”)


// Błąd odczytu harmonogramu. Oczekiwanie do czasu naprawienia
błędu.
return reconcile.Result{}, err
}
reqLogger.Info(“Zakończenie przetwarzania harmonogramu”, “Result”,
“diff”,
fmt.Sprintf(“%v”, d))
if d > 0 {
// Jeszcze nie nadszedł czas na wykonanie polecenia. Należy
oczekiwać

// do zaplanowanego czasu.
return reconcile.Result{RequeueAfter: d}, nil
}
reqLogger.Info(“Nadszedł czas!”, “Gotowy do wykonania”,
instance.Spec.Command)

instance.Status.Phase = cnatv1alpha1.PhaseRunning
case cnatv1alpha1.PhaseRunning:
reqLogger.Info(“Etap: RUNNING”)
pod := newPodForCR(instance)

// Instancja rodzaju At jest ustawiana jako właściciel i kontroler.


err := controllerutil.SetControllerReference(instance, pod, r.scheme)
if err != nil {
// Ponowne zakolejkowanie i zgłoszenie błędu.
return reconcile.Result{}, err

}
found := &corev1.Pod{}
nsName := types.NamespacedName{Name: pod.Name, Namespace:
pod.Namespace}
err = r.Get(context.TODO(), nsName, found)

// Próba sprawdzenia, czy pod już istnieje. Jeśli pod nie istnieje
// (co jest oczekiwane), należy utworzyć zgodny ze specyfikacją
jednorazowy pod:
if err != nil && errors.IsNotFound(err) {

err = r.Create(context.TODO(), pod)


if err != nil {
// Ponowne zakolejkowanie i zgłoszenie błędu.
return reconcile.Result{}, err
}

reqLogger.Info(“Uruchomiono pod”, “name”, pod.Name)


} else if err != nil {
// Ponowne zakolejkowanie i zgłoszenie błędu.
return reconcile.Result{}, err
} else if found.Status.Phase == corev1.PodFailed ||
found.Status.Phase == corev1.PodSucceeded {

reqLogger.Info(“Kontener zakończył pracę”, “reason”,


found.Status.Reason, “message”, found.Status.Message)
instance.Status.Phase = cnatv1alpha1.PhaseDone
} else {

// Nie należy ponownie kolejkować żądania, ponieważ dzieje się to


// automatycznie po zmianie statusu poda.
return reconcile.Result{}, nil
}
case cnatv1alpha1.PhaseDone:

reqLogger.Info(“Etap: DONE”)
return reconcile.Result{}, nil
default:
reqLogger.Info(“NOP”)

return reconcile.Result{}, nil


}

// Aktualizowanie instancji rodzaju At. Przypisanie odpowiedniego etapu


do statusu:

err = r.Status().Update(context.TODO(), instance)


if err != nil {
return reconcile.Result{}, err
}

// Nie należy ponownie kolejkować żądania. Należy jednak uzgodnić stan,


ponieważ pod
// lub niestandardowy zasób został zmodyfikowany.
return reconcile.Result{}, nil
}

Warto zauważyć, że wywołanie Update na końcu kodu operuje na podzasobie /status (zob. punkt
„Podzasób status”), a nie na całym niestandardowym zasobie. Oznacza to, że stosujemy tu
zalecaną praktykę rozdziału na status i specyfikację.
Po utworzeniu niestandardowego zasobu example-at można obejrzeć dane wyjściowe lokalnie
uruchomionego operatora:
$ make run

...
{“level”:”info”,”ts”:1555063897.488535,”logger”:”controller”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555063897.488621,”logger”:”controller”,

“msg”:”Etap: PENDING”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555063897.4886441,”logger”:”controller”,
“msg”:”Sprawdzanie harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Target”:”2019-04-12T10:12:00Z”}
{“level”:”info”,”ts”:1555063897.488703,”logger”:”controller”,

“msg”:”Zakończono przetwarzanie
harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Result”:”2019-04-12 10:12:00 +0000 UTC with a diff of 22.511336s”}
{“level”:”info”,”ts”:1555063907.489264,”logger”:”controller”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}

{“level”:”info”,”ts”:1555063907.489402,”logger”:”controller”,
“msg”:”Etap: PENDING”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555063907.489428,”logger”:”controller”,
“msg”:”Sprawdzanie harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,

“Target”:”2019-04-12T10:12:00Z”}
{“level”:”info”,”ts”:1555063907.489486,”logger”:”controller”,
“msg”:”Zakończono przetwarzanie
harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Result”:”2019-04-12 10:12:00 +0000 UTC with a diff of 12.510551s”}

{“level”:”info”,”ts”:1555063917.490178,”logger”:”controller”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555063917.4902349,”logger”:”controller”,
“msg”:”Etap: PENDING”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555063917.490247,”logger”:”controller”,

“msg”:”Sprawdzanie harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Target”:”2019-04-12T10:12:00Z”}
{“level”:”info”,”ts”:1555063917.490278,”logger”:”controller”,
“msg”:”Zakończono przetwarzanie
harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Result”:”2019-04-12 10:12:00 +0000 UTC with a diff of 2.509743s”}

{“level”:”info”,”ts”:1555063927.492718,”logger”:”controller”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555063927.49283,”logger”:”controller”,
“msg”:”Etap: PENDING”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555063927.492857,”logger”:”controller”,

“msg”:”Sprawdzanie harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Target”:”2019-04-12T10:12:00Z”}
{“level”:”info”,”ts”:1555063927.492915,”logger”:”controller”,
“msg”:”Zakończono przetwarzanie
harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,

“Result”:”2019-04-12 10:12:00 +0000 UTC with a diff of -7.492877s”}


{“level”:”info”,”ts”:1555063927.4929411,”logger”:”controller”,
“msg”:”Nadszedł czas!”,”namespace”:”cnat”,”at”:
“example-at”,”Gotowy do wykonania”:”echo SUPER”}

{“level”:”info”,”ts”:1555063927.626236,”logger”:”controller”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555063927.626303,”logger”:”controller”,
“msg”:”Etap: RUNNING”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555063928.07445,”logger”:”controller”,

“msg”:”Uruchomiono pod”,”namespace”:”cnat”,”at”:”example-at”,
“name”:”example-at-pod”}
{“level”:”info”,”ts”:1555063928.199562,”logger”:”controller”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}

{“level”:”info”,”ts”:1555063928.199645,”logger”:”controller”,
“msg”:”Etap: DONE”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555063937.631733,”logger”:”controller”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555063937.631783,”logger”:”controller”,

“msg”:”Etap: DONE”,”namespace”:”cnat”,”at”:”example-at”}
...
Aby sprawdzić, czy nasz niestandardowy kontroler wykonał swoje zadanie, wywołaj następujące
polecenie:
$ kubectl get at,pods

NAME AGE
at.cnat.programming-kubernetes.info/example-at 11m

NAME READY STATUS RESTARTS AGE


pod/example-at-pod 0/1 Completed 0 38s
Doskonale! Pod example-at-pod został utworzony. Teraz możesz zobaczyć wynik wykonanej
operacji:
$ kubectl logs example-at-pod
SUPER
Gdy skończysz tworzyć niestandardowy kontroler w trybie lokalnym (tak jak w tym
przykładzie), zapewne zechcesz wygenerować na jego podstawie obraz kontenera. Obraz
kontenera z tym niestandardowym kontrolerem można później wykorzystać np. w instalacji
Kubernetesa. Za pomocą poniższego polecenia możesz wygenerować obraz kontenera i
przenieść go do repozytorium quay.io/pk/cnat:

$ export IMG=quay.io/pk/cnat:v1
$ make docker-build
$ make docker-push
Teraz przejdźmy do narzędzia Operator SDK, które współdzieli z Kubebuilderem część kodu
bazowego i API.

Operator SDK
Aby ułatwić budowanie aplikacji dla Kubernetesa, firmy CoreOS i Red Hat opracowały
platformę Operator Framework. Częścią tej platformy jest narzędzie Operator SDK
(http://bit.ly/2KtpK7D), które umożliwia programistom tworzenie operatorów bez szczegółowej
wiedzy na temat API Kubernetesa.
Operator SDK zapewnia narzędzia do budowania i testowania operatorów oraz tworzenia
pakietów z nimi. Choć to SDK oferuje rozbudowane możliwości (przede wszystkim w obszarze
testowania), tu skupimy się na implementowaniu operatora narzędzia cnat
(http://bit.ly/2RpHhON) z użyciem tego SDK. Zobacz też powiązany katalog w naszym
repozytorium Git (http://bit.ly/2FpCtE9).
Zacznijmy od podstaw — zainstaluj narzędzie Operator SDK (http://bit.ly/2ZBQlCT) i sprawdź,
czy dostępne są wszystkie zależności:
$ dep version
dep:
version : v0.5.1

build date : 2019-03-11


git hash : faa6189
go version : go1.12
go compiler : gc
platform : darwin/amd64
features : ImportDuringSolve=false

$ operator-sdk --version
operator-sdk version v0.6.0

Przygotowania
Teraz możemy wygenerować szkielet operatora dla narzędzia cnat:

$ operator-sdk new cnat-operator && cd cnat-operator


Następnie, bardzo podobnie jak w Kubebuilderze, należy dodać API, czyli — prosto mówiąc —
zainicjować niestandardowy kontroler:
$ operator-sdk add api \
--api-version=cnat.programming-kubernetes.info/v1alpha1 \
--kind=At

$ operator-sdk add controller \


--api-version=cnat.programming-kubernetes.info/v1alpha1 \
--kind=At

Te polecenia generują potrzebny szablonowy kod, a także liczne funkcje pomocnicze, np.
przeznaczone do głębokiego kopiowania funkcje DeepCopy(), DeepCopyInto() i
DeepCopyObject().
Teraz możesz zastosować automatycznie wygenerowaną definicję CRD w klastrze Kubernetesa:
$ kubectl apply -f deploy/crds/cnat_v1alpha1_at_crd.yaml

$ kubectl get crds


NAME CREATED AT
ats.cnat.programming-kubernetes.info 2019-04-01T14:03:33Z

Uruchom lokalnie nasz niestandardowy kontroler narzędzia cnat. Od tego momentu może on
zacząć przetwarzanie żądań:
$ OPERATOR_NAME=cnatop operator-sdk up local --namespace “cnat”
INFO[0000] Running the operator locally.
INFO[0000] Using namespace cnat.
{“level”:”info”,”ts”:1555041531.871706,”logger”:”cmd”,
“msg”:”Go Version: go1.12.1”}
{“level”:”info”,”ts”:1555041531.871785,”logger”:”cmd”,
“msg”:”Go OS/Arch: darwin/amd64”}
{“level”:”info”,”ts”:1555041531.8718028,”logger”:”cmd”,
“msg”:”Version of operator-sdk: v0.6.0”}
{“level”:”info”,”ts”:1555041531.8739321,”logger”:”leader”,
“msg”:”Trying to become the leader.”}
{“level”:”info”,”ts”:1555041531.8743382,”logger”:”leader”,
“msg”:”Skipping leader election; not running in a cluster.”}
{“level”:”info”,”ts”:1555041536.1611362,”logger”:”cmd”,
“msg”:”Registering Components.”}
{“level”:”info”,”ts”:1555041536.1622112,”logger”:”kubebuilder.controller”,
“msg”:”Starting EventSource”,”controller”:”at-controller”,

“source”:”kind source: /, Kind=”}


{“level”:”info”,”ts”:1555041536.162519,”logger”:”kubebuilder.controller”,
“msg”:”Starting EventSource”,”controller”:”at-controller”,
“source”:”kind source: /, Kind=”}
{“level”:”info”,”ts”:1555041539.978822,”logger”:”metrics”,
“msg”:”Skipping metrics Service creation; not running in a cluster.”}
{“level”:”info”,”ts”:1555041539.978875,”logger”:”cmd”,
“msg”:”Starting the Cmd.”}
{“level”:”info”,”ts”:1555041540.179469,”logger”:”kubebuilder.controller”,

“msg”:”Starting Controller”,”controller”:”at-controller”}
{“level”:”info”,”ts”:1555041540.280784,”logger”:”kubebuilder.controller”,
“msg”:”Starting workers”,”controller”:”at-controller”,”worker count”:1}
Niestandardowy kontroler pozostanie w tym stanie do czasu utworzenia niestandardowego
zasobu ats.cnat.programming-kubernetes.info. Zróbmy to:
$ cat deploy/crds/cnat_v1alpha1_at_cr.yaml
apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At
metadata:

name: example-at
spec:
schedule: “2019-04-11T14:56:30Z”
command: “echo SUPER”

$ kubectl apply -f deploy/crds/cnat_v1alpha1_at_cr.yaml

$ kubectl get at
NAME AGE
at.cnat.programming-kubernetes.info/example-at 54s

Logika biznesowa
W logice biznesowej w operatorze należy zaimplementować dwie rzeczy:

W pliku pkg/apis/cnat/v1alpha1/at_types.go (http://bit.ly/31Ip2sF) trzeba zmodyfikować


strukturę AtSpec, aby zawierała potrzebne pola, np. schedule i command. Należy też użyć
polecenia operator-sdk generate k8s do ponownego wygenerowania kodu, a także
zastosować polecenie operator-sdk generate openapi w celu utworzenia fragmentów
związanych z OpenAPI.
W pliku pkg/controller/at/at_controller.go (http://bit.ly/2Fpo5Mi) należy zmodyfikować
metodę Reconcile(request reconcile.Request), aby tworzyć pod w czasie
zdefiniowanym w polu Spec.Schedule.

Oto szczegółowy przegląd zmian wprowadzonych w wygenerowanym kodzie (skupiamy się tu


na istotnych fragmentach). Plik at_types.go:
// AtSpec definiuje oczekiwany stan zasobu rodzaju At.
// +k8s:openapi-gen=true

type AtSpec struct {


// Schedule to oczekiwany czas wykonania polecenia.
// Uwaga: używany jest tu czas w formacie UTC — https://www.utctime.net.
Schedule string `json:”schedule,omitempty”`
// Command to polecenie, które ma zostać wykonane w powłoce Bash.
Command string `json:”command,omitempty”`
}

// AtStatus definiuje zaobserwowany stan zasobu rodzaju At.

// +k8s:openapi-gen=true
type AtStatus struct {
// Phase reprezentuje stan harmonogramu. Do czasu wykonania polecenia
stan
// to PENDING, po wykonaniu polecenia stan to DONE.
Phase string `json:”phase,omitempty”`
}
W pliku at_controller.go należy zaimplementować diagram stanów dla trzech etapów z
przejściami ze stanu PENDING do RUNNING i z RUNNING do DONE.

Projekt controller-runtime (http://bit.ly/2ZFtDKd) to następne narzędzie od


grupy SIG API Machinery. Ma udostępniać zestaw niskopoziomowych
mechanizmów do tworzenia kontrolerów w postaci pakietów języka Go. Więcej
szczegółów znajdziesz w rozdziale 4.
Ponieważ Kubebuilder i Operator SDK mają wspólne środowisko uruchomieniowe dla
kontrolerów, funkcja Reconcile() w obu tych narzędziach jest taka sama:
func (r *ReconcileAt) Reconcile(request reconcile.Request) (reconcile.Result,
error) {
to-samo-co-dla-Kubebuildera
}
Po utworzeniu niestandardowego zasobu example-at lokalnie uruchomiony operator wyświetli
następujące dane wyjściowe:
$ OPERATOR_NAME=cnatop operator-sdk up local --namespace “cnat”
INFO[0000] Running the operator locally.
INFO[0000] Using namespace cnat.
...
{“level”:”info”,”ts”:1555044934.023597,”logger”:”controller_at”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555044934.023713,”logger”:”controller_at”,
“msg”:”Etap: PENDING”,”namespace”:”cnat”,”at”:”example-at”}

{“level”:”info”,”ts”:1555044934.0237482,”logger”:”controller_at”,
“msg”:”Sprawdzanie harmonogramu”,”namespace”:”cnat”,”at”:
“example-at”,”Target”:”2019-04-12T04:56:00Z”}
{“level”:”info”,”ts”:1555044934.02382,”logger”:”controller_at”,
“msg”:”Zakończono przetwarzanie
harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Result”:”2019-04-12 04:56:00 +0000 UTC with a diff of 25.976236s”}
{“level”:”info”,”ts”:1555044934.148148,”logger”:”controller_at”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555044934.148224,”logger”:”controller_at”,

“msg”:”Etap: PENDING”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555044934.148243,”logger”:”controller_at”,
“msg”:”Sprawdzanie harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Target”:”2019-04-12T04:56:00Z”}
{“level”:”info”,”ts”:1555044934.1482902,”logger”:”controller_at”,
“msg”:”Zakończono przetwarzanie
harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Result”:”2019-04-12 04:56:00 +0000 UTC with a diff of 25.85174s”}
{“level”:”info”,”ts”:1555044944.1504588,”logger”:”controller_at”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555044944.150568,”logger”:”controller_at”,
“msg”:”Etap: PENDING”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555044944.150599,”logger”:”controller_at”,
“msg”:”Sprawdzanie harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Target”:”2019-04-12T04:56:00Z”}

{“level”:”info”,”ts”:1555044944.150663,”logger”:”controller_at”,
“msg”:”Zakończono przetwarzanie
harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Result”:”2019-04-12 04:56:00 +0000 UTC with a diff of 15.84938s”}
{“level”:”info”,”ts”:1555044954.385175,”logger”:”controller_at”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555044954.3852649,”logger”:”controller_at”,
“msg”:”Etap: PENDING”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555044954.385288,”logger”:”controller_at”,
“msg”:”Sprawdzanie harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,

“Target”:”2019-04-12T04:56:00Z”}
{“level”:”info”,”ts”:1555044954.38534,”logger”:”controller_at”,
“msg”:”Zakończono przetwarzanie
harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Result”:”2019-04-12 04:56:00 +0000 UTC with a diff of 5.614691s”}
{“level”:”info”,”ts”:1555044964.518383,”logger”:”controller_at”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555044964.5184839,”logger”:”controller_at”,
“msg”:”Etap: PENDING”,”namespace”:”cnat”,”at”:”example-at”}

{“level”:”info”,”ts”:1555044964.518566,”logger”:”controller_at”,
“msg”:”Sprawdzanie harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Target”:”2019-04-12T04:56:00Z”}
{“level”:”info”,”ts”:1555044964.5186381,”logger”:”controller_at”,
“msg”:”Zakończono przetwarzanie
harmonogramu”,”namespace”:”cnat”,”at”:”example-at”,
“Result”:”2019-04-12 04:56:00 +0000 UTC with a diff of -4.518596s”}
{“level”:”info”,”ts”:1555044964.5186849,”logger”:”controller_at”,
“msg”:”Nadszedł czas!”,”namespace”:”cnat”,”at”:”example-at”,
“Gotowy do wykonania”:”echo SUPER”}
{“level”:”info”,”ts”:1555044964.642559,”logger”:”controller_at”,

“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}


{“level”:”info”,”ts”:1555044964.642622,”logger”:”controller_at”,
“msg”:”Etap: RUNNING”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555044964.911037,”logger”:”controller_at”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555044964.9111192,”logger”:”controller_at”,
“msg”:”Etap: RUNNING”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555044966.038684,”logger”:”controller_at”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}

{“level”:”info”,”ts”:1555044966.038771,”logger”:”controller_at”,
“msg”:”Etap: DONE”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555044966.708663,”logger”:”controller_at”,
“msg”:”=== uzgadnianie stanu At”,”namespace”:”cnat”,”at”:”example-at”}
{“level”:”info”,”ts”:1555044966.708749,”logger”:”controller_at”,
“msg”:”Etap: DONE”,”namespace”:”cnat”,”at”:”example-at”}
...
Widać tu trzy etapy działania operatora: stan PENDING do czasu 1555044964.518566, następnie
stan RUNNING, a na końcu stan DONE.
Aby sprawdzić poprawność funkcji w niestandardowym kontrolerze i wynik operacji, wpisz
następujące instrukcje:
$ kubectl get at,pods
NAME AGE
at.cnat.programming-kubernetes.info/example-at 23m

NAME READY STATUS RESTARTS AGE


pod/example-at-pod 0/1 Completed 0 46s

$ kubectl logs example-at-pod

SUPER
Gdy skończysz tworzyć niestandardowy kontroler w trybie lokalnym (tak jak w tym
przykładzie), zapewne zechcesz wygenerować na jego podstawie obraz kontenera. Obraz
kontenera z tym niestandardowym kontrolerem można później wykorzystać np. w instalacji
Kubernetesa. Aby wygenerować obraz kontenera, możesz zastosować następujące polecenie:
$ operator-sdk build $REGISTRY/PROJECT/IMAGE
Oto kilka innych źródeł, gdzie znajdziesz więcej informacji i przykładów związanych z
narzędziem Operator SDK:

„A Complete Guide to Kubernetes Operator SDK” (http://bit.ly/2RqkGSf) autorstwa


Toadera Sebastiana, opublikowany w serwisie BanzaiCloud.
Artykuł z bloga Roba Szumskiego „Building a Kubernetes Operator for Prometheus and
Thanos” (http://bit.ly/2KvgHmu).
„Kubernetes Operator Development Guidelines for Improved Usability”
(http://bit.ly/31P7rPC) opracowany przez firmę CloudARK, opublikowany w serwisie
ITNEXT.

W podsumowaniu rozdziału przyjrzyjmy się kilku innym sposobom pisania niestandardowych


kontrolerów i operatorów.

Inne podejścia
Oprócz opisanych już rozwiązań (lub w połączeniu z nimi) warto poznać następujące projekty,
biblioteki i narzędzia:
Metacontroller (https://metacontroller.app)

Zasada działania Metacontrollera oparta jest na udostępnianiu deklaratywnej specyfikacji


stanu i zmian z komunikacją w formacie JSON oraz pętlą uzgadniania stanu uruchamianą
na podstawie poziomu. Kod przyjmuje dane w formacie JSON opisujące zaobserwowany
stan i zwraca dane w tym samym formacie opisujące oczekiwany stan. Jest to przydatne
przede wszystkim do szybkiego automatyzowania zadań w dynamicznych językach
skryptowych takich jak Python i JavaScript. Obok tworzenia prostych kontrolerów
Metacontroller umożliwia łączenie API w abstrakcje wyższego poziomu (zob. np. projekt
BlueGreenDeployment — http://bit.ly/31KNTfi).
KUDO (https://kudo.dev)
Narzędzie KUDO jest podobne do Metacontrollera i umożliwia deklaratywne tworzenie
operatorów Kubernetesa z uwzględnieniem całego cyklu życia aplikacji. W skrócie można
napisać, że przypomina używanie produktów firmy Mesoshpere opartych na platformie
Apache Mesos, ale w świecie Kubernetesa. KUDO to oprogramowanie oparte na
specyficznej filozofii, ale jest łatwe w użyciu i wymaga małej (lub nawet zerowej) ilości
kodu. Wystarczy utworzyć kolekcję manifestów Kubernetesa z wbudowaną logiką, aby
zdefiniować, jakie operacje i kiedy mają być wykonywane.
Rook operator kit (http://bit.ly/2J34faw)
Jest to popularna biblioteka do implementowania operatorów. Powstała na podstawie
narzędzia Rook operator, ale obecnie jest rozwijana jako odrębny, niezależny projekt.
ericchiang/k8s (http://bit.ly/2ZHc5h0)
Jest to uproszczony klient w języku Go autorstwa Erica Chianga z obsługą formatu protobuf
z Kubernetesa. Działa podobnie jak oficjalne narzędzie client-go z Kubernetesa, ale
importuje tylko dwie zewnętrzne zależności. Choć ma pewne ograniczenia (np. w obszarze
konfiguracji dostępu do klastra; http://bit.ly/2ZBQIxh), jest prostym w użyciu pakietem
języka Go.
kutil (http://bit.ly/2Fq3ojh)
Firma AppsCode udostępnia w projekcie kutil dodatki dla klienta client-go.

Rozwiązania oparte na klientach z wiersza poleceń


Innym podejściem, stosowanym po stronie klienta głównie w ramach eksperymentów i
testów, jest programowe używanie narzędzia kubectl (zob. np. bibliotekę kubecuddler —
http://bit.ly/2L3CDoi).

Choć w tej książce koncentrujemy się na pisaniu operatorów za pomocą języka


Go, możesz używać do tego także innych języków. Dwa znane przykładowe
narzędzia, które to umożliwiają, to Shell-operator firmy Flant
(http://bit.ly/2ZxkZ0m), pozwalający pisać operatory w postaci dobrych,
sprawdzonych skryptów powłoki, oraz Kopf (od ang. Kubernetes operators
framework) firmy Zalando (http://bit.ly/2WRXU6Q), platforma i biblioteka
Pythona.

Na początku rozdziału wspomnieliśmy, że dziedzina operatorów szybko się zmienia i coraz


większa liczba praktyków dzieli się swoją wiedzą w postaci kodu i zalecanych technik. Warto
więc śledzić ten obszar pod kątem nowych narzędzi. Koniecznie przeglądaj fora i inne źródła w
internecie, np. kanały #kubernetes-operators, #kubebuilder i #client-go-docs w
przestrzeni roboczej Kubernetes Slack, aby poznać nowe podejścia i/lub przedyskutować
problemy oraz uzyskać pomoc, gdy nie będziesz umiał sobie z czymś poradzić.

Wnioski i przyszłe kierunki rozwoju


Nie wiadomo jeszcze, które techniki pisania operatorów okażą się najbardziej popularne i
najczęściej stosowane. W ekosystemie Kubernetesa nad niestandardowymi zasobami i
kontrolerami pracuje kilka grup SIG. Najważniejszą z nich jest grupa SIG API Machinery
(http://bit.ly/2RuTPEp), odpowiedzialna za niestandardowe zasoby i kontrolery oraz projekt
Kubebuilder (http://bit.ly/2I8w9mz). Twórcy pakietu Operator SDK zintensyfikowali prace nad
dostosowaniem go do API Kubebuildera, dlatego podejścia wykorzystujące te narzędzia w
dużym stopniu się pokrywają.

Podsumowanie
W tym rozdziale przyjrzeliśmy się różnym narzędziom umożliwiającym wydajniejsze pisanie
niestandardowych kontrolerów i operatorów. W przeszłości jedynym rozwiązaniem było
wzorowanie się na projekcie sample-controller. Jednak dzięki narzędziom Kubebuilder i
Operator SDK obecnie dostępne są dwie techniki, które pozwalają skupić się na logice
biznesowej niestandardowego kontrolera zamiast na szablonowym kodzie. Na szczęście te dwa
narzędzia współdzielą wiele API i dużo kodu, dlatego zmiana jednego na drugie nie powinna
nastręczać dużych trudności.
Pora zobaczyć, jak udostępniać efekty naszej pracy, czyli jak tworzyć pakiety i dystrybuować
napisane kontrolery.

1 Prezentujemy tu tylko istotne fragmenty. Sama funkcja zawiera dużo szablonowego kodu,
który nie jest istotny w kontekście omawianych zagadnień.
Rozdział 7. Udostępnianie
kontrolerów i operatorów
Gdy już zaznajomiłeś się z tworzeniem niestandardowych kontrolerów, pora przejść do
udostępniania swoich niestandardowych kontrolerów i operatorów w środowisku
produkcyjnym. W tym rozdziale omawiamy operacyjne aspekty kontrolerów i operatorów,
pokazujemy, jak tworzyć pakiety zawierające takie komponenty, a także przedstawiamy dobre
praktyki uruchamiania kontrolerów w środowisku produkcyjnym. Dowiesz się też, jak zadbać o
to, by Twoje punkty rozszerzeń nie zaszkodziły bezpieczeństwu lub wydajności klastra
Kubernetesa.

Zarządzanie cyklem życia i pakowanie


W tym podrozdziale omówimy zarządzanie cyklem życia operatorów. Wyjaśnimy, jak spakować i
udostępnić kontroler lub operator, a także jak wprowadzać aktualizacje. Gdy będziesz gotów
udostępnić operator użytkownikom, musisz umożliwić im zainstalowanie go. Wymaga to
spakowania potrzebnych artefaktów, np. manifestów w formacie YAML definiujących plik
binarny kontrolera (zwykle w formie instalacji dla Kubernetesa), a także definicji CRD i
zasobów związanych z bezpieczeństwem (takich jak konta usług i niezbędne uprawnienia
systemu RBAC). Gdy u docelowych użytkowników będzie już działała określona wersja
operatora, przydatny będzie mechanizm do aktualizowania kontrolera, najlepiej z obsługą
wersjonowania i brakiem przestojów.
Zacznijmy od najprostszych zadań: pakowania i udostępniania kontrolerów, aby użytkownicy
mogli instalować je w łatwy sposób.

Pakowanie — trudności
W Kubernetesie zasoby są zdefiniowane z użyciem manifestów, zwykle w formacie YAML. Jest
to niskopoziomowy interfejs do deklarowania stanu zasobów. Jednak pliki manifestów mają
ograniczenia. Najważniejsze w kontekście pakowania aplikacji kontenerowych jest to, że
manifesty w formacie YAML są statyczne. Oznacza to, że wszystkie wartości w pliku YAML są
zapisane na stałe. Dlatego jeśli chcesz np. zmienić obraz kontenera w manifeście instalacji
(http://bit.ly/2WZ1uRD), musisz utworzyć nowy manifest.

Przyjrzyj się konkretnemu przykładowi. Załóżmy, że poniższa instalacja Kubernetesa jest


zapisana w manifeście mycontroller.yaml, reprezentującym niestandardowy kontroler, który
użytkownicy mają instalować:

apiVersion: apps/v1beta1
kind: Deployment

metadata:
name: mycustomcontroller
spec:
replicas: 1

template:
metadata:

labels:

app: customcontroller
spec:

containers:
- name: thecontroller

image: example/controller:0.1.0
ports:

- containerPort: 9999
env:

- name: REGION
value: eu-west-1

Załóżmy, że zmienna środowiskowa REGION definiuje określone cechy środowiska


uruchomieniowego dla kontrolera, np. dostępność innych usług, takich jak zarządzany system
service mesh. Oznacza to, że choć wartość domyślna eu-west-1 może być sensowna,
użytkownicy mogą i zapewne będą ją zmieniać zgodnie z własnymi preferencjami lub polityką
firmy.
Manifest mycontroller.yaml jest plikiem statycznym, w którym wszystkie wartości są
zdefiniowane w momencie jego tworzenia, a klienty takie jak kubectl nie mają wbudowanej
obsługi modyfikowalnych fragmentów manifestów. Jak więc umożliwić użytkownikom
podawanie wartości zmiennych lub zastępowanie istniejących wartości w czasie wykonywania
programu? W jaki sposób w opisanym rozwiązaniu użytkownik może przypisać do zmiennej
REGION np. wartość us-east-2, gdy instaluje kontroler za pomocą polecenia kubectl apply?

Aby przezwyciężyć w Kubernetesie ograniczenia statycznych, podawanych w procesie


budowania manifestów w formacie YAML, można tworzyć szablony manifestów (np. za pomocą
menedżera Helm) lub w inny sposób umożliwiać zmianę danych (np. przy użyciu narzędzia
Kustomize) na podstawie wartości od użytkowników lub właściwości z czasu wykonywania
kodu.

Helm
Helm (https://helm.sh), nazywany przez jego autorów najlepszym menedżerem pakietów dla
Kubernetesa, został pierwotnie opracowany przez firmę Deis, obecnie jest projektem fundacji
CNCF (ang. Cloud Native Computing Foundation; https://www.cncf.io), a główni uczestnicy
projektu pracują dla firm Microsoft, Google i Bitnami (obecnie należącej do VMware).

Helm pomaga instalować i aktualizować aplikacje dla Kubernetesa za pomocą definiowania i


wprowadzania tak zwanych pakietów chart, które są sparametryzowanymi manifestami w
formacie YAML. Oto fragment przykładowego szablonu pakietu chart (http://bit.ly/2XmLk3R):

apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include “flagger.fullname” . }}

...

spec:

replicas: 1
strategy:

type: Recreate

selector:

matchLabels:

app.kubernetes.io/name: {{ template “flagger.name” . }}


app.kubernetes.io/instance: {{ .Release.Name }}

template:

metadata:

labels:
app.kubernetes.io/name: {{ template “flagger.name” . }}

app.kubernetes.io/instance: {{ .Release.Name }}

spec:

serviceAccountName: {{ template “flagger.serviceAccountName” . }}


containers:

- name: flagger

securityContext:

readOnlyRootFilesystem: true

runAsUser: 10001
image: “{{ .Values.image.repository }}:{{ .Values.image.tag }}”

Widać tu, że zmienne są kodowane w formacie {{ ._Tu.jakaś.wartość_ }}, odpowiadającym


szablonom języka Go (http://bit.ly/2N2Q3DW).

Aby zainstalować pakiet chart, można uruchomić polecenie helm install. Helm udostępnia
kilka sposobów wyszukiwania i instalowania pakietów chart, jednak najłatwiejszy polega na
użyciu jednego z oficjalnych stabilnych pakietów chart:

# Pobieranie najnowszej listy pakietów chart:

$ helm repo update

# Instalowanie systemu MySQL:

$ helm install stable/mysql

Released smiling-penguin
# Wyświetlanie uruchomionych aplikacji:
$ helm ls

NAME VERSION UPDATED STATUS CHART

smiling-penguin 1 Wed Sep 28 12:59:46 2016 DEPLOYED mysql-0.1.0

# Usuwanie aplikacji:
$ helm delete smiling-penguin

Removed smiling-penguin

Aby spakować kontroler, musisz utworzyć pakiet chart narzędzia Helm i gdzieś go opublikować
— domyślnie w publicznym indeksowanym repozytorium udostępnianym w serwisie Helm Hub
(https://hub.helm.sh; zob. rysunek 7.1).

Rysunek 7.1. Na zrzucie z serwisu Helm Hub widać publicznie dostępne pakiety chart narzędzia
Helm

Aby znaleźć więcej informacji na temat tworzenia pakietów chart narzędzia Helm, w wolnym
czasie zapoznaj się z następującymi źródłami:

Znakomity artykuł „How to Create Your First Helm Chart” w witrynie Bitnami
(http://bit.ly/2ZIlODJ).
Tekst „Using S3 as a Helm Repository” (http://bit.ly/2KzwLDY) dotyczący przechowywania
pakietów chart narzędzia Helm wewnątrz organizacji.
Oficjalna dokumentacja narzędzia Helm: „The Chart Best Practices Guide”
(http://bit.ly/31GbayW).

Helm jest popularny po części z powodu łatwości użytkowania. Jednak zdaniem części osób
obecna architektura narzędzia Helm nie jest w pełni bezpieczna (http://bit.ly/2WXM5vZ). Dobra
wiadomość jest taka, że społeczność aktywnie pracuje nad rozwiązaniem wykrytych problemów.

Kustomize
Kustomize (https://kustomize.io) umożliwia deklaratywne modyfikowanie konfiguracji plików
manifestu w Kubernetesie zgodnie ze znanym API Kubernetesa. Narzędzie to udostępniono w
połowie 2018 r. (http://bit.ly/2L5Ec5f), a obecnie jest rozwijane jako projekt grupy SIG CLI
przez społeczność Kubernetesa.

Omawiane narzędzie możesz zainstalować na swoim komputerze jako samodzielny produkt


(http://bit.ly/2Y3JeCV). Jeśli używasz nowej wersji interfejsu kubectl (nowszej niż 1.14),
Kustomize jest udostępniane (http://bit.ly/2IEYqRG) razem z tym programem i można je
aktywować za pomocą opcji wiersza poleceń -k.

Kustomize umożliwia dostosowanie zawartości nieprzetworzonych plików manifestu w formacie


YAML bez zmian w pierwotnym manifeście. Jak wygląda to w praktyce? Załóżmy, że chcesz
spakować nasz niestandardowy kontroler cnat. Definiujesz w tym celu plik kustomize.yaml w
mniej więcej takiej zawartości:

imageTags:

- name: quay.io/programming-kubernetes/cnat-operator

newTag: 0.1.0

resources:

- cnat-controller.yaml

Teraz możesz zastosować ten plik do pliku cnat-controller.yaml o następującej treści:

apiVersion: apps/v1beta1
kind: Deployment

metadata:

name: cnat-controller

spec:

replicas: 1

template:

metadata:

labels:

app: cnat
spec:

containers:

- name: custom-controller

image: quay.io/programming-kubernetes/cnat-operator
Zastosuj polecenie kustomize build, a uzyskasz następujące dane wyjściowe (bez
modyfikowania pliku cnat-controller.yaml!):
apiVersion: apps/v1beta1

kind: Deployment

metadata:

name: cnat-controller

spec:

replicas: 1

template:

metadata:
labels:

app: cnat

spec:

containers:

- name: custom-controller

image: quay.io/programming-kubernetes/cnat-operator:0.1.0

Dane wyjściowe z wywołania kustomize build można później wykorzystać np. w wywołaniu
kubectl apply, a wszystkie zmiany (http://bit.ly/2LbCDTr) zostaną automatycznie
uwzględnione.

Bardziej szczegółowe omówienie narzędzia Kustomize i korzystania z niego znajdziesz w


następujących źródłach:

Wpis na blogu Sébastiena Goasguena „Configuring Kubernetes Applications with


kustomize” (http://bit.ly/2JbgJOR).
Artykuł Kevina Davina „Kustomize — The right way to do templating in Kubernetes”
(http://bit.ly/2JpJgPm).
Film „TGI Kubernetes 072: Kustomize and friends” (http://bit.ly/2XoHm6C), gdzie możesz
zobaczyć, jak Joe Beda korzysta z narzędzia Kustomize.

Ponieważ w kubectl wbudowana jest obsługa narzędzia Kustomize, zapewne coraz więcej osób
będzie z niego korzystać. Warto zauważyć, że choć rozwiązuje ono niektóre problemy
(modyfikowanie wartości), inne obszary, np. sprawdzanie poprawności i aktualizacje, mogą
wymagać używania Kustomize razem z językami takimi jak CUE firmy Google
(http://bit.ly/32heAZl).

Aby podsumować temat pakowania, warto przyjrzeć się niektórym innym rozwiązaniom
stosowanym przez praktyków.

Inne techniki pakowania kodu


Oto kilka wartych uwagi alternatyw dla wcześniej opisanych technik pakowania kodu (w sieci
znajdziesz też wiele innych rozwiązań http://bit.ly/2X553FE):

Narzędzia uniksowe
Do modyfikowania wartości w nieprzetworzonych manifestach Kubernetesa możesz używać
w skryptach powłoki różnych narzędzi wiersza poleceń, np. sed, awk i jq. Jest to popularne
podejście i, przynajmniej do czasu udostępnienia Helma, jedno z najczęściej stosowanych
rozwiązań. Istotnym powodem jego popularności jest to, że minimalizuje ilość zależność i
jest stosunkowo przenośne w środowiskach uniksowych.

Tradycyjne systemy zarządzania konfiguracją

Do pakowania i udostępniania operatora możesz zastosować dowolny tradycyjny system


zarządzania konfiguracją, np. Ansible, Puppet, Chef lub Salt.

Języki natywne dla chmury

Nowa generacja języków programowania natywnych dla chmury (http://bit.ly/2Rwh5lu),


takich jak Pulumi i Ballerina, umożliwia m.in. pakowanie natywnych aplikacji dla
Kubernetesa i zarządzanie cyklem ich życia.

ytt (https://get-ytt.io)

Ytt to następne narzędzie do obsługi szablonów w formacie YAML. W ytt używany jest
język, który jest zmodyfikowaną wersją języka Starlark, opracowanego do obsługi
konfiguracji w firmie Google (http://bit.ly/2NaqoJh). Ytt przetwarza semantycznie struktury
YAML, a jego twórcy kładą nacisk na możliwość ponownego wykorzystania kodu.

Ksonnet (https://ksonnet.io)
Jest to narzędzie do zarządzania konfiguracją z manifestów Kubernetesa, pierwotnie
opracowane w firmie Heptio (obecnie należącej do VMware). Ksonnet został uznany za
przestarzały i nie jest już aktywnie rozwijany, dlatego stosuj go na własne ryzyko.
Więcej o opisanych tu rozwiązaniach przeczytasz w artykule Jessego Suena „The State of
Kubernetes Configuration Management: An Unsolved Problem” (http://bit.ly/2N9BkXM).

Po ogólnym omówieniu metod pakowania kodu pora przyjrzeć się najlepszym praktykom
pakowania i udostępniania kontrolerów oraz operatorów.

Najlepsze praktyki z obszaru pakowania kodu


Gdy pakujesz i publikujesz operatory, pamiętaj o opisanych tu najlepszych praktykach. Są one
istotne niezależnie od tego, które narzędzie wybierzesz (Helm, Kustomize, skrypty powłoki itd.):

Odpowiednio skonfiguruj kontrolę dostępu. Zdefiniuj specjalne konto usługi dla kontrolera
z minimalnymi uprawnieniami systemu RBAC. Więcej informacji znajdziesz w punkcie
„Odpowiednie uprawnienia”.
Rozważ zasięg niestandardowego kontrolera. Czy ma zarządzać niestandardowymi
zasobami z jednej przestrzeni nazw, czy z różnych przestrzeni? Zapoznaj się z
wypowiedziami Aleksa Ellisa na Twitterze (http://bit.ly/2ZHd5S7), gdzie opisane są wady i
zalety poszczególnych podejść.
Wykonaj testy i profilowanie dla kontrolera, aby ustalić generowane przez niego
obciążenie i skalowalność. Firma Red Hat opracowała szczegółowy zestaw wymogów wraz
z instrukcjami w poradniku dla autorów operatorów w serwisie OperatorHub
(http://bit.ly/2IEplx4).
Upewnij się, że definicje CRD i kontroler są dobrze udokumentowane, najlepiej za pomocą
wewnątrzwierszowej dokumentacji dostępnej w serwisie GoDoc (https://godoc.org). Za
wzór niech posłuży Ci projekt vank-vaults firmy Banzai Cloud (http://bit.ly/2XtfPVB).

Zarządzanie cyklem życia


Bardziej ogólnym i całościowym zagadnieniem (w porównaniu z pakowaniem i udostępnianiem
kodu) jest zarządzanie cyklem życia. W tej dziedzinie trzeba uwzględnić cały łańcuch
dostarczania oprogramowania — od rozwoju, przez udostępnianie, po aktualizacje — i
maksymalnie go zautomatyzować. Także w tym obszarze pionierem była firma CoreOS (a
później Red Hat), gdzie tę samą logikę, która doprowadziła do powstania operatorów,
zastosowano do zarządzania cyklem ich życia. Aby zainstalować i później aktualizować
niestandardowy kontroler z operatora, używany jest operator, który potrafi, no cóż, obsługiwać
operatory. Częścią platformy Operator Framework (obejmującej też opisane w punkcie
„Operator SDK” narzędzie Operator SDK) jest menedżer Operator Lifecycle Manager (OLM;
http://bit.ly/2HIfDcR).
Jimmy Zelinskie, jedna z głównych osób odpowiedzialnych za menedżer OLM, opisuje to
narzędzie tak (http://bit.ly/2KEfoSu):
OLM wykonuje wiele zadań za twórców operatorów, a dodatkowo rozwiązuje ważny
problem, nad którym mało kto się zastanawia — jak skutecznie zarządzać
pierwszoklasowymi rozszerzeniami Kubernetesa w czasie ich użytkowania.
W skrócie można napisać, że OLM umożliwia deklaratywne instalowanie i aktualizowanie
operatorów oraz ich zależności, w tym komplementarnych narzędzi do zarządzania pakietami
(np. narzędzia Helm). To od programisty zależy, czy zechce stosować kompletne rozwiązanie
OLM, czy utworzyć doraźne narzędzie na potrzeby wersjonowania i aktualizowania kodu.
Niezależnie od tego należy jednak zastosować jakąś strategię. W niektórych sytuacjach, np. w
procesie certyfikacji w serwisie Operator Hub (http://bit.ly/2KBlymy) firmy Red Hat,
odpowiednie narzędzia są nie tylko zalecane, ale nawet wymagane we wszystkich bardziej
skomplikowanych scenariuszach instalacji — nawet jeśli nie chcesz udostępniać swoich
operatorów w tym serwisie.

Instalacje gotowe do użytku w


środowisku produkcyjnym
W tym podrozdziale objaśniamy, jak przygotować niestandardowe kontrolery i operatory do
użytku w środowisku produkcyjnym. Oto ogólna lista kontrolna:

Użyj instalacji Kubernetesa (http://bit.ly/2q7vR7Y) lub obiektów typu DeamonSet do


nadzorowania niestandardowego kontrolera, aby był automatycznie ponownie
uruchamiany po awariach (które z pewnością będą miały miejsce).
Opracuj kontrolę stanu za pomocą specjalnych punktów końcowych przeznaczonych do
sprawdzania aktywności i gotowości do pracy. To w połączeniu z poprzednim krokiem
sprawi, że kod będzie bardziej odporny na problemy.
Rozważ zastosowanie modelu lider-obserwator/rezerwa, aby mieć pewność, że nawet po
awarii poda z kontrolerem inna jednostka przejmie jego zadania. Warto jednak zauważyć,
że synchronizowanie stanu nie jest prostym zadaniem.
Udostępnij zasoby do kontroli dostępu, np. konto usługi i role, stosując zasadę
minimalnych potrzebnych uprawnień. Szczegółowe informacje zawiera punkt
„Odpowiednie uprawnienia”.
Rozważ automatyzację procesu budowania, w tym testów. Więcej wskazówek znajdziesz w
punkcie „Zautomatyzowany proces budowania i testowania”.
Aktywnie rozwijaj system monitorowania i rejestrowania zdarzeń. Z punktu
„Niestandardowe kontrolery i obserwowalność” dowiesz się, co i jak należy robić.

Zachęcamy też do zapoznania się ze wspomnianym już artykułem „Kubernetes Operator


Development Guidelines for Improved Usability” (http://bit.ly/31P7rPC), gdzie znajdziesz więcej
informacji.
Odpowiednie uprawnienia
Niestandardowy kontroler jest częścią warstwy kontroli w Kubernetesie. Musi wczytywać stan
zasobów, tworzyć zasoby w Kubernetesie (i czasem poza nim) oraz informować o stanie
własnych zasobów. Do tego niestandardowy kontroler potrzebuje odpowiedniego zestawu
uprawnień zapisanego w formie ustawień systemu RBAC. Odpowiedni dobór tych ustawień jest
tematem tego punktu.
Zacznijmy od podstaw — na potrzeby uruchamiania swoich kontrolerów zawsze powinieneś
tworzyć specjalne konto usługi (http://bit.ly/2RwoSQp). Oznacza to, że nigdy nie powinieneś
[1]
stosować konta default z danej przestrzeni nazw .

Aby ułatwić sobie pracę, możesz zdefiniować rolę ClusterRole z niezbędnymi regułami
systemu RBAC i obiekt RoleBinding wiążący tę rolą z określoną przestrzenią nazw. Pozwala to
wykorzystać tę samą rolę w różnych przestrzeniach nazw, co wyjaśniono w punkcie Using
RBAC Authorization w dokumentacji (http://bit.ly/2LdVFsj).

Zgodnie z zasadą minimalizowania uprawnień należy przypisać kontrolerowi wyłącznie


uprawnienia konieczne do wykonywania jego zadań. Na przykład, jeśli kontroler zarządza tylko
podami, nie ma potrzeby nadawać mu uprawnień do wyświetlania lub tworzenia instalacji albo
usług. Upewnij się też, że kontroler nie będzie instalował definicji CRD i/lub webhooków do
kontroli dostępu. Oznacza to, że kontroler nie powinien mieć uprawnień do zarządzania
definicjami CRD i webhookami.

Typowe narzędzia do tworzenia niestandardowych kontrolerów, opisane w rozdziale 6., zwykle


udostępniają gotowe funkcje do generowania reguł RBAC. Na przykład Kubebuilder generuje
obok operatora pokazane poniżej pliki systemu RBAC (http://bit.ly/2RRCyFO):

$ ls -al rbac/
total 40
drwx------ 7 mhausenblas staff 224 12 Apr 09:52 .

drwx------ 7 mhausenblas staff 224 12 Apr 09:55 ..


-rw------- 1 mhausenblas staff 280 12 Apr 09:49 auth_proxy_role.yaml

-rw------- 1 mhausenblas staff 257 12 Apr 09:49 auth_proxy_role_binding.yaml


-rw------- 1 mhausenblas staff 449 12 Apr 09:49 auth_proxy_service.yaml

-rw-r--r-- 1 mhausenblas staff 1044 12 Apr 10:50 rbac_role.yaml


-rw-r--r-- 1 mhausenblas staff 287 12 Apr 10:50 rbac_role_binding.yaml

Gdy przyjrzysz się automatycznie wygenerowanym rolom i powiązaniom systemu RBAC,


zobaczysz precyzyjną konfigurację. Oto zawartość pliku rbac_role.yaml:
apiVersion: rbac.authorization.k8s.io/v1

kind: ClusterRole
metadata:

creationTimestamp: null
name: manager-role
rules:

- apiGroups:
- apps
resources:
- deployments

verbs: [“get”, “list”, “watch”, “create”, “update”, “patch”, “delete”]


- apiGroups:
- apps

resources:
- deployments/status

verbs: [“get”, “update”, “patch”]


- apiGroups:

- cnat.programming-kubernetes.info
resources:

- ats
verbs: [“get”, “list”, “watch”, “create”, “update”, “patch”, “delete”]
- apiGroups:

- cnat.programming-kubernetes.info
resources:

- ats/status
verbs: [“get”, “update”, “patch”]

- apiGroups:
- admissionregistration.k8s.io

resources:
- mutatingwebhookconfigurations
- validatingwebhookconfigurations

verbs: [“get”, “list”, “watch”, “create”, “update”, “patch”, “delete”]


- apiGroups:

- “”
resources:

- secrets
verbs: [“get”, “list”, “watch”, “create”, “update”, “patch”, “delete”]
- apiGroups:

- “”
resources:

- services
verbs: [“get”, “list”, “watch”, “create”, “update”, “patch”, “delete”]
Gdy przyjrzysz się uprawnieniom wygenerowanym przez Kubebuildera dla wersji v1,
[2]
prawdopodobnie będziesz zaskoczony . My byliśmy. Zgodnie z najlepszymi praktykami
kontroler — jeśli nie zachodzą specjalne okoliczności — nie powinien mieć możliwości:

Zapisywania zasobów, które w kodzie są tylko wczytywane. Na przykład, jeśli tylko


obserwujesz usługi i instalacje, usuń dla roli dostęp do operacji create, update, patch i
delete.
Dostępu do wszystkich tajnych danych. Zawsze należy udostępniać tylko minimalny
zestaw niezbędnych tajnych danych.
Zapisywania konfiguracji: MutatingWebhookConfigurations lub
ValidatingWebhookConfigurations. To uprawnienie pozwala uzyskać dostęp do
wszystkich zasobów w klastrze.
Zapisywania definicji CustomResourceDefinition. Należy zauważyć, że w pokazanej roli
dla klastra nie jest to dozwolone, jednak warto wspomnieć o tej kwestii. Tworzenie
definicji CRD powinno odbywać się w odrębnym procesie, a nie w samym kontrolerze.
Zapisywanie podzasobu /status (zob. punkt „Podzasoby”) zewnętrznych zasobów, którymi
dany kontroler nie zarządza. Na przykład tu kontroler cnat nie zarządza instalacjami i nie
powinny znajdować się one w jego zasięgu.

Kubebuilder oczywiście nie potrafi precyzyjnie zrozumieć, jak działa kod kontrolera. Dlatego
nie jest zaskoczeniem, że wygenerowane reguły systemu RBAC pozwalają na zdecydowanie
zbyt wiele. Zalecamy dokładne sprawdzanie uprawnień i ograniczanie ich do minimum na
podstawie przedstawionej listy kontrolnej.

Możliwość odczytu wszystkich sekretów w systemie zapewnia kontrolerowi


dostęp do wszystkich tokenów kont usługi. To tak, jakby możliwy był dostęp do
wszystkich haseł w klastrze. Możliwość zapisu konfiguracji
MutatingWebhookConfigurations lub ValidatingWebhookConfigurations
pozwala przechwytywać i modyfikować wszystkie żądania API w systemie.
Otwiera to drzwi do instalowania rootkitów w klastrach Kubernetesa. Obie
opisane możliwości są w oczywisty sposób bardzo niebezpieczne i uważane za
antywzorce, dlatego najlepiej jest ich unikać.
Aby uniknąć przyznawania zbyt wielu uprawnień — czyli by ograniczyć prawa
dostępu do tych, które są bezwzględnie konieczne — rozważ zastosowanie
narzędzia audit2rbac (http://bit.ly/2IDW1qm). To narzędzie używa dzienników
audytu do wygenerowania odpowiedniego zestawu uprawnień, co skutkuje
bezpieczniejszą konfiguracją i mniejszymi problemami w przyszłości.

A oto zawartość pliku rbac_role_binding.yaml:


apiVersion: rbac.authorization.k8s.io/v1

kind: ClusterRoleBinding
metadata:
creationTimestamp: null

name: manager-rolebinding
roleRef:

apiGroup: rbac.authorization.k8s.io
kind: ClusterRole

name: manager-role
subjects:
- kind: ServiceAccount
name: default

namespace: system
Więcej o najlepszych praktykach dotyczących systemu RBAC i związanych z nim narzędziach
znajdziesz w serwisie RBAC.dev (https://rbac.dev). Jest to witryna poświęcona systemowi RBAC
w Kubernetesie. Teraz przejdźmy do zagadnień związanych z testami i wydajnością
niestandardowych kontrolerów.

Zautomatyzowany proces budowania i testowania


Zalecaną praktyką w obszarze rozwiązań natywnych dla chmury jest opracowanie
zautomatyzowanego procesu budowania niestandardowego kontrolera. Taki mechanizm jest
zwykle nazywany ciągłą integracją i obejmuje testy jednostkowe, testy integracyjne, budowanie
obrazu kontenera, a czasem nawet testy sanity i testy dymne (http://bit.ly/1Z9jXp5).
Organizacja CNCF (ang. Cloud Native Computing Foundation) przechowuje interaktywną listę
(http://bit.ly/2J2vy4L) wielu otwartych narzędzi do ciągłej integracji.
W procesie budowania kontrolera pamiętaj o tym, że powinien on wykorzystywać jak najmniej
zasobów obliczeniowych, a jednocześnie obsługiwać jak największą liczbę klientów. Każdy
zasób niestandardowy oparty na opracowanej przez Ciebie definicji CRD można tu uznać za
klienta. Jak jednak stwierdzić, ile zasobów on zużywa, jak dobrze się skaluje, a także czy i w
jakich miejscach powoduje wyciekanie pamięci?

Po uzyskaniu stabilnej wersji niestandardowego kontrolera można i należy przeprowadzić


zestaw testów. Mogą one obejmować następujące (jak również inne) elementy:

Testy związane z wydajnością, które znajdują się w samym Kubernetesie


(http://bit.ly/2X556g8), a także w narzędziu kboom (http://bit.ly/2Fuy4zU), mogą
udostępniać dane związane ze skalowaniem i zużyciem zasobów.
Testy wytrzymałościowe, np. używane w Kubernetesie (http://bit.ly/2KBZmZc), dotyczą
długoterminowego użytkowania aplikacji — od kilku godzin do dni. Celem takich testów
jest wykrycie wyciekania zasobów, np. plików lub pamięci roboczej.

Najlepszą praktyką jest używanie takich testów w procesie ciągłej integracji. Oznacza to, że
należy od początku automatyzować proces budowania niestandardowego kontrolera oraz
testowania i pakowania kodu. Jeśli chcesz przyjrzeć się przykładowej konfiguracji, zapoznaj się
ze znakomitym tekstem Marko Mudrinića „Spawning Kubernetes Clusters in CI for Integration
and E2E tests” (http://bit.ly/2FwN1RU).

Teraz zajmijmy się najlepszymi praktykami, które ułatwiają skuteczne rozwiązywanie


problemów — wbudowaną obsługą obserwowalności.

Niestandardowe kontrolery i obserwowalność


W tym punkcie zajmiemy się aspektami obserwowalności niestandardowych kontrolerów, a
konkretnie — rejestrowaniem zdarzeń i monitorowaniem.

Rejestrowanie zdarzeń
Upewnij się, że zapewniona jest wystarczająca ilość rejestrowanych informacji, aby umożliwić
rozwiązywanie problemów (http://bit.ly/2WXD85D). Jak zwykle w środowisku kontenerowym
rejestrowane informacje są przesyłane do standardowego wyjścia (stdout), gdzie mogą być
używane albo w postaci zagregowanej, albo na poziomie podów z użyciem polecenia kubectl
logs. Zagregowane dane mogą być udostępniane za pomocą rozwiązań specyficznych dla
dostawców chmury, np. narzędzia Stackdriver w chmurze Google Cloud lub narzędzia
CloudWatch w usłudze AWS. Dostępne są też niestandardowe zestawy rozwiązań, np.
Elasticsearch-Logstash-Kibana i Elasticsearch-Fluentd-Kibana. Receptury z tego obszaru
znajdziesz w książce Kubernetes Cookbook (http://bit.ly/2FTgJzk) Sébastiena Goasguena i
Michaela Hausenblasa (O’Reilly).

Przyjrzyj się przykładowemu fragmentowi z dziennika naszego niestandardowego kontrolera


cnat:
{ “level”:”info”,

“ts”:1555063927.492718,
“logger”:”controller”,

“msg”:”=== uzgadnianie stanu At” }


{ “level”:”info”,

“ts”:1555063927.49283,
“logger”:”controller”,

“msg”:”Etap: PENDING” }
{ “level”:”info”,
“ts”:1555063927.492857,

“logger”:”controller”,
“msg”:”Sprawdzanie harmonogramu” }

{ “level”:”info”,
“ts”:1555063927.492915,

“logger”:”controller”,
“msg”:”Zakończono przetwarzanie harmonogramu” }

Przejdźmy do tego, jak rejestrować zdarzenia. Zwykle preferujemy rejestrowanie


ustrukturyzowane (http://bit.ly/31TPRu3) i konfigurowalne poziomy rejestrowania —
przynajmniej debug i info. W kodzie bazowym Kubernetesa używane są przede wszystkim dwa
rozwiązania. Powinieneś rozważyć używanie jednego z nich, chyba że masz dobry powód do
tego, by korzystać z innej metody:

Interfejs logger (dostępny np. w pliku httplog.go, http://bit.ly/2WWV54w, w połączeniu z


typem konkretnym respLogger) pozwala rejestrować informacje takie jak status i błędy.
Narzędzie klog (http://bit.ly/31OJxUu), odgałęzienie projektu glog firmy Google, to
ustrukturyzowany logger używany w Kubernetesie. Choć narzędzie to ma słabe punkty,
warto je poznać.

A co należy rejestrować? Upewnij się, że rejestrujesz szczegółowe informacje związane ze


standardowym działaniem logiki biznesowej. Na przykład w implementacji kontrolera cnat
opartej na narzędziu Operator SDK można w pliku at_controller.go (http://bit.ly/2Fpo5Mi)
skonfigurować logger w następujący sposób:
reqLogger := log.WithValues(“namespace”, request.Namespace, “at”,
request.Name)
Następnie w logice biznesowej w funkcji Reconcile(request reconcile.Request) należy
zastosować taki kod:
case cnatv1alpha1.PhasePending:

reqLogger.Info(“Etap: PENDING”)
// Jeśli polecenie nie zostało jeszcze wykonane, trzeba sprawdzić, czy
// nie nadszedł czas jego uruchomienia:
reqLogger.Info(“Sprawdzanie harmonogramu”, “Target”,
instance.Spec.Schedule)

// Sprawdzanie, czy nie nadszedł czas uruchomienia polecenia.


// Margines błędu to dwie sekundy:
d, err := timeUntilSchedule(instance.Spec.Schedule)
if err != nil {

reqLogger.Error(err, “Błąd przetwarzania harmonogramu”)


// Błąd odczytu harmonogramu. Należy poczekać na naprawienie problemu.
return reconcile.Result{}, err
}
reqLogger.Info(“Zakończono przetwarzanie harmonogramu”, “Result”, “diff”,

fmt.Sprintf(“%v”, d))
if d > 0 {
// Nie nadszedł jeszcze czas na wykonanie polecenia. Należy poczekać do
zaplanowanego czasu.
return reconcile.Result{RequeueAfter: d}, nil

}
reqLogger.Info(“Nadszedł czas!”, “Gotowy do wykonania”,
instance.Spec.Command)
instance.Status.Phase = cnatv1alpha1.PhaseRunning
Ten fragment kodu w języku Go pozwala zobaczyć, co należy rejestrować, a przede wszystkim
kiedy używać wywołań regLogger.Info i reqLogger.Error.
Po omówieniu podstaw rejestrowania zdarzeń pora przejść do powiązanego zagadnienia —
wskaźników!

Monitorowanie, instrumentacja i audyty


Prometheus (https://prometheus.io) to znakomite otwarte, dostosowane do kontenerów
narzędzie do monitorowania, które można stosować w różnych środowiskach (w środowisku
lokalnym i w chmurze). Zgłaszanie alertów dla każdego zdarzenia jest niepraktyczne, dlatego
warto zastanowić się nad tym, które jednostki muszą być informowane o poszczególnych
zdarzeniach. Możesz np. stosować politykę, zgodnie z którą zdarzenia dotyczące węzłów i
przestrzeni nazw są obsługiwane przez administratorów infrastruktury, a administratorzy
przestrzeni nazw lub programiści otrzymują informacje o zdarzeniach z poziomu podów.
Najbardziej popularnym narzędziem do wizualizowania zebranych wskaźników jest z pewnością
Grafana (https://grafana.com). Na rysunku 7.2 pokazane są przykładowe wskaźniki z
Prometheusa zwizualizowane w narzędziu Grafana (zrzut pochodzi z dokumentacji
Prometheusa; http://bit.ly/2Oi4YcA).
Rysunek 7.2. Wskaźniki z Prometheusa zwizualizowane w narzędziu Grafana

Jeśli używasz systemu service mesh (np. opartych na pośredniku Envoy — https://envoy.com —
Istio i App Mesh lub narzędzia Linkerd), instrumentacja zwykle jest od razu gotowa lub
wymaga tylko niewielkiej pracy (konfiguracji). W przeciwnym razie musisz zastosować
odpowiednie biblioteki, np. dostępne w Prometheusu (http://bit.ly/2xb2qmv), aby udostępnić we
własnym kodzie odpowiednie wskaźniki. W tym kontekście możesz zapoznać się z nowym
projektem Service Mesh Interface (SMI; https://smi-spec.io), powstałym na początku 2019 r.,
który ma zapewniać dla systemów service mesh standardowy interfejs oparty na
niestandardowych zasobach i kontrolerach.
Inną przydatną funkcją dostępną w Kubernetesie za pomocą serwera API są audyty
(http://bit.ly/2O4WBkL). Umożliwiają one rejestrowanie sekwencji operacji wpływających na
klaster. W polityce audytowania można stosować różne strategie — od nierejestrowania zdarzeń
po rejestrowanie metadanych na temat zdarzeń, treści żądań i treści odpowiedzi. Możesz też
używać na zapleczu prostego mechanizmu rejestrowania zdarzeń lub zastosować webhooki do
zintegrowania rozwiązania z niezależnymi systemami.

Podsumowanie
W tym rozdziale skupiliśmy się na tym, jak przygotować operatory do użytku w środowisku
produkcyjnym. Omówiliśmy tu operacyjne aspekty kontrolerów i operatorów, w tym pakowanie,
zabezpieczenia i wydajność.
W ten sposób omówiliśmy podstawy pisania i użytkowania niestandardowych kontrolerów i
operatorów Kubernetesa. Teraz przejdziemy do następnego sposobu rozszerzania Kubernetesa
— tworzenia niestandardowego serwera API.

1 Szczegółowe omówienie korzystania z konta usługi znajdziesz też w artykule Luca


Juggeryʼego „Kubernetes Tips: Using a ServiceAccount” (http://bit.ly/2X0fjKK).
2 Zgłosiliśmy w związku z tym problem Issue 748 (http://bit.ly/2J7Qys4) dotyczący projektu
Kubebuilder.
Rozdział 8. Niestandardowe
serwery API
Zamiast definicji CRD możesz zastosować niestandardowy serwer API. Niestandardowe
serwery API mogą udostępniać grupy API z zasobami w taki sam sposób, jak robi to główny
serwer API Kubernetesa. W odróżnieniu od definicji CRD tu niemal nie ma ograniczeń w
zakresie tego, co można zrobić z niestandardowym serwerem API.
Ten rozdział rozpoczynamy od podania listy powodów, dla których definicje CRD mogą w
określonych sytuacjach nie być odpowiednim rozwiązaniem. Opisujemy tu wzorce agregowania,
które umożliwiają rozszerzenie API Kubernetesa o niestandardowy serwer API. Na zakończenie
dowiesz się, jak zaimplementować niestandardowy serwer API w języku Go.

Scenariusze stosowania
niestandardowych serwerów API
Niestandardowy serwer API można zastosować zamiast definicji CRD. Może on robić wszystko
to, co definicje CRD i zapewnia prawie nieograniczoną elastyczność. Oczywiście wiąże się to z
kosztami w postaci złożoności zarówno prac programistycznych, jak i eksploatacji.
Przyjrzyj się niektórym ograniczeniom definicji CRD z czasu, gdy powstaje ta książka (stabilną
wersją jest obecnie Kubernetes 1.14). Definicje CRD:

Do składowania danych używają systemu etcd (lub innego systemu stosowanego w


serwerze API Kubernetesa).
Nie obsługują formatu protobuf (używają tylko formatu JSON).
Obsługują tylko dwa rodzaje podzasobów: /status i /scale (zob. punkt „Podzasoby”).
[1]
Nie obsługują kontrolowanego usuwania . Finalizatory pozwalają zasymulować ten
proces, ale nie umożliwiają ustalenia czasu kontrolowanego usunięcia zasobu.
Znacznie zwiększają obciążenie procesora serwera API Kubernetesa, ponieważ wszystkie
algorytmy (np. do sprawdzania poprawności) są zaimplementowane w generyczny sposób.
Udostępniają w punktach końcowych API tylko standardowe operacje CRUD.
Nie obsługują współistnienia zasobów (czyli zasobów w różnych grupach API lub zasobów
[2]
o różnych nazwach korzystających ze wspólnej pamięci) .

Niestandardowy serwer API nie ma takich ograniczeń. Niestandardowy serwer API:

Może używać dowolnego systemu składowania danych. Istnieją niestandardowe serwery


API, takie jak:
serwer API wskaźników (http://bit.ly/2FvgfAV), który w celu zmaksymalizowania
wydajności przechowuje dane w pamięci;
serwery API do tworzenia kopii lustrzanych repozytorium obrazów Dockera z
platformy OpenShift (http://redhat.com/openshift);
serwery API zapisujące dane w bazie danych szeregów czasowych;
serwery API do tworzenia kopii lustrzanych API z chmury;
serwery API do tworzenia kopii lustrzanych innych obiektów API, np. projektów z
platformy OpenShift (http://redhat.com/openshift) z kopiami lustrzanymi przestrzeni
nazw Kubernetesa.
Mogą obsługiwać format protobuf, tak jak robią to wszystkie natywne zasoby
Kubernetesa. Wymaga to utworzenia pliku .proto za pomocą narzędzia go-to-protobuf
(http://bit.ly/31OLSie) i użycia kompilatora dla formatu protobuf narzędzia protoc do
wygenerowania obiektów serializacji kompilowanych potem do postaci binarnej.
Mogą udostępniać dowolne niestandardowe podzasoby. Na przykład serwer API
Kubernetesa udostępnia podzasoby /exec, /logs, /port-forward i inne. Większość z nich
korzysta z niestandardowych protokołów, takich jak strumieniowanie z użyciem
protokołów WebSockets lub HTTP/2.
Mogą obsługiwać kontrolowane usuwanie zasobów, tak jak Kubernetes robi to dla podów.
Narzędzie kubectl czeka na usunięcie zasobu, a użytkownik może nawet podać
niestandardowy czas kontrolowanego zamknięcia go.
Mogą implementować operacje takie jak sprawdzanie poprawności, kontrola dostępu i
konwersja w maksymalnie wydajny sposób z użyciem języka Go bez zwiększającej
opóźnienie wymiany komunikatów między webhookami. Może to być istotne w
scenariuszach, gdy wysoka wydajność ma znaczenie, a także gdy używanych jest wiele
obiektów. Pomyśl o obiektach reprezentujących pody w wielkim klastrze z tysiącami
węzłów i o dwa rzędy wielkości większą liczbą podów.
Mogą implementować niestandardowe operacje, np. atomowe rezerwowanie adresu IP
usługi dla rodzaju Service z podstawowej grupy v1. W momencie tworzenia usługi
przypisywany i bezpośrednio zwracany jest jej unikatowy adres IP. Specjalne operacje
takie jak ta mogą oczywiście być implementowane w pewnym stopniu za pomocą
webhooków kontroli dostępu (zob. punkt „Webhooki kontroli dostępu”), choć takie
webhooki nie mają niektórych informacji o tym, czy przekazany obiekt rzeczywiście został
utworzony lub zmodyfikowany. Są wywoływane optymistycznie, a na dalszym etapie
procesu żądanie może zostać anulowane. Oznacza to, że trudno jest radzić sobie z
efektami ubocznymi w webhookach, ponieważ nie istnieje wyzwalacz do wycofywania
operacji po nieudanym żądaniu.
Mogą udostępniać zasoby, które współdzielą pamięć (np. mają ten sam prefiks ścieżki
klucza w systemie etcd), ale znajdują się w różnych grupach API lub mają inne nazwy. Na
przykład Kubernetes przechowuje instalacje i inne zasoby w grupie API extensions/v1, a
następnie przenosi je do specyficznych grup API, takich jak apps/v1.

Oznacza to, że niestandardowe serwery API są dobrym rozwiązaniem w sytuacjach, gdy


definicje CRD mają ograniczenia. Gdy wprowadzane są zmiany i ważne jest, aby — modyfikując
działanie kodu — nie naruszyć kompatybilności zasobów, niestandardowe serwery API często
dają znacznie więcej swobody.

Przykład — pizzeria
Aby dowiedzieć się, jak implementowane są niestandardowe serwery API, w tym podrozdziale
przyjrzymy się przykładowemu projektowi — niestandardowemu serwerowi API, który
implementuje API pizzerii. Zapoznaj się teraz z wymaganiami.

Chcemy utworzyć dwa rodzaje w grupie API restaurant.programming-kubernetes.info:


Topping

Dodatki do pizzy (np. salami, mozzarella lub pomidory).

Pizza
Typ pizzy sprzedawanej w pizzerii.
Dodatki to zasoby z poziomu klastra, przechowujące tylko liczbę zmiennoprzecinkową
oznaczającą cenę jednostkową danego dodatku. Instancja tego rodzaju jest bardzo prosta:

apiVersion: restaurant.programming-kubernetes.info/v1alpha1

kind: Topping

metadata:
name: mozzarella

spec:

cost: 1.0

Każda pizza może mieć dowolną liczbę dodatków. Oto przykład:

apiVersion: restaurant.programming-kubernetes.info/v1alpha1
kind: Pizza

metadata:

name: margherita

spec:
toppings:

- mozzarella

- pomidory

Lista dodatków jest uporządkowana (jak każda lista w formacie YAML lub JSON), jednak jeśli
chodzi o semantykę typu, kolejność dodatków nie ma znaczenia. Klient niezależnie od
kolejności dodatków otrzyma tę samą pizzę. Na liście dozwolone mają być powtórzenia, aby
umożliwić zamówienie np. pizzy z dodatkowym serem.

Wszystkie te rzeczy można łatwo zaimplementować za pomocą definicji CRD. Teraz dodajmy
[3]
wymagania wykraczające poza podstawowe możliwości definicji CRD :

W specyfikacji pizzy dozwolone mają być tylko te dodatki, dla których istnieje powiązany
obiekt typu Topping.
Załóżmy też, że początkowo udostępniliśmy to API w wersji v1alpha1, jednak ostatecznie
stwierdziliśmy, że chcemy utworzyć inną reprezentację dodatków w wersji v1beta1 tego
samego API.

Oznacza to, że chcemy mieć dwie wersje tego API i móc płynnie przechodzić między nimi.

Kompletną implementację tego API jako niestandardowego serwera API znajdziesz w


repozytorium tej książki w serwisie GitHub (http://bit.ly/2x9C3gR). W dalszej części rozdziału
omawiamy wszystkie główne części tego projektu i pokazujemy, jak działa. Na wiele zagadnień
opisanych w poprzednich rozdziałach spojrzysz teraz z innej perspektywy. Przede wszystkim
poznasz implementację różnych elementów (w tym serwera API Kubernetesa) w języku Go.
Bardziej zrozumiałe staną się także różne decyzje projektowe z definicji CRD.

Dlatego gorąco zachęcamy do zapoznania się z tym rozdziałem, nawet jeśli nie planujesz
tworzyć niestandardowego serwera API. Możliwe, że prezentowane tu techniki w przyszłości
będą dostępne także w definicjach CRD. Wtedy przyda Ci się wiedza z zakresu
niestandardowych serwerów API.
Architektura — agregowanie
Zanim przejdziemy to technicznych szczegółów implementacji, chcemy na ogólnym poziomie
opisać architekturę niestandardowych serwerów API w kontekście klastra Kubernetesa.

Niestandardowe serwery API to procesy udostępniające grupy API, zwykle budowane za


pomocą generycznej biblioteki serwerów API k8s.io/apiserver (http://bit.ly/2X3joNX). Takie
procesy mogą działać w klastrze lub poza nim. Gdy działają w klastrze, zwykle są uruchamiane
w podach, a do komunikacji z nimi służą usługi.

Główny serwer API Kubernetesa, kube-apiserver, zawsze jest pierwszym punktem kontaktu
dla narzędzia kubectl i innych klientów API. Grupy API udostępniane przez niestandardowy
serwer API są przekazywane przez proces kube-apiserver do procesu niestandardowego
serwera API. Oznacza to, że proces kube-apiserver wie wszystko o niestandardowych
serwerach API i udostępnianych przez nie grupach API, aby móc kierować do tych serwerów
odpowiednie żądania.

Komponent pośredniczący, kube-aggregator (http://bit.ly/2X10C9W), działa w procesie kube-


apiserver. Proces przekazywania żądań API do niestandardowego serwera API to agregowanie
API.

Przyjrzyj się teraz dokładniej ścieżkom żądań kierowanych do niestandardowego serwera API,
ale przychodzących do gniazda TCP serwera API Kubernetesa (zob. rysunek 8.1):

1. Żądania są odbierane przez serwer API Kubernetesa.


2. Żądania przechodzą przez łańcuch funkcji obsługi, które odpowiadają za uwierzytelnianie,
rejestrowanie zdarzeń na potrzeby audytu, podmianę tożsamości, ograniczanie
maksymalnej liczby przetwarzanych żądań, autoryzację itd. (rysunek 8.1 nie przedstawia
wszystkich operacji).
3. Ponieważ serwer API Kubernetesa zna zagregowane API, może przechwytywać żądania
kierowane do ścieżki HTTP /apis/nazwa-zagregowanej-grupy-API.
4. Serwer API Kubernetesa przekazuje żądanie do niestandardowego serwera API.
Rysunek 8.1. Główny serwer API Kubernetesa, kube-apiserver, ze zintegrowanym pośrednikiem
kube-aggregator

Agregator kube-aggregator przekazuje żądania ze ścieżki HTTP z odpowiednią grupą API


(czyli wszystko od członu /apis/nazwa-grupy/wersja). Nie musi przy tym znać zasobów
udostępnianych przez tę wersję grupy API.

Z kolei punkty końcowe /apis i apis/nazwa-grupy związane z wykrywaniem informacji z


wszystkich zagregowanych niestandardowych serwerów API kube-aggregator obsługuje
samodzielnie (wedle zdefiniowanego porządku opisanego w następnym punkcie) i zwraca
wyniki bez komunikowania się z zagregowanymi niestandardowymi serwerami API. Zamiast
tego używa informacji z zasobu APIService. Przyjrzyjmy się dokładnie temu procesowi.

Usługi API
Aby serwer API Kubernetesa znał grupy API udostępniane przez niestandardowy serwer API, w
grupie API apiregistration.k8s.io/v1 trzeba utworzyć jeden obiekt APIService. Takie
obiekty zwracają tylko grupy API i ich wersje. Nie podają zasobów ani innych szczegółowych
informacji:

apiVersion: apiregistration.k8s.io/v1beta1

kind: APIService

metadata:

name: nazwa

spec:

group: nazwa-grupy-API
version: wersja-grupy-API

service:

namespace: przestrzeń-nazw-usługi-niestandardowego-serwera-API

name: -usługa-serwera-API

caBundle: zestaw-certyfikatów-w-formacie-base64
insecureSkipTLSVerify: wartość-logiczna

groupPriorityMinimum: 2000

versionPriority: 20

Możesz używać dowolnej nazwy, ale dla przejrzystości warto stosować takie, które określają
nazwę i wersję grupy API, np. nazwa-grupy-wersja.
Usługą może być zwykła usługa ClusterIP (http://bit.ly/2X0zEEu) z klastra. Może to być także
usługa ExternalName z określoną nazwą DNS dla niestandardowych serwerów API spoza
klastra. W obu sytuacjach trzeba używać portu 443. W czasie, gdy powstaje ta książka, żadne
inne porty usług nie są obsługiwane. Jednak dzięki odwzorowaniu docelowego portu usługi
można zastosować dla podów niestandardowego serwera API dowolny wyższy (najlepiej
niezarejestrowany) port, dlatego nie jest to istotne ograniczenie.

Zestaw certyfikatów (caBundle) jest używany, by serwer API Kubernetesa mógł zaufać usłudze,
z którą się komunikuje. Warto zauważyć, że żądania kierowane do API mogą zawierać poufne
dane. Aby uniknąć ataków typu man-in-the-middle, należy skonfigurować pole caBundle i nie
aktywować niebezpiecznej opcji insecureSkipTLSVerify. Jest to ważne przede wszystkim w
klastrach produkcyjnych, gdzie używany jest mechanizm wymiany certyfikatów.

W obiekcie typu APIService ustawiane są też dwa priorytety. Ich działanie jest dość
skomplikowane, a opis znajdziesz w dokumentacji języka Go dla typu APIService:
// GroupPriorityMininum to minimalny priorytet, jaki powinna mieć dana grupa.
Wyższy priorytet oznacza, że

// grupa będzie preferowana przez klienty względem grup o niższym


priorytecie.

// Zauważ, że dla innych wersji tej grupy podane mogą być wyższe wartości

// GroupPriorityMinimum, dlatego grupa jako całość też będzie miała wyższy


priorytet.

//

// Podstawowe sortowanie jest oparte na wartości GroupPriorityMinimum (od


najwyższej do najniższej —

// 20 znajdzie się przed 10). Sortowanie pomocnicze odbywa się na podstawie


alfabetycznego

// porównywania nazw obiektów (v1.bar przed v1.foo). Oto zalecane ustawienia:

// *.k8s.io (oprócz rozszerzeń) na poziomie 18000, a środowiska PaaS

// (OpenShift, Deis) na poziomie 2000.

GroupPriorityMinimum int32 `json:”groupPriorityMinimum”`

// VersionPriority kontroluje kolejność wersji API w danej grupie. Ta wartość


// musi być większa od zera. Podstawowe sortowanie odbywa się na podstawie
wartości
// VersionPriority od najwyższych do najniższych (20 przed 10). Ponieważ ta
wartość jest używana

// wewnątrz grupy, może być mała, zwykle ok. 10. Gdy priorytety wersji są
takie same,

// do wyznaczania kolejności w grupie służy łańcuch znaków z wersją.

// Jeśli ten łańcuch znaków jest „w stylu Kubernetesa”, dana wersja znajdzie
się przed wersjami

// bez łańcucha znaków „w stylu Kubernetesa” (będą one porządkowane


leksykograficznie). Wersje

// „w stylu Kubernetesa” zaczynają się od „v”, po czym następuje liczba


(wersja główna),
// opcjonalnie słowo „alpha” lub „beta” i kolejna liczba (podwersja).

// Wersje są sortowane w kolejności GA > beta > alpha (gdzie GA to wersja

// bez przyrostka takiego jak beta lub alpha), następnie według wersji,
// a potem według podwersji. Oto przykładowa posortowana lista wersji:

// v10, v2, v1, v11beta2, v10beta3, v3beta1, v12alpha1, v11alpha2, foo1,


foo10.
VersionPriority int32 `json:”versionPriority”`

Oznacza to, że wartość GroupPriorityMinimum określa, jakiego obszaru dotyczy priorytet


grupy. Jeśli kilka obiektów typu APIService dla różnych wersji ma różne priorytety, wybierany
jest ten o najwyższej wartości.

Drugi priorytet wyznacza kolejność wersji, aby zdefiniować preferowaną wersję do użytku w
dynamicznych klientach.
Oto lista wartości GroupPriorityMinimum dla natywnych grup API Kubernetesa:

var apiVersionPriorities = map[schema.GroupVersion]priority{


{Group: “”, Version: “v1”}: {group: 18000, version: 1},

{Group: “extensions”, Version: “v1beta1”}: {group: 17900, version: 1},


{Group: “apps”, Version: “v1beta1”}: {group: 17800,
version: 1},

{Group: “apps”, Version: “v1beta2”}: {group: 17800,


version: 9},

{Group: “apps”, Version: “v1”}: {group: 17800,


version: 15},
{Group: “events.k8s.io”, Version: “v1beta1”}: {group: 17750,
version: 5},
{Group: “authentication.k8s.io”, Version: “v1”}: {group: 17700,
version: 15},

{Group: “authentication.k8s.io”, Version: “v1beta1”}: {group: 17700,


version: 9},
{Group: “authorization.k8s.io”, Version: “v1”}: {group: 17600,
version: 15},
{Group: “authorization.k8s.io”, Version: “v1beta1”}: {group: 17600,
version: 9},
{Group: “autoscaling”, Version: “v1”}: {group: 17500,
version: 15},

{Group: “autoscaling”, Version: “v2beta1”}: {group: 17500,


version: 9},
{Group: “autoscaling”, Version: “v2beta2”}: {group: 17500,
version: 1},
{Group: “batch”, Version: “v1”}: {group: 17400,
version: 15},

{Group: “batch”, Version: “v1beta1”}: {group: 17400,


version: 9},

{Group: “batch”, Version: “v2alpha1”}: {group: 17400,


version: 9},
{Group: “certificates.k8s.io”, Version: “v1beta1”}: {group: 17300,
version: 9},
{Group: “networking.k8s.io”, Version: “v1”}: {group: 17200,
version: 15},

{Group: “networking.k8s.io”, Version: “v1beta1”}: {group: 17200,


version: 9},
{Group: “policy”, Version: “v1beta1”}: {group: 17100,
version: 9},
{Group: “rbac.authorization.k8s.io”, Version: “v1”}: {group: 17000,
version: 15},

{Group: “rbac.authorization.k8s.io”, Version: “v1beta1”}: {group: 17000,


version: 12},
{Group: “rbac.authorization.k8s.io”, Version: “v1alpha1”}: {group: 17000,
version: 9},
{Group: “settings.k8s.io”, Version: “v1alpha1”}: {group: 16900,
version: 9},

{Group: “storage.k8s.io”, Version: “v1”}: {group: 16800,


version: 15},

{Group: “storage.k8s.io”, Version: “v1beta1”}: {group: 16800,


version: 9},
{Group: “storage.k8s.io”, Version: “v1alpha1”}: {group: 16800,
version: 1},
{Group: “apiextensions.k8s.io”, Version: “v1beta1”}: {group: 16700,
version: 9},

{Group: “admissionregistration.k8s.io”, Version: “v1”}: {group: 16700,


version: 15},
{Group: “admissionregistration.k8s.io”, Version: “v1beta1”}: {group:
16700, version: 12},

{Group: “scheduling.k8s.io”, Version: “v1”}: {group: 16600,


version: 15},

{Group: “scheduling.k8s.io”, Version: “v1beta1”}: {group: 16600,


version: 12},
{Group: “scheduling.k8s.io”, Version: “v1alpha1”}: {group: 16600,
version: 9},

{Group: “coordination.k8s.io”, Version: “v1”}: {group: 16500,


version: 15},

{Group: “coordination.k8s.io”, Version: “v1beta1”}: {group: 16500,


version: 9},
{Group: “auditregistration.k8s.io”, Version: “v1alpha1”}: {group: 16400,
version: 1},
{Group: “node.k8s.io”, Version: “v1alpha1”}: {group: 16300,
version: 1},

{Group: “node.k8s.io”, Version: “v1beta1”}: {group: 16300,


version: 9},
}

Dlatego zastosowanie wartości 2000 dla API podobnych do platform PaaS oznacza, że będą one
[4]
umieszczone na końcu listy .

Kolejność grup API jest istotna w procesie odwzorowywania REST w narzędziu kubectl (zob.
punkt „Odwzorowania REST”). To oznacza, że ta kolejność ma wpływ na pracę użytkowników.
Jeśli występują niezgodne nazwy zasobów lub krótkie nazwy, wybierany jest zasób z najwyższą
wartością GroupPriorityMinimum.
Ponadto kolejność oparta na priorytetach może być używana także w wyjątkowej sytuacji
zastępowania wersji grupy API za pomocą niestandardowego serwera API. Możesz np. (z
dowolnych przyczyn) zastąpić natywną grupę API Kubernetesa zmodyfikowaną grupą,
ustawiając dla niestandardowej usługi API niższą wartość GroupPriorityMinimum niż wartości
z pokazanej tabeli.

Warto przypomnieć, że serwer API Kubernetesa nie musi znać listy zasobów ani na potrzeby
służących do wykrywania informacji punktów końcowych /apis i apis/nazwa-grupy, ani na
potrzeby przekazywania żądań. Lista zasobów jest zwracana tylko przez trzeci punkt końcowy
do wykrywania informacji: /apis/nazwa-grupy/wersja. Jednak, co opisaliśmy w poprzednim
punkcie, ten punkt końcowy jest obsługiwany przez zagregowany niestandardowy serwer API, a
nie przez agregator kube-aggregator.

Wewnętrzna struktura niestandardowego serwera


API
Niestandardowy serwer API w większości przypomina serwer API Kubernetesa, choć oczywiście
ma inne implementacje grup API, a także nie ma wbudowanych komponentów kube-
aggregator i apiextension-apiserver (ten ostatni udostępnia definicje CRD). Dlatego obraz
architektury (rysunek 8.2) jest tu prawie taki sam jak na rysunku 8.1.
Rysunek 8.2. Zagregowany niestandardowy serwer API oparty na bibliotece k8s.io/apiserver

Można tu zauważyć kilka rzeczy. Zagregowany serwer API:

Ma tę samą podstawową wewnętrzną strukturę co serwer API Kubernetesa.


Ma własny łańcuch funkcji obsługi żądań, obejmujący uwierzytelnianie, audyt, podmianę
tożsamości, ograniczanie maksymalnej liczby żądań i autoryzację (w rozdziale wyjaśniamy,
dlaczego jest ona niezbędna; zob. np. punkt „Delegowana autoryzacja”).
Ma własny potok obsługi zasobów, obejmujący dekodowanie, konwersję, kontrolę dostępu,
odwzorowania REST i kodowanie.
Wywołuje webhooki kontroli dostępu.
Może zapisywać dane w systemie etcd (choć może używać także innego magazynu
danych). Nie musi używać tego samego klastra z systemem etcd, z którego korzysta
serwer API Kubernetesa.
Ma własną implementację schematu i rejestru dla niestandardowych grup API.
Implementację rejestru można w dowolnym stopniu dostosować do potrzeb.
Ponownie przeprowadza uwierzytelnianie. Zwykle stosowane jest uwierzytelnianie
certyfikatu klienta i uwierzytelnianie oparte na tokenach, kierując do serwera API żądania
obiektu TokenAccessReview. Dalej szczegółowo omawiamy architekturę uwierzytelniania i
obsługę zaufania.
Przeprowadza własny audyt. To oznacza, że serwer API Kubernetesa sprawdza określone
pola, ale tylko na poziomie meta. Audyt na poziomie obiektów jest wykonywany przez
zagregowany niestandardowy serwer API.
Przeprowadza własną autoryzację, kierując żądania obiektu SubjectAccessReview do
serwera API Kubernetesa. Szczegółowe omówienie autoryzacji znajdziesz dalej.

Delegowane uwierzytelnianie i obsługa zaufania


Zagregowany niestandardowy serwer API (oparty na bibliotece k8s.io/apiserver;
http://bit.ly/2X3joNX) jest zbudowany z wykorzystaniem tej samej biblioteki uwierzytelniania co
serwer API Kubernetesa. Do uwierzytelniania użytkownika może stosować certyfikaty klientów
lub tokeny.
Ponieważ zagregowany niestandardowy serwer API jest w architekturze umieszczony za
serwerem API Kubernetesa (to znaczy, że serwer API Kubernetesa otrzymuje żądania i
przekazuje je do zagregowanego niestandardowego serwera API), odbiera żądania już
uwierzytelnione przez serwer API Kubernetesa. Serwer API Kubernetesa przechowuje efekt
uwierzytelniania (nazwę użytkownika i jego grupę) w nagłówkach żądań HTTP, zwykle X-
Remote-User i X-Remote-Group. Te nagłówki można skonfigurować za pomocą opcji --
requestheader-username-headers i --requestheader-group-headers.
Zagregowany niestandardowy serwer API musi wiedzieć, kiedy ma ufać tym nagłówkom. W
przeciwnym razie każda jednostka wywołująca może stwierdzić, że uwierzytelniła użytkownika,
i ustawić te nagłówki. Dlatego używany jest specjalny nagłówek żądania z certyfikatem. Jest on
zapisany w obiekcie kube-system/extension-apiserver-authentication typu ConfigMap (plik
podawany jest w opcji requestheader-client-ca-file). Oto przykładowy kod:
apiVersion: v1
kind: ConfigMap

metadata:
name: extension-apiserver-authentication

namespace: kube-system
data:

client-ca-file: |
-----BEGIN CERTIFICATE-----

...
-----END CERTIFICATE-----
requestheader-allowed-names: ‘[“aggregator”]’

requestheader-client-ca-file: |
-----BEGIN CERTIFICATE-----

...
-----END CERTIFICATE-----

requestheader-extra-headers-prefix: ‘[“X-Remote-Extra-”]’
requestheader-group-headers: ‘[“X-Remote-Group”]’

requestheader-username-headers: ‘[“X-Remote-User”]’
Na podstawie tych informacji zagregowany niestandardowy serwer API z ustawieniami
domyślnymi uwierzytelni:

Klienty używające certyfikatów pasujących do podanego pliku client-ca-file.


Klienty wstępnie uwierzytelnione przez serwer API Kubernetesa, których żądania są
przekazywane z użyciem danego ustawienia requestheader-client-ca-file oraz dla których
nazwa użytkownika i przynależność do grupy są zapisane w nagłówkach HTTP X-Remote-
Group i X-Remote-User.
Warto też wspomnieć o mechanizmie TokenAccessReview. Polega on na przekazywaniu
tokenów okaziciela (otrzymanych w nagłówku HTTP Authorization: bearer token) z
powrotem na serwer API Kubernetesa w celu sprawdzenia, czy są prawidłowe. Mechanizm
sprawdzania tokenów dostępu jest domyślnie wyłączony, jednak opcjonalnie można go włączyć.
Zob. punkt „Wzorzec opcji i konfiguracji oraz szablonowy kod potrzebny do uruchomienia
serwera”.
W dalszych punktach zobaczysz, jak skonfigurować delegowane uwierzytelnianie. Choć tu
szczegółowo omawiamy ten proces, w zagregowanym niestandardowym serwerze API odbywa
się on w większości automatycznie dzięki bibliotece k8s.io/apiserver. Jednak z pewnością warto
wiedzieć, co dzieje się na zapleczu — zwłaszcza jeśli chodzi o bezpieczeństwo.

Delegowana autoryzacja
Po zakończeniu uwierzytelniania konieczna jest autoryzacja każdego żądania. Autoryzacja jest
oparta na liście nazw użytkowników i grup. Domyślnym mechanizmem autoryzacji w
Kubernetesie jest RBAC.

RBAC odwzorowuje tożsamości na role, a role na reguły autoryzacji, na podstawie których


żądania są akceptowane lub odrzucane. Nie będziemy tu szczegółowo omawiać obiektów
związanych z autoryzacją RBAC takich jak role i role z poziomu klastra lub powiązania ról i
powiązania ról z poziomu klastra (więcej informacji znajdziesz w punkcie „Odpowiednie
uprawnienia”). Jeśli chodzi o architekturę, wystarczy wiedzieć, że zagregowany niestandardowy
serwer API autoryzuje żądania na podstawie delegowanej autoryzacji za pomocą obiektów typu
SubjectAccessReview. Taki serwer API nie analizuje samodzielnie reguł RBAC, ale deleguje to
zadanie do serwera API Kubernetesa.

Dlaczego zagregowane serwery API zawsze muszą wykonywać dodatkowy krok


autoryzacji?
Każde żądanie otrzymane przez serwer API Kubernetesa i przekazane do zagregowanego
niestandardowego serwera API przechodzi proces uwierzytelniania i autoryzacji (zob.
rysunek 8.1). To oznacza, że zagregowany niestandardowy serwer API mógłby pominąć
dla takich żądań delegowany proces autoryzacji.
Jednak wstępna autoryzacja nie jest gwarantowana i może zostać w każdym momencie
wycofana (są plany, by w przyszłości oddzielić agregator kube-aggregator od serwera
kube-apiserver w celu poprawy bezpieczeństwa i skalowalności). Ponadto żądania
kierowane bezpośrednio do zagregowanego niestandardowego serwera API (np.
uwierzytelniane na podstawie certyfikatów klienta lub sprawdzania tokenów dostępu) nie
przechodzą przez serwer API Kubernetesa, dlatego nie są wstępnie autoryzowane.
Oznacza to, że pominięcie delegowanej autoryzacji powoduje lukę bezpieczeństwa,
dlatego jest zdecydowanie odradzane.

Przyjrzyjmy się teraz szczegółowo delegowanej autoryzacji.

Obiekt rodzaju SubjectAccessReview jest przesyłany ze zagregowanego niestandardowego


serwera API na serwer API Kubernetesa na żądanie (jeśli szukanej odpowiedzi nie ma w
buforze autoryzacji). Oto przykładowy obiekt tego rodzaju:

apiVersion: authorization.k8s.io/v1
kind: SubjectAccessReview

spec:
resourceAttributes:
group: apps
resource: deployments

verb: create
namespace: default

version: v1
name: example
user: michael

groups:
- system:authenticated

- admins
- authors

Serwer API Kubernetesa odbiera taki obiekt ze zagregowanego niestandardowego serwera API,
sprawdza reguły RBAC w klastrze i podejmuje decyzję, po czym zwraca obiekt rodzaju
SubjectAccessReview z ustawionym polem statusu. Oto przykład:

apiVersion: authorization.k8s.io/v1
kind: SubjectAccessReview

status:
allowed: true
denied: false

reason: “reguła foo zezwala na to żądanie”


Warto zauważyć, że możliwe jest, iż pola allowed i denied oba mają wartość false. To oznacza,
że serwer API Kubernetesa nie mógł podjąć decyzji. Wtedy inna jednostka autoryzacyjna w
zagregowanym niestandardowym serwerze API może podjąć decyzję (w serwerach API
zaimplementowany jest łańcuch jednostek autoryzacji odpytywanych jedna po drugiej;
delegowana autoryzacja jest jedną z jednostek w tym łańcuchu). To rozwiązanie można
wykorzystać do opracowania modelu niestandardowej logiki autoryzacji, jeśli w określonej
sytuacji nie są używane reguły RBAC, a zamiast tego wykorzystywany jest zewnętrzny system
autoryzacji.
Warto zauważyć, że w celu poprawy wydajności mechanizm delegowanej autoryzacji utrzymuje
lokalny bufor w każdym zagregowanym niestandardowym serwerze API. Domyślnie zapisane są
w nim 1024 pozycje, przy czym:

dla zaakceptowanych żądań autoryzacji obowiązuje 5-minutowy czas wygasania,


dla odrzuconych żądań autoryzacji obowiązuje 30-sekundowy czas wygasania.

Te wartości można zmodyfikować za pomocą opcji --authorization-webhook-cache-


authorized-ttl i --authorization-webhook-cache-unauthorized-ttl.

W dalszych punktach zobaczysz, jak delegowana autoryzacja jest konfigurowana w kodzie.


Podobnie jak przy uwierzytelnianiu w zagregowanym niestandardowym serwerze API
autoryzacja jest obsługiwana w większości automatycznie z użyciem biblioteki k8s.io/apiserver.

Pisanie niestandardowych serwerów API


W poprzednich punktach przyjrzeliśmy się architekturze zagregowanych serwerów API. Tu
pokażemy, jak implementować takie serwery w języku Go.
Główny serwer API Kubernetesa jest zaimplementowany z wykorzystaniem biblioteki
k8s.io/apiserver. Niestandardowy serwer API używa tego samego kodu. Główna różnica polega
na tym, że niestandardowy serwer API działa wewnątrz klastra. To oznacza, że można przyjąć,
iż w klastrze dostępny jest serwer kube-apiserver, i wykorzystać ten serwer na potrzeby
delegowanej autoryzacji i pobierania innych natywnych zasobów Kubernetesa.
Zakładamy też, że system etcd jest dostępny i gotowy do użytku w zagregowanym
niestandardowym serwerze API. Nie ma znaczenia, czy ten system etcd jest dedykowany, czy
współdzielony z serwerem API Kubernetesa. Aby uniknąć konfliktów, niestandardowy serwer
API używa innej przestrzeni kluczy w systemie etcd.
W przykładowym kodzie z tego rozdziału nawiązujemy do kodu z serwisu GitHub
(http://bit.ly/2x9C3gR), dlatego jeśli chcesz zapoznać się z kompletnym kodem źródłowym,
zajrzyj właśnie tam. Tu prezentujemy tylko najciekawsze fragmenty, jednak zawsze możesz
zapoznać się z kompletnym projektem, poeksperymentować z nim i — co bardzo ważne, jeśli
chodzi o uczenie się — uruchomić go w rzeczywistym klastrze.
W projekcie pizza-apiserver zaimplementowane jest przykładowe API z punktu „Przykład —
pizzeria”.

Wzorzec opcji i konfiguracji oraz szablonowy kod


potrzebny do uruchomienia serwera
1. W bibliotece k8s.io/apiserver do utworzenia działającego serwera API używany jest
wzorzec opcji i konfiguracji.

Zacznijmy od kilku struktur z opcjami, powiązanych z flagami. Możesz wykorzystać


struktury z biblioteki k8s.io/apiserver i dodać własne niestandardowe opcje. Struktury z
opcjami z biblioteki k8s.io/apiserver można zmodyfikować w kodzie na potrzeby
specjalnych zastosowań, a podane flagi dodać do zbioru flag, aby były dostępne dla
użytkowników.
W przykładzie (http://bit.ly/2x9C3gR) zaczynamy w bardzo prosty sposób — wszystko
będzie oparte na strukturze RecommendedOptions. Ta struktura konfiguruje wszystko w
sposób odpowiedni dla normalnego zagregowanego niestandardowego serwera API dla
prostych API:
import (
...
informers “github.com/programming-kubernetes/pizza-apiserver/pkg/

generated/informers/externalversions”
)

const defaultEtcdPathPrefix = “/registry/restaurant.programming-


kubernetes.info”

type CustomServerOptions struct {


RecommendedOptions *genericoptions.RecommendedOptions
SharedInformerFactory informers.SharedInformerFactory
}

func NewCustomServerOptions(out, errOut io.Writer) *CustomServerOptions


{

o := &CustomServerOptions{
RecommendedOptions: genericoptions.NewRecommendedOptions(
defaultEtcdPathPrefix,
apiserver.Codecs.LegacyCodec(v1alpha1.SchemeGroupVersion),

genericoptions.NewProcessInfo(“pizza-apiserver”, “pizza-
apiserver”),
),
}

return o
}

Struktura CustomServerOptions zawiera zagnieżdżone pole typu RecommendedOptions i jedno


dodatkowe pole. NewCustomServerOptions to konstruktor, który zapełnia strukturę
CustomServerOptions wartościami domyślnymi.

Przyjrzyjmy się teraz ciekawszym szczegółom:

defaultEtcdPathPrefix to przedrostek wszystkich naszych kluczy z systemu etcd.


Używana przestrzeń kluczy to /registry/pizza-apiserver.programming-kubernetes.info,
różna od przestrzeni kluczy Kubernetesa.
SharedInformerFactory to fabryka współdzielonych informatorów z poziomu procesu dla
naszych niestandardowych zasobów. Pozwala ona uniknąć tworzenia zbędnych
informatorów dla tych samych zasobów (zob. rysunek 3.5). Warto zauważyć, że fabryka
jest importowana z wygenerowanego kodu informatora z naszego projektu, a nie z
biblioteki client-go.
Funkcja NewRecommendedOptions przypisuje wartości domyślne do wszystkich
odpowiednich pól zagregowanego niestandardowego serwera API.

Przyjrzyj się teraz pokrótce kodowi funkcji NewRecommendedOptions:

return &RecommendedOptions{
Etcd: NewEtcdOptions(storagebackend.NewDefaultConfig(prefix,
codec)),
SecureServing: sso.WithLoopback(),
Authentication: NewDelegatingAuthenticationOptions(),

Authorization: NewDelegatingAuthorizationOptions(),
Audit: NewAuditOptions(),
Features: NewFeatureOptions(),
CoreAPI: NewCoreAPIOptions(),
ExtraAdmissionInitializers:
func(c *server.RecommendedConfig) ([]admission.PluginInitializer,
error) {

return nil, nil


},
Admission: NewAdmissionOptions(),
ProcessInfo: processInfo,

Webhook: NewWebhookOptions(),
}
W razie potrzeby wszystkie te pola można zmodyfikować. Na przykład, jeśli potrzebny jest
niestandardowy port domyślny, ustaw pole
RecommendedOptions.SecureServing.SecureServingOptions.BindPort.

Przyjrzyjmy się szybko dostępnym opcjom:

Etcd konfiguruje stos narzędzi do odczytywania i zapisywania danych w systemie etcd.


SecureServing konfiguruje wszystkie kwestie związane z protokołem HTTPS (porty,
certyfikaty itd.).
Authentication konfiguruje delegowane uwierzytelnianie opisane w punkcie
„Delegowane uwierzytelnianie i obsługa zaufania”.
Authorization konfiguruje delegowaną autoryzację opisaną w punkcie „Delegowana
autoryzacja”.
Audit konfiguruje stos narzędzi do audytowania. Domyślnie są one wyłączone, jednak
można je skonfigurować, aby generowały plik dziennika audytu lub przekazywały
związane z audytem zdarzenia do zewnętrznego systemu zaplecza.
Features konfiguruje bramkę dla mechanizmów z wersji alfa i beta.
CoreAPI przechowuje ścieżkę do pliku kubeconfig na potrzeby dostępu do głównego
serwera API. Domyślnie wykorzystywana jest konfiguracja używana w danym klastrze.
Admission określa stos wtyczek kontroli dostępu (modyfikujących i sprawdzających
poprawność), wykonywanych dla każdego przychodzącego żądania kierowanego do API.
Ten stos można rozbudować o niestandardowe wtyczki kontroli dostępu. Można też
zmodyfikować domyślny łańcuch kontroli dostępu na potrzeby niestandardowego serwera
API.
ExtraAdmissionInitializers umożliwia dodawanie inicjalizatorów na potrzeby kontroli
dostępu. Inicjalizatory przygotowują np. środowisko dla informatorów i klientów za
pomocą niestandardowego serwera API. Więcej o niestandardowej kontroli dostępu
dowiesz się z punktu „Kontrola dostępu”.
ProcessInfo przechowuje informacje (nazwę procesu i przestrzeń nazw) związane z
tworzeniem obiektów reprezentujących zdarzenia. Obie wartości są tu ustawione na
pizza-apiserver.
Webhook konfiguruje działanie webhooków (określa ogólne ustawienia dla webhooków
uwierzytelniania i kontroli dostępu). Ustawiane są tu wartości domyślne odpowiednie dla
niestandardowego serwera API działającego w klastrze. Dla serwerów API działających
poza klastrem należy w tym miejscu skonfigurować sposób dostępu do webhooków.

Opcje są powiązane z flagami (zwyczajowo opcje są tworzone na tym samym poziomie


abstrakcji co flagi). Zgodnie z ogólną regułą opcje nie przechowują „wykonywanych” struktur
danych. Są używane na etapie rozruchu, a następnie przekształcane na obiekty konfiguracji lub
serwera. Później te obiekty są wykonywane.
Do sprawdzania poprawności opcji służy metoda Validate() error. Ta metoda sprawdza też,
czy flagi podane przez użytkownika mają logiczny sens.

Opcje można uzupełnić w celu ustawienia wartości domyślnych, które nie powinny pojawiać się
w systemie pomocy dotyczącym flag, ale które są niezbędne do uzyskania kompletnego zestawu
opcji.
Opcje są przekształcane na konfigurację serwera przez metodę Config()
(*apiserver.Config, error). Początkowo używana jest rekomendowana konfiguracja
domyślna, a następnie stosowane są do niej opcje:

func (o *CustomServerOptions) Config() (*apiserver.Config, error) {


err :=
o.RecommendedOptions.SecureServing.MaybeDefaultWithSelfSignedCerts(
“localhost”, nil, []net.IP{net.ParseIP(“127.0.0.1”)},
)

if err != nil {
return nil,
fmt.Errorf(“Błąd tworzenia samodzielnie podpisywanego certyfikatu:
%v”, err)
}

[... pominięto o.RecommendedOptions.ExtraAdmissionInitializers ...]

serverConfig := genericapiserver.NewRecommendedConfig(apiserver.Codecs)
err = o.RecommendedOptions.ApplyTo(serverConfig, apiserver.Scheme);

if err != nil {
return nil, err
}

config := &apiserver.Config{
GenericConfig: serverConfig,
ExtraConfig: apiserver.ExtraConfig{},
}
return config, nil

}
Tworzona tu konfiguracja zawiera uruchamiane struktury danych. Oznacza to, że konfiguracje
to obiekty czasu wykonywania programu — w odróżnieniu od opcji, które odpowiadają flagom.
Wiersz o.RecommendedOptions.SecureServing.MaybeDefaultWithSelfSignedCerts tworzy
samodzielnie podpisywane certyfikaty potrzebne, jeśli użytkownik nie przekazał flag
reprezentujących wcześniej wygenerowane certyfikaty.
Funkcja genericapiserver.NewRecommendedConfig zwraca rekomendowaną konfigurację
domyślną, a RecommendedOptions.ApplyTo modyfikuje ją na podstawie flag (i innych podanych
opcji).
Struktura z konfiguracją w projekcie pizza-apiserver to nakładka na obiekt typu
RecommendedConfig dla naszego przykładowego niestandardowego serwera API:
type ExtraConfig struct {

// Tu umieść niestandardową konfigurację.


}

type Config struct {


GenericConfig *genericapiserver.RecommendedConfig

ExtraConfig ExtraConfig
}

// CustomServer zawiera stan niestandardowego serwera API Kubernetesa.

type CustomServer struct {


GenericAPIServer *genericapiserver.GenericAPIServer
}

type completedConfig struct {

GenericConfig genericapiserver.CompletedConfig
ExtraConfig *ExtraConfig
}

type CompletedConfig struct {


// Zagnieżdżony prywatny wskaźnik, którego instancji
// nie można tworzyć poza tym pakietem.
*completedConfig
}

Jeśli potrzebny jest dodatkowy stan dla uruchomionego niestandardowego serwera API, należy
go umieścić w strukturze ExtraConfig.
Podobnie jak dla struktur z opcjami, tak i dla konfiguracji istnieje metoda Complete()
CompletedConfig ustawiająca wartości domyślne. Ponieważ dla konfiguracji niezbędne jest
wywołanie Complete(), często jest ono wymuszane za pomocą systemu plików poprzez dodanie
nieeksportowanego typu danych completedConfig. Pomysł polega na tym, że jedynie
wywołanie Complete() może przekształcić strukturę Config w completedConfig:
func (cfg *Config) Complete() completedConfig {
c := completedConfig{
cfg.GenericConfig.Complete(),
&cfg.ExtraConfig,
}

c.GenericConfig.Version = &version.Info{
Major: “1”,
Minor: “0”,
}

return completedConfig{&c}
}
Uzupełnioną konfigurację można przekształcić w strukturę czasu wykonania CustomServer,
używając konstruktora New():

// New na podstawie podanej konfiguracji zwraca nową instancję typu


CustomServer.
func (c completedConfig) New() (*CustomServer, error) {
genericServer, err := c.GenericConfig.New(
“pizza-apiserver”,

genericapiserver.NewEmptyDelegate(),
)
if err != nil {
return nil, err
}

s := &CustomServer{
GenericAPIServer: genericServer,
}

[ ... pominięto instalowanie API ...]

return s, nil
}

Warto zauważyć, że celowo pominęliśmy tu kod instalacji API. Wrócimy do niego w punkcie
„Instalowanie API” (dowiesz się tam, jak w trakcie rozruchu połączyć rejestry ze standardowym
serwerem API). Rejestr implementuje API i semantykę składowania danych dla grupy API.
Omówienie tego procesu dla grupy API dla pizzerii znajdziesz w punkcie „Rejestr i strategia”.
Ostatecznie można uruchomić obiekt typu CustomServer, wywołując metodę Run(stopCh <-
chan struct{}) error. W tym przykładzie jest ona wywoływana w metodzie Run obiektu opcji.
Oznacza to, że metoda CustomServerOptions.Run:

tworzy konfigurację,
uzupełnia konfigurację,
tworzy obiekt typu CustomServer,
wywołuje metodę CustomServer.Run.

A oto potrzebny kod:


func (o CustomServerOptions) Run(stopCh <-chan struct{}) error {

config, err := o.Config()


if err != nil {
return err
}

server, err := config.Complete().New()


if err != nil {
return err
}

server.GenericAPIServer.AddPostStartHook(“start-pizza-apiserver-
informers”,
func(context genericapiserver.PostStartHookContext) error {
config.GenericConfig.SharedInformerFactory.Start(context.StopCh)

o.SharedInformerFactory.Start(context.StopCh)
return nil
},
)

return server.GenericAPIServer.PrepareRun().Run(stopCh)
}
Funkcja PrepareRun() podłącza specyfikację OpenAPI i może wykonywać także inne operacje
potrzebne po zainstalowaniu API. Po tym wywołaniu metoda Run uruchamia sam serwer.
Wykonywanie metody zostaje wtedy zablokowane do czasu zamknięcia kanału stopCh.

W tym przykładzie podłączany jest też haczyk pouruchomieniowy (ang. post-start hook) start-
pizza--apiserver-informers. Jak wskazuje na to nazwa, ten haczyk jest wywoływany, gdy
serwer HTTPS już działa i oczekuje na żądania. Tu ten haczyk uruchamia fabryki
współdzielonych informatorów.
Zauważ, że nawet lokalne wewnątrzprocesowe informatory dla zasobów z niestandardowego
serwera API komunikują się z interfejsem hosta lokalnego z użyciem protokołu HTTPS. Dlatego
należy je uruchamiać wtedy, gdy serwer już działa i oczekuje w porcie HTTPS na żądania.
Warto też zauważyć, że punkt końcowy /healthz zwraca informację o powodzeniu tylko po
udanym wykonaniu wszystkich haczyków pouruchomieniowych.
Cały kod przygotowujący środowisko jest już gotowy. W projekcie pizza-apiserver cały ten
kod jest opakowany w polecenie cobra:
// NewCommandStartCustomServer to funkcja obsługi polecenia ‚start master’ z
wiersza poleceń

// używająca domyślnego obiektu typu CustomServerOptions.


func NewCommandStartCustomServer(
defaults *CustomServerOptions,
stopCh <-chan struct{},
) *cobra.Command {

o := *defaults
cmd := &cobra.Command{
Short: “Uruchamianie niestandardowego serwera API”,
Long: “Uruchamianie niestandardowego serwera API”,

RunE: func(c *cobra.Command, args []string) error {


if err := o.Complete(); err != nil {
return err
}
if err := o.Validate(); err != nil {

return err
}
if err := o.Run(stopCh); err != nil {
return err

}
return nil
},
}

flags := cmd.Flags()
o.RecommendedOptions.AddFlags(flags)

return cmd

}
Po utworzeniu funkcji NewCommandStartCustomServer metoda main() procesu jest prosta:
func main() {
logs.InitLogs()
defer logs.FlushLogs()

stopCh := genericapiserver.SetupSignalHandler()

options := server.NewCustomServerOptions(os.Stdout, os.Stderr)


cmd := server.NewCommandStartCustomServer(options, stopCh)
cmd.Flags().AddGoFlagSet(flag.CommandLine)
if err := cmd.Execute(); err != nil {

klog.Fatal(err)
}
}
Zwróć uwagę przede wszystkim na wywołanie SetupSignalHandler. Konfiguruje ono obsługę
sygnałów uniksowych. Sygnały SIGINT (zgłaszany po wciśnięciu kombinacji Ctrl+C w oknie
terminala) i SIGKILL powodują tu zamknięcie kanału stopu. Kanał stopu jest przekazywany do
działającego niestandardowego serwera API, który zostaje zamknięty po zamknięciu tego
kanału. Dlatego główna pętla po otrzymaniu jednego z tych sygnałów inicjuje zamykanie
serwera. Procedura zamykania jest kontrolowana, ponieważ przed zamknięciem serwera
kończone są wykonywane żądania (domyślnie mają one 60 sekund na zakończenie pracy). Kod
gwarantuje też, że wszystkie żądania są przesyłane do kodu zaplecza odpowiedzialnego za
audyt i że żadne dane związane z audytem nie zostają pominięte. Gdy wszystkie te działania
zostaną zakończone, wywołanie cmd.Execute() zwraca sterowanie, a proces kończy pracę.

Pierwsze uruchomienie
Wszystko jest już gotowe do pierwszego uruchomienia niestandardowego serwera API. Jeśli
konfiguracja klastra znajduje się w pliku ~/.kube/config, możesz użyć jej na potrzeby
delegowanego uwierzytelniania i delegowanej autoryzacji:
$ cd $GOPATH/src/github.com/programming-kubernetes/pizza-apiserver
$ etcd &

$ go run . --etcd-servers localhost:2379 \


--authentication-kubeconfig ~/.kube/config \
--authorization-kubeconfig ~/.kube/config \
--kubeconfig ~/.kube/config

I0331 11:33:25.702320 64244 plugins.go:158]


Loaded 3 mutating admission controller(s) successfully in the following
order:
NamespaceLifecycle,MutatingAdmissionWebhook,PizzaToppings.
I0331 11:33:25.702344 64244 plugins.go:161]

Loaded 1 validating admission controller(s) successfully in the following


order:
ValidatingAdmissionWebhook.
I0331 11:33:25.714148 64244 secure_serving.go:116] Serving securely on
[::]:443
Ten kod uruchamia generyczne punkty końcowe API i rozpoczyna ich obsługę:

$ curl -k https://localhost:443/healthz
ok
Można też wyświetlić punkt końcowy odpowiedzialny za wykrywanie informacji, jednak na razie
wynik takiego wywołania nie jest interesujący. Nie utworzyliśmy API, dlatego wyświetlana lista
jest pusta:

$ curl -k https://localhost:443/apis
{
“kind”: “APIGroupList”,
“groups”: []
}

Przyjrzyjmy się teraz dotychczasowym operacjom na ogólnym poziomie:

Uruchomiliśmy niestandardowy serwer API z zalecanymi opcjami i konfiguracją.


Mamy standardowy łańcuch funkcji obsługi żądań, obejmujący delegowane
uwierzytelnianie, delegowaną autoryzację i audyt.
Mamy działający serwer HTTPS, który obsługuje żądanie kierowane do generycznych
punktów końcowych: /logs, /metrics, /version, /healthz i /apis.

Na rysunku 8.3 te operacje są pokazane z lotu ptaka.


Rysunek 8.3. Niestandardowy serwer API bez API

Typy wewnętrzne i konwersja


Po skonfigurowaniu działającego niestandardowego serwera API pora zaimplementować same
API. Jednak najpierw trzeba zrozumieć wersje API i sposób ich obsługi w serwerze API.

Każdy serwer API udostępnia wiele zasobów i ich wersji (zob. rysunek 2.3). Niektóre zasoby
mają wiele wersji. Aby umożliwić korzystanie z wielu wersji zasobu, serwer API musi
dokonywać konwersji między wersjami.
W celu uniknięcia wykładniczego wzrostu liczby potrzebnych konwersji między wersjami
serwery API używają wersji wewnętrznej w implementacji logiki API. Wersja wewnętrzna jest
czasem nazywana wersją osiową (ang. hub version), ponieważ jest swoistą osią, na którą i z
której konwertowane są wszystkie pozostałe wersje (zob. rysunek 8.4). Wewnętrzna logika API
jest implementowana tylko raz dla wersji centralnej.

Rysunek 8.4. Konwersja na wersję wewnętrzną i w drugą stronę

Na rysunku 8.5 pokazane jest, jak serwery API używają wersji wewnętrznej w cyklu życia
żądania kierowanego do API:
Rysunek 8.5. Konwersja obiektów API w cyklu życia żądania

Użytkownik przesyła żądania dotyczące konkretnej wersji (np. v1).


Serwer API dekoduje treść żądania i przekształca je na wersję wewnętrzną.
Serwer API przekazuje żądanie dla wersji wewnętrznej do mechanizmów kontroli dostępu
i sprawdzania poprawności.
Logika API dla wersji wewnętrznych jest zaimplementowana w rejestrze.
System etcd wczytuje i zapisuje wersjonowany obiekt (w wersji v2, która jest tu wersją
składowania), czyli dokonuje konwersji z wersji wewnętrznej i w drugą stronę.
W ostatnim kroku wynik jest przekształcany na wersję z żądania; tu jest nią v1.

Na każdym styku wewnętrznej wersji centralnej i wersji zewnętrznej zachodzi konwersja. Na


rysunku 8.6 możesz policzyć konwersje potrzebne dla każdej funkcji obsługi żądania. Operacje
zapisu (np. tworzenia lub modyfikacji) wymagają przynajmniej czterech konwersji (lub nawet
więcej, jeśli w klastrze działają webhooki kontroli dostępu). Widać więc, że konwersja jest
niezwykle istotną operacją w każdej implementacji API.
Rysunek 8.6. Konwersje i ustawianie wartości domyślnych w cyklu życia żądania

Na rysunku 8.6 pokazano nie tylko konwersje, ale też ustawianie wartości domyślnych (ang.
defaulting). Jest to proces uzupełniania nieokreślonych wartości pól. Ustawianie wartości
domyślnych jest ściśle powiązane z konwersją i zawsze jest stosowane do wersji zewnętrznej
przesyłanej w żądaniu od użytkownika, z systemu etcd lub z webhooka kontroli dostępu, ale
nigdy w momencie konwersji z wersji centralnej na wersję zewnętrzną.
Logika ustawiania wartości domyślnych może się zmieniać w cyklu życia serwera API. Wyobraź
sobie, że dodałeś do typu nowe pole. Użytkownik może mieć zapisane na dysku dawne obiekty.
Takie obiekty mogą się też znajdować w systemie etcd. Jeśli nowe pole ma wartość domyślną,
zostanie ona ustawiona, gdy dawne, zapisane obiekty będą przesyłane na serwer API lub gdy
użytkownik pobierze jeden z dawnych obiektów z systemu etcd. Będzie się wtedy wydawać, że
nowe pole istniało od zawsze, podczas gdy w rzeczywistości to proces ustawiania wartości
domyślnych na serwerze API przypisze do pola wartość w trakcie przetwarzania żądania.

Konwersja jest niezbędna, jeśli chodzi o działanie serwera API. Bardzo istotne
jest też, aby wszystkie konwersje (w jedną i drugą stronę) były poprawne ze
względu na konwersję powrotną. Poprawność ze względu na konwersję
powrotną oznacza, że w grafie wersji (zob. rysunek 8.4) można dokonać
konwersji w jedną, a następnie w drugą stronę, rozpoczynając od dowolnych
wartości, i nigdy nie skutkuje to utratą informacji. Konwersje muszą więc być
wzajemnie jednoznaczne i działać w modelu jeden do jednego. Możliwe musi
być np. przejście od losowego (ale poprawnego) obiektu z wersji v1 do
wewnętrznej wersji centralnej, a następnie ponownie do wersji v1. Wynikowy
obiekt musi być równoważny pierwotnemu.
Zapewnienie poprawności ze względu na konwersję powrotną często wymaga
starannego zastanowienia. Prawie zawsze wpływa to na projekt nowych wersji
API, a także na rozszerzanie dawnych typów, aby można było w nich zapisać
informacje przechowywane w nowych wersjach.
Krótko mówiąc: zapewnianie poprawności ze względu na konwersję powrotną
jest trudne — czasem bardzo. Zobacz punkt „Testowanie konwersji
powrotnych”, aby się dowiedzieć, jak skutecznie testować konwersję powrotną.

Pisanie typów API


Zobaczyłeś już, że w celu dodania API do niestandardowego serwera API trzeba napisać typy
dla wewnętrznej wersji centralnej i typy dla wersji zewnętrznej oraz przeprowadzać konwersję
między nimi. Zajmiemy się tym teraz w projekcie dotyczącym pizzerii (http://bit.ly/2x9C3gR).
Typy API zwyczajowo są umieszczane w pakiecie pkg/apis/nazwa-grupy projektu. Plik
pkg/apis/nazwa--grupy/types.go zawiera typy wersji wewnętrznej, a w plikach pkg/apis/nazwa-
grupy/wersja/types.go znajdują się typy wersji zewnętrznych. W naszym przykładzie używane
będą więc pliki pkg/apis/restaurant/types.go, pkg/apis/restaurant/v1alpha1/types.go i
pkg/apis/restaurant/v1beta1/types.go.
Kod konwersji będzie znajdował się w plikach pkg/apis/nazwa-
grupy/wersja/zz_generated.conversion.go (dane wyjściowe generatora conversion-gen) i
pkg/apis/nazwa-grupy/wersja/conversion.go (niestandardowe konwersje pisane przez
programistę).

W podobny sposób kod do ustawiania wartości domyślnych będzie zapisywany w plikach


pkg/apis/nazwa-grupy/wersja/zz_generated.defaults.go (dane wyjściowe generatora
defaulter-gen) i pkg/apis/nazwa-grupy/wersja/defaults.go (niestandardowy kod ustawiania
wartości domyślnych napisany przez programistę). W naszym przykładzie będą to pliki
pkg/apis/restaurant/v1alpha1/defaults.go i pkg/apis/restaurant/v1beta1/defaults.go.
Więcej szczegółów na temat konwersji i ustawiania wartości domyślnych dowiesz się z punktów
„Konwersje” i „Ustawianie wartości domyślnych”.
Jeśli pominąć konwersje i ustawianie wartości domyślnych, większość omawianego procesu
opisaliśmy już w kontekście definicji CRD w punkcie „Budowa typu”. Typy natywne dla wersji
zewnętrznych z niestandardowego serwera API są definiowane w identyczny sposób.
Ponadto istnieje plik pkg/apis/nazwa-grupy/types.go dla typów wersji wewnętrznej. Główna
różnica polega na tym, że w wersji wewnętrznej zmienna SchemeGroupVersion w pliku
register.go ma wartość runtime.APIVersionInternal (jest to skrót dla wartości
„__internal”).
// SchemeGroupVersion to wersja grupy używana do rejestrowania obiektów.
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version:
runtime.APIVersionInternal}
Inna różnica między plikiem pkg/apis/nazwa-grupy/types.go a plikami dla wersji zewnętrznych
to brak znaczników dla formatów JSON i protobuf.

Znaczniki dla formatu JSON są używane przez niektóre generatory do


wykrywania, czy plik types.go jest przeznaczony dla wersji zewnętrznej, czy dla
wersji wewnętrznej. Dlatego zawsze pamięta o usunięciu tych znaczników, gdy
kopiujesz i wklejasz typy z wersji zewnętrznych w celu tworzenia lub
modyfikowania typów wersji wewnętrznych.
Warto też wspomnieć o funkcji pomocniczej służącej do instalowania wszystkich wersji grupy
API w schemacie. Ta funkcja pomocnicza jest zwyczajowo umieszczana w pliku pkg/apis/nazwa-
grupy/install/install.go. W pliku pkg/apis/restaurant/install/install.go w naszym
niestandardowym serwerze API ta funkcja jest bardzo prosta:
// Install rejestruje grupę API i dodaje typy do schematu.
func Install(scheme *runtime.Scheme) {
utilruntime.Must(restaurant.AddToScheme(scheme))
utilruntime.Must(v1beta1.AddToScheme(scheme))
utilruntime.Must(v1alpha1.AddToScheme(scheme))
utilruntime.Must(scheme.SetVersionPriority(

v1beta1.SchemeGroupVersion,
v1alpha1.SchemeGroupVersion,
))
}
Ponieważ używanych jest kilka wersji, trzeba zdefiniować priorytety. Kolejność wersji będzie
używana do ustalenia domyślnej wersji składowania zasobu. Była też istotna przy wyborze
wersji w wewnętrznych klientach (zwracających obiekty z wersji wewnętrznej; zob. uwagę
„Wersjonowane klienty a dawne klienty wewnętrzne”). Jednak klienty wewnętrzne są
wycofywane. W przyszłości nawet w kodzie serwera API używany będzie klient wersji
zewnętrznych.

Konwersje
Konwersja polega na wzięciu obiektu z jednej wersji i przekształceniu go na obiekt z innej
wersji. Konwersja jest implementowana z użyciem funkcji konwersji. Część takich funkcji jest
pisana ręcznie (zwyczajowo są one umieszczane w pliku pkg/apis/nazwa-
grupy/wersja/conversion.go), a inne są automatycznie generowane przez generator
conversion-gen (http://bit.ly/31RewiP; te funkcje zwyczajowo są umieszczane w pliku
pkg/apis/nazwa-grupy/wersja/zz_generated.conversion.go).
Konwersja jest inicjowana za pomocą schematu (zob. punkt „Schemat”) i metody Convert(), do
której przekazywane są obiekt źródłowy in i obiekt docelowy out:
func (s *Scheme) Convert(in, out interface{}, context interface{}) error
Parametr context jest opisany tak:
// …opcjonalne pole, które jednostki wywołujące mogą używać do przekazywania
// informacji do funkcji konwersji.

Ten parametr jest używany tylko w wyjątkowych sytuacjach. Zwykle ma wartość nil. Dalej w
rozdziale przyjrzymy się zasięgowi funkcji konwersji. Zobaczysz tam, jak uzyskać dostęp do
kontekstu w funkcjach konwersji.
Aby można było przeprowadzić konwersję, w schemacie znane muszą być typy API z języka Go,
powiązane z nimi identyfikatory GVK i funkcje konwersji dla tych identyfikatorów GVK.
Generator conversion-gen rejestruje wygenerowane funkcje konwersji za pomocą lokalnego
obiektu do budowania schematu. W naszym przykładowym niestandardowym serwerze API plik
zz_generated.conversion.go rozpoczyna się tak:
func init() {
localSchemeBuilder.Register(RegisterConversions)
}

// RegisterConversions dodaje funkcje konwersji do danego schematu.


// Funkcja jest publiczna, aby umożliwić tworzenie dowolnych schematów.
func RegisterConversions(s *runtime.Scheme) error {
if err := s.AddGeneratedConversionFunc(
(*Topping)(nil),
(*restaurant.Topping)(nil),
func(a, b interface{}, scope conversion.Scope) error {
return Convert_v1alpha1_Topping_To_restaurant_Topping(

a.(*Topping),
b.(*restaurant.Topping),
scope,
)
},
); err != nil {
return err
}
...

return nil
}
...
Funkcja Convert_v1alpha1_Topping_To_restaurant_Topping() jest wygenerowana.
Przyjmuje obiekt z wersji v1alpha1 i przekształca go na obiekt wersji wewnętrznej.

Przedstawiona skomplikowana konwersja typów przekształca typizowaną


funkcję konwersji w funkcję func(a, b interface{}, scope
conversion.Scope) error o określonym typie. W schemacie używana jest ta
ostatnia, ponieważ można ją wywoływać bez korzystania z mechanizmu
refleksji. Refleksja działałaby tu powoli z powodu wielu koniecznych alokacji
pamięci.

Ręcznie napisane konwersje z pliku conversion.go są na etapie generowania kodu traktowane


priorytetowo, ponieważ generator conversion-gen pomija typy, jeśli w pakietach znajdzie dla
nich ręcznie napisaną funkcję konwersji z nazwą zgodną ze wzorcem Convert_nazwa-pakietu-
źródłowego_Kind_To_nazwa-pakietu-docelowego_Kind. Oto przykład:
func Convert_v1alpha1_PizzaSpec_To_restaurant_PizzaSpec(
in *PizzaSpec,
out *restaurant.PizzaSpec,
s conversion.Scope,
) error {
...
return nil
}

W najprostszym scenariuszu funkcje konwersji kopiują wartości z obiektu źródłowego do


docelowego. Jednak w przedstawionym przykładzie, gdzie specyfikacja pizzy z wersji v1alpha1
jest konwertowana na typ z wersji wewnętrznej, proste kopiowanie nie wystarcza. Trzeba
zastosować inną strukturę, która wygląda tak:
func Convert_v1alpha1_PizzaSpec_To_restaurant_PizzaSpec(
in *PizzaSpec,
out *restaurant.PizzaSpec,
s conversion.Scope,
) error {
idx := map[string]int{}

for _, top := range in.Toppings {


if i, duplicate := idx[top]; duplicate {
out.Toppings[i].Quantity++
continue
}
idx[top] = len(out.Toppings)
out.Toppings = append(out.Toppings, restaurant.PizzaTopping{
Name: top,
Quantity: 1,

})
}

return nil
}
Oczywiście żaden generator kodu nie jest na tyle inteligentny, aby zrozumieć zamiary
użytkownika definiującego różne typy.
Warto zauważyć, że w trakcie konwersji obiekt źródłowy nigdy nie może być modyfikowany.
Jednak zupełnie normalne i wysoce zalecane (często z powodu wydajności) jest ponowne
wykorzystywanie źródłowych struktur danych w docelowym obiekcie, jeśli typy tych struktur są
ze sobą zgodne.
Jest to tak ważne, że powtarzamy to w ostrzeżeniu. Ewentualne modyfikacje obiektów mają
wpływ nie tylko na implementację konwersji, ale też na jednostki uruchamiające konwersję i
korzystające z efektów konwersji.

Funkcje konwersji nie mogą modyfikować źródłowego obiektu, jednak obiekt


wyjściowy może współdzielić struktury danych z obiektem źródłowym. To
oznacza, że jeśli niedozwolone są zmiany w pierwotnym obiekcie, jednostka
używająca obiektu wyjściowego z procesu konwersji musi dbać o to, aby nie
modyfikować obiektu.
Załóżmy, że masz obiekt *core.Pod w wersji wewnętrznej i konwertujesz go na
obiekt dla wersji v1 (podv1 *corev1.Pod) oraz modyfikujesz wynikowy obiekt
podv1. Może to skutkować zmianami w pierwotnym podzie. Jeśli ten pod
pochodzi z informatora, taka sytuacja jest bardzo niebezpieczna, ponieważ
informatory mają współdzielony bufor i modyfikacja poda prowadzi do
niespójnego stanu bufora.
Dlatego pamiętaj o opisanej cesze konwersji i wykonuj głębokie kopiowanie,
jeśli jest konieczne, aby uniknąć niepożądanych i potencjalnie niebezpiecznych
modyfikacji.

Choć współdzielenie struktur danych związane jest z ryzykiem, pozwala w wielu sytuacjach
uniknąć niepotrzebnego alokowania pamięci. Generator kodu porównuje struktury źródłowe i
docelowe oraz używa pakietu unsafe języka Go do przekształcania za pomocą prostej konwersji
typów wskaźników do struktur z tym samym układem pamięci. Ponieważ typ reprezentujący
pizzę w wersji wewnętrznej i w wersji v1beta1 ma ten sam układ pamięci, otrzymujemy
następującą funkcję:
func autoConvert_restaurant_PizzaSpec_To_v1beta1_PizzaSpec(
in *restaurant.PizzaSpec,
out *PizzaSpec,
s conversion.Scope,
) error {
out.Toppings = *(*[]PizzaTopping)(unsafe.Pointer(&in.Toppings))
return nil
}

Na poziomie języka maszynowego jest to pusta instrukcja, dlatego funkcja działa błyskawicznie.
Pozwala to uniknąć alokowania pamięci dla wycinka i kopiowania kolejnych elementów z
argumentu in do out.
Warto też wspomnieć o trzecim argumencie funkcji konwersji — o zasięgu konwersji
(conversion.Scope).
Zasięg konwersji zapewnia dostęp do wielu wartości z poziomu meta. Można np. uzyskać
dostęp do wartości context przekazanej do metody Convert(in, out interface{}, context
interface{}) error schematu:
s.Meta().Context
Można też uruchomić konwersję podobiektów (za pomocą wywołania s.Convert) lub
przeprowadzić konwersję bez uwzględniania zarejestrowanych funkcji konwersji (przy użyciu
wywołania s.DefaultConvert).

Jednak w większości scenariuszy konwersja nie wymaga używania zasięgu. Dla uproszczenia
zasięg można ignorować, chyba że natrafisz na skomplikowaną sytuację, w której potrzebny jest
kontekst wykraczający poza obiekty źródłowy i docelowy.

Ustawianie wartości domyślnych


Jest to etap w cyklu życia żądania API, na którym ustawiane są wartości domyślne dla
pominiętych pól z przychodzących obiektów (przesyłanych z klienta lub systemu etcd). Na
przykład w podzie znajduje się pole restartPolicy. Jeśli użytkownik nie poda jego wartości,
domyślnie zastosowana zostanie wartość Always.

Wyobraź sobie, że jest rok 2014 i używasz bardzo starej wersji Kubernetesa. Pole
restartPolicy zostało właśnie dodane do systemu w jego najnowszej ówcześnie wersji. Po
zaktualizowaniu klastra systemie etcd znajduje się pod bez pola restartPolicy. Wywołanie
kubectl get pod wczyta dawny pod z systemu etcd, a kod do ustawiania wartości domyślnych
ustawi wartość domyślną Always. Z perspektywy użytkownika dawny pod w magiczny sposób
uzyskuje nowe pole restartPolicy.
Wróć do rysunku 8.6 i zobacz, gdzie obecnie ustawiane są wartości domyślne w procesie
obsługi żądania w Kubernetesie. Zauważ, że operacja ta jest wykonywana tylko dla typów z
wersji zewnętrznych, a nie dla typów z wersji wewnętrznej.
Teraz przyjrzyj się kodowi odpowiedzialnemu za ustawianie wartości domyślnych. Proces ten
(podobnie jak konwersje) jest inicjowany przez kod z biblioteki k8s.io/apiserver za pomocą
schematu. Dlatego w schemacie trzeba zarejestrować funkcje ustawiające wartości domyślne
dla niestandardowych typów.

Większość kodu do ustawiania wartości domyślnych jest generowana (też podobnie jak w
przypadku konwersji) za pomocą pliku binarnego defaulter-gen (http://bit.ly/2J108vK). Ten
generator analizuje typy API i tworzy funkcje ustawiające wartości domyślne w pliku
pkg/apis/nazwa-grupy/wersja/zz_generated.defaults.go. Wygenerowany kod domyślnie nie robi
nic innego oprócz wywoływania funkcji ustawiających wartości domyślne podstruktur.
Własną logikę ustawiania wartości domyślnych możesz zdefiniować za pomocą wzorca nazw dla
funkcji ustawiających takie wartości — SetDefaultsRodzaj:
func SetDefaultsRodzaj(obj *Typ) {
...
}
Ponadto — inaczej niż dla konwersji — trzeba ręcznie zarejestrować wygenerowaną funkcję w
lokalnym obiekcie do budowania schematu:
func init() {
localSchemeBuilder.Register(RegisterDefaults)
}
Tu funkcja RegisterDefaults jest generowana w pakiecie pkg/apis/nazwa-
grupy/wersja/zz_generated.defaults.go.
W kodzie do ustawiania wartości domyślnych niezwykle istotna jest wiedza o tym, czy pole
zostało ustawione przez użytkownika, czy nie. W wielu sytuacjach nie jest to oczywiste.
Język Go ma wartości zerowe dla wszystkich typów i używa ich, jeśli pole nie występuje w
przekazanych danych w formacie JSON lub protobuf. Załóżmy, że pole logiczne foo ma wartość
domyślną true. Wartością zerową tego pola jest false. Niestety, nie jest jasne, czy wczytana
wartość false została ustawiona przez użytkownika, czy jest wartością zerową dla pól
logicznych.
Aby uniknąć takich sytuacji, dla typów API w języku Go często trzeba posłużyć się typem
wskaźnikowym (w omawianym przykładzie jest to *bool). Wartość false podana przez
użytkownika skutkuje różnym od nil wskaźnikiem typu logicznego do wartości false. Jeśli
użytkownik podał wartość true, powstaje różny od nil wskaźnik typu logicznego do wartości
true. Jeżeli nie została podana żadna wartość, wskaźnik jest równy nil. Te sytuacje można
wykrywać w kodzie do ustawiania wartości domyślnych:
func SetDefaultsRodzaj(obj *Typ) {
if obj.Foo == nil {
x := true

obj.Foo = &x
}
}
Ten kod zapewnia oczekiwaną semantykę „foo domyślnie przyjmuje wartość true”.

Sztuczka z używaniem wskaźników działa dla typów prostych takich jak


łańcuchy znaków. Dla map i tablic często trudno jest uzyskać poprawność ze
względu na konwersje powrotne, jeśli nie będziesz wykrywać map i tablic o
wartości nil oraz pustych. Dlatego większość defaulterów dla map i tablic w
Kubernetesie w obu tych sytuacjach ustawia wartości domyślne, co pozwala
poradzić sobie z błędami związanymi z kodowaniem i dekodowaniem.

Testowanie konwersji powrotnych


Poprawna obsługa konwersji jest trudna. Testy konwersji powrotnych to niezbędne narzędzie
do automatycznego sprawdzania w randomizowanym teście, czy konwersje działają zgodnie z
planem i czy nie powodują utraty danych przy konwersjach między wszystkimi znanymi
wersjami grup.
Testy konwersji powrotnych zwykle są umieszczane w pliku install.go
(pkg/apis/restaurant/install/roundtrip_test.go) i wywołują funkcje testowania konwersji
powrotnych z repozytorium API Machinery:
import (

...
“k8s.io/apimachinery/pkg/api/apitesting/roundtrip”
restaurantfuzzer “github.com/programming-kubernetes/pizza-
apiserver/pkg/apis/
restaurant/fuzzer”
)

func TestRoundTripTypes(t *testing.T) {


roundtrip.RoundTripTestForAPIGroup(t, Install, restaurantfuzzer.Funcs)

}
Wewnętrznie wywołanie RoundTripTestForAPIGroup instaluje grupę API w tymczasowym
schemacie za pomocą funkcji Install. Następnie tworzy losowe obiekty dla wersji
wewnętrznej, używając przekazanego fuzzera, i konwertuje te obiekty do wybranej wersji
zewnętrznej i z powrotem do wersji wewnętrznej. Wynikowe obiekty muszą być
odpowiednikami obiektów pierwotnych. Takie testy są powtarzane setki lub tysiące razy dla
wszystkich wersji zewnętrznych.
Fuzzer to funkcja zwracająca wycinek z funkcjami generującymi losowe obiekty typów (ang.
randomizer) z wersji wewnętrznych i ich podtypów. W tym przykładzie fuzzer znajduje się w
pliku pkg/apis/restaurant/fuzzer/fuzzer.go i zwraca funkcję generującą losowe obiekty ze
specyfikacją:
// Funcs zwraca fuzzery dla grupy API dla pizzerii.
var Funcs = func(codecs runtimeserializer.CodecFactory) []interface{} {
return []interface{}{
func(s *restaurant.PizzaSpec, c fuzz.Continue) {

c.FuzzNoCustom(s) // Pierwsze generowanie losowych


// wartości (później ta funkcja nie jest
wywoływana).

// Unikanie pustego obiektu typu Toppings (ustawiane są wartości


domyślne).
if len(s.Toppings) == 0 {
s.Toppings = []restaurant.PizzaTopping{
{“salami”, 1},
{“mozzarella”, 1},

{“pomidory”, 1},
}
}

seen := map[string]bool{}
for i := range s.Toppings {
// Quantity ma być liczbą dodatnią, ale nie za dużą.
s.Toppings[i].Quantity = 1 + c.Intn(10)

// Usuwanie powtórzeń.
for {
if !seen[s.Toppings[i].Name] {
break
}
s.Toppings[i].Name = c.RandString()
}
seen[s.Toppings[i].Name] = true
}
},

}
}
Jeśli nie zostanie podana funkcja generująca losowe obiekty, używana biblioteka
github.com/google/gofuzz (http://bit.ly/2KJrb27) spróbuje uzyskać takie obiekty, ustawiając
losowe wartości dla typów bazowych i rekurencyjnie przetwarzając wskaźniki, struktury, mapy i
wycinki, ostatecznie wywołując niestandardowe funkcje generujące losowe obiekty (jeśli
programista je podał).
W trakcie pisania funkcji generującej losowe obiekty jednego z typów wygodnie jest najpierw
wywołać funkcję c.FuzzNoCustom(s). Ustawia ona losowe wartości dla podanego obiektu s, a
dodatkowo wywołuje niestandardowe funkcje dla podstruktur, ale już nie dla samego s.
Następnie programista może ograniczyć i poprawić losowe wartości, aby obiekt był prawidłowy.

Ważne jest, aby fuzzery było możliwie ogólne i generowały jak najwięcej
poprawnych obiektów. Jeśli fuzzer ma zbyt wiele ograniczeń, pokrycie danych
testami będzie niskie. W trakcie rozwijania Kubernetesa w wielu sytuacjach nie
wykryto błędów regresji, ponieważ używane fuzzery były niskiej jakości.
Fuzzer musi uwzględniać tylko obiekty, które są poprawne i które odpowiadają
obiektom, jakie można zdefiniować w wersjach zewnętrznych. Często trzeba
ograniczyć losowe wartości ustawiane przez funkcję c.FuzzNoCustom(s) w
taki sposób, aby losowe obiekty były prawidłowe. Na przykład łańcuch znaków
z adresem URL nie musi umożliwiać konwersji powrotnej dla przypadkowych
wartości, jeśli w procesie sprawdzania poprawności nieprawidłowe łańcuchy
znaków są odrzucane.

W przykładzie dotyczącym typu PizzaSpec kod najpierw wywołuje funkcję c.FuzzNoCustom(s),


a następnie poprawia obiekt:

Ustawia wartości domyślne, gdy lista dodatków jest pusta.


Ustawia sensowną liczbę jednostek dla każdego dodatku (bez tego konwersja do wersji
v1alpha1 byłaby bardzo złożona z powodu możliwych dużych liczb na listach łańcuchów
znaków).
Normalizuje nazwy dodatków, ponieważ wiadomo, że powtarzające się dodatki ze
specyfikacji pizzy nie są konwertowane zwrotnie (dla typów wewnętrznych; warto
zauważyć, że w typach z wersji v1alpha1 występują powtórzenia).

Sprawdzanie poprawności
Sprawdzanie poprawności przychodzących obiektów ma miejsce zaraz po deserializacji,
ustawieniu wartości domyślnych i konwersji do wersji wewnętrznej. Na pokazanym wcześniej
rysunku 8.5 widać, że sprawdzanie poprawności jest wykonywane między modyfikującymi
wtyczkami kontroli dostępu, a sprawdzającymi poprawność wtyczkami kontroli dostępu, na
długo przed wykonaniem logiki tworzącej lub modyfikującej obiekty.
To oznacza, że sprawdzanie poprawności trzeba zaimplementować raz — dla wersji
wewnętrznej (nie trzeba tego robić dla wszystkich wersji zewnętrznych). Ma to tę zaletę, że —
co oczywiste — zmniejsza ilość pracy związanej z implementacją i gwarantuje spójność między
wersjami. Z drugiej strony błędy sprawdzania poprawności nie dotyczą wersji zewnętrznych.
Zdarza się to w zasobach Kubernetesa, jednak w praktyce nie stanowi to istotnego problemu.
W tym punkcie opiszemy implementowanie funkcji do sprawdzania poprawności. Łączenie ich z
niestandardowym serwerem API (wywoływanie sprawdzania poprawności w strategii, która
konfiguruje generyczny rejestr) omówimy w następnym punkcie. Rysunek 8.5 jest więc nieco
mylący, ale za to przejrzysty.
Na razie wystarczy przyjrzeć się punktowi wejścia do procesu sprawdzania poprawności w
strategii:
func (pizzaStrategy) Validate(
ctx context.Context, obj runtime.Object,

) field.ErrorList {
pizza := obj.(*restaurant.Pizza)
return validation.ValidatePizza(pizza)
}
Ten kod wywołuje funkcję do sprawdzania poprawności ValidateRodzaj(obj *Rodzaj)
field.ErrorList z pakietu do sprawdzania poprawności dla grupy API
pkg/apis/grupa/validation.
Funkcja do sprawdzania poprawności zwraca listę błędów. Zwykle są one zapisywane w tym
samym stylu — zwracane wartości są dołączane do listy błędów w trakcie rekurencyjnego
przetwarzania typu z użyciem jednej funkcji do sprawdzania poprawności dla każdej struktury:

// ValidatePizza sprawdza poprawność obiektu typu Pizza.


func ValidatePizza(f *restaurant.Pizza) field.ErrorList {
allErrs := field.ErrorList{}

errs := ValidatePizzaSpec(&f.Spec, field.NewPath(“spec”))


allErrs = append(allErrs, errs...)

return allErrs
}

// ValidatePizzaSpec sprawdza poprawność obiektu typu PizzaSpec.


func ValidatePizzaSpec(
s *restaurant.PizzaSpec,
fldPath *field.Path,
) field.ErrorList {
allErrs := field.ErrorList{}
prevNames := map[string]bool{}
for i := range s.Toppings {
if s.Toppings[i].Quantity <= 0 {

allErrs = append(allErrs, field.Invalid(


fldPath.Child(“toppings”).Index(i).Child(“quantity”),
s.Toppings[i].Quantity,
“nie może być ujemne lub zerowe.”,
))
}
if len(s.Toppings[i].Name) == 0 {
allErrs = append(allErrs, field.Invalid(
fldPath.Child(“toppings”).Index(i).Child(“name”),

s.Toppings[i].Name,
“nie może być puste.”,
))
} else {
if prevNames[s.Toppings[i].Name] {
allErrs = append(allErrs, field.Invalid(
fldPath.Child(“toppings”).Index(i).Child(“name”),
s.Toppings[i].Name,
“musi być unikatowe.”,

))
}
prevNames[s.Toppings[i].Name] = true
}
}

return allErrs
}
Warto zwrócić uwagę na określanie ścieżki do pola z użyciem wywołań Child i Index. Ta
ścieżka jest zapisana w formacie JSON i wyświetlana po wystąpieniu błędów.

Kod do sprawdzania poprawności to odpowiednie miejsce do kontrolowania


nazw obiektów w momencie ich tworzenia. Można np. dopuszczać tylko nazwy
jednowyrazowe lub nie zezwalać na znaki inne niż alfanumeryczne.
Dla dowolnego pola ObjectMeta można zastosować niestandardowe
ograniczenia, choć w przypadku wielu pól jest to niewskazane, ponieważ może
to naruszyć działanie podstawowego API. Ograniczenia związane z nazwami
występują dla wielu zasobów, ponieważ nazwa pojawia się w innych systemach
lub w innych kontekstach wymagających specjalnego formatowania.
Jednak nawet jeśli przygotowano specjalny proces sprawdzania poprawności z
użyciem pola ObjectMeta w niestandardowym serwerze API, generyczny
rejestr po udanym zakończeniu niestandardowego sprawdzania poprawności
zawsze sprawdza poprawność na podstawie generycznych reguł. Dzięki temu
można najpierw zwracać bardziej specyficzne komunikaty o błędach z
niestandardowego kodu.

Często występuje dodatkowy, nieco odmienny zestaw funkcji do sprawdzania poprawności w


operacjach modyfikacji (pokazany zestaw jest przeznaczony dla operacji tworzenia obiektów).
W naszym przykładowym serwerze API te funkcje mogą wyglądać tak:
func (pizzaStrategy) ValidateUpdate(
ctx context.Context,
obj, old runtime.Object,
) field.ErrorList {
objPizza := obj.(*restaurant.Pizza)

oldPizza := old.(*restaurant.Pizza)
return validation.ValidatePizzaUpdate(objPizza, oldPizza)
}
Takie funkcje można wykorzystać do upewniania się, że modyfikowane są wyłącznie pola, które
nie są przeznaczone tylko do odczytu. Kod sprawdzający poprawność w trakcie modyfikacji
często wywołuje zwykłe funkcje do sprawdzania poprawności, a dodatkowo przeprowadza
kontrolę związaną z modyfikacjami.

Rejestr i strategia
Zobaczyłeś już, jak definiowane i sprawdzane są typy API. Następny krok to
zaimplementowanie logiki REST dla tych typów API. Na rysunku 8.7 pokazane jest, że rejestr to
centralny element implementacji grupy API. Generyczny kod obsługi żądań REST z biblioteki
k8s.io/apiserver kieruje wywołania do tego rejestru.
Rysunek 8.7. Składowanie zasobów i rejestr generyczny

Rejestr generyczny
Logika REST jest zwykle implementowana w rejestrze generycznym. Jest to — zgodnie z nazwą
— generyczna implementacja interfejsów rejestru z pakietu k8s.io/apiserver/pkg/registry/rest.
W rejestrze generycznym zaimplementowane jest domyślna obsługa żądań REST dla
„normalnych” zasobów. Prawie wszystkie zasoby Kubernetesa korzystają z tej implementacji.
Tylko dla kilku — dla tych, które nie utrwalają obiektów, np. SubjectAccessReview; zob. punkt
„Delegowana autoryzacja” — stosowane są niestandardowe implementacje.
W pliku k8s.io/apiserver/pkg/registry/rest/rest.go znajdziesz wiele interfejsów luźno
odpowiadających operacjom HTTP i niektórym mechanizmom API. Jeśli dany interfejs jest
zaimplementowany w rejestrze, kod punktu końcowego API udostępnia określone funkcje REST.
Ponieważ w rejestrze generycznym zaimplementowanych jest większość interfejsów z pakietu
k8s.io/apiserver/pkg/registry/rest, zasoby używające tego rejestru obsługują wszystkie
domyślne operacje HTTP z Kubernetesa (zob. punkt „Interfejs HTTP serwera API”). Oto lista
zaimplementowanych interfejsów wraz z opisem z dokumentacji go doc z kodu źródłowego
Kubernetesa:
CollectionDeleter
Obiekt, który potrafi usunąć kolekcję zasobów REST.
Creater
Obiekt, który potrafi utworzyć instancję obiektu REST.

CreaterUpdater
Obiekt składowania danych, który musi obsługiwać operacje tworzenia i modyfikacji.
Exporter
Obiekt, który wie, jak przygotować zasoby REST na potrzeby eksportu.
Getter
Obiekt, który potrafi pobrać zasób REST o podanej nazwie.
GracefulDeleter
Obiekt, który wie, jak przekazać opcje usuwania, aby umożliwić opóźnione usuwanie
obiektu REST.
Lister

Obiekt, który potrafi pobrać zasoby pasujące do podanych kryteriów dotyczących pola i
etykiety.
Patcher
Obiekt składowania danych, który obsługuje pobieranie i modyfikacje.
Scoper
Wymagany obiekt określający zasięg zasobu.
Updater
Obiekt, który potrafi zmodyfikować instancję obiektu REST.
Watcher

Obiekt, który powinien być implementowany przez wszystkie obiekty składowania danych
mające umożliwiać obserwowanie zmian za pomocą API Watch.
Przyjrzyj się jednemu z tych interfejsów, Creater:
// Creater to obiekt, który potrafi utworzyć instancję obiektu REST.
type Creater interface {
// New zwraca pusty obiekt, który można zastosować razem z funkcją Create
// po umieszczeniu w nim danych z żądania.
// Obiekt musi być typu wskaźnikowego, aby można go użyć w wywołaniu
Codec.DecodeInto([]byte,
// runtime.Object).

New() runtime.Object

// Create tworzy nową wersję zasobu.


Create(
ctx context.Context,
obj runtime.Object,
createValidation ValidateObjectFunc,
options *metav1.CreateOptions,
) (runtime.Object, error)
}
Rejestr z implementacją tego interfejsu potrafi tworzyć obiekty. Inaczej niż w interfejsie
NamedCreater tu nazwa nowego obiektu pochodzi z pola ObjectMeta.Name lub jest generowana
za pomocą wywołania ObjectMeta.GenerateName. Jeśli rejestr implementuje interfejs
NamedCreater, nazwę można przekazać w ścieżce HTTP.
Należy zrozumieć, że zaimplementowane interfejsy określają, które operacje będą obsługiwane
w punkcie końcowym API tworzonym w wyniku zainstalowania danego API w niestandardowym
serwerze API. W punkcie „Instalowanie API” pokazane jest, jak instalowanie wygląda w kodzie.

Strategia
Rejestr generyczny można w pewnym stopniu zmodyfikować, używając obiektu nazywanego
strategią. Strategia udostępnia wywołania zwrotne do mechanizmów takich jak sprawdzanie
poprawności (zob. punkt „Sprawdzanie poprawności”).
Strategia implementuje interfejsy REST strategii podane tu wraz z ich opisami z dokumentacji
go doc (definicje znajdziesz w pakiecie k8s.io/apiserver/pkg/registry/rest):
RESTCreateStrategy
Definiuje podstawowe sprawdzanie poprawności, akceptowane dane wejściowe i sposób
generowania nazw na potrzeby tworzenia obiektu zgodnie z konwencjami z API
Kubernetesa.
RESTDeleteStrategy
Definiuje usuwanie obiektu zgodnie z konwencjami z API Kubernetesa.
RESTGracefulDeleteStrategy
Musi być zaimplementowany w rejestrze obsługującym kontrolowane usuwanie.
GarbageCollectionDeleteStrategy
Musi być zaimplementowany w rejestrze, w którym jednostki zależne mają być domyślnie
osierocane.
RESTExportStrategy

Definiuje, jak eksportować obiekty Kubernetesa.


RESTUpdateStrategy
Definiuje podstawowe sprawdzanie poprawności, akceptowane dane wejściowe i sposób
generowania nazw na potrzeby modyfikowania obiektu zgodnie z konwencjami z API
Kubernetesa.
Przyjrzyj się ponownie strategii związanej z tworzeniem obiektów:
type RESTCreateStrategy interface {
runtime.ObjectTyper
// Generator nazw jest używany, gdy ustawione jest standardowe pole
GenerateName.
// Generator NameGenerator będzie wywoływany przed sprawdzaniem
poprawności.
names.NameGenerator

// NamespaceScoped ma wartość true, jeśli obiekt musi znajdować się w


przestrzeni nazw.
NamespaceScoped() bool
// PrepareForCreate jest wywoływana przed sprawdzaniem poprawności w
ramach tworzenia obiektu,
// aby go znormalizować. Na przykład usuwa pola, które nie mają być
utrwalane,
// sortuje listy, w których kolejność nie jest istotna, itd. Nie należy
usuwać tu pól,
// których obecność skutkowałaby błędem sprawdzania poprawności.
//
// Często jest implementowana do sprawdzania typu oraz inicjowania lub
zerowania
// statusu. Zerowanie statusu z powodu jego zmiany odbywa się
wewnętrznie. Zewnętrzne
// jednostki wywołujące API (użytkownicy) nie powinny ustawiać
początkowego

// statusu nowo tworzonych obiektów.


PrepareForCreate(ctx context.Context, obj runtime.Object)
// Validate zwraca wartość typu ErrorList z błędami sprawdzania
poprawności lub nil.
// Jest wywoływana po zapełnieniu domyślnych pól obiektu, ale przed
// jego utrwaleniem. Ta metoda nie powinna modyfikować
// obiektu.
Validate(ctx context.Context, obj runtime.Object) field.ErrorList
// Canonicalize umożliwia przekształcenie obiektu do postaci kanonicznej.
To
// gwarantuje, że kod operujący takimi obiektami może korzystać z ich
standardowej postaci
// w operacjach takich jak porównania. Canonicalize jest wywoływana po
// udanym sprawdzaniu poprawności, ale przed utrwaleniem obiektu.
// Ta metoda może modyfikować obiekt. Często implementowana na potrzeby
// sprawdzania typu lub jako pusta metoda.
Canonicalize(obj runtime.Object)
}
Zagnieżdżona wartość typu ObjectTyper rozpoznaje obiekty — sprawdza, czy obiekt z żądania
jest obsługiwany przez rejestr. Ważne jest, aby tworzyć obiekty właściwego rodzaju (np. za
pomocą zasobu foo należy tworzyć tylko zasoby Foo).

NameGenerator generuje nazwy na podstawie pola ObjectMeta.GenerateName.


Za pomocą pola NamespaceScoped strategia może obsługiwać zasoby z poziomu klastra lub z
poziomu przestrzeni nazw, zwracając wartość false lub true.
Metoda PrepareForCreate jest wywoływana dla przychodzącego obiektu przed sprawdzaniem
poprawności.
Metodę Validate poznałeś wcześniej w punkcie „Sprawdzanie poprawności”. Jest to punkt
wejścia dla funkcji do sprawdzania poprawności.
Metoda Canonicalize przeprowadza normalizację (np. sortuje wycinki).

Podłączanie strategii do rejestru generycznego


Obiekt strategii jest łączony z instancją rejestru generycznego. Oto konstruktor obiektów
magazynu danych REST dla niestandardowego serwera API z serwisu GitHub
(http://bit.ly/2Y0Mtyn):
// NewREST zwraca obiekt typu RESTStorage współdziałający z usługami API.

func NewREST(
scheme *runtime.Scheme,
optsGetter generic.RESTOptionsGetter,
) (*registry.REST, error) {
strategy := NewStrategy(scheme)

store := &genericregistry.Store{
NewFunc: func() runtime.Object { return &restaurant.Pizza{} },
NewListFunc: func() runtime.Object { return &restaurant.PizzaList{}
},
PredicateFunc: MatchPizza,
DefaultQualifiedResource: restaurant.Resource(“pizzas”),

CreateStrategy: strategy,
UpdateStrategy: strategy,
DeleteStrategy: strategy,
}
options := &generic.StoreOptions{

RESTOptions: optsGetter,
AttrFunc: GetAttrs,
}
if err := store.CompleteWithOptions(options); err != nil {
return nil, err
}
return &registry.REST{store}, nil
}
Ta funkcja tworzy obiekt rejestru generycznego typu genericregistry.Store i ustawia kilka
pól. Liczne z tych pól są opcjonalne. Jeśli programista ich nie ustawi, funkcja
store.CompleteWithOptions przypisze do nich wartości domyślne.
Instancja niestandardowej strategii najpierw jest tworzona przez konstruktor NewStrategy, a
następnie łączona z rejestrem na potrzeby użytkowania operatorów create, update i delete.
Ponadto funkcja z pola NewFunc tworzy nową instancję obiektu, a funkcja z pola NewListFunc
tworzy nową listę obiektów. Funkcja z pola PredicateFunc przekształca selektor (który może
zostać przekazany w żądaniu listy) na funkcję predykatową i filtruje obiekty czasu wykonania.
Zwrócony obiekt to rejestr REST. W przykładowym projekcie (http://bit.ly/2Rxcv6G) jest to tylko
prosta nakładka na obiekt rejestru generycznego pozwalająca uzyskać własny typ:
type REST struct {
*genericregistry.Store
}

Teraz wszystko jest już gotowe do utworzenia instancji API i powiązania jej z niestandardowym
serwerem API. W następnym punkcie zobaczysz, jak na tej podstawie utworzyć funkcję obsługi
żądań HTTP.

Instalowanie API
Aby aktywować API na serwerze API, trzeba wykonać dwa kroki:

1. Wersję API trzeba zainstalować w schemacie z typami API (oraz funkcjami do konwersji i
ustawiania wartości domyślnych) serwera.
2. Wersję API trzeba zainstalować w multiplekserze żądań HTTP na serwerze.

Pierwszy krok jest zwykle wykonywany centralnie w funkcjach init w ramach rozruchu
serwera API. W przykładowym niestandardowym serwerze API dzieje się to w pliku
pkg/apiserver/apiserver.go, w którym zdefiniowane są obiekty serverConfig i CustomServer
(zob. punkt „Wzorzec opcji i konfiguracji oraz szablonowy kod potrzebny do uruchomienia
serwera”):

import (
...
“k8s.io/apimachinery/pkg/runtime”
“k8s.io/apimachinery/pkg/runtime/serializer”

“github.com/programming-kubernetes/pizza-
apiserver/pkg/apis/restaurant/install”
)

var (
Scheme = runtime.NewScheme()
Codecs = serializer.NewCodecFactory(Scheme)
)
Następnie dla każdej grupy API, która ma być udostępniana, należy wywołać funkcję
Install():
func init() {
install.Install(Scheme)
}

Z przyczyn technicznych do schematu trzeba też dodać typy związane z wykrywaniem


informacji (w przyszłych wersjach biblioteki k8s.io/apiserver zapewne nie będzie to konieczne):
func init() {
// Trzeba dodać opcje do pustej wersji v1.
// DO ZROBIENIA: poprawić kod serwera, aby nie było to konieczne.
metav1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: “v1”})
// DO ZROBIENIA: sprawić, aby generyczny serwer API tego nie wymagał.
unversioned := schema.GroupVersion{Group: “”, Version: “v1”}
Scheme.AddUnversionedTypes(unversioned,
&metav1.Status{},

&metav1.APIVersions{},
&metav1.APIGroupList{},
&metav1.APIGroup{},
&metav1.APIResourceList{},
)
}
To powoduje zarejestrowanie w globalnym schemacie naszych typów API, w tym funkcji do
konwersji i ustawiania wartości domyślnych. Oznacza to, że pusty schemat z rysunku 8.3 ma
teraz wszystkie informacje o naszych typach.
Drugi krok polega na dodaniu grupy API do multipleksera żądań HTTP. Kod generycznego
serwera API zagnieżdżony w strukturze CustomServer zawiera metodę
InstallAPIGroup(apiGroupInfo *APIGroupInfo) error, która konfiguruje dla grupy API cały
potok obsługi żądań.
Jedyne, co musisz zrobić, to przekazać poprawnie zapełnioną strukturę typu APIGroupInfo.
Zrobimy to w konstruktorze New() (*CustomServer, error) typu completedConfig:
// New zwraca nową instancję typu CustomServer na podstawie podanej
konfiguracji.
func (c completedConfig) New() (*CustomServer, error) {
genericServer, err := c.GenericConfig.New(“pizza-apiserver”,
genericapiserver.NewEmptyDelegate())
if err != nil {
return nil, err

s := &CustomServer{
GenericAPIServer: genericServer,
}

apiGroupInfo :=
genericapiserver.NewDefaultAPIGroupInfo(restaurant.GroupName,
Scheme, metav1.ParameterCodec, Codecs)

v1alpha1storage := map[string]rest.Storage{}

pizzaRest := pizzastorage.NewREST(Scheme,
c.GenericConfig.RESTOptionsGetter)
v1alpha1storage[“pizzas”] = customregistry.RESTInPeace(pizzaRest)

toppingRest := toppingstorage.NewREST(
Scheme, c.GenericConfig.RESTOptionsGetter,
)

v1alpha1storage[“toppings”] = customregistry.RESTInPeace(toppingRest)

apiGroupInfo.VersionedResourcesStorageMap[“v1alpha1”] = v1alpha1storage

v1beta1storage := map[string]rest.Storage{}

pizzaRest = pizzastorage.NewREST(Scheme,
c.GenericConfig.RESTOptionsGetter)
v1beta1storage[“pizzas”] = customregistry.RESTInPeace(pizzaRest)

apiGroupInfo.VersionedResourcesStorageMap[“v1beta1”] = v1beta1storage
if err := s.GenericAPIServer.InstallAPIGroup(&apiGroupInfo); err != nil {
return nil, err
}

return s, nil
}
Obiekt apiGroupInfo zawiera referencje do rejestru generycznego, który zmodyfikowaliśmy w
punkcie „Rejestr i strategia” z użyciem strategii. Dla każdej wersji grupy i zasobu za pomocą
zaimplementowanych konstruktorów tworzona jest instancja rejestru.

Nakładka customregistry.RESTInPeace to funkcja pomocnicza, która wywołuje funkcję panic,


gdy konstruktory rejestru zwrócą błąd:
func RESTInPeace(storage rest.StandardStorage, err error)
rest.StandardStorage {
if err != nil {
err = fmt.Errorf(“Nie można utworzyć magazynu danych REST: %v”, err)
panic(err)
}
return storage
}

Sam rejestr jest niezależny od wersji, ponieważ używa obiektów wersji wewnętrznej (zob. punkt
8.5). Dlatego dla każdej wersji wywoływany jest ten sam konstruktor rejestru.
Wywołanie InstallAPIGroup powoduje uzyskanie kompletnego niestandardowego serwera API
gotowego do udostępniania naszej niestandardowej grupy API (zob. rysunek 8.7).
Po wszystkich tych żmudnych przygotowaniach pora zobaczyć nasze nowe grupy API w akcji. W
tym celu uruchomimy serwer w sposób pokazany w punkcie „Pierwsze uruchomienie”. Jednak
tym razem lista wykrytych informacji nie jest pusta — zawiera nowo zarejestrowany zasób:
$ curl -k https://localhost:443/apis
{
“kind”: “APIGroupList”,
“groups”: [

{
“name”: “restaurant.programming-kubernetes.info”,
“versions”: [
{
“groupVersion”: “restaurant.programming-kubernetes.info/v1beta1”,
“version”: “v1beta1”
},
{
“groupVersion”: “restaurant.programming-kubernetes.info/v1alpha1”,
“version”: “v1alpha1”
}

],
“preferredVersion”: {
“groupVersion”: “restaurant.programming-kubernetes.info/v1beta1”,
“version”: “v1beta1”
},
“serverAddressByClientCIDRs”: [
{
“clientCIDR”: “0.0.0.0/0”,
“serverAddress”: “:443”

}
]
}
]
}
W ten sposób prawie osiągnęliśmy cel, jakim jest udostępnianie API pizzerii. Podłączyliśmy
wersje grupy API, dodaliśmy konwersje, a sprawdzanie poprawności działa prawidłowo.
Brakuje jednak kodu sprawdzającego, czy podany dodatek do pizzy istnieje w klastrze. Można
byłoby umieścić taki kod w funkcjach do sprawdzania poprawności. Jednak zwyczajowo są to
funkcje do sprawdzania poprawności formatu. Są statyczne i nie wymagają do działania innych
zasobów.
Bardziej złożone sprawdzanie poprawności jest implementowane w kodzie kontroli dostępu.
Jest to temat następnego punktu.

Kontrola dostępu
Każde żądanie po unmarshallingu, ustawieniu wartości domyślnych i konwersji na typy
wewnętrzne przechodzi przez łańcuch wtyczek kontroli dostępu (zob. rysunek 8.2). Żądania
przechodzą kontrolę dostępu dwukrotnie:

z użyciem wtyczek modyfikujących,


z użyciem wtyczek sprawdzających poprawność.

Ta sama wtyczka może zarówno modyfikować żądania, jak i sprawdzać ich poprawność. Dlatego
może być wywoływana przez mechanizm kontroli dostępu dwukrotnie:

Raz na etapie modyfikowania, gdzie wszystkie wtyczki modyfikujące są wywoływane jedna


po drugiej.
Raz na etapie sprawdzania poprawności, gdzie wywoływane (czasem równolegle) są
wszystkie wtyczki sprawdzające poprawność.
Wtyczka może implementować interfejsy kontroli dostępu związane zarówno z
modyfikowaniem, jak i ze sprawdzaniem poprawności, udostępniając dwie różne metody dla
obu tych zadań.

Przed podziałem na modyfikowanie i sprawdzanie poprawności każda wtyczka


była wywoływana jednokrotnie. Prawie niemożliwe było wówczas
prześledzenie, jakie modyfikacje wprowadziły poszczególne wtyczki, a także
jaka kolejność uruchamiania wtyczek kontroli dostępu jest sensowna i prowadzi
do spójnego działania kodu.
Obecna dwuetapowa architektura gwarantuje przynajmniej to, że we
wszystkich wtyczkach poprawność jest sprawdzana na końcu, co gwarantuje
spójność.

Oprócz tego łańcuch wtyczek (ich kolejność) jest taki sam w obu etapach kontroli dostępu.
Wtyczki są zawsze włączane lub wyłączane dla obu etapów.
Wtyczki kontroli dostępu — przynajmniej te zaimplementowane w języku Go w sposób opisany
w tym rozdziale — współdziałają z typami z wersji wewnętrznej. Z kolei webhooki kontroli
dostępu (zob. punkt „Webhooki kontroli dostępu”) są oparte na typach z wersji zewnętrznych i
wymagają konwersji przy przesyłaniu żądań do webhooka i z powrotem (gdy używane są
webhooki modyfikujące).
Po wszystkich tych teoretycznych rozważaniach pora przejść do kodu.

Implementacja
Wtyczka kontroli dostępu to typ implementujący:

interfejs wtyczek kontroli dostępu Interface,


opcjonalnie interfejs MutationInterface,
opcjonalnie interfejs ValidationInterface.

Wszystkie te trzy interfejsy znajdują się w pakiecie k8s.io/apiserver/pkg/admission:


// Operation to typ operacji na zasobie sprawdzanej
// w ramach kontroli dostępu.
type Operation string

// Stałe z typu Operation.


const (

Create Operation = “CREATE”


Update Operation = “UPDATE”
Delete Operation = “DELETE”
Connect Operation = “CONNECT”
)

// Interface to abstrakcyjny dołączalny interfejs używany do podejmowania


// decyzji w ramach kontroli dostępu.
type Interface interface {
// Handles zwraca true, jeśli dany kontroler kontroli dostępu obsługuje
określoną operację.
// Możliwe operacje to: CREATE, UPDATE, DELETE i
// CONNECT.
Handles(operation Operation) bool.
}

type MutationInterface interface {


Interface

// Admit podejmuje decyzje z zakresu kontroli dostępu na podstawie


atrybutów żądania.
Admit(a Attributes, o ObjectInterfaces) (err error)
}

// ValidationInterface to abstrakcyjny dołączalny interfejs używany do


podejmowania
// decyzji w ramach kontroli dostępu.
type ValidationInterface interface {
Interface

// Validate podejmuje decyzje z zakresu kontroli dostępu na podstawie


atrybutów żądania.
// NIE może modyfikować żądania.
Validate(a Attributes, o ObjectInterfaces) (err error)
}
Widać tu, że metoda Handles z interfejsu Interface odpowiada za filtrowanie operacji. Wtyczki
modyfikujące są wywoływane w funkcji Admit, a wtyczki sprawdzające poprawność — w funkcji
Validate.
Typ ObjectInterfaces zapewnia dostęp do funkcji pomocniczych, zwykle implementowanych
w schemacie:
type ObjectInterfaces interface {
// GetObjectCreater zwraca obiekt typu ObjectCreater dla żądanego
obiektu.
GetObjectCreater() runtime.ObjectCreater
// GetObjectTyper zwraca obiekt typu ObjectTyper dla żądanego obiektu.
GetObjectTyper() runtime.ObjectTyper
// GetObjectDefaulter zwraca obiekt typu ObjectDefaulter dla żądanego
obiektu.
GetObjectDefaulter() runtime.ObjectDefaulter
// GetObjectConvertor zwraca obiekt typu ObjectConvertor dla żądanego
obiektu.

GetObjectConvertor() runtime.ObjectConvertor
}
Atrybuty przekazane do wtyczki (w funkcji Admit, Validate lub w obu tych funkcjach)
zawierają wszystkie potrzebne do zaimplementowania zaawansowanych testów informacje,
jakie można pobrać z żądania:
// Attributes to interfejs używany przez kontroler AdmissionController do
pobierania
// informacji o żądaniu, które służą do podejmowania decyzji z zakresu
kontroli dostępu.
type Attributes interface {

// GetName zwraca nazwę obiektu występującą w żądaniu.


// W operacji CREATE klient może pominąć nazwę i polegać na tym,
// że serwer ją wygeneruje. W takim scenariuszu ta metoda zwraca
// pusty łańcuch znaków.
GetName() string
// GetNamespace zwraca powiązaną z żądaniem przestrzeń nazw (jeśli taka
istnieje).
GetNamespace() string

// GetResource zwraca nazwę żądanego zasobu. Nie jest ona identyczna


// z rodzajem. Przykład to: pods.

GetResource() schema.GroupVersionResource
// GetSubresource zwraca nazwę żądanego podzasobu. Jest to

// inny zasób o zasięgu zasobu nadrzędnego, od którego może


// mieć inny rodzaj.

// Na przykład dla punktu końcowego /pods zasób to „pods”, a rodzaj to


„Pod”, natomiast dla punktu
// końcowego /pods/foo/status zasób to „pods”, podzasób to „status”, a
rodzaj to
// „Pod” (ponieważ status dotyczy podów). Z kolei punktem końcowym wiązań

// dla podów może być /pods/foo/binding z zasobem „pods”,


// podzasobem „binding” i rodzajem „Binding”.

GetSubresource() string
// GetOperation zwraca wykonywaną operację.
GetOperation() Operation

// IsDryRun o wartości true informuje, że modyfikacje z danego żądania


nie zostaną

// utrwalone. Ma to zapobiegać efektom ubocznym z kontrolerów kontroli


dostępu
// i przeciążeniu metody uzgadniającej stan.

// Jednak wartość false nie oznacza, że modyfikacje zostaną utrwalone.


// Nadal mogą zostać odrzucone na dalszym

// etapie sprawdzania poprawności.


IsDryRun() bool

// GetObject zwraca obiekt z przychodzącego żądania przed


// ustawieniem wartości domyślnych.

GetObject() runtime.Object

// GetOldObject zwraca istniejący obiekt. To pole jest zapełniane tylko w


żądaniach UPDATE.

GetOldObject() runtime.Object
// GetKind zwraca typ przetwarzanego obiektu (np. Pod).

GetKind() schema.GroupVersionKind
// GetUserInfo zwraca informacje o użytkowniku zgłaszającym dane żądanie.

GetUserInfo() user.Info

// AddAnnotation ustawia adnotacje na podstawie pary klucz-wartość. Klucz


należy
// podawać z kwalifikatorem, np.
podsecuritypolicy.admission.k8s.io/admit-policy,
// gdzie „podsecuritypolicy” to nazwa wtyczki, „admission.k8s.io”

// to nazwa organizacji, a „admit-policy” to nazwa klucza.


// Jeśli format klucza jest nieprawidłowy, zwracany jest błąd.

// Próba zastąpienia adnotacji nową wartością też skutkuje zwróceniem


błędu.

// Adnotacje można dodawać w obu interfejsach:


// ValidationInterface i MutationInterface.

AddAnnotation(key, value string) error

}
Na etapie modyfikowania (czyli w implementacji metody Admit(a Attributes) error) można
zmieniać atrybuty, a bardziej precyzyjnie — obiekt zwrócony przez funkcję GetObject()
runtime.Object.

Na etapie sprawdzania poprawności modyfikacje nie są dozwolone.


Na obu etapach można wywołać funkcję AddAnnotation(key, value string) error. Pozwala
ona dodawać adnotacje na końcu danych wyjściowych serwera API używanych do audytu. Te
adnotacje mogą pomóc zrozumieć, dlaczego wtyczka kontroli dostępu zmodyfikowała lub
odrzuciła żądanie.

Odrzucenie żądania jest sygnalizowane zwróceniem błędu różnego od nil przez funkcję Admit
lub Validate.

We wtyczkach kontroli dostępu modyfikujących żądania dobrą praktyką jest


sprawdzanie prawidłowości zmian na etapie sprawdzania poprawności. Wynika
to z tego, że inne wtyczki, w tym webhooki kontroli dostępu, mogą
wprowadzać dodatkowe zmiany. Jeśli wtyczka kontroli dostępu gwarantuje, że
określone niezmienniki są zachowane, tylko na etapie sprawdzania
poprawności można zagwarantować, że rzeczywiście tak jest.

Wtyczki kontroli dostępu muszą implementować metodę Handles(operation Operation) bool


z interfejsu admission.Interface. W tym samym pakiecie znajduje się typ pomocniczy
Handler. Instancję tego typu można utworzyć za pomocą wywołania NewHandler(ops
...Operation) *Handler. Aby zaimplementować metodę Handles, należy zagnieździć instancję
typu Handler w niestandardowej wtyczce kontroli dostępu:

type CustomAdmissionPlugin struct {


*admission.Handler

...
}

Wtyczki kontroli dostępu zawsze powinny najpierw sprawdzać identyfikator GVK przekazanego
obiektu:

func (d *PizzaToppingsPlugin) Admit(


a admission.Attributes,

o ObjectInterfaces,

) error {
// Interesują nas tylko pizze.

if a.GetKind().GroupKind() != restaurant.Kind(“Pizza”) {
return nil

}
...

}
Podobnie powinien wyglądać kod przy sprawdzaniu poprawności:

func (d *PizzaToppingsPlugin) Validate(

a admission.Attributes,
o ObjectInterfaces,

) error {
// Interesują nas tylko pizze.
if a.GetKind().GroupKind() != restaurant.Kind(“Pizza”) {

return nil
}

...
}

Dlaczego infrastruktura serwera API nie filtruje wstępnie obiektów?


W natywnych wtyczkach kontroli dostępu nie ma mechanizmu rejestracji, który
zapewniałby dostępność informacji o obsługiwanych obiektach dla serwera API i pozwalał
wywoływać wtyczki tylko dla obsługiwanych przez nie obiektów. Jednym z powodów
takiego rozwiązania jest to, że wiele wtyczek serwera API Kubernetesa (na potrzeby
którego wymyślono mechanizm kontroli dostępu) obsługuje bardzo dużą liczbę obiektów.

Kompletna przykładowa implementacja kontroli dostępu wygląda tak:


// Admit gwarantuje, że przetwarzany obiekt jest rodzaju Pizza.

// Dodatkowo sprawdza, czy użyte dodatki są znane.

func (d *PizzaToppingsPlugin) Validate(


a admission.Attributes,

_ admission.ObjectInterfaces,
) error {

// Interesują nas tylko pizze.


if a.GetKind().GroupKind() != restaurant.Kind(“Pizza”) {

return nil
}

if !d.WaitForReady() {
return admission.NewForbidden(a, fmt.Errorf(“Niegotowy”))

obj := a.GetObject()
pizza := obj.(*restaurant.Pizza)

for _, top := range pizza.Spec.Toppings {


err := _, err := d.toppingLister.Get(top.Name)

if err != nil && errors.IsNotFound(err) {

return admission.NewForbidden(
a,

fmt.Errorf(“Nieznany dodatek: %s”, top.Name),


)
}

}
return nil

Ten kod wykonuje następujące kroki:

1. Sprawdza, czy przekazany obiekt jest właściwego rodzaju.


2. Blokuje dostęp, jeśli informatory nie są jeszcze gotowe.
3. Sprawdza za pomocą listera informującego o dodatkach, czy każdy dodatek wymieniony w
specyfikacji pizzy rzeczywiście istnieje jako obiekt typu Topping w klastrze.

Warto zauważyć, że lister jest tu tylko interfejsem do przechowywanego w pamięci magazynu


danych informatora. Dlatego wywołania Get będą szybko przetwarzane.

Rejestrowanie
Wtyczki kontroli dostępu trzeba zarejestrować. Służy do tego funkcja Register:

func Register(plugins *admission.Plugins) {


plugins.Register(

“PizzaTopping”,

func(config io.Reader) (admission.Interface, error) {


return New()

},
)

}
Ta funkcja jest dodawana do listy wtyczek w obiekcie typu RecommendedOptions (zob. punkt
„Wzorzec opcji i konfiguracji oraz szablonowy kod potrzebny do uruchomienia serwera”):
func (o *CustomServerOptions) Complete() error {

// Rejestrowanie wtyczek kontroli dostępu.

pizzatoppings.Register(o.RecommendedOptions.Admission.Plugins)

// Dodawanie wtyczek kontroli dostępu do pola RecommendedPluginOrder.


oldOrder := o.RecommendedOptions.Admission.RecommendedPluginOrder

o.RecommendedOptions.Admission.RecommendedPluginOrder =
append(oldOrder, “PizzaToppings”)

return nil

Tu lista RecommendedPluginOrder jest wstępnie zapełniana generycznymi wtyczkami kontroli


dostępu, które powinny być włączone w każdym serwerze API, jeśli ma być on dobrym
„obywatelem” klastra zgodnym z konwencjami obowiązującymi dla API.
Zalecaną praktyką jest niezmienianie kolejności wtyczek. Jednym z powodów jest to, że trudno
jest ustalić właściwą ich kolejność. Oczywiście można dodać niestandardową wtyczkę w miejscu
innym niż koniec listy, jeśli jest to konieczne ze względu na działanie danej wtyczki.
Użytkownik niestandardowego serwera API może wyłączyć niestandardową wtyczkę kontroli
dostępu, używając standardowych flag konfiguracji łańcucha kontroli dostępu (np. flagi --
disable-admission-plugins). Nasza wtyczka domyślnie jest włączona, ponieważ nie jest
bezpośrednio wyłączana.
Wtyczki kontroli dostępu można konfigurować za pomocą pliku konfiguracyjnego. W tym celu
należy wykorzystać dane wyjściowe z obiektu typu io.Reader z pokazanej wcześniej funkcji
Register. Opcja --admission-control-config-file pozwala przekazać plik konfiguracyjny
do wtyczki. Oto przykład:

kind: AdmissionConfiguration
apiVersion: apiserver.k8s.io/v1alpha1

plugins:
- name: CustomAdmissionPlugin

path: custom-admission-plugin.yaml
Inna możliwość to wewnątrzwierszowe podawanie konfiguracji, aby cała konfiguracja kontroli
dostępu znajdowała się w jednym miejscu:
kind: AdmissionConfiguration

apiVersion: apiserver.k8s.io/v1alpha1

plugins:
- name: CustomAdmissionPlugin

configuration:
wewnątrzwierszowa-niestandardowa-konfiguracja-w-formacie-yaml

Pokrótce wspomnieliśmy, że wtyczka kontroli dostępu używa informatora na temat dodatków,


aby sprawdzać istnienie dodatków podanych w pizzy. Nie wyjaśniliśmy jednak, jak połączyć
takie informatory z wtyczkami kontroli dostępu. Zróbmy to teraz.

Podłączanie zasobów
Wtyczki kontroli dostępu często potrzebują do działania klientów, informatorów lub innych
zasobów. Potrzebne zasoby można podłączyć za pomocą inicjalizatorów wtyczek.
Istnieje wiele standardowych inicjalizatorów wtyczek. Jeśli dana wtyczka ma być przez nie
wywoływana, musi implementować określone interfejsy z wywołaniami zwrotnymi (więcej na
ten temat dowiesz się z pakietu k8s.io/apiserver/pkg/admission/initializer):
// WantsExternalKubeClientSet definiuje funkcję udostępniającą obiekt
ClientSet
// wtyczkom kontroli dostępu, które potrzebują tego obiektu.

type WantsExternalKubeClientSet interface {


SetExternalKubeClientSet(kubernetes.Interface)

admission.InitializationValidator
}
// WantsExternalKubeInformerFactory definiuje funkcję udostępniającą obiekt
InformerFactory

// wtyczkom kontroli dostępu, które potrzebują tego obiektu.


type WantsExternalKubeInformerFactory interface {

SetExternalKubeInformerFactory(informers.SharedInformerFactory)
admission.InitializationValidator

// WantsAuthorizer definiuje funkcję udostępniającą obiekt Authorizer

// wtyczkom kontroli dostępu, które potrzebują tego obiektu.


type WantsAuthorizer interface {

SetAuthorizer(authorizer.Authorizer)
admission.InitializationValidator

// WantsScheme definiuje funkcję przyjmującą obiekt runtime.Scheme


// dla wtyczek kontroli dostępu, które potrzebują tego obiektu.

type WantsScheme interface {

SetScheme(*runtime.Scheme)
admission.InitializationValidator

}
Jeśli zaimplementujesz wybrane z tych funkcji, wtyczka będzie wywoływana w trakcie rozruchu
serwera, aby uzyskać dostęp np. do zasobów Kubernetesa lub globalnego schematu serwera
API.

Ponadto zaimplementowany powinien być interfejs admission.InitializationValidator,


przeprowadzający ostateczny test tego, czy wtyczka jest prawidłowo skonfigurowana:

// InitializationValidator przechowuje funkcję ValidateInitialization,


odpowiadającą

// za sprawdzanie poprawności zainicjalizowanych współdzielonych zasobów. Ten


interfejs
// należy zaimplementować we wtyczkach kontroli dostępu.

type InitializationValidator interface {


ValidateInitialization() error

}
Standardowe inicjalizatory są bardzo przydatne, jednak potrzebujemy dostępu do informatora
dotyczącego dodatków do pizzy. Zobacz więc, jak dodać własne inicjalizatory. Inicjalizator
obejmuje:
Implementację interfejsu z rodziny Wants* (np. WantsRestaurantInformerFactory), który
powinien być zaimplementowany we wtyczce kontroli dostępu:

// WantsRestaurantInformerFactory definiuje funkcję, która udostępnia


obiekt typu

// InformerFactory potrzebującym go wtyczkom kontroli dostępu.

type WantsRestaurantInformerFactory interface {


SetRestaurantInformerFactory(informers.SharedInformerFactory)

admission.InitializationValidator
}

Strukturę inicjalizatora implementującą interfejs admission.PluginInitializer:


func (i restaurantInformerPluginInitializer) Initialize(

plugin admission.Interface,
) {

if wants, ok := plugin.(WantsRestaurantInformerFactory); ok {

wants.SetRestaurantInformerFactory(i.informers)
}

}
Metoda Initialize() sprawdza tu, czy przekazana wtyczka implementuje powiązany
niestandardowy interfejs inicjalizatora Wants*. Jeśli tak jest, inicjalizator wywołuje
zaimplementowaną metodę dla danej wtyczki.

Kod przypisujący konstruktor inicjalizatora do pola


RecommendedOptions.ExtraAdmissionInitializers (zob. punkt „Wzorzec opcji i
konfiguracji oraz szablonowy kod potrzebny do uruchomienia serwera”):
func (o *CustomServerOptions) Config() (*apiserver.Config, error) {

...

o.RecommendedOptions.ExtraAdmissionInitializers =
func(c *genericapiserver.RecommendedConfig) (

[]admission.PluginInitializer, error,
) {

client, err :=
clientset.NewForConfig(c.LoopbackClientConfig)

if err != nil {
return nil, err

informerFactory := informers.NewSharedInformerFactory(
client, c.LoopbackClientConfig.Timeout,

)
o.SharedInformerFactory = informerFactory
return []admission.PluginInitializer{

custominitializer.New(informerFactory),
}, nil

}
...

}
Ten kod tworzy klienta z pętlą zwrotną dla grupy API dla pizzerii, tworzy odpowiednią
fabrykę informatorów, zapisuje ją w opcjach o i zwraca inicjalizator wtyczki dodający tę
fabrykę.

Tak jak obiecywaliśmy, kontrola dostępu jest ostatnim krokiem w implementowaniu


niestandardowego serwera API dla grupy API pizzerii. Teraz chcemy zobaczyć, jak działa ten
serwer API, ale nie w sztucznym środowisku na maszynie lokalnej, tylko w rzeczywistym
klastrze Kubernetesa. To oznacza, że musimy przyjrzeć się instalacji zagregowanego
niestandardowego serwera API.

Synchronizowanie informatorów
Jeśli informatory są używane we wtyczkach kontroli dostępu, zawsze należy najpierw
sprawdzić, czy są zsynchronizowane. Dopiero potem można ich używać w funkcjach
Admit() i Validate(). Do tego czasu należy odrzucać żądania, zgłaszając błąd
Forbidden.
Używając pomocniczej struktury Handler (opisanej w punkcie „Implementacja”), można
zsynchronizować informatory za pomocą funkcji Handler.WaitForReady():
if !d.WaitForReady() {

return admission.NewForbidden(
a, fmt.Errorf(“Brak gotowości do obsługi żądań”),

}
Aby w metodzie WaitForReady() uwzględnić metodę HasSynced() niestandardowego
informatora, należy dodać tę ostatnią do funkcji informujących o gotowości w
implementacji inicjalizatora. Oto przykład:

func (d *PizzaToppingsPlugin) SetRestaurantInformerFactory(


f informers.SharedInformerFactory) {

d.toppingLister = f.Restaurant().V1Alpha1().Toppings().Lister()

d.SetReadyFunc(f.Restaurant().V1Alpha1().Toppings().Informer().HasSynced)
}
Instalowanie niestandardowych
serwerów API
W punkcie „Usługi API” pokazaliśmy obiekt typu APIService służący do rejestrowania wersji
grupy API z niestandardowego serwera API za pomocą agregatora z serwera API Kubernetesa:
apiVersion: apiregistration.k8s.io/v1beta1

kind: APIService
metadata:

name: nazwa
spec:

group: nazwa-grupy-API

version: wersja-grupy-API
service:

namespace: przestrzeń-nazw-usług-niestandardowego-serwera-API
name: usługa-niestandardowego-serwera-API

caBundle: zestaw-certyfikatów-w-formacie-base64
insecureSkipTLSVerify: wartość-logiczna

groupPriorityMinimum: 2000
versionPriority: 20

Obiekt typu APIService wskazuje usługę. Zwykle jest to normalna usługa IP z klastra. Oznacza
to, że niestandardowy serwer API jest instalowany w klastrze z użyciem podów. Usługa
przekazuje żądania do tych podów.

Przyjrzyj się manifestowi Kubernetesa, który pozwala zainstalować serwer API.

Manifesty instalacji
Używane są tu następujące manifesty (znajdziesz je w przykładowym kodzie w serwisie GitHub;
http://bit.ly/2J6CVIz). Będą one częścią instalacji niestandardowej usługi API w klastrze:

Obiekt typu APIService dla obu wersji — v1alpha1:

apiVersion: apiregistration.k8s.io/v1beta1
kind: APIService

metadata:
name: v1alpha1.restaurant.programming-kubernetes.info

spec:

insecureSkipTLSVerify: true
group: restaurant.programming-kubernetes.info

groupPriorityMinimum: 1000
versionPriority: 15

service:
name: api

namespace: pizza-apiserver
version: v1alpha1

i v1beta1:

apiVersion: apiregistration.k8s.io/v1beta1
kind: APIService

metadata:
name: v1alpha1.restaurant.programming-kubernetes.info

spec:
insecureSkipTLSVerify: true

group: restaurant.programming-kubernetes.info
groupPriorityMinimum: 1000

versionPriority: 15

service:
name: api

namespace: pizza-apiserver
version: v1alpha1

Warto zauważyć, że ustawiane jest tu pole insecureSkipTLSVerify. Jest to dopuszczalne


w trakcie rozwoju oprogramowania, ale niewłaściwe w instalacjach produkcyjnych. W
punkcie „Certyfikaty i zaufanie” zobaczysz, jak to naprawić.
Obiekt typu Service działający przed instancjami niestandardowego serwera API
uruchomionymi w klastrze:

apiVersion: v1
kind: Service

metadata:
name: api

namespace: pizza-apiserver
spec:

ports:
- port: 443

protocol: TCP

targetPort: 8443
selector:

apiserver: “true”
Obiekt typu Deployment (tak jak tutaj) lub DaemonSet dla podów niestandardowego
serwera API:

apiVersion: apps/v1
kind: Deployment

metadata:
name: pizza-apiserver

namespace: pizza-apiserver
labels:

apiserver: “true”

spec:
replicas: 1

selector:
matchLabels:

apiserver: “true”
template:

metadata:
labels:

apiserver: “true”

spec:
serviceAccountName: apiserver

containers:
- name: apiserver

image: quay.io/programming-kubernetes/pizza-apiserver:latest
imagePullPolicy: Always

command: [“/pizza-apiserver”]
args:

- --etcd-servers=http://localhost:2379

- --cert-dir=/tmp/certs
- --secure-port=8443

- --v=4
- name: etcd

image: quay.io/coreos/etcd:v3.2.24
workingDir: /tmp

Przestrzeń nazw, w której będą działać usługa i instalacja:


apiVersion: v1
kind: Namespace
metadata:

name: pizza-apiserver

spec: {}

Zagregowany serwer API często jest instalowany w węzłach zarezerwowanych dla podów
warstwy kontroli (nazywanych węzłami nadrzędnymi — ang. master). Wtedy warto zastosować
obiekt typu DaemonSet, aby uruchamiać jedną instancję niestandardowego serwera API na
jeden węzeł nadrzędny. To prowadzi do konfiguracji z wysoką dostępnością usługi. Warto
zauważyć, że serwery API są bezstanowe. Oznacza to, że można je łatwo instalować wiele razy i
nie wymaga to wyboru lidera.

Po utworzeniu pokazanych manifestów praca jest już prawie ukończona. Jednak, jak to często
bywa, bezpieczna instalacja wymaga dodatkowego zastanowienia. Może zauważyłeś, że pody
(zdefiniowane w pokazanej instalacji) używają niestandardowego konta usługi, apiserver.
Można je utworzyć za pomocą kolejnego manifestu:

kind: ServiceAccount

apiVersion: v1
metadata:

name: apiserver
namespace: pizza-apiserver

To konto usługi wymaga zestawu uprawnień, które można dodać za pomocą obiektów systemu
RBAC.

Konfigurowanie systemu RBAC


Konto usługi API przede wszystkim wymaga generycznych uprawnień do:

Cyklu życia przestrzeni nazw

Obiekty można tworzyć tylko w istniejącej przestrzeni nazw i usuwać wraz z usunięciem
danej przestrzeni nazw. W tym celu serwer API musi pobierać, wyświetlać i obserwować
przestrzenie nazw.
Webhooków kontroli dostępu

Webhooki kontroli dostępu skonfigurowane w polach MutatingWebhookConfigurations i


ValidatedWebhookConfigurations są wywoływane niezależnie przez każdy serwer API. W
tym celu mechanizm kontroli dostępu w niestandardowym serwerze API musi pobierać,
wyświetlać i obserwować te zasoby.

Oba te aspekty można skonfigurować, tworząc rolę klastra w systemie RBAC:


kind: ClusterRole

apiVersion: rbac.authorization.k8s.io/v1

metadata:
name: aggregated-apiserver-clusterrole

rules:
- apiGroups: [“”]

resources: [“namespaces”]
verbs: [“get”, “watch”, “list”]

- apiGroups: [“admissionregistration.k8s.io”]
resources: [“mutatingwebhookconfigurations”,
“validatingwebhookconfigurations”]

verbs: [“get”, “watch”, “list”]


Tę rolę należy powiązać z kontem usługi apiserver za pomocą obiektu typu
ClusterRoleBinding:
apiVersion: rbac.authorization.k8s.io/v1

kind: ClusterRoleBinding
metadata:

name: pizza-apiserver-clusterrolebinding
roleRef:

apiGroup: rbac.authorization.k8s.io
kind: ClusterRole

name: aggregated-apiserver-clusterrole

subjects:
- kind: ServiceAccount

name: apiserver
namespace: pizza-apiserver

Na potrzeby delegowanego uwierzytelniania i delegowanej autoryzacji konto usługi trzeba


powiązać z istniejącą rolą systemu RBAC extension-apiserver-authentication-reader:

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding

metadata:

name: pizza-apiserver-auth-reader
namespace: kube-system

roleRef:
apiGroup: rbac.authorization.k8s.io

kind: Role
name: extension-apiserver-authentication-reader

subjects:
- kind: ServiceAccount

name: apiserver

namespace: pizza-apiserver
i z istniejącą rolą klastra systemu RBAC system:auth-delegator:
apiVersion: rbac.authorization.k8s.io/v1

kind: ClusterRoleBinding
metadata:

name: pizza-apiserver:system:auth-delegator
roleRef:

apiGroup: rbac.authorization.k8s.io
kind: ClusterRole

name: system:auth-delegator

subjects:
- kind: ServiceAccount

name: apiserver
namespace: pizza-apiserver

Uruchamianie niestandardowego serwera API bez


zabezpieczeń
Po przygotowaniu wszystkich manifestów i skonfigurowaniu systemu RBAC pora zainstalować
serwer API w rzeczywistym klastrze.

Z katalogu z repozytorium pobranym z serwisu GitHub (http://bit.ly/2x9C3gR) i z narzędziem


kubectl ze skonfigurowanymi uprawnieniami cluster-admin (jest to potrzebne, ponieważ
reguły RBAC nigdy nie mogą zwiększać poziomu dostępu) wykonaj odpowiednie instrukcje:
$ cd $GOPATH/src/github.com/programming-kubernetes/pizza-apiserver

$ cd artifacts/deployment

$ kubectl apply -f ns.yaml # Najpierw utwórz przestrzeń nazw.


$ kubectl apply -f . # Tworzenie wszystkich opisanych wcześniej
manifestów.
Teraz można uruchomić niestandardowy serwer API:

$ kubectl get pods -A


NAMESPACE NAME READY STATUS AGE

pizza-apiserver pizza-apiserver-7779f8d486-8fpgj 0/2 ContainerCreating 1s


$ # Po pewnym czasie.

$ kubectl get pods -A

pizza-apiserver pizza-apiserver-7779f8d486-8fpgj 2/2 Running 75s


Gdy serwer działa, należy dokładnie sprawdzić, czy serwer API Kubernetesa stosuje agregację
(czyli czy przekazuje żądania). Najpierw sprawdź za pomocą zasobu APIServices, czy serwer
API Kubernetesa wykrywa, że nasz niestandardowy serwer API jest dostępny:

$ kubectl get apiservices v1alpha1.restaurant.programming-kubernetes.info


NAME SERVICE AVAILABLE
v1alpha1.restaurant.programming-kubernetes.info pizza-apiserver/api True

Wygląda to poprawnie. Spróbuj teraz wyświetlić pizze. Włącz rejestrowanie zdarzeń, aby
zobaczyć, czy nie wystąpiły błędy:

$ kubectl get pizzas --v=7

...
... GET https://localhost:58727/apis?timeout=32s

...
... GET https://localhost:58727/apis/restaurant.programming-kubernetes.info/

v1alpha1?timeout=32s
...

... GET https://localhost:58727/apis/restaurant.programming-kubernetes.info/


v1beta1/namespaces/default/pizzas?limit=500

... Request Headers:

... Accept: application/json;as=Table;v=v1beta1;g=meta.k8s.io,


application/json

... User-Agent: kubectl/v1.15.0 (darwin/amd64) kubernetes/f873d2a


... Response Status: 200 OK in 6 milliseconds

No resources found.
Wygląda to bardzo dobrze. Widać, że kubectl stosuje wykrywanie informacji, aby ustalić, czym
jest pizza. W tym celu kieruje do API restaurant.programming-kubernetes.info/v1beta1
zapytanie o listę pizz. Nie jest zaskoczeniem, że na razie żadne pizze nie są dostępne.
Oczywiście można to zmienić:
$ cd ../examples

$ # Najpierw instalowane są dodatki.

$ ls topping* | xargs -n 1 kubectl create -f


$ kubectl create -f pizza-margherita.yaml

pizza.restaurant.programming-kubernetes.info/margherita created
$ kubectl get pizza -o yaml margherita

apiVersion: restaurant.programming-kubernetes.info/v1beta1
kind: Pizza

metadata:
creationTimestamp: “2019-05-05T13:39:52Z”

name: margherita

namespace: default
resourceVersion: “6”
pizzas/margherita

uid: 42ab6e88-6f3b-11e9-8270-0e37170891d3
spec:

toppings:
- name: mozzarella

quantity: 1
- name: pomidory

quantity: 1

status: {}
Wszystko wygląda świetnie. Jednak pizza margherita była prosta. Wypróbujmy w praktyce
ustawianie wartości domyślnych, tworząc pizzę, dla której nie są podane żadne dodatki:
apiVersion: restaurant.programming-kubernetes.info/v1alpha1

kind: Pizza
metadata:

name: salami
spec:

Mechanizm ustawiania wartości domyślnych powinien przekształcić ten obiekt w pizzę salami z
dodatkiem w postaci salami. Sprawdźmy to:
$ kubectl create -f empty-pizza.yaml

pizza.restaurant.programming-kubernetes.info/salami created
$ kubectl get pizza -o yaml salami

apiVersion: restaurant.programming-kubernetes.info/v1beta1
kind: Pizza

metadata:
creationTimestamp: “2019-05-05T13:42:42Z”

name: salami

namespace: default
resourceVersion: “8”

pizzas/salami
uid: a7cb7af2-6f3b-11e9-8270-0e37170891d3

spec:
toppings:

- name: salami
quantity: 1

- name: mozzarella
quantity: 1

- name: pomidory

quantity: 1
status: {}

Wygląda to na pyszną pizzę z salami.


Sprawdźmy teraz, czy działa niestandardowa wtyczka kontroli dostępu. Najpierw należy usunąć
wszystkie pizze i dodatki, a następnie spróbować ponownie utworzyć pizze:
$ kubectl delete pizzas --all

pizza.restaurant.programming-kubernetes.info “margherita” deleted


pizza.restaurant.programming-kubernetes.info “salami” deleted

$ kubectl delete toppings --all

topping.restaurant.programming-kubernetes.info “mozzarella” deleted


topping.restaurant.programming-kubernetes.info “salami” deleted

topping.restaurant.programming-kubernetes.info “pomidory” deleted


$ kubectl create -f pizza-margherita.yaml

Error from server (Forbidden): error when creating “pizza-margherita.yaml”:


pizzas.restaurant.programming-kubernetes.info “margherita” is forbidden:

unknown topping: mozzarella


Nie ma margherity bez mozzarelli, jak w każdej dobrej włoskiej restauracji.

Wygląda na to, że zakończyliśmy implementowanie rozwiązania opisanego w punkcie „Przykład


— pizzeria”. Ale to jeszcze nie koniec pracy. Chodzi o bezpieczeństwo. Ponownie. Nie
zadbaliśmy o odpowiednie certyfikaty. Złośliwy sprzedawca pizzy może spróbować znaleźć się
między użytkownikami a niestandardowym serwerem API, ponieważ serwer API Kubernetesa
akceptuje dowolne certyfikaty bez ich sprawdzania. Naprawmy to.

Certyfikaty i zaufanie
Obiekt typu APIService zawiera pole caBundle. Określa ono, na jakiej podstawie agregator (na
serwerze API Kubernetesa) może zaufać niestandardowemu serwerowi API. To pole zawiera
certyfikat (wraz z certyfikatami pośrednimi) używany do sprawdzania, czy zagregowany serwer
API ma zadeklarowaną tożsamość. W każdej poważnej instalacji w tym polu należy umieścić
odpowiedni zestaw certyfikatów.

Zaufanie w drugą stronę — ze strony niestandardowego serwera API dla serwera API
Kubernetesa — i wstępne uwierzytelnianie żądań są opisane w punkcie „Delegowane
uwierzytelnianie i obsługa zaufania”. W tym obszarze nie trzeba robić nic więcej.

Choć w obiekcie typu APIService można ustawić pole


insecureSkipTLSVerify na wartość true, aby wyłączyć sprawdzanie
certyfikatów, w środowisku produkcyjnym takie rozwiązanie jest złym
pomysłem. Serwer API Kubernetesa przesyła żądania do zaufanego
zagregowanego serwera API. Przypisanie do polecenia
insecureSkipTLSVerify wartości true sprawia, że dowolna jednostka może
się podać za zagregowany serwer API. Jest to oczywiście niebezpieczne i nie
należy używać tego podejścia w środowiskach produkcyjnych.

Wróćmy do przykładu z pizzą. Aby rozwiązanie było bezpieczne, potrzebny jest certyfikat
serwera i klucz dla niestandardowego serwera API z instalacji. Obie te informacje umieścimy w
sekrecie serving-cert i zamontujemy w podzie w ścieżce /var/run/apiserver/serving-cert/tls.
{crt,key}. Następnie użyjemy pliku tls.crt jako certyfikatu w obiekcie typu APIService.
Wszystkie te elementy znajdziesz w przykładowym kodzie w serwisie GitHub
(http://bit.ly/2XxtJWP).

Logika generowania certyfikatów jest zapisana w skrypcie w pliku Makefile


(http://bit.ly/2KGn0nw).
Warto zauważyć, że w praktyce prawdopodobnie używalibyśmy certyfikatu klastra lub firmy,
który można powiązać z obiektem typu APIService.
Aby zobaczyć działanie zabezpieczeń, możesz albo uruchomić nowy klaster, albo ponownie
wykorzystać poprzedni klaster i zastosować nowe, bezpieczne manifesty:
$ cd ../deployment-secure

$ make
openssl req -new -x509 -subj “/CN=api.pizza-apiserver.svc”

-nodes -newkey rsa:4096


-keyout tls.key -out tls.crt -days 365

Generating a 4096 bit RSA private key

......................++
................................................................++

writing new private key to ‘tls.key’


...

$ ls *.yaml | xargs -n 1 kubectl apply -f


clusterrolebinding.rbac.authorization.k8s.io/pizza-apiserver:system:auth-
delegator
unchanged

rolebinding.rbac.authorization.k8s.io/pizza-apiserver-auth-reader unchanged

deployment.apps/pizza-apiserver configured
namespace/pizza-apiserver unchanged

clusterrolebinding.rbac.authorization.k8s.io/pizza-apiserver-
clusterrolebinding

unchanged
clusterrole.rbac.authorization.k8s.io/aggregated-apiserver-clusterrole
unchanged
serviceaccount/apiserver unchanged

service/api unchanged

secret/serving-cert created
apiservice.apiregistration.k8s.io/v1alpha1.restaurant.programming-
kubernetes.info
configured

apiservice.apiregistration.k8s.io/v1beta1.restaurant.programming-
kubernetes.info

configured
Zwróć uwagę na właściwą nazwę CN=api.pizza-apiserver.svc w certyfikacie. Serwer API
Kubernetesa przekazuje żądanie do usługi api/pizza-apiserver, dlatego w certyfikacie trzeba
umieścić nazwę DNS tej usługi.

Należy dokładnie sprawdzić, czy flaga insecureSkipTLSVerify w obiekcie typu APIService na


pewno jest wyłączona:
$ kubectl get apiservices v1alpha1.restaurant.programming-kubernetes.info -o
yaml
apiVersion: apiregistration.k8s.io/v1

kind: APIService
metadata:

name: v1alpha1.restaurant.programming-kubernetes.info
...

spec:

caBundle: LS0tLS1C...
group: restaurant.programming-kubernetes.info

groupPriorityMinimum: 1000
service:

name: api
namespace: pizza-apiserver

version: v1alpha1
versionPriority: 15

status:

conditions:
- lastTransitionTime: “2019-05-05T14:07:07Z”

message: all checks passed


reason: Passed

status: “True”
type: Available

artifacts/deployment
Dane wyglądają zgodnie z oczekiwaniami. Flaga insecureSkipTLSVerify zniknęła, a w polu
caBundle zapisana jest wartość certyfikatu w formacie base64. Ponadto usługa nadal jest
dostępna.
Zobacz teraz, czy kubectl wciąż może kierować zapytania do API:

$ kubectl get pizzas

No resources found.
$ cd ../examples

$ ls topping* | xargs -n 1 kubectl create -f


topping.restaurant.programming-kubernetes.info/mozzarella created

topping.restaurant.programming-kubernetes.info/salami created
topping.restaurant.programming-kubernetes.info/pomidory created

$ kubectl create -f pizza-margherita.yaml


pizza.restaurant.programming-kubernetes.info/margherita created

Pizza margherita wróciła. Tym razem jest idealnie zabezpieczona. Złośliwy sprzedawca pizzy
nie ma szans na rozpoczęcie ataku man-in-the-middle. Buon appetito!

Współdzielenie systemu etcd


Zagregowane serwery API używające opcji z obiektu typu RecommendedOptions (zob. punkt
„Wzorzec opcji i konfiguracji oraz szablonowy kod potrzebny do uruchomienia serwera”)
korzystają z systemu etcd do składowania danych. To oznacza, że w każdej instalacji
niestandardowego serwera API dostępny musi być klaster z systemem etcd.

Może to być klaster wewnętrzny, np. zainstalowany za pomocą operatora etcd


(http://bit.ly/2JTz8SK). Ten operator umożliwia uruchamianie klastra etcd i zarządzanie nim w
deklaratywny sposób, a także odpowiada za modyfikacje, skalowanie w górę i w dół oraz
tworzenie kopii zapasowych. Pozwala to znacznie zmniejszyć obciążenie operacyjne serwera.

Można też zastosować system etcd z warstwy kontroli klastra (czyli z serwera kube-
apiserver). W zależności od środowiska (samodzielnie instalowane, lokalne lub usługi
hostowane takie jak GKE — Google Container Engine) takie rozwiązanie może być albo
akceptowalne, albo niemożliwe, ponieważ użytkownik nie ma dostępu do takiego klastra (jest
tak w GKE). Gdy to podejście jest akceptowalne, w niestandardowym serwerze API trzeba
zastosować ścieżkę różną od ścieżki z serwera API Kubernetes lub innych użytkowników
systemu etcd. W przykładowym niestandardowym serwerze API ta ścieżka wygląda tak:

const defaultEtcdPathPrefix =
“/registry/pizza-apiserver.programming-kubernetes.github.com”

func NewCustomServerOptions() *CustomServerOptions {

o := &CustomServerOptions{
RecommendedOptions: genericoptions.NewRecommendedOptions(

defaultEtcdPathPrefix,

...
),

}
return o
}

Przedrostek ścieżki dla systemu etcd jest tu inny niż w ścieżkach dla serwera API Kubernetesa,
gdzie używane są inne nazwy grup API.

Istotne jest też, że dla systemu etcd można zastosować pośrednika. W projekcie etcdproxy-
controller (http://bit.ly/2Na2VrN) zaimplementowano ten mechanizm z użyciem wzorca
operator. Oznacza to, że pośredniki dla systemu etcd mogą być instalowane w klastrze
automatycznie i konfigurowane przy użyciu obiektów typu EtcdProxy.
Pośredniki systemu etcd automatycznie odwzorowują klucze, dlatego gwarantowane jest, że
przedrostki kluczy systemu etcd nie będą powodować konfliktów. Dzięki temu można
współużytkować klastry z systemem etcd w wielu zagregowanych serwerach API bez obaw o
to, że jeden z tych serwerów będzie wczytywał lub modyfikował dane innego serwera. Poprawia
to bezpieczeństwo w środowisku, w którym klastry ze współdzielonym systemem etcd są
niezbędne — np. z powodu ograniczonych zasobów lub w celu uniknięcia kosztów operacyjnych.
Na podstawie kontekstu trzeba wybrać jedną z dostępnych opcji. Zagregowane serwery API
mogą oczywiście korzystać na zapleczu także z innych systemów składowania danych —
przynajmniej teoretycznie, ponieważ zaimplementowanie interfejsów składowania danych z
biblioteki k8s.io/apiserver wymaga dużej ilości niestandardowego kodu.

Podsumowanie
Był to długi rozdział, ale dotarłeś do końca. Zdobyłeś wiele informacji na temat API w
Kubernetesie i ich implementacji.

Zobaczyłeś, jak agregowanie niestandardowych serwerów API wpasowuje się w architekturę


klastra Kubernetesa. Dowiedziałeś się, jak niestandardowy serwer API otrzymuje żądania
przekazywane przez serwer API Kubernetes. Wiesz już, w jaki sposób serwer API Kubernetesa
wstępnie uwierzytelnia te żądania oraz jak implementowane są grupy API (z wersjami
zewnętrznymi i wewnętrznymi). Zrozumiałeś również, jak obiekty są dekodowane do postaci
struktur języka Go, jak ustawiane są wartości domyślne w tych obiektach, jak obiekty są
konwertowane na typy wersji wewnętrznej, a także jak przechodzą przez proces kontroli
dostępu i sprawdzania poprawności oraz jak docierają do rejestru. Zobaczyłeś, jak strategia jest
podłączana do generycznego rejestru w celu implementacji „zwykłych” zasobów REST
(podobnych do zasobów Kubernetesa), jak dodać niestandardową kontrolę dostępu i jak
skonfigurować niestandardowe wtyczki kontroli dostępu z niestandardowym inicjalizatorem.
Wiesz już, jak przygotować kod do uruchomienia niestandardowego serwera API z wieloma
wersjami grupy API i jak z użyciem obiektu typu APIServices zainstalować grupę API w
klastrze. Dowiedziałeś się także, jak skonfigurować reguły systemu RBAC, aby niestandardowy
serwer API mógł wykonywać swoje zadania. Omówiliśmy, jak narzędzie kubectl kieruje
zapytania do grup API. Na zakończenie zobaczyłeś, jak za pomocą certyfikatów zabezpieczać
połączenie z niestandardowym serwerem API.

To dużo materiału. Teraz znacznie lepiej rozumiesz, czym są API w Kubernetesie i jak są
implementowane. Mamy nadzieję, że zachęci Cię to do:

zaimplementowania własnego niestandardowego serwera API,


zapoznania się z wewnętrznymi mechanizmami Kubernetesa,
wniesienia w przyszłości wkładu w rozwój Kubernetesa.

Wierzymy, że będzie to dla Ciebie dobry punkt wyjścia.


1 Kontrolowane usuwanie oznacza, że klient może przekazać w wywołaniu usuwającym zasób
czas wykonania tej operacji. Samo usuwanie jest asynchronicznie wykonywane przez kontroler
(w przypadku podów odpowiada za to kubelet), który siłowo usuwa zasób. Dzięki temu pody
mają czas na uporządkowane zakończenie pracy.
2 W Kubernetesie współistnienie jest używane do przenoszenia zasobów (np. instalacji z grupy
API exten-sions/v1beta1) do tematycznych grup API (np. apps/v1). W definicjach CRD
współdzielona pamięć nie jest obsługiwana.

3 W rozdziale 9. zobaczysz, że dostępne w najnowszych wersjach Kubernetesa webhooki do


konwersji definicji CRD i kontroli dostępu do nich umożliwiają dodanie omawianych tu
mechanizmów także do definicji CRD.

4 PaaS to akronim od „Platform as a Service”, czyli platforma jako usługa.


Rozdział 9. Zaawansowane
zasoby niestandardowe
W tym rozdziale przedstawimy zaawansowane zagadnienia związane z zasobami
niestandardowymi: wersjonowanie, konwersję i kontrolery kontroli dostępu.
Gdy używanych jest kilka wersji zasobu, definicje CRD stają się dużo bardziej rozbudowane i
trudniej jest je odróżnić od zasobów API opartych na języku Go. Oczywiście znacznie rośnie
przy tym poziom złożoności — zarówno w obszarze programowania i konserwacji kodu, jak i w
dziedzinie eksploatacji. Omawiane tu mechanizmy nazwaliśmy „zaawansowanymi”, ponieważ
powodują przejście definicji CRD z — czysto deklaratywnego — poziomu manifestów do świata
języka Go, czyli prawdziwych projektów rozwoju oprogramowania.
Nawet jeśli nie planujesz budować niestandardowego serwera API, a zamiast tego zamierzasz
bezpośrednio używać definicji CRD, gorąco zachęcamy do tego, by nie pomijać rozdziału 8.
Wiele zagadnień związanych z zaawansowanymi definicjami CRD ma bezpośrednie
odpowiedniki w świecie niestandardowych serwerów API i jest z nimi powiązanych. Dzięki
lekturze rozdziału 8. znacznie łatwiej będzie Ci zrozumieć także ten rozdział.
Kod wszystkich przedstawianych i omawianych tu rozdziałów jest dostępny w repozytorium w
serwisie GitHub (http://bit.ly/2RBSjAl).

Wersjonowanie niestandardowych
zasobów
W rozdziale 8. zobaczyłeś, że zasoby są dostępne w różnych wersjach API. W przykładowym
niestandardowym serwerze API zasoby reprezentujące pizzę istnieją jednocześnie w wersjach
v1alpha1 i v1beta1 (zob. punkt „Przykład — pizzeria”). W niestandardowym serwerze API
każdy obiekt w żądaniu jest najpierw konwertowany z wersji używanego punktu końcowego API
na wersję wewnętrzną (zob. punkt „Typy wewnętrzne i konwersja”), a następnie konwertowany
z powrotem na wersję zewnętrzną na potrzeby składowania danych i zwracania odpowiedzi.
Mechanizm konwersji jest implementowany w funkcjach konwersji. Niektóre takie funkcje są
pisane ręcznie, inne generowane (zob. punkt „Konwersje”).
Wersjonowanie API to rozbudowany mechanizm dostosowywania i usprawniania API przy
zachowaniu zgodności ze starszymi klientami. Wersjonowanie odgrywa centralną rolę we
wszystkich komponentach Kubernetesa, gdzie pozwala promować API z wersji alfa do wersji
beta i ostatecznie do wersji ogólnie dostępnej. W tym procesie w API często zmienia się
struktura lub są one rozszerzane.
Przez długi czas wersjonowanie było mechanizmem dostępnym tylko za pomocą opisanych w
rozdziale 8. zagregowanych serwerów API. Każde rozbudowane API wymaga w pewnym
momencie wersjonowania, ponieważ naruszanie zgodności z konsumentami API jest
nieakceptowalne.
Na szczęście bardzo niedawno do Kubernetesa dodano wersjonowanie definicji CRD. W
Kubernetesie 1.14 ten mechanizm jest dostępny w wersji alfa, a w Kubernetesie 1.15
promowano go do wersji beta. Warto zauważyć, że konwersja wymaga strukturalnych
schematów sprawdzania poprawności w formacie OpenAPI v3 (zob. punkt „Sprawdzanie
poprawności niestandardowych zasobów”). Jednak narzędzia takie jak Kubebuilder i tak
generują schematy strukturalne. Szczegóły techniczne są opisane w punkcie „Schematy
strukturalne”.

Pokazujemy tu działanie wersjonowania, ponieważ w bliskiej przyszłości będzie ono bardzo


istotne w wielu ważnych zastosowaniach niestandardowych zasobów.

Poprawianie kodu do obsługi pizzerii


Aby pokazać przebieg konwersji niestandardowych zasobów, ponownie zaimplementujemy
przykładowy kod z rozdziału 8. służący do obsługi pizzerii. Tym razem użyjemy tylko definicji
CRD (bez zagregowanego serwera API).

Na potrzeby konwersji skoncentrujemy się na zasobie Pizza:


apiVersion: restaurant.programming-kubernetes.info/v1alpha1

kind: Pizza
metadata:

name: margherita
spec:

toppings:
- mozzarella

- tomato

Ten obiekt w wersji v1beta1 powinien mieć inną reprezentację wycinka z dodatkami:
apiVersion: restaurant.programming-kubernetes.info/v1beta1
kind: Pizza

metadata:

name: margherita

spec:
toppings:

- name: mozzarella

quantity: 1

- name: tomato

quantity: 1
W wersji v1alpha1 do reprezentowania dodatkowego sera służą powtórzenia dodatków,
natomiast w wersji v1beta1 ten sam efekt jest uzyskiwany za pomocą pola quantity dla
każdego dodatku. Kolejność dodatków nie ma tu znaczenia.

Celem jest zaimplementowanie przejść między tymi reprezentacjami — konwersji z wersji


v1alpha1 na v1beta1 i w drugą stronę. Najpierw jednak należy zdefiniować API za pomocą
definicji CRD. Zauważ, że w jednym klastrze nie można utworzyć zagregowanego serwera API i
definicji CRD dla tej samej wersji grupy. Dlatego przed rozpoczęciem tworzenia definicji CRD
usuń opisany w rozdziale 8. obiekt typu APIServices.
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition

metadata:

name: pizzas.restaurant.programming-kubernetes.info

spec:
group: restaurant.programming-kubernetes.info

names:

kind: Pizza

listKind: PizzaList

plural: pizzas
singular: pizza

scope: Namespaced

version: v1alpha1

versions:
- name: v1alpha1

served: true

storage: true

schema: ...
- name: v1beta1

served: true

storage: false

schema: ...

W tej definicji CRD zdefiniowane są dwie wersje zasobu: v1alpha1 i v1beta1. Pierwsza z nich
jest skonfigurowana jako wersja składowania danych (zob. rysunek 9.1). Oznacza to, że każdy
obiekt zapisywany w systemie etcd jest najpierw konwertowany do wersji v1alpha1.
Rysunek 9.1. Konwersja i wersja składowania danych

Obecna definicja CRD pozwala utworzyć obiekt w wersji v1alpha1 i pobrać go w wersji
v1beta1, jednak oba punkty końcowe API zwracają ten sam obiekt. Nie tego oczekiwaliśmy,
jednak zaraz poprawimy kod.

Najpierw jednak dodajmy definicję CRD do klastra i utwórzmy pizzę margheritę:


apiVersion: restaurant.programming-kubernetes.info/v1alpha1

kind: Pizza

metadata:

name: margherita

spec:

toppings:

- mozzarella
- tomato

Zarejestruj tę definicję CRD, a następnie utwórz obiekt reprezentujący pizzę margheritę:

$ kubectl create -f pizza-crd.yaml

$ kubectl create -f margherita-pizza.yaml

Zgodnie z oczekiwaniami dla obu wersji otrzymasz ten sam obiekt:

$ kubectl get pizza margherita -o yaml

apiVersion: restaurant.programming-kubernetes.info/v1beta1

kind: Pizza

metadata:
creationTimestamp: “2019-04-14T11:39:20Z”

generation: 1

name: margherita

namespace: pizza-apiserver

resourceVersion: “47959”

selfLink: /apis/restaurant.programming-kubernetes.info/v1beta1/namespaces/

pizza-apiserver/

pizzas/margherita
uid: f18427f0-5ea9-11e9-8219-124e4d2dc074

spec:

toppings:

- mozzarella

- tomato

W Kubernetesie używana jest standardowa kolejność wersji:


v1alpha1

Niestabilna — może zostać usunięta lub zmienić się w dowolnym momencie. Często
domyślnie jest nieaktywna.

v1beta1

To krok w kierunku stabilności. Będzie istnieć w przynajmniej jednym wydaniu równolegle


do wersji v1. Działa jak kontrakt, zgodnie z którym nie będą wprowadzane zmiany
niekompatybilne z API.

v1

Stabilna (ogólnie dostępna). Pozostanie dostępna i będzie kompatybilna.

Wersja ogólnie dostępna jest wybierana jako pierwsza. Potem używane są wersje beta, a na
końcu wersje alfa. Numery wersji (podobnie jak numery podwersji) są porządkowane od
najwyższego do najniższego. Każda wersja definicji CRD, która nie pasuje do tego wzorca, jest
umieszczana na końcu (takie wersje są porządkowane alfabetycznie).

Dlatego tu wywołanie kubectl get pizza zwraca v1beta1, choć obiekt został utworzony w
wersji v1alpha1.

Architektura webhooków konwersji


Dodamy teraz konwersję z wersji v1alpha1 na v1beta1 i w drugą stronę. Konwersje definicji
CRD są implementowane w Kubernetesie z użyciem webhooków. Na rysunku 9.2 pokazany jest
przebieg tego procesu:

Rysunek 9.2. Webhook konwersji

1. Klient żąda wersji (np. za pomocą naszego wywołania kuebectl get pizza margherita).
2. System etcd ma zapisany obiekt w jakiejś wersji.
3. Jeśli wersje nie pasują do siebie, składowany obiekt jest przesyłany w celu konwersji na
serwer z webhookiem. Webhook zwraca odpowiedź z przekształconym obiektem.
4. Przekształcony obiekt jest odsyłany do klienta.

Trzeba zaimplementować serwer webhooka. Wcześniej jednak przyjrzyj się API webhooka.
Serwer API Kubernetesa przesyła obiekt typu ConversionReview z grupy API
apiextensions.k8s.io/v1beta1:

type ConversionReview struct {

metav1.TypeMeta `json:”,inline”`

Request *ConversionRequest
Response *ConversionResponse

Pole Request jest ustawiane w treści przesyłanej do webhooka. Pole Response jest ustawiane w
odpowiedzi.

Żądanie wygląda tak:

type ConversionRequest struct {

...

// DesiredAPIVersion to wersja, na którą przekształcane są obiekty.

// Oto przykład: „myapi.example.com/v1”.


DesiredAPIVersion string

// Objects to lista przekształcanych obiektów reprezentujących zasoby


niestandardowe.

Objects []runtime.RawExtension

}
W łańcuchu znaków DesiredAPIVersion używany jest standardowy format wersji API znany z
konstrukcji TypeMeta: grupa/wersja.

W polu Objects zapisana jest kolekcja obiektów. Używany jest wycinek, ponieważ dla jednego
żądania listy pizz webhook otrzymuje jedno żądanie konwersji, a w wycinku zapisywane są
wszystkie obiekty na potrzeby danego żądania listy.

Webhook przekształca i zapisuje odpowiedź:

type ConversionResponse struct {

...

// ConvertedObjects to lista przekształconych wersji obiektów


request.objects, jeśli pole Result oznacza

// powodzenie; w przeciwnym razie lista jest pusta. Webhook powinien


ustawić

// pole apiVersion tych obiektów na wartość


ConversionRequest.desiredAPIVersion.

// Długość tej listy musi być identyczna z długością listy wejściowej.


Obie listy muszą zawierać te same obiekty

// w tej samej kolejności (obiekty muszą mieć te same identyfikatory UID


i metadane).
ConvertedObjects []runtime.RawExtension

// Result zawiera wynik konwersji i dodatkowe szczegóły, jeśli konwersja


zakończyła się
// niepowodzeniem. Wymagane pole Result.status informuje, czy konwersja
zakończyła się porażką,
// czy sukcesem. Po udanej konwersji to pole musi mieć wartość Success.

// Po nieudanej konwersji pole należy ustawić na wartość Failure,

// podać dodatkowe szczegóły w polu result.message` i zwrócić status HTTP


200.

// Pole Result.message posłuży do utworzenia komunikatu o błędzie

// dla użytkownika końcowego.


Result metav1.Status

Pole Result informuje serwer API Kubernetesa o tym, czy konwersja zakończyła się
powodzeniem.

Ale kiedy w procesie obsługi żądania wywoływany jest nasz webhook odpowiedzialny za
konwersję? Jakiego rodzaju obiektu wejściowego można oczekiwać? Aby lepiej to zrozumieć,
przyjrzyj się ogólnemu procesowi obsługi żądania z rysunku 9.3. Wszystkie zapełnione i
kreskowane kółka reprezentują tu konwersje w kodzie biblioteki k8s.io/apiserver.

Rysunek 9.3. Wywołania webhooka konwersji dla niestandardowych zasobów

Dla zasobów niestandardowych, inaczej niż w zagregowanych niestandardowych serwerach API


(zob. punkt „Typy wewnętrzne i konwersje”), nie są używane typy wersji wewnętrznej. Dlatego
w przypadku zasobów niestandardowych tylko kreskowane kółka oznaczają konwersje (rysunek
9.4). Pełne kółka dla zasobów niestandardowych oznaczają operacje puste. Tak więc konwersje
zasobów niestandardowych zachodzą tylko przy wymianie danych z systemem etcd.

Rysunek 9.4. Miejsca konwersji zasobów niestandardowych

Dlatego można założyć, że webhook będzie wywoływany w dwóch zaznaczonych miejscach w


procesie obsługi żądania (zob. rysunek 9.3).

Warto też zauważyć, że żądania PATCH powodują automatyczne ponawianie prób po konflikcie
(modyfikacje nie powodują ponawiania prób i bezpośrednio zwracają błędy do jednostki
wywołującej). Każde ponowienie próby obejmuje odczyt i zapis danych w systemie etcd
(kreskowane kółka na rysunku 9.3), co oznacza dwa wywołania webhooka w każdej takiej
iteracji.

Wszystkie ostrzeżenia dotyczące istotności konwersji wymienione w punkcie


„Konwersje” są istotne także tutaj. Konwersje muszą być poprawne. Błędy
szybko prowadzą do utraty danych i niespójnego działania API.

Zanim zaczniesz implementować webhook, warto wyjaśnić, co webhook może robić, a czego
trzeba w nim unikać:

Kolejność obiektów w żądaniu i odpowiedzi musi pozostać taka sama.


Obiektów typu ObjectMeta nie wolno modyfikować (wyjątkiem są etykiety i adnotacje).
Konwersja działa na zasadzie wszystko albo nic. Albo wszystkie obiekty są poprawnie
konwertowane, albo cała operacja kończy się niepowodzeniem.
Implementacja webhooka konwersji
Po omówieniu teorii pora rozpocząć implementowanie projektu webhooka. Kod źródłowy
znajdziesz w repozytorium (http://bit.ly/2IHXKLn) obejmującym:

Implementację webhooka w postaci serwera WWW z obsługą protokołu HTTPS.


Zestaw punktów końcowych:
/convert/v1beta1/pizza, który przekształca obiekt pizzy między wersjami v1alpha1 i
v1beta1;
/admit/v1beta1/pizza, gdzie pole spec.toppings domyślnie zawiera dodatki
mozzarella, pomidory i salami;
/validate/v1beta1/pizza, sprawdzający, czy dla każdego podanego dodatku istnieje
odpowiedni obiekt.

Dwa ostatnie punkty końcowe to webhooki kontroli dostępu, opisane szczegółowo w punkcie
„Webhooki kontroli dostępu”. Ten sam plik binarny z webhookiem służy do obsługi kontroli
dostępu i konwersji.
Członu v1beta1 z podanych ścieżek nie należy mylić z wersją v1beta1 grupy API dla pizzerii; tu
ten człon oznacza wersję grupy API apiextensions.k8s.io obsługiwaną w webhooku. W
[1]
przyszłości obsługiwana będzie wersja v1 API webhooka . Wtedy dodamy v1 jako następny
punkt końcowy, aby zapewnić obsługę dawnych (z dzisiejszej perspektywy) i nowych klastrów
Kubernetesa. W manifeście definicji CRD można podać, które wersje webhook obsługuje.

Przyjrzyjmy się teraz działaniu webhooka konwersji. Dalej szczegółowo opisujemy, jak
zainstalować webhook w rzeczywistym klastrze. Warto przypomnieć, że w Kubernetesie 1.14
webhooki konwersji nadal są dostępne w wersji alfa, dlatego trzeba je ręcznie aktywować za
pomocą bramki funkcji CustomResourceWebhookConversion. W Kubernetesie 1.15 te webhooki
są dostępne w wersji beta.

Przygotowywanie serwera HTTPS


Pierwszy krok polega na uruchomieniu serwera WWW z obsługą protokołu TLS (np. HTTPS).
Webhooki w Kubernetesie wymagają protokołu HTTPS. Webhook konwersji wymaga nawet
certyfikatów sprawdzonych przez serwer API Kubernetes na podstawie zestawu certyfikatów
przekazanego przez obiekt niestandardowego zasobu.

W przykładowym projekcie używamy bezpiecznej biblioteki serwera wchodzącej w skład


biblioteki k8s.io/apiserver. Zapewnia ona wszystkie flagi protokołu TLS i operacje, jakie możesz
znać z serwera kube-apiserver lub zagregowanego serwera API.

Bezpieczny kod serwera z biblioteki k8s.io/apiserver jest zgodny ze wzorcem opcji i konfiguracji
(zob. punkt „Wzorzec opcji i konfiguracji oraz szablonowy kod potrzebny do uruchomienia
serwera”). Bardzo łatwo jest dodać ten kod do własnego pliku binarnego:

func NewDefaultOptions() *Options {


o := &Options{

*options.NewSecureServingOptions(),
}

o.SecureServing.ServerCert.PairName = “pizza-crd-webhook”
return o
}
type Options struct {

SecureServing options.SecureServingOptions
}

type Config struct {


SecureServing *server.SecureServingInfo

func (o *Options) AddFlags(fs *pflag.FlagSet) {


o.SecureServing.AddFlags(fs)

func (o *Options) Config() (*Config, error) {

err := o.SecureServing.MaybeDefaultWithSelfSignedCerts(“0.0.0.0”, nil,


nil)

if err != nil {
return nil, err

c := &Config{}

if err := o.SecureServing.ApplyTo(&c.SecureServing); err != nil {


return nil, err

return c, nil

}
W funkcji main w pliku binarnym tworzona jest instancja struktury Options, po czym do tej
instancji przypisywany jest zbiór flag:
opt := NewDefaultOptions()
fs := pflag.NewFlagSet(“pizza-crd-webhook”, pflag.ExitOnError)

globalflag.AddGlobalFlags(fs, “pizza-crd-webhook”)
opt.AddFlags(fs)
if err := fs.Parse(os.Args); err != nil {

panic(err)
}

// Tworzenie konfiguracji uruchomieniowej.


cfg, err := opt.Config()

if err != nil {
panic(err)

stopCh := server.SetupSignalHandler()

...

// Uruchamianie serwera.

restaurantInformers.Start(stopCh)
if doneCh, err := cfg.SecureServing.Serve(

handlers.LoggingHandler(os.Stdout, mux),
time.Second * 30, stopCh,

); err != nil {
panic(err)
} else {

<-doneCh
}

W miejscu wielokropka konfigurowany jest multiplekser żądań HTTP z naszymi trzema


ścieżkami:

// Rejestrowanie funkcji obsługi żądań.


restaurantInformers := restaurantinformers.NewSharedInformerFactory(
clientset, time.Minute * 5,

)
mux := http.NewServeMux()

mux.Handle(“/convert/v1beta1/pizza”, http.HandlerFunc(conversion.Serve))
mux.Handle(“/admit/v1beta1/pizza”,
http.HandlerFunc(admission.ServePizzaAdmit))

mux.Handle(“/validate/v1beta1/pizza”,
http.HandlerFunc(admission.ServePizzaValidation(restaurantInformers)))
restaurantInformers.Start(stopCh)

Ponieważ webhook do sprawdzania poprawności pizzy dostępny za pomocą ścieżki


/validate/v1beta1/pizza musi znać istniejące obiekty dodatków w klastrze, tworzona jest
instancja fabryki współdzielonych informatorów na potrzeby grupy API
restaurant.programming-kubernetes.info.

Teraz przyjrzyj się implementacji webhooka konwersji powiązanej z wywołaniem


conversion.Serve. Jest to zwykła funkcja obsługi żądań HTTP z języka Go, co oznacza, że
przyjmuje ona argumenty w postaci żądania i generatora odpowiedzi.

Treść żądania zawiera obiekt typu ConversionReview z grupy API


apiextensions.k8s.io/v1beta1. Dlatego najpierw trzeba wczytać treść żądania, a następnie
odkodować wycinek z bajtami. Użyjemy do tego obiektu deserializacji z repozytorium API
Machinery:
func Serve(w http.ResponseWriter, req *http.Request) {
// Odczyt treści.

body, err := ioutil.ReadAll(req.Body)


if err != nil {

responsewriters.InternalError(w, req,
fmt.Errorf(“Nieudany odczyt treści: %v”, err))

return
}

// Dekodowanie treści do obiektu typu ConversionReview


gv := apiextensionsv1beta1.SchemeGroupVersion

reviewGVK := gv.WithKind(“ConversionReview”)
obj, gvk, err := codecs.UniversalDeserializer().Decode(body, &reviewGVK,

&apiextensionsv1beta1.ConversionReview{})
if err != nil {

responsewriters.InternalError(w, req,
fmt.Errorf(“Nieudane dekodowanie treści: %v”, err))

return
}
review, ok := obj.(*apiextensionsv1beta1.ConversionReview)

if !ok {
responsewriters.InternalError(w, req,

fmt.Errorf(“Nieoczekiwany identyfikator GVK: %s”, gvk))


return

}
if review.Request == nil {

responsewriters.InternalError(w, req,
fmt.Errorf(“Nieoczekiwane żądanie nil”))

return
}
...

}
W tym kodzie używana jest fabryka kodeków codecs utworzona na podstawie schematu. W
schemacie trzeba umieścić typy z grupy apiextensions.k8s.io/v1beta1. Należy też dodać typy z
grupy API dla pizzerii. W przekazywanym obiekcie typu ConversionReview typ pizzy jest
zagnieżdżony w obiekcie typu runtime.RawExtension. Więcej na ten temat dowiesz się już
niedługo.

Zacznijmy od utworzenia schematu i fabryki kodeków:


import (

apiextensionsv1beta1 “k8s.io/apiextensions-
apiserver/pkg/apis/apiextensions/v1beta1”
“github.com/programming-kubernetes/pizza-crd/pkg/apis/restaurant/install”

...
)

var (

scheme = runtime.NewScheme()
codecs = serializer.NewCodecFactory(scheme)

func init() {
utilruntime.Must(apiextensionsv1beta1.AddToScheme(scheme))

install.Install(scheme)
}
Typ runtime.RawExtension to nakładka na obiekty podobne do obiektów Kubernetesa
zagnieżdżane w polu innego obiektu. Budowa tego typu jest bardzo prosta:
type RawExtension struct {

// Raw to zserializowana postać danego obiektu.


Raw []byte `protobuf:”bytes,1,opt,name=raw”`
// Object może przechowywać reprezentację rozszerzenia. Jest ona
przydatna
// do pracy z wersjonowanymi strukturami.
Object Object `json:”-”`

}
Typ runtime.RawExtension udostępnia też dwie specjalne metody do marshallingu danych w
formatach JSON i protobuf. Istnieje również specjalna logika do konwersji obiektów typu
runtime.Object „w locie”, w ramach konwersji na typy wersji wewnętrznej (czyli kod do
automatycznego kodowania i dekodowania obiektów).
Gdy używane są definicje CRD, nie istnieją typy wersji wewnętrznej, dlatego automatyczna
konwersja nie ma tu znaczenia. Pole RawExtension.Raw jest zapełniane wycinkiem z bajtami,
reprezentującym w formacie JSON obiekt pizzy przesyłany do webhooka w celu konwersji.
Dlatego trzeba odkodować ten wycinek. Warto ponownie zauważyć, że jeden obiekt typu
ConversionReview może przechowywać wiele obiektów, dlatego konieczne jest ich
przetwarzania w pętli:
// Konwersja obiektów.

review.Response = &apiextensionsv1beta1.ConversionResponse{
UID: review.Request.UID,
Result: metav1.Status{
Status: metav1.StatusSuccess,
},

}
var objs []runtime.Object
for _, in := range review.Request.Objects {
if in.Object == nil {

var err error


in.Object, _, err = codecs.UniversalDeserializer().Decode(
in.Raw, nil, nil,
)
if err != nil {

review.Response.Result = metav1.Status{
Message: err.Error(),
Status: metav1.StatusFailure,
}

break
}
}

obj, err := convert(in.Object, review.Request.DesiredAPIVersion)

if err != nil {
review.Response.Result = metav1.Status{
Message: err.Error(),
Status: metav1.StatusFailure,
}
break

}
objs = append(objs, obj)
}
Wywołanie convert dokonuje konwersji obiektu in.Object, używając oczekiwanej wersji API
jako wersji docelowej. Warto zauważyć, że natychmiast po wystąpieniu pierwszego błędu kod
wychodzi z pętli.

Na zakończenie kod ustawia pole Response w obiekcie typu ConversionReview i zapisuje je


jako treść odpowiedzi dla żądania, używając obiektu zapisu odpowiedzi z repozytorium API
Machinery. Ten ostatni obiekt używa fabryki kodeków do utworzenia obiektu serializacji:
if review.Response.Result.Status == metav1.StatusSuccess {
for _, obj = range objs {

review.Response.ConvertedObjects =
append(review.Response.ConvertedObjects,
runtime.RawExtension{Object: obj},
)

}
}

// Zapis uzgodnionej odpowiedzi.


responsewriters.WriteObject(

http.StatusOK, gvk.GroupVersion(), codecs, review, w, req,


)
Teraz trzeba zaimplementować konwersję pizzy. Po wszystkich wcześniejszych przygotowaniach
algorytm konwersji jest najłatwiejszym fragmentem kodu. Wystarczy sprawdzić, czy otrzymany
został obiekt pizzy w znanej wersji, a następnie przeprowadzić konwersję z wersji v1beta1 na
v1alpha1 lub na odwrót:

func convert(in runtime.Object, apiVersion string) (runtime.Object, error) {


switch in := in.(type) {
case *v1alpha1.Pizza:
if apiVersion != v1beta1.SchemeGroupVersion.String() {

return nil, fmt.Errorf(“Niemożliwia konwersja %s na %s”,


v1alpha1.SchemeGroupVersion, apiVersion)
}
klog.V(2).Infof(“Konwersja %s/%s z %s na %s”, in.Namespace, in.Name,
v1alpha1.SchemeGroupVersion, apiVersion)

out := &v1beta1.Pizza{
TypeMeta: in.TypeMeta,
ObjectMeta: in.ObjectMeta,
Status: v1beta1.PizzaStatus{
Cost: in.Status.Cost,

},
}
out.TypeMeta.APIVersion = apiVersion

idx := map[string]int{}
for _, top := range in.Spec.Toppings {
if i, duplicate := idx[top]; duplicate {
out.Spec.Toppings[i].Quantity++
continue

}
idx[top] = len(out.Spec.Toppings)
out.Spec.Toppings = append(out.Spec.Toppings,
v1beta1.PizzaTopping{
Name: top,

Quantity: 1,
})
}
return out, nil

case *v1beta1.Pizza:
if apiVersion != v1alpha1.SchemeGroupVersion.String() {
return nil, fmt.Errorf(“Niemożliwa konwersja %s na %s”,
v1beta1.SchemeGroupVersion, apiVersion)

}
klog.V(2).Infof(“Konwersja %s/%s z %s na %s”,
in.Namespace, in.Name, v1alpha1.SchemeGroupVersion, apiVersion)
out := &v1alpha1.Pizza{
TypeMeta: in.TypeMeta,

ObjectMeta: in.ObjectMeta,
Status: v1alpha1.PizzaStatus{
Cost: in.Status.Cost,
},

}
out.TypeMeta.APIVersion = apiVersion

for i := range in.Spec.Toppings {


for j := 0; j < in.Spec.Toppings[i].Quantity; j++ {

out.Spec.Toppings = append(
out.Spec.Toppings, in.Spec.Toppings[i].Name)
}
}

return out, nil

default:
}

klog.V(2).Infof(“Nieznany typ %T”, in)


return nil, fmt.Errorf(“Nieznany typ %T”, in)
}
Warto zauważyć, że przy konwersji w obu kierunkach wystarczy skopiować obiekty typów
TypeMeta i ObjectMeta, zmienić wersję API na pożądaną, a następnie przekształcić wycinek z
dodatkami; ten wycinek jest jedyną częścią obiektów, która w poszczególnych wersjach ma inną
strukturę.
Jeśli istnieje więcej wersji, konieczna jest konwersja dwustronna dla każdej pary wersji. Inna
możliwość to oczywiście użycie wersji centralnej, tak jak w zagregowanym serwerze API („Typy
wewnętrzne i konwersja”). Nie trzeba wtedy implementować konwersji między wszystkimi
obsługiwanymi wersjami zewnętrznymi.

Instalowanie webhooka konwersji


Teraz zainstalujemy webhook konwersji. Wszystkie potrzebne manifesty znajdziesz w serwisie
GitHub (http://bit.ly/2KEx4xo).
Webhooki konwersji dla definicji CRD są uruchamiane w klastrze i umieszczane za obiektem
usługi. Ten obiekt usługi należy podać w specyfikacji webhooka konwersji w manifeście CRD:
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition

metadata:
name: pizzas.restaurant.programming-kubernetes.info
spec:
...

conversion:
strategy: Webhook
webhookClientConfig:
caBundle: zestaw-certyfikatów-w-formacie-base64
service:

namespace: pizza-crd
name: webhook
path: /convert/v1beta1/pizza
Zestaw certyfikatów musi pasować do certyfikatu serwera używanego przez webhook. W
przykładowym projekcie używamy pliku Makefile (http://bit.ly/2FukVac) do generowania
certyfikatów z użyciem protokołu OpenSSL i dołączamy je do manifestu za pomocą
zastępowania tekstu.
Warto zauważyć, że serwer API Kubernetesa przyjmuje, iż webhook obsługuje wszystkie podane
wersje definicji CRD. Ponadto dla jednej definicji CRD można podać tylko jeden taki webhook.
Powinno to jednak wystarczać, ponieważ definicje CRD i webhooki konwersji są zwykle
rozwijane przez ten sam zespół.

Zwróć też uwagę na to, że w obecnej wersji API apiextensions.k8s.io/v1beta1 należy używać
portu usługi 443. W usłudze można jednak odwzorować ten port na dowolny port używany
przez pody z webhookiem. W tym przykładzie odwzorowujemy port 443 na port 8443
obsługiwany przez plik binarny webhooka.

Konwersja w praktyce
Teraz gdy już rozumiesz, jak działa webhook konwersji i jak jest powiązany z klastrem, pora
zobaczyć, jak działa.

Zakładamy, że pobrałeś przykładowy projekt. Przyjmujemy też, że masz klaster z włączonymi


webhookami konwersji (albo użyłeś bramki funkcji w Kubernetesie 1.14, albo korzystasz z
Kubernetesa 1.15 lub nowszego, gdzie webhooki konwersji są domyślnie włączone). Jednym ze
sposobów na utworzenie takiego klastra jest użycie projektu kind (http://bit.ly/2X75lvS), który
zapewnia obsługę Kubernetesa 1.14.1, i lokalnego pliku kind-config.yaml, aby włączyć bramkę
funkcji alfa dla webhooków konwersji (w punkcie „Czym jest programowanie dla Kubernetesa?”
podane są inne sposoby tworzenia klastrów na potrzeby programowania rozwiązań):

kind: Cluster
apiVersion: kind.sigs.k8s.io/v1alpha3
kubeadmConfigPatchesJson6902:
- group: kubeadm.k8s.io

version: v1beta1
kind: ClusterConfiguration
patch: |
- op: add
path: /apiServer/extraArgs

value: {}
- op: add
path: /apiServer/extraArgs/feature-gates
value: CustomResourceWebhookConversion=true
Następnie można utworzyć klaster:

$ kind create cluster --image kindest/node-images:v1.14.1 --config kind-


config.yaml
$ export KUBECONFIG=”$(kind get kubeconfig-path --name=”kind”)”
Teraz można zainstalować manifesty (http://bit.ly/2KEx4xo):

$ cd pizza-crd
$ cd manifest/deployment
$ make
$ kubectl create -f ns.yaml
$ kubectl create -f pizza-crd.yaml

$ kubectl create -f topping-crd.yaml


$ kubectl create -f sa.yaml
$ kubectl create -f rbac.yaml
$ kubectl create -f rbac-bind.yaml

$ kubectl create -f service.yaml


$ kubectl create -f serving-cert-secret.yaml
$ kubectl create -f deployment.yaml
Oto użyte pliki manifestów:
ns.yaml

Tworzy przestrzeń nazw pizza-crd.


pizza-crd.yaml
Definiuje zasób pizzy w grupie API restaurant.programming-kubernetes.info (z
wersjami v1alpha1 i v1beta1 tego zasobu) z pokazaną wcześniej konfiguracją webhooka
konwersji.

topping-crd.yaml
Definiuje niestandardowy zasób dodatków w tej samej grupie API. Ten zasób istnieje tylko
w wersji v1alpha1.
sa.yaml
Dodaje konto usługi webhook.
rbac.yaml
Definiuje role służące do wczytywania, wyświetlania i obserwowania dodatków.

rbac-bind.yaml
Wiąże role systemu RBAC z poprzedniego pliku z kontem usługi webhook.
service.yaml
Definiuje usługi webhook i odwzorowuje port 443 na 8443 z podów z webhookiem.

serving-cert-secret.yaml
Zawiera certyfikat serwera i klucz prywatny przeznaczone do użytku w podach z
webhookiem. Ten certyfikat jest używany bezpośrednio w zestawie certyfikatów w
przedstawionym wcześniej manifeście CRD reprezentującym pizzę.
deployment.yaml
Uruchamia pody z webhookami. W opcjach --tls-cert-file i --tls-private-key należy
użyć sekretu z certyfikatem serwera.
Teraz można wreszcie utworzyć pizzę margheritę:
$ cat ../examples/margherita-pizza.yaml
apiVersion: restaurant.programming-kubernetes.info/v1alpha1
kind: Pizza

metadata:
name: margherita
spec:
toppings:

- mozzarella
- tomato
$ kubectl create ../examples/margherita-pizza.yaml
pizza.restaurant.programming-kubernetes.info/margherita created
Obecnie, gdy gotowy jest już webhook konwersji, można pobrać ten sam obiekt w obu wersjach.
Najpierw bezpośrednio w wersji v1alpha1:
$ kubectl get pizzas.v1alpha1.restaurant.programming-kubernetes.info \
margherita -o yaml
apiVersion: restaurant.programming-kubernetes.info/v1alpha1

kind: Pizza
metadata:
creationTimestamp: “2019-04-14T21:41:39Z”
generation: 1
name: margherita
namespace: pizza-crd
resourceVersion: “18296”

pizzas/margherita
uid: 15c1c06a-5efe-11e9-9230-0242f24ba99c
spec:
toppings:

- mozzarella
- tomato
status: {}
Ten sam obiekt w wersji v1beta1 ma inną strukturę toppings:
$ kubectl get pizzas.v1beta1.restaurant.programming-kubernetes.info \

margherita -o yaml
apiVersion: restaurant.programming-kubernetes.info/v1beta1
kind: Pizza
metadata:

creationTimestamp: “2019-04-14T21:41:39Z”
generation: 1
name: margherita
namespace: pizza-crd
resourceVersion: “18296”

pizzas/margherita
uid: 15c1c06a-5efe-11e9-9230-0242f24ba99c
spec:
toppings:

- name: mozzarella
quantity: 1
- name: tomato
quantity: 1
status: {}

W dzienniku poda z webhookiem widoczne jest następujące wywołanie uruchamiające


konwersję:
I0414 21:46:28.639707 1 convert.go:35] Converting pizza-crd/margherita
from restaurant.programming-kubernetes.info/v1alpha1
to restaurant.programming-kubernetes.info/v1beta1

10.32.0.1 - - [14/Apr/2019:21:46:28 +0000]


“POST /convert/v1beta1/pizza?timeout=30s HTTP/2.0” 200 968
Tak więc webhook wykonuje swoje zadanie zgodnie z oczekiwaniami.

Webhooki kontroli dostępu


W punkcie „Scenariusze stosowania niestandardowych serwerów API” opisaliśmy sytuacje, w
których zagregowany serwer API jest lepszym wyborem niż niestandardowe zasoby. Jest wiele
powodów, dla których lepiej jest mieć swobodę implementowania niektórych operacji za
pomocą języka Go, bez ograniczania się do deklaratywnych rozwiązań z manifestów CRD.
W poprzednim punkcie zobaczyłeś, jak za pomocą języka Go napisać webhooki do konwersji
niestandardowych zasobów. Podobny mechanizm służy do dodawania niestandardowej kontroli
dostępu do niestandardowych zasobów. Także tu używany jest język Go.
Mamy tu podobną swobodę co w przypadku niestandardowych wtyczek kontroli dostępu w
zagregowanych serwerach API (zob. punkt „Kontrola dostępu”). Dostępne są webhooki kontroli
dostępu modyfikujące i sprawdzające poprawność. Są one wywoływane w tym samym miejscu
co dla zasobów natywnych. Ilustruje to rysunek 9.5.

Rysunek 9.5. Kontrola dostępu w procesie obsługi żądań zasobów niestandardowych

W punkcie „Sprawdzanie poprawności niestandardowych zasobów” zapoznałeś się ze


sprawdzaniem poprawności niestandardowych zasobów na podstawie specyfikacji OpenAPI. Na
rysunku 9.5 sprawdzanie poprawności odbywa się w polu „Sprawdzanie poprawności”.
Webhooki kontroli dostępu sprawdzające poprawność są wywoływane po tym kroku, a
webhooki kontroli dostępu modyfikujące żądanie — przed tym krokiem.
Webhooki kontroli dostępu są umieszczone prawie na końcu zestawu wtyczek kontroli dostępu
(przed sprawdzaniem limitu zasobów). W Kubernetesie 1.14 webhooki kontroli dostępu są
dostępne w wersji beta, dlatego można z nich korzystać w większości klastrów.

W wersji v1 API webhooków kontroli dostępu planowane jest umożliwienie


dwóch przebiegów przez łańcuch kontroli dostępu. To oznacza, że wtyczki lub
webhooki kontroli dostępu z wcześniejszych miejsc w łańcuchu mogą w
pewnym zakresie polegać na danych wyjściowych z wtyczek lub webhooków z
dalszych miejsc. Dlatego w przyszłości ten mechanizm będzie dawał jeszcze
większe możliwości.

Wymogi związane z kontrolą dostępu w przykładzie


W przykładzie dotyczącym pizzerii kontrola dostępu jest używana do wielu rzeczy:

Jeśli pole spec.toppings ma wartość nil lub jest puste, domyślnie przypisywane są do
niego mozzarella, pomidory i salami.
Nieznane pola należy usuwać z niestandardowego zasobu w formacie JSON; nie są one
utrwalane w systemie etcd.
Pole spec.toppings musi zawierać tylko te dodatki, dla których istnieje odpowiedni
obiekt.

Pierwsze dwa zadania są związane z modyfikacjami. Trzecie zadanie polega wyłącznie na


sprawdzaniu poprawności. Dlatego do wykonania tych kroków używamy jednego webhooka
modyfikującego i jednego webhooka sprawdzającego poprawność.

Trwają prace nad dodaniem natywnego ustawiania wartości domyślnych w


schematach sprawdzania poprawności w OpenAPI v3 (http://bit.ly/2ZFH8JY).
OpenAPI udostępnia pole default, a serwer API będzie uwzględniał je w
przyszłości. Ponadto usuwanie nieznanych pól stanie się standardowym
działaniem dla wszystkich zasobów. Będzie ono wykonywane przez serwer API
Kubernetes za pomocą mechanizmu okrajania (ang. pruning;
http://bit.ly/2Xzt2wm).
W Kubernetesie 1.15 okrajanie jest dostępne w wersji beta. W Kubernetesie
1.16 ma pojawić się ustawianie wartości domyślnych w wersji beta. Gdy oba te
mechanizmy są dostępne w docelowym klastrze, oba kroki z pokazanej
wcześniej listy można zaimplementować bez używania webhooków.

Architektura webhooków kontroli dostępu


Webhooki kontroli dostępu są bardzo podobne do opisanych wcześniej w tym rozdziale
webhooków konwersji.
Takie webhooki są instalowane w klastrze, umieszczane za usługą odwzorowującą port 443 na
jeden z portów podów i wywoływane za pomocą obiektu typu AdmissionReview z grupy API
admission.k8s.io/v1beta1:
---
// AdmissionReview reprezentuje żądanie i odpowiedź w procesie weryfikacji
dostępu.
type AdmissionReview struct {
metav1.TypeMeta `json:”,inline”`

// Request opisuje atrybuty żądania w procesie weryfikacji dostępu.


// +optional
Request *AdmissionRequest `json:”request,omitempty”`
// Response opisuje atrybuty odpowiedzi w procesie weryfikacji dostępu.
// +optional

Response *AdmissionResponse `json:”response,omitempty”`


}
---
Obiekt typu AdmissionRequest zawiera wszystkie informacje z atrybutów kontroli dostępu
(zob. punkt „Implementacja”):

// AdmissionRequest opisuje atrybuty admission.Attributes żądania w procesie


weryfikacji dostępu.
type AdmissionRequest struct {
// UID to identyfikator jednego żądania lub jednej odpowiedzi. Pozwala
odróżniać instancje

// żądań, które poza identyfikatorem są identyczne (równoległe żądania,


żądania, które
// nie zostały zmodyfikowane w stosunku do wcześniejszych itd.).
Identyfikator UID służy
// do śledzenia wymiany komunikatów (żądanie/odpowiedź) między serwerem
API Kubernetesa a

// webhookiem, a nie do śledzenia żądania od użytkownika. Nadaje się do


łączenia wpisów z
// dziennika pochodzących z webhooka i serwera API na potrzeby audytu lub
debugowania.
UID types.UID `json:”uid”`

// Kind to typ przetwarzanego obiektu, np. Pod.


Kind metav1.GroupVersionKind `json:”kind”`
// Resource to nazwa żądanego zasobu. Nie jest ona równoważna
// z rodzajem (np. pods).
Resource metav1.GroupVersionResource `json:”resource”`

// SubResource to nazwa żądanego podzasobu. Jest to odrębny zasób


// z zasięgiem ograniczonym do zasobu nadrzędnego, przy czym może być
innego
// rodzaju. Na przykład dla punktu końcowego /pods zasób to „pods”, a
rodzaj to
// „Pod”, natomiast dla punktu końcowego /pods/foo/status zasób to
„pods”, podzasób

// to „status”, a rodzaj to „Pod” (ponieważ status dotyczy podów).


Punktem końcowym
// wiązania dla poda może być jednak /pods/foo/binding, dla którego
// zasób to „pods”, podzasób to „binding”, a rodzaj to „Binding”.
// +optional

SubResource string `json:”subResource,omitempty”`


// Name to nazwa obiektu używana w żądaniu. W operacji CREATE
// klient może pominąć nazwę i polegać na tym, że to serwer ją
// wygeneruje. Wtedy ta metoda zwraca pusty łańcuch znaków.

// +optional
Name string `json:”name,omitempty”`
// Namespace to przestrzeń nazw powiązana z żądaniem (jeśli taka
istnieje).
// +optional

Namespace string `json:”namespace,omitempty”`


// Operation to wykonywana operacja.
Operation Operation `json:”operation”`
// UserInfo to informacje o użytkowniku zgłaszającym żądanie.
UserInfo authenticationv1.UserInfo `json:”userInfo”`

// Object to obiekt z przychodzącego żądania


// przed zastosowaniem wartości domyślnych.
// +optional
Object runtime.RawExtension `json:”object,omitempty”`

// OldObject to istniejący obiekt. To pole jest zapełniane tylko dla


żądań UPDATE.
// +optional
OldObject runtime.RawExtension `json:”oldObject,omitempty”`
// DryRun informuje, że modyfikacje dla danego żądania

// na pewno nie będą utrwalane.


// Wartość domyślna to false.
// +optional
DryRun *bool `json:”dryRun,omitempty”`

}
Ten sam obiekt typu AdmissionReview jest używany dla obu typów webhooków kontroli
dostępu (modyfikujących i sprawdzających poprawność). Jedyna różnica polega na tym, że dla
webhooków modyfikujących obiekt typu AdmissionResponse może mieć pola patch i
patchType, które są uwzględniane na serwerze API Kubernetesa po otrzymaniu przez niego
odpowiedzi od webhooka. W odpowiedziach od webhooków sprawdzających poprawność te dwa
pola pozostają puste.
Najważniejszym polem dla naszych potrzeb jest tu Object, które — tak jak w pokazanym
webhooku konwersji — używa typu runtime.RawExtension do zapisania obiektu
reprezentującego pizzę.
Dostępny jest też dawny obiekt dla żądań modyfikacji. Moglibyśmy np. sprawdzać w nim pola,
które są przeznaczone tylko do odczytu, ale zostały w żądaniu zmodyfikowane. W naszym
przykładzie tego nie robimy, jednak w Kubernetesie często spotkasz się z kodem, w którym
zaimplementowana jest taka logika. Dotyczy to np. większości pól podów, ponieważ nie można
zmienić polecenia dla poda po jego utworzeniu.
W Kubernetesie 1.14 obiekt częściowej modyfikacji zwrócony przez modyfikujący webhook musi
być typu Patch z formatu JSON (zob. RFC 6902). Ten obiekt opisuje, jak należy zmodyfikować
obiekt w zgodzie z obowiązującym niezmiennikiem.

Warto zauważyć, że zalecaną praktyką jest końcowa ocena prawidłowości za pomocą webhooka
sprawdzającego poprawność każdej zmiany wprowadzanej przez webhook modyfikujący —
przynajmniej wtedy, gdy sprawdzane właściwości mają wpływ na działanie kodu. Wyobraź
sobie, że kilka modyfikujących webhooków zmienia te same pola obiektu. Nie masz wtedy
pewności, czy dane zmiany przetrwają do końca działania łańcucha modyfikujących webhooków
kontroli dostępu.
Obecnie dla modyfikujących webhooków nie jest używana kolejność inna niż alfabetyczna.
Trwają dyskusje nad tym, by w przyszłości to zmienić.
Kolejność działania webhooków sprawdzających poprawność oczywiście nie ma znaczenia, a
serwer API Kubernetesa wywołuje je równolegle, aby przyspieszyć pracę. Z kolei webhooki
modyfikujące zwiększają opóźnienie każdego żądania, które przetwarzają, ponieważ są
wywoływane sekwencyjnie.

Opóźnienie często wynosi ok. 100 ms (oczywiście jest to wysoce zależne od środowiska).
Dlatego sekwencyjne uruchamianie wielu webhooków skutkuje znacznym opóźnieniem, które
użytkownik odczuwa w trakcie tworzenia lub modyfikowania obiektów.

Rejestrowanie webhooków kontroli dostępu


Webhooki kontroli dostępu nie są rejestrowane w manifeście CRD. Wynika to z tego, że są
stosowane nie tylko do niestandardowych zasobów, ale do zasobów każdego rodzaju. Możesz
nawet dodać niestandardowe webhooki kontroli dostępu dla standardowych zasobów
Kubernetesa.
Istnieją natomiast obiekty używane do rejestracji: MutatingWebhookRegistration i
ValidatingWebhookRegistration. Różnią się one tylko nazwą rodzaju:
apiVersion: admissionregistration.k8s.io/v1beta1
kind: MutatingWebhookConfiguration

metadata:
name: restaurant.programming-kubernetes.info
webhooks:
- name: restaurant.programming-kubernetes.info
failurePolicy: Fail
sideEffects: None
admissionReviewVersions:
- v1beta1
rules:
- apiGroups:

- “restaurant.programming-kubernetes.info”
apiVersions:
- v1alpha1
- v1beta1
operations:
- CREATE
- UPDATE
resources:
- pizzas

clientConfig:
service:
namespace: pizza-crd
name: webhook
path: /admit/v1beta1/pizza
caBundle: zestaw-certyfikatów
Ten kod rejestruje nasz webhook z początku rozdziału przeznaczony dla zasobów pizza-crd na
potrzeby obsługi kontroli dostępu z modyfikacjami do dwóch wersji zasobu pizza, grupy API
restaurant.programming-kubernetes.info oraz operacji HTTP CREATE i UPDATE (umożliwia
też częściowe modyfikacje).

Istnieją też inne sposoby na ograniczenie w konfiguracji webhooka listy pasujących zasobów.
Można m.in. zastosować selektor przestrzeni nazw (aby pominąć np. przestrzeń nazw warstwy
kontroli w celu uniknięcia problemów z rozruchem serwera) oraz bardziej zaawansowane
wzorce z symbolami wieloznacznymi i podzasobami.
Warto też zwrócić uwagę na tryb obsługi awarii — Fail lub Ignore. Określa on, co robić, jeśli
nie można uzyskać dostępu do webhooka lub gdy zawiedzie on z innego powodu.

Webhooki kontroli dostępu mogą zakłócić działanie klastrów, jeśli zostaną


zainstalowane w nieprawidłowy sposób. Webhook kontroli dostępu powiązany z
typami podstawowymi może sprawić, że cały klaster przestanie pracować.
Dlatego trzeba zachować wyjątkową ostrożność, wywołując webhooki kontroli
dostępu dla zasobów innych niż CRD.
Dobrą specyficzną praktyką jest unikanie w webhookach obsługi warstwy
kontroli i samych zasobów webhooków.
Implementowanie webhooka kontroli dostępu
Po pracy nad webhookiem kontroli dostępu z początku rozdziału nietrudno jest dodać
mechanizmy kontroli dostępu. Wiesz już też, że w funkcji main pliku binarnego webhooka
pizza-crd-webhook zarejestrowane są ścieżki /admit/v1beta1/pizza i /validate/v1beta1/pizza:
mux.Handle(“/admit/v1beta1/pizza”,
http.HandlerFunc(admission.ServePizzaAdmit))
mux.Handle(“/validate/v1beta1/pizza”, http.HandlerFunc(
admission.ServePizzaValidation(restaurantInformers)))
Pierwsza z dwóch funkcji obsługi żądań HTTP wygląda prawie tak samo jak w webhooku
konwersji:
func ServePizzaAdmit(w http.ResponseWriter, req *http.Request) {
// Odczyt treści.

body, err := ioutil.ReadAll(req.Body)


if err != nil {
responsewriters.InternalError(w, req,
fmt.Errorf(“Nieudany odczyt treści: %v”, err))
return
}

// Dekodowanie treści do postaci obiektu typu AdmissionReview.


reviewGVK :=
admissionv1beta1.SchemeGroupVersion.WithKind(“AdmissionReview”)
decoder := codecs.UniversalDeserializer()
into := &admissionv1beta1.AdmissionReview{}
obj, gvk, err := decoder.Decode(body, &reviewGVK, into)
if err != nil {
responsewriters.InternalError(w, req,
fmt.Errorf(“Nieudane dekodowanie treści: %v”, err))
return
}

review, ok := obj.(*admissionv1beta1.AdmissionReview)
if !ok {
responsewriters.InternalError(w, req,
fmt.Errorf(“Nieoczekiwany identyfikator GVK: %s”, gvk))
return
}
if review.Request == nil {
responsewriters.InternalError(w, req,
fmt.Errorf(“Nieoczekiwane żądanie nil”))

return
}
...
}
W webhooku sprawdzającym poprawność trzeba podłączyć informator (służący do sprawdzania,
czy w klastrze istnieją dodatki). Dopóki informator nie jest zsynchronizowany, zwracany jest
wewnętrzny błąd. Informator, który nie jest zsynchronizowany, zawiera niekompletne dane,
dlatego dodatki mogłyby zostać uznane za nieznane, a prawidłowa pizza — odrzucona:
func ServePizzaValidation(informers
restaurantinformers.SharedInformerFactory)
func (http.ResponseWriter, *http.Request)

{
toppingInformer :=
informers.Restaurant().V1alpha1().Toppings().Informer()
toppingLister := informers.Restaurant().V1alpha1().Toppings().Lister()

return func(w http.ResponseWriter, req *http.Request) {


if !toppingInformer.HasSynced() {
responsewriters.InternalError(w, req,
fmt.Errorf(“Informatory nie są gotowe”))

return
}

// Odczyt treści.
body, err := ioutil.ReadAll(req.Body)
if err != nil {
responsewriters.InternalError(w, req,
fmt.Errorf(“Nieudany odczyt treści: %v”, err))
return

// Dekodowanie treści do postaci obiektu typu AdmissionReview.


gv := admissionv1beta1.SchemeGroupVersion
reviewGVK := gv.WithKind(“AdmissionReview”)
obj, gvk, err := codecs.UniversalDeserializer().Decode(body,
&reviewGVK,
&admissionv1beta1.AdmissionReview{})
if err != nil {
responsewriters.InternalError(w, req,
fmt.Errorf(“Nieudane dekodowanie treści: %v”, err))

return
}
review, ok := obj.(*admissionv1beta1.AdmissionReview)
if !ok {
responsewriters.InternalError(w, req,
fmt.Errorf(“Nieoczekiwany identyfikator GVK: %s”, gvk))
return
}
if review.Request == nil {

responsewriters.InternalError(w, req,
fmt.Errorf(“Nieoczekiwane żądanie nil”))
return
}
...
}
}
Podobnie jak w webhooku konwersji, tak i powyżej skonfigurowaliśmy schemat i fabrykę
kodeków, używając grupy API dla kontroli dostępu i grupy API dla pizzerii:

var (
scheme = runtime.NewScheme()
codecs = serializer.NewCodecFactory(scheme)
)

func init() {
utilruntime.Must(admissionv1beta1.AddToScheme(scheme))
install.Install(scheme)
}

Dzięki temu można odkodować zagnieżdżony obiekt pizzy (tym razem tylko jeden, bez
wycinków) z obiektu typu AdmissionReview:
// Dekodowanie obiektu.

if review.Request.Object.Object == nil {
var err error
review.Request.Object.Object, _, err =
codecs.UniversalDeserializer().Decode(review.Request.Object.Raw, nil,
nil)
if err != nil {
review.Response.Result = &metav1.Status{
Message: err.Error(),
Status: metav1.StatusFailure,

}
responsewriters.WriteObject(http.StatusOK, gvk.GroupVersion(),
codecs, review, w, req)
return
}
}
Następnie ma miejsce etap modyfikacji w procesie kontroli dostępu (ustawianie wartości
domyślnych pola spec.toppings dla obu wersji API):
orig := review.Request.Object.Raw
var bs []byte

switch pizza := review.Request.Object.Object.(type) {


case *v1alpha1.Pizza:
// Domyślne dodatki.
if len(pizza.Spec.Toppings) == 0 {
pizza.Spec.Toppings = []string{“pomidory”, “mozzarella”, “salami”}
}
bs, err = json.Marshal(pizza)
if err != nil {
responsewriters.InternalError(w, req,

fmt.Errorf(“Nieoczekiwany błąd kodowania: %v”, err))


return
}

case *v1beta1.Pizza:
// Domyślne dodatki.
if len(pizza.Spec.Toppings) == 0 {
pizza.Spec.Toppings = []v1beta1.PizzaTopping{
{“pomidory”, 1},
{“mozzarella”, 1},

{“salami”, 1},
}
}
bs, err = json.Marshal(pizza)
if err != nil {
responsewriters.InternalError(w, req,
fmt.Errorf(“Nieoczekiwany błąd kodowania: %v”, err))
return
}

default:
review.Response.Result = &metav1.Status{
Message: fmt.Sprintf(“Nieoczekiwany typ %T”,
review.Request.Object.Object),
Status: metav1.StatusFailure,
}
responsewriters.WriteObject(http.StatusOK, gvk.GroupVersion(),
codecs, review, w, req)
return

}
Można też zastosować algorytmy konwersji z webhooka konwersji, a następnie
zaimplementować ustawianie wartości domyślnych tylko dla jednej z wersji. Oba podejścia są
akceptowalne, a to, które z nich będzie lepsze, zależy od sytuacji. Tu ustawianie wartości
domyślnych jest na tyle proste, że można je zaimplementować dwukrotnie.
Ostatni krok polega na obliczeniu częściowej modyfikacji — różnicy między pierwotnym
obiektem (zapisanym w zmiennej orig w formacie JSON) a nowym z ustawionymi wartościami
domyślnymi:
// Porównywanie wersji pierwotnej i wersji z ustawionymi wartościami
domyślnymi.
ops, err := jsonpatch.CreatePatch(orig, bs)

if err != nil {
responsewriters.InternalError(w, req,
fmt.Errorf(“Nieoczekiwany błąd wyznaczania różnicy: %v”, err))
return
}
review.Response.Patch, err = json.Marshal(ops)
if err != nil {
responsewriters.InternalError(w, req,
fmt.Errorf(“Nieoczekiwany błąd kodowania częściowej modyfikacji: %v”,
err))
return

}
typ := admissionv1beta1.PatchTypeJSONPatch
review.Response.PatchType = &typ
review.Response.Allowed = true
Używamy tu biblioteki JSON-Patch (http://bit.ly/2IKxwIk; a dokładniej — odgałęzienie Matta
Bairda, http://bit.ly/2xfBIsN, z krytycznymi poprawkami — http://bit.ly/2XxKfWP), aby uzyskać
częściową modyfikację między pierwotnym obiektem orig a zmodyfikowanym obiektem bs. Oba
te obiekty są przekazywane jako wycinki z bajtami w formacie JSON. Inna możliwość to
bezpośrednie operowanie nietypizowanymi danymi w formacie JSON i ręczne uzyskanie
częściowej modyfikacji w formacie JSON. Wybór jednej z tych technik zależy od sytuacji.
Wygodne jest używanie biblioteki diff.

Następnie, tak jak w webhooku konwersji, należy zapisać odpowiedź za pomocą służącego do
tego obiektu, używając utworzonej wcześniej fabryki kodeków:
responsewriters.WriteObject(
http.StatusOK, gvk.GroupVersion(), codecs, review, w, req,
)
Webhook sprawdzający poprawność jest bardzo podobny, ale do sprawdzania istnienia obiektów
reprezentujących dodatki używa listera dodatków ze współdzielonego informatora:
switch pizza := review.Request.Object.Object.(type) {
case *v1alpha1.Pizza:
for _, topping := range pizza.Spec.Toppings {

_, err := toppingLister.Get(topping)
if err != nil && !errors.IsNotFound(err) {
responsewriters.InternalError(w, req,
fmt.Errorf(“Nieudane wyszukiwanie dodatku %q: %v”, topping,
err))
return
} else if errors.IsNotFound(err) {
review.Response.Result = &metav1.Status{
Message: fmt.Sprintf(“Nieznany dodatek %q “, topping),
Status: metav1.StatusFailure,
}

responsewriters.WriteObject(http.StatusOK, gvk.GroupVersion(),
codecs, review, w, req)
return
}
}
review.Response.Allowed = true
case *v1beta1.Pizza:
for _, topping := range pizza.Spec.Toppings {
_, err := toppingLister.Get(topping.Name)

if err != nil && !errors.IsNotFound(err) {


responsewriters.InternalError(w, req,
fmt.Errorf(“Nieudane wyszukiwanie dodatku %q: %v”, topping,
err))
return
} else if errors.IsNotFound(err) {
review.Response.Result = &metav1.Status{
Message: fmt.Sprintf(“Nieznany dodatek %q”, topping),
Status: metav1.StatusFailure,

}
responsewriters.WriteObject(http.StatusOK, gvk.GroupVersion(),
codecs, review, w, req)
return
}
}
review.Response.Allowed = true
default:
review.Response.Result = &metav1.Status{

Message: fmt.Sprintf(“Nieoczekiwany typ %T”,


review.Request.Object.Object),
Status: metav1.StatusFailure,
}
}
responsewriters.WriteObject(http.StatusOK, gvk.GroupVersion(),
codecs, review, w, req)

Webhook kontroli dostępu w praktyce


Instalujemy dwa webhooki kontroli dostępu, tworząc w klastrze dwa obiekty odpowiedzialne za
rejestrację:
$ kubectl create -f validatingadmissionregistration.yaml
$ kubectl create -f mutatingadmissionregistration.yaml

Teraz nie da się już utworzyć pizzy z nieznanymi dodatkami:


$ kubectl create -f ../examples/margherita-pizza.yaml
Error from server: error when creating “../examples/margherita-pizza.yaml”:
admission webhook “restaurant.programming-kubernetes.info” denied the
request:
Nieznany dodatek “pomidory”
W dzienniku webhooka można znaleźć następujące wpisy:
0414 22:45:46.873541 1 pizzamutation.go:115] Defaulting pizza-crd/ in
version admission.k8s.io/v1beta1, Kind=AdmissionReview

10.32.0.1 - - [14/Apr/2019:22:45:46 +0000]


“POST /admit/v1beta1/pizza?timeout=30s HTTP/2.0” 200 871
10.32.0.1 - - [14/Apr/2019:22:45:46 +0000]
“POST /validate/v1beta1/pizza?timeout=30s HTTP/2.0” 200 956
Po zapisaniu dodatków w katalogu z przykładem można ponownie utworzyć pizzę margheritę:
$ kubectl create -f ../examples/topping-tomato.yaml
$ kubectl create -f ../examples/topping-salami.yaml
$ kubectl create -f ../examples/topping-mozzarella.yaml
$ kubectl create -f ../examples/margherita-pizza.yaml

pizza.restaurant.programming-kubernetes.info/margherita created
Sprawdźmy też, czy ustawianie wartości domyślnych działa zgodnie z oczekiwaniami. Chcemy
utworzyć pustą pizzę:
apiVersion: restaurant.programming-kubernetes.info/v1alpha1
kind: Pizza
metadata:
name: salami
spec:
Kod powinien w niej ustawić wartości domyślne dla pizzy salami — i tak się dzieje:
$ kubectl create -f ../examples/empty-pizza.yaml

pizza.restaurant.programming-kubernetes.info/salami created
$ kubectl get pizza salami -o yaml
apiVersion: restaurant.programming-kubernetes.info/v1beta1
kind: Pizza
metadata:
creationTimestamp: “2019-04-14T22:49:40Z”
generation: 1
name: salami
namespace: pizza-crd

resourceVersion: “23227”
uid: 962e2dda-5f07-11e9-9230-0242f24ba99c
spec:
toppings:
- name: pomidory
quantity: 1
- name: mozzarella
quantity: 1
- name: salami

quantity: 1
status: {}
Gotowe — pizza salami z wszystkimi oczekiwanymi dodatkami. Smacznego!
Przed końcem rozdziału warto przyjrzeć się wersji apiextensions.k8s.io/v1 grupy API
niestandardowych zasobów (jest to wersja ogólnie dostępna zamiast wersji beta). Ważne są w
niej schematy strukturalne.

Schematy strukturalne i przyszłość


definicji CRD
Od Kubernetesa 1.15 schemat sprawdzania poprawności z OpenAPI v3 (zob. punkt
„Sprawdzanie poprawności niestandardowych zasobów”) będzie istotny dla definicji CRD,
ponieważ podanie schematu będzie wymagane, gdy używana będzie jedna z następujących
nowych funkcji:

konwersja niestandardowych zasobów (zob. rysunek 9.2),


okrajanie (zob. punkt „Okrajanie a zachowywanie nieznanych pól”),
ustawianie wartości domyślnych (zob. punkt „Wartości domyślne”),
publikowanie schematu OpenAPI (http://bit.ly/2RzeA1O).

Ściśle rzecz biorąc, definiowanie schematu nadal jest opcjonalne, a każda istniejąca definicja
CRD nadal będzie działać. Jednak bez schematu żadna z wymienionych funkcji nie będzie
dostępna dla danego niestandardowego zasobu.
Ponadto podany schemat musi być zgodny z określonymi regułami, aby było wiadomo, że
podane typy są zgodne z konwencjami obowiązującymi dla API Kubernetesa
(http://bit.ly/2Nfd9Hn). Takie schematy nazywamy strukturalnymi.

Schematy strukturalne
Schemat strukturalny to schemat sprawdzania poprawności z OpenAPI v3 (zob. punkt
„Sprawdzanie poprawności niestandardowych zasobów”), który spełnia trzy następujące
warunki:

1. Określa niepusty typ (za pomocą słowa kluczowego type z OpenAPI) dla korzenia, dla
każdego pola węzła obiektu (za pomocą słów kluczowych properties i
additionalProperties z OpenAPI) i dla każdego elementu węzła tablicy (za pomocą
słowa kluczowego items z OpenAPI). Wyjątkami są:
węzły z ustawieniem x-kubernetes-int-or-string: true,
węzły z ustawieniem x-kubernetes-preserve-unknown-fields: true.
2. Wszystkie pola w obiektach i elementy w tablicach, które są podane w sekcjach allOf,
anyOf, oneOf lub not, mają też w schemacie określone pole/element poza tymi logicznymi
sekcjami.
3. W schemacie w sekcjach allOf, anyOf, oneOf i not nie są ustawiane pola description,
type, default, additionProperties lub nullable. Wyjątkiem są dwa wzorce dla
ustawienia x-kubernetes-int-or-string: true (zob. punkt „IntOrString i
RawExtension”).
4. Jeśli podane jest pole metadata, dozwolone są jedynie ograniczenia dotyczące pól
metadata.name i metadata.generateName.

Oto przykład, który nie jest strukturalny:


properties:
foo:
pattern: “abc”
metadata:
type: object

properties:
name:
type: string
pattern: “^a”
finalizers:
type: array
items:
type: string
pattern: “my-finalizer”

anyOf:
- properties:
bar:
type: integer
minimum: 42
required: [“bar”]
description: “obiekt foo bar”
Ten schemat nie jest strukturalny, ponieważ nie spełnia pewnych warunków:

Brakuje typu dla korzenia (warunek 1.).


Brakuje typu foo (warunek 1.).
Typ bar z sekcji anyOf nie jest podany poza tą sekcją (warunek 2.).
Pole type obiektu bar znajduje się w sekcji anyOf (warunek 3.).
W sekcji anyOf znajduje się pole description (warunek 3.).
Nie można stosować ograniczeń dla pola metadata.finalizers (warunek 4.).

Z kolei ten analogiczny schemat jest strukturalny:


type: object
description: “obiekt foo bar”
properties:
foo:

type: string
pattern: “abc”
bar:
type: integer
metadata:
type: object
properties:
name:
type: string
pattern: “^a”

anyOf:
- properties:
bar:
minimum: 42
required: [“bar”]
Naruszenie reguł schematu strukturalnego jest zgłaszane w warunku NonStructural definicji
CRD.
Możesz sam się przekonać, że schemat z przykładu cnat z punktu „Sprawdzanie poprawności
niestandardowych zasobów” i schematy z przykładu z definicjami CRD dla pizzerii
(http://bit.ly/31MrFcO) rzeczywiście są strukturalne.
Okrajanie a zachowywanie nieznanych pól
W niestandardowych zasobach wszystkie (często sprawdzone) dane w formacie JSON są
zapisywane w takiej postaci jak w systemie etcd. To oznacza, że nieznane pola (jeśli w ogóle
używany jest schemat sprawdzania poprawności z OpenAPI v3) są zachowywane. Jest to różnica
w porównaniu z natywnymi zasobami Kubernetesa takimi jak pody. Jeśli użytkownik poda pole
spec.dowolnePole, będzie ono akceptowane w punkcie końcowych HTTPS serwera API, ale
usuwane (okrajane) przed zapisem danego poda w systemie etcd.
Jeżeli zdefiniowany jest schemat sprawdzania poprawności z OpenAPI v3 (albo w globalnym
polu spec.validation.openAPIV3Schema, albo dla każdej wersji), można włączyć okrajanie
(usuwanie nieznanych pól przy tworzeniu i modyfikowaniu obiektów), ustawiając wartość pola
spec.preserveUnknownFields na false.
[2]
Przyjrzyj się kodowi narzędzia cnat . Gdy używasz klastra z Kubernetesem 1.15, możesz
włączyć okrajanie:
apiVersion: apiextensions.k8s.io/v1beta1

kind: CustomResourceDefinition
metadata:
name: ats.cnat.programming-kubernetes.info
spec:
...
preserveUnknownFields: false
Następnie można utworzyć instancję z nieznanym polem:
apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At

metadata:
name: example-at
spec:
schedule: “2019-07-03T02:00:00Z”
command: echo “Witaj, świecie!”
someGarbage: 42
Jeśli pobierzesz ten obiekt za pomocą wywołania kubectl get at example-at, zobaczysz, że
pole someGarbage jest usuwane:
apiVersion: cnat.programming-kubernetes.info/v1alpha1

kind: At
metadata:
name: example-at
spec:
schedule: “2019-07-03T02:00:00Z”
command: echo “Witaj, świecie!”
Można powiedzieć, że pole someGarbage zostało przycięte.
W Kubernetesie 1.15 okrajanie jest dostępne w wersji apiextensions/v1beta1, ale domyślnie jest
wyłączone (pole spec.preserveUnknownFields domyślnie ma wartość true). W wersji
apiextensions/v1 nie będzie można tworzyć nowych definicji CRD z ustawieniem
spec.preserveUnknownFields: true.

Sterowanie okrajaniem
Gdy w definicji CRD znajduje się ustawienie spec.preserveUnknownField: false, okrajanie
jest włączone dla wszystkich zasobów niestandardowych danego typu we wszystkich wersjach.
Można jednak wyłączyć okrajanie dla poddrzewa w formacie JSON, używając ustawienia x-
kubernetes--preserve-unknown-fields: true w schemacie sprawdzania poprawności z
OpenAPI v3:
type: object
properties:
json:
x-kubernetes-preserve-unknown-fields: true
W polu json można zapisać dowolną wartość w formacie JSON, a żadne dane nie będą
okrajane.
Można też podać częściową specyfikację dozwolonych danych w formacie JSON:
type: object
properties:
json:
x-kubernetes-preserve-unknown-fields: true
type: object
description: dowolne dane w formacie JSON
W tym podejściu dozwolone są jedynie wartości typu object.

Okrajanie zostaje ponownie włączone dla każdej podanej właściwości (można też zastosować
słowo kluczowe additionalProperties):
type: object
properties:
json:
x-kubernetes-preserve-unknown-fields: true
type: object
properties:
spec:
type: object

properties:
foo:
type: string
bar:
type: string
Przy takich ustawieniach poniższa wartość:
json:
spec:
foo: abc

bar: def
something: x
status:
something: x
jest okrajana do następującej postaci:
json:
spec:
foo: abc
bar: def

status:
something: x
To oznacza, że pole something w podanym obiekcie spec jest okrajane (ponieważ istnieje
specyfikacja tego obiektu), ale zewnętrzne dane nie zostają przycięte. Nie istnieje specyfikacja
obiektu status, dlatego pole status.something nie jest okrajane.

IntOrString i RawExtension
Zdarza się, że schematy strukturalne nie dają wystarczających możliwości. Jest tak np. w
przypadku pola polimorficznego, cechującego się tym, że może być różnego typu. Wśród
natywnych typów API Kubernetesa tak działa typ IntOrString.
Można korzystać z typu IntOrString w definicjach CRD, używając w schemacie dyrektywy x-
kubernetes-int-or-string: true. Podobnie można zadeklarować obiekt typu
runtime.RawExtension, używając ustawienia x-kubernetes-embedded-object: true.

Oto przykład:
type: object
properties:
intorstr:
type: object
x-kubernetes-int-or-string: true
embedded:
x-kubernetes-embedded-object: true
x-kubernetes-preserve-unknown-fields: true

Ten kod deklaruje:

pole intorstr przechowujące liczbę całkowitą lub łańcuch znaków,


pole embedded przechowujące obiekt podobny do obiektów Kubernetesa, np. kompletną
specyfikację poda.

Szczegółowe informacje na temat podanych dyrektyw znajdziesz w oficjalnej dokumentacji


definicji CRD (http://bit.ly/2Lnmw61).
Ostatnim tematem, jaki chcemy omówić w związku ze schematami strukturalnymi, jest
ustawianie wartości domyślnych.

Wartości domyślne
W natywnych typach Kubernetesa często ustawiane są określone wartości domyślne.
Ustawianie wartości domyślnych dla typów CRD było możliwe tylko za pomocą modyfikujących
webhooków kontroli dostępu (zob. punkt „Webhooki kontroli dostępu”). Jednak w Kubernetesie
1.15 do definicji CRD dodano bezpośrednią obsługę ustawiania wartości domyślnych (zob.
dokument z projektem tego mechanizmu — http://bit.ly/2ZFH8JY). Służą do tego opisane w
poprzednim punkcie schematy z OpenAPI v3.

W Kubernetesie 1.15 nadal jest to funkcja w wersji alfa. Jest ona domyślnie
wyłączona, a do włączania służy bramka funkcji CustomResourceDefaulting.
Jednak po promocji do wersji beta, co zapewne nastąpi w Kubernetesie 1.16,
ustawianie wartości domyślnych w definicjach CRD stanie się powszechne.

Aby ustawić wartości domyślne wybranych pól, wystarczy podać odpowiednią wartość z
użyciem słowa kluczowego default w schemacie z OpenAPI v3. Jest to bardzo przydatne, gdy
dodajesz nowe pola do typu.
Zacznijmy od schematu z narzędzia cnat z punktu „Sprawdzanie poprawności
niestandardowych zasobów”. Załóżmy, że chcemy umożliwić podawanie obrazu kontenera, przy
czym domyślnie ma być używany obraz busybox. W tym celu do schematu z OpenAPI v3
dodamy pole image typu string i ustawimy dla niego wartość domyślną busybox:
type: object
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object

spec:
type: object
properties:
schedule:
type: string
pattern: “^\d{4}-([0]\d|1[0-2])-([0-2]\d|3[01])...”
command:

type: string
image:
type: string
default: “busybox”
required:
- schedule
- command
status:
type: object

properties:
phase:
type: string
required:
- metadata
- apiVersion
- kind
- spec
Jeśli użytkownik utworzy instancję bez podawania obrazu, wartość obrazu zostanie ustawiona
automatycznie:

apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At
metadata:
name: example-at
spec:
schedule: “2019-07-03T02:00:00Z”
command: echo “Witaj, świecie!”
W trakcie tworzenia instancji ten kod zostanie automatycznie przekształcony w:
apiVersion: cnat.programming-kubernetes.info/v1alpha1

kind: At
metadata:
name: example-at
spec:
schedule: “2019-07-03T02:00:00Z”
command: echo “Witaj, świecie!”
image: busybox
Jest to bardzo wygodne i znacznie ułatwia korzystanie z definicji CRD. Co więcej, wszystkie
dawne obiekty utrwalone w systemie etcd automatycznie otrzymają nowe pole, gdy będą
[3]
wczytywane z serwera API .
Warto zauważyć, że utrwalone obiekty z systemu etcd nie będą zastępowane (nie będzie
przeprowadzana automatyczna migracja). Wartości domyślne będą dodawane dopiero przy
odczycie, „w locie”, i utrwalane wyłącznie w momencie modyfikowania obiektu z innego
powodu.

Podsumowanie
Webhooki kontroli dostępu i konwersji sprawiają, że definicje CRD wkraczają na zupełnie nowy
poziom. Wcześniej niestandardowe zasoby były używane głównie w prostych, mało
zaawansowanych sytuacjach — często na potrzeby konfiguracji lub wewnętrznych aplikacji,
gdzie kompatybilność API nie była zbyt istotna.
Dzięki webhookom niestandardowe zasoby znacznie upodabniają się do zasobów natywnych
dzięki długiemu cyklowi życia i rozbudowanej semantyce. Zobaczyłeś, jak implementować
zależności między różnymi zasobami i jak konfigurować ustawianie wartości domyślnych dla
pól.
Na tym etapie zapewne masz wiele pomysłów dotyczących tego, gdzie wykorzystać poznane
funkcje w istniejących definicjach CRD. Jesteśmy ciekawi innowacji, jakie społeczność
użytkowników w przyszłości opracuje na podstawie tych funkcji.

1 Zgodnie z planami w Kubernetesie 1.16 grupy apiextensions.k8s.io i


admissionregistration.k8s.io mają zostać promowane do wersji v1.

2 Używamy tu przykładu z narzędziem cnat zamiast przykładu z pizzerią, ponieważ ten


pierwszy ma prostą strukturę — m.in. tylko jedną wersję. Oczywiście wszystkie informacje
dotyczą też rozwiązań z wieloma wersjami (z jednym schematem dla każdej wersji).
3 Na przykład za pomocą wywołania kubectl get ats -o yaml.
Dodatek A. Materiały

Ogólne
Oficjalna dokumentacja Kubernetesa (https://kubernetes.io/docs/home).
Społeczność skupiona wokół Kubernetesa w serwisie GitHub (http://bit.ly/2LX2YF8).
Kanał client-go docs w przestrzeni roboczej Kubernetes Slack.
Kubernetes deep dive: API Server — część 1. (https://red.ht/2IJBDEk).
Kubernetes deep dive: API Server — część 2. (https://red.ht/2RAEv9s).
Kubernetes deep dive: API Server — część 3. (https://red.ht/2NaXgBD).
Kubernetes API Server, część 1. (http://bit.ly/2IKh0be).
The Mechanics of Kubernetes (http://bit.ly/2IV2lcb).
Dokumentacja go doc biblioteki k8s.io/api (https://godoc.org/k8s.io/api).

Książki
Kubernetes: Up and Running, wydanie drugie (https://oreil.ly/2SaANU4), Kelsey
Hightower i inni (O’Reilly).
Cloud Native DevOps with Kubernetes (https://oreil.ly/2BaE1iq), John Arundel i Justin
Domingus (O’Reilly).
Managing Kubernetes (https://oreil.ly/2wtHcAm), Brendan Burns i Craig Tracey (O’Reilly).
Kubernetes Cookbook (http://bit.ly/2FTgJzk), Sébastien Goasguen i Michael Hausenblas
(O’Reilly).
The Kubebuilder Book (https://book.kubebuilder.io).

Samouczki i przykłady
Kubernetes by Example (http://kubernetesbyexample.com).
The Katacoda Kubernetes Playground (http://bit.ly/31Sydqp).
Banzai Cloud Operator SDK (http://bit.ly/2ZG3OtA).
Operator Developer Guide (http://bit.ly/2Fx4zh4).

Artykuły
Writing a Kubernetes Operator in Golang (http://bit.ly/2Ei2hCr).
Stay Informed with Kubernetes Informers (http://bit.ly/2Y5OKYX).
Events, the DNA of Kubernetes (http://bit.ly/31Tvey8).
Kubernetes Events Explained (http://bit.ly/2XzwEOM).
Level Triggering and Reconciliation in Kubernetes (http://bit.ly/2FmLLAW).
Comparing Kubernetes Operator Pattern with Alternatives (http://bit.ly/2XxGEYO).
Kubernetes Operators (https://kubedex.com/operators).
Kubernetes Custom Resource, Controller and Operator Development Tools
(http://bit.ly/2FpO4Ug).
Demystifying Kubernetes Operators with the Operator SDK: Part 1
(http://bit.ly/2NbGRwZ).
Under the Hood of Kubebuilder Framework (http://bit.ly/2X2NpgX).
Best Practices for Building Kubernetes Operators and Stateful Apps
(http://bit.ly/2NdvQeJ).
Kubernetes Operator Development Guidelines (http://bit.ly/31P7rPC).
Mutating Webhooks with slok/kubewebhook (http://bit.ly/2RyScG1).

Repozytoria
kubernetes-client (http://bit.ly/2xfSrfT).
kubernetes/kubernetes (http://bit.ly/2SltTLP).
kubernetes/perf-tests (http://bit.ly/2X556g8).
cncf/apisnoop (http://bit.ly/32u5SqN).
open-policy-agent/gatekeeper (http://bit.ly/2LXCpiX).
stakater/Konfigurator (http://bit.ly/2JBX8HO).
ynqa/kubernetes-rust (https://github.com/ynqa/kubernetes-rust).
hossainemruz/k8s-initializer-finalizer-practice (http://bit.ly/30GzTSF).
munnerz/k8s-api-pager-demo (http://bit.ly/30Ep2IT).
m3db/m3db-operator (http://bit.ly/2XURVi2).
O autorach
Michael Hausenblas pracuje na stanowisku developer advocate w Amazon Web Services.
Należy do jednostki zespołu odpowiedzialnego za usługi kontenerowe skupiającej się na
bezpieczeństwie kontenerów. Michael dzieli się swoim doświadczeniem z zakresu natywnej
infrastruktury i natywnych aplikacji dla chmury, tworząc wersje demonstracyjne
oprogramowania, pisząc artykuły na blogach i książki, prowadząc prelekcje i współtworząc
otwarte oprogramowanie. Przed zatrudnieniem się w AWS pracował dla firm Red Hat,
Mesosphere i MapR oraz dla dwóch instytucji naukowych w Irlandii i Austrii.
Stefan Schimanski pracuje w firmie Red Hat jako główny inżynier oprogramowania w
obszarze technologii Go, Kubernetes i OpenShift. Koncentruje się na serwerze API
Kubernetesa, a przede wszystkim na implementowaniu definicji CRD, bibliotece API Machinery
i publikowaniu repozytoriów roboczych Kubernetesa: client-go, apimachinery, api i innych.
Przed przyjściem do Red Hat Stefan pracował w firmie Mesosphere nad technologiami
Marathon i Spark oraz produktami związanymi z Kubernetesem. Był też freelancerem i
konsultantem z zakresu wysoko dostępnych systemów rozproszonych. Wcześniej Stefan
prowadził badania w dziedzinie logiki matematycznej w zakresie matematyki konstruktywnej,
systemów typów i rachunku lambda.

Kolofon
Zwierzę na okładce książki Kubernetes to samotnik (Tringa ochropus). Zarówno nazwa
rodzajowa, jak i nazwa gatunkowa pochodzą ze starożytnej greki. Mały brodzący ptak nazywany
trungas przykuł kiedyś uwagę Arystotelesa, a słowo ochropus można rozbić na dwa
starogreckie słowa okhros i pous, oznaczające ochrę i stopę.
Jedynym żyjącym gatunkiem blisko spokrewnionym z samotnikiem jest brodziec ciemnorzytny.
Zasięg występowania samotników jest bardzo duży i obejmuje prawie wszystkie kontynenty.
Samotniki pochodzą z Azji, a zimą migrują do cieplejszych krajów. Żyją i żerują w różnych
środowiskach wodnych. W stawach, rzekach i podmokłych lasach samotniki znajdują
pożywienie, jakim są owady, pająki, drobne skorupiaki, ryby i rośliny.

Samotniki mają szeroką pierś i krótką szyję. Ich dzioby są długie i smukłe. Z bliska na
zielonobrązowych skrzydłach samotników widoczne są małe jasne kropki. Kolor upierzenia jest
odwrotnością koloru jaj, które są płowożółte z brązowymi plamkami. W typowym lęgu znajduje
się od dwóch do czterech jaj, z których po trzech tygodniach wylęgają się pisklęta. Samotniki
wysiadują jaja w porzuconych gniazdach innych ptaków, a nawet w gniazdach wiewiórek.

Wiele gatunków z okładek książek wydawnictwa OʼReilly jest zagrożonych. Wszystkie one są
ważne dla świata.
Ilustracja z okładki została opracowana przez Karen Montgomery na podstawie czarno-białej
ryciny z książki General Zoology Shawa.
Spis treści
Opinie na temat książki Kubernetes. Tworzenie natywnych aplikacji działających w chmurze
Przedmowa
Dla kogo przeznaczona jest ta książka?
Po co napisaliśmy tę książkę?
Ekosystem
Technologie, które powinieneś znać
Konwencje stosowane w tej książce
Korzystanie z przykładowego kodu
Podziękowania
Rozdział 1. Wprowadzenie
Czym jest programowanie dla Kubernetesa?
Przykład wprowadzający
Wzorce rozszerzania
Kontrolery i operatory
Pętla sterowania
Zdarzenia
Wyzwalacze sterowane zmianami i sterowane poziomem
Modyfikowanie świata zewnętrznego lub obiektów w klastrze
Współbieżność optymistyczna
Operatory
Podsumowanie
Rozdział 2. Podstawy API Kubernetesa
Serwer API
Interfejs HTTP serwera API
Terminologia związana z API
Wersjonowanie API w Kubernetesie
Deklaratywne zarządzanie stanem
Używanie API w wierszu poleceń
W jaki sposób serwer API przetwarza żądania?
Podsumowanie
Rozdział 3. Podstawy klienta client-go
Repozytoria
Biblioteka klienta
Typy w API Kubernetesa
Repozytorium API Machinery
Tworzenie i używanie klientów
Wersjonowanie i kompatybilność
Wersje API i gwarancje kompatybilności
Obiekty Kubernetesa w Go
TypeMeta
ObjectMeta
Sekcje spec i status
Zbiory klientów
Podzasoby status — UpdateStatus
Wyświetlanie i usuwanie obiektów
Czujki
Rozszerzanie klientów
Opcje klientów
Informatory i buforowanie
Kolejka zadań
Repozytorium API Machinery — szczegóły
Rodzaje
Zasoby
Odwzorowania REST
Schemat
Vendoring
glide
dep
Moduły języka Go
Podsumowanie
Rozdział 4. Używanie niestandardowych zasobów
Wykrywanie informacji
Definicje typów
Zaawansowane mechanizmy niestandardowych zasobów
Sprawdzanie poprawności niestandardowych zasobów
Kategorie i krótkie nazwy
Wyświetlane kolumny
Podzasoby
Podzasób status
Podzasób scale
Niestandardowe zasoby z perspektywy programisty
Klient dynamiczny
Klienty typizowane
Budowa typu
Struktura pakietów języka Go
Klienty typizowane tworzone za pomocą generatora client-gen
Klient controller-runtime z narzędzi Operator SDK i Kubebuilder
Podsumowanie
Rozdział 5. Automatyzowanie generowania kodu
Po co stosować generatory kodu?
Wywoływanie generatorów
Kontrolowanie generatorów za pomocą znaczników
Znaczniki globalne
Znaczniki lokalne
Znaczniki dla generatora deepcopy-gen
runtime.Object i DeepCopyObject
Znaczniki dla generatora client-gen
Generatory informer-gen i lister-gen
Podsumowanie
Rozdział 6. Narzędzia służące do tworzenia operatorów
Czynności wstępne
Wzorowanie się na projekcie sample-controller
Przygotowania
Logika biznesowa
Kubebuilder
Przygotowania
Logika biznesowa
Operator SDK
Przygotowania
Logika biznesowa
Inne podejścia
Wnioski i przyszłe kierunki rozwoju
Podsumowanie
Rozdział 7. Udostępnianie kontrolerów i operatorów
Zarządzanie cyklem życia i pakowanie
Pakowanie — trudności
Helm
Kustomize
Inne techniki pakowania kodu
Najlepsze praktyki z obszaru pakowania kodu
Zarządzanie cyklem życia
Instalacje gotowe do użytku w środowisku produkcyjnym
Odpowiednie uprawnienia
Zautomatyzowany proces budowania i testowania
Niestandardowe kontrolery i obserwowalność
Rejestrowanie zdarzeń
Monitorowanie, instrumentacja i audyty
Podsumowanie
Rozdział 8. Niestandardowe serwery API
Scenariusze stosowania niestandardowych serwerów API
Przykład — pizzeria
Architektura — agregowanie
Usługi API
Wewnętrzna struktura niestandardowego serwera API
Delegowane uwierzytelnianie i obsługa zaufania
Delegowana autoryzacja
Pisanie niestandardowych serwerów API
Wzorzec opcji i konfiguracji oraz szablonowy kod potrzebny do uruchomienia serwera
Pierwsze uruchomienie
Typy wewnętrzne i konwersja
Pisanie typów API
Konwersje
Ustawianie wartości domyślnych
Testowanie konwersji powrotnych
Sprawdzanie poprawności
Rejestr i strategia
Rejestr generyczny
Strategia
Podłączanie strategii do rejestru generycznego
Instalowanie API
Kontrola dostępu
Implementacja
Rejestrowanie
Podłączanie zasobów
Instalowanie niestandardowych serwerów API
Manifesty instalacji
Konfigurowanie systemu RBAC
Uruchamianie niestandardowego serwera API bez zabezpieczeń
Certyfikaty i zaufanie
Współdzielenie systemu etcd
Podsumowanie
Rozdział 9. Zaawansowane zasoby niestandardowe
Wersjonowanie niestandardowych zasobów
Poprawianie kodu do obsługi pizzerii
Architektura webhooków konwersji
Implementacja webhooka konwersji
Przygotowywanie serwera HTTPS
Instalowanie webhooka konwersji
Konwersja w praktyce
Webhooki kontroli dostępu
Wymogi związane z kontrolą dostępu w przykładzie
Architektura webhooków kontroli dostępu
Rejestrowanie webhooków kontroli dostępu
Implementowanie webhooka kontroli dostępu
Webhook kontroli dostępu w praktyce
Schematy strukturalne i przyszłość definicji CRD
Schematy strukturalne
Okrajanie a zachowywanie nieznanych pól
Sterowanie okrajaniem
IntOrString i RawExtension
Wartości domyślne
Podsumowanie
Dodatek A. Materiały
Ogólne
Książki
Samouczki i przykłady
Artykuły
Repozytoria
O autorach
Kolofon

You might also like