API Machinery

API Machinery je knižnica, ktorá poskytuje základné abstrakcie a nástroje na prácu s Kubernetes API objektami (resources). Je základným komponentom pri programovaní vlastných nástrojov pre Kubernetes, operátorov atď.

Jej úlohou je pomocť s definovaním, validovaním, verzovaním a serializáciou API objektov. Je nápomocná ako na strane klientov, tak aj na strane Kubernetes API servra.

API Machinery nemusíme použivať len v súvislosti s Kubernetesom. Môže poslúžiť aj ako základ pre tvorbu vlastného deklarativného API. Či už budeme chcieť vytvoriť vlastný server s deklaratívnym API, alebo nástroj, ktorý bude objekty načítavať zo súborov.

Povedzme si aj to, čo API Machinery nieje. Táto knižnica nerieši samotnú komunikáciu s Kubernetes API serverom. K tomu slúží iná knižnica k8s.io/client-go. Taktiež API Machinery neposkytuje žiadne konkrétne objekty ako Pod, Deployment alebo Node. Samotné objekty sa nachádzajú v samostatnej knižnici Kubernetes API k8s.io/api. Tieto knižnice ale majú priamu závislosť na API Machinery.

Kubernetes objekt

Aby sme lepšie pochopili význam tejto knižnice, tak si musime najprv zodpovedať: čo je Kubernetes objekt. Objekty vo svete Kubernetes sú semištrukturované dáta, napríklad vo formáte YAML. Sú to práve tie chronický známe objekty ako Pod, Deployment atď. Poďme sa ale bližšie pozrieť na to, z akých základných častí sa taký taký objekt sklada. Predstavme si teda Pod:

apiVersion: v1
kind: Pod
metadata:
  name: coredns-5d78c9869d-5qq6x
  namespace: kube-system
  labels:
    ...
  annotations:
    ...
spec:
  containers:
    ...
status:
  phase: Running
  conditions:
    ...

Každý objekt môžem rozdeliť na 4 základné časti:

Každý objekt teda začína tzv. Kind a verziou. Objekt je sám nositelom informácie o svojom type a verzii. Vďaka tomu, dokážeme s objektami pracovať v rôznej forme a niesme tak limitovaní len na REST API. Objekty môžu mať napríklad formu súborov - manifestov. Kind je niečo ako typ. Preto ak ďalej v texte budem hovoriť o type objektu, budem tym myslieť práve Kind. Hoci pri ukážke Podu je len v1, tak verzia nieje len číslo. Vo verzii je aj informácia, do ktorej skupiny Kind patrí. Niečo ako package.

Poďme ale ďalej. Nasleduje časť metadata. Tu sú informácie ako meno objektu (Pozor! Nie typ), namespace, do ktorého objekt patrí, ale aj ďalšie meta informácie ako labels, či annotations. Táto časť je pre všetky typy rovnaká.

Asi najzaujimavejšiou časťou je spec. Ide o akésy telo objektu, kde sa nachádzajú samotné dáta objektu.

Posledný je status. Treba si uvedomiť, že Kubernetes API je silne deklaratívne. To znamená, že spec je len akýsi požadovaný stav, v akom by som rád objekt videl. Požadovaný však neznamená reálny. Napríklad replicas v Deployment neznamená, že okamžite máme toľko replík Podov. Kubernetes totiž tento požadovaný stav môže aj odmietnúť. A to z z rôznych dôvodov. Skrz status nam dáva Kubernetes vlastne akúsi spätnú väzbu o spracovaní požadovaného stavu. Táto časť je podobne ako spec pre každý typ objektu iná, podobne ako spec.

Môj prvý objekt

Takže už vieme, čo je objekt. Predstavme si, že si v Go chcem vytvoriť knižnicu, ktorá bude vedieť pracovať s objektom - Starfighter. Objekt by mohol vyzerať následovne:

apiVersion: starwars.okontajneroch.sk/v1
kind: Starfighter
metadata:
  name: x-wing-1
spec:   
  faction: rebellion
  type: x-wing
  pilot: Luke Skywalker
status:
  phases:
    - "ready to flight"
    - "flies in space"
    - "wings to attack position"

Ako prvé si vytvorím nový Go projekt a adresár /api/v1. Budem používať zaužívanú štrukturu adresárov, a názvy súborov, aby sa v mojej knižnici vedeli zorientovať aj iní ľudia z Kubernetes sveta.

$ go mod init github.com/okontajneroch/starwars
$ mkdir -p api/v1
$ go get k8s.io/apimachinery/pkg/runtime

Vytvorím si súbor api/v1/types.go, kde budem dávať všetky potrebné typy. Ako prvé si vytvorím typy pre spec a status:

package v1

import (
   metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// príklad enumerácie
type Faction string

const (
   Rebellion Faction = "rebellion"
   Empire    Faction = "empire"
)

// dátova štruktúra pre `spec`
type StarfighterSpec struct {
   Faction Faction `json:"faction"`
   Type    string  `json:"type"`
   Pilot   string  `json:"pilot"`
}

// dátova štruktúra pre `status`
type StarfighterStatus struct {
   Phases  []string `json:"phases"`
}

Kód zatiaľ nieje komplikovaný a ide o klasické dátove štruktúry, ktoré vieme serializovať na JSON.

Okrem spec a status budem potrebovať ešte Metadata. Kedže ide o časť, ktorá je všade rovnaká, tak API Machinery už poskytuje hotovú štruktúru ObjectMeta. Táto štruktura disponuje parametrami ako Name, Namespace, UID, ale aj Labels či Annotations.

Poďme to teda zlepiť do finálnej štruktúry Starfighter:

type Starfighter struct {
   metav1.TypeMeta   `json:",inline"`
   metav1.ObjectMeta `json:"metadata,omitempty"`
   Spec StarfighterSpec `json:"spec,omitempty"`
   Status StarfighterStatus `json:"status,omitempty"`
}

Za zmienku ešte stojí TypeMeta. Ten reprezentuje dvojicu version a kind.

Aby ale Starfighster bola štruktúra, ktorá bude plnohodnotne reprezentovať API objekt, tak musí ešte implementovať rozhranie runtime.Object, ktoré vyzerá asi takto:

type Object interface {
   GetObjectKind() schema.ObjectKind
   DeepCopyObject() Object
}

Metóda GetObjectKind je už implementovaná v TypeMeta, čiže o tú sa nemusím starať. Čo potrebujem ale doimplementovať je DeepCopyObject(). Jej úlohou je vytvoriť kópiu objektu v pamäti. Túto metódu ale umiestním do súboru api/v1/deepcopy.go.

package v1

import "k8s.io/apimachinery/pkg/runtime"

func (in *Starfighter) DeepCopyObject() runtime.Object {
   out := new(Starfighter)
   out.TypeMeta = in.TypeMeta
   in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)

   out.Spec.Faction = in.Spec.Faction
   out.Spec.Type = in.Spec.Type
   out.Spec.Pilot = in.Spec.Pilot

   // malá mágia s kopírovaním polí, keďže
   // potrebujem plnú kópiu celého objektu a nesmiem
   // referencovať na existujúce dáta
   phases_in, phases_out := &in.Status.Phases, &out.Status.Phases
   *phases_out = make([]string, len(*phases_in))
   copy(*phases_out, *phases_in)

   return out
}

Ide o pomerne nudné a pracné kopírovanie dát hore-dole. Najzložitejšia časť kódu sa týka phases a ide o kopírovanie poľa. ObjectMeta disponuje svojou vlastnou metódou DeepCopyInto(), ktorou si viem uľahčiť prácu.

Samozrejme aby toto nebolo také pracné, tak neskôr si celé implementovanie DeepCopyObject() zjednoduším pomocou generátora. Zatiaľ si však všetko vytváram ručne, aby som pochopil presne čo objekt je.

Teraz mám už pripravené všetko na to, aby som s objetom Starfighter vedel pracovať. Teda - skoro všetko.

GroupVersionKind a GroupVersionResource

Skôr ako pojdeme ďalej, si musíme ozrejmiť čo je GroupVersionKind (GVK) a GroupVersionResource (GVR). Ide tak trochu o neštastnú komplikáciu v celom Kubernetes API.

img

O tom, čo je skupina (Group) a verzia (Version) sa asi netreba veľmi rozpisovať.
Ako som už spomínal vyžsie, Group a Version v YAML manifestoch tvori jeden celok ako apiVersion: starwars.okontajneroch.sk/v1

Čo je ale Kind a čo Resource?

Už vieme, že Kind je vlastne akýsi typ. Je vo forme jednotného čísla, napríklad Pod alebo Starfighter.

Aký je ale význam Resource? Resource sa používa v súvislosti z mapovaním objektu na REST API endpoint. Predstavme si, že mám YAML manifest s objektom. Ak chcem použiť kubectl apply, tak kubectl potrebuje vedieť, aký endpoint patrí tomuto objektu. V minulom článku o Kubernetes API som písal o tom, ako sú tieto endpointy organizované. Enpoint teda vyzerá asi takto:

/apis/{group}/{version}/namespaces/{namespace}/{resource}

Tu už ale neviem použiť Kind - napríklad Pod. Resource má totiž formu množného čísla - napríklad pods. Musím preto vedieť aký Resource patrí akému Kind. Treba mi vedieť mapovať GroupVersionKind (GVK) na GroupVersionResource (GVR). Za toto mapovanie je zodpovedný tzv. RESTMapper. Jeho interface je súčaštou API Machinery, no implementácia je skôr záležitosťou inej knižnice - client-go. Ale o tom si presne povieme nabudúce, keď sa budeme baviť o tejto knižnici.

Schéma a SchemeBuilder

Keď chceme deserializovať objekt z YAML na Go štruktúru, tak potrebujeme vedieť, že aká štrutúra zodpovedá ktorej skupine, verzii a Kind. API Machinery to rieši pomocou registrácie v Scheme. K tomu ale potrebujeme pre našu knižnicu vytvoriť SchemeBuilder. Zaregistrujem si moju novú štruktúru Starfighter do skupiny starwars.okontajneroch.sk, s verziou v1. Vytvorím si api/v1/register.go:

package v1

import (
   "k8s.io/apimachinery/pkg/runtime"
   "k8s.io/apimachinery/pkg/runtime/schema"
)

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

func addKnownTypes(scheme *runtime.Scheme) error {
   scheme.AddKnownTypes(
      schema.GroupVersion{
         Group:   "starwars.okontajneroch.sk",
         Version: "v1",
      },
      &Starfighter{},
   )

   return nil
}

Pre nás je najdôležitejšia funckia addKnownTypes(). Tu sa odohráva samotná registrácia štruktúry Starfighter do Scheme. Tu povieme, že Starfighter patrí do skupiny starwars.okontajneroch.sk a verzie v1. Tu by som mohol registrovať všetký dátove štruktúry skupiny starwars.okontajneroch.sk.

Funkcia je následne použitá pri inicializácii tzv SchemeBuilder. Dôvodom, prečo je registrácia riešená cez SchemeBuilder je, akási kompozícia. Aby sme vedeli vytvoriť schému, ktorá bude vedieť serializovať a deserializovať rôzne objekty rôznych knižníc.

Okrem toho je tu ešte globálna premenna AddToScheme, ktorá len referuje na SchemeBuilder. Toto je taký štandard v rámci všetkých knižníc. Pridal som ho hlavne z dôvodu konzistencie s inými knižnicami.

Serializácia a Deserializácia

API Machinery rieši aj serializáciu a deserializáciu. Poskytuje serialízery, pre rôzné formáty ako YAML, JSON alebo Protobuf. Ako taká serializácia bude vyzerať? Povedzme že chcem si vypísať na štandardný výstup nejaký môj Starfighter objekt v YAML formáte.

V existujúcom projekte si vytvorím main.go:

package main

import starwarsv1 "github.com/sn3d/starwars/api/v1"

func createXWing() *starwarsv1.Starfighter {
   xwing := &starwarsv1.Starfighter{}

   xwing.APIVersion = "starwars.okontajneroch.sk/v1"
   xwing.Kind = "Starfighter"
   xwing.ObjectMeta.Name = "x-wing-1"

   xwing.Spec = starwarsv1.StarfighterSpec{
      Faction: starwarsv1.Rebellion,
      Type:    "x-wing",
      Pilot:   "Luke Skywalker",
   }

   return xwing
}

Zatial tu mám len funkciu createXWing(), ktorá vytvorí a naplní celú dátovú štrukturu Starfighter. Tieto dáta budem chcieť serializovať na YAML.

Začnem teda s vytvorením schémy v main():

func main() {
   scheme := runtime.NewScheme()
   starwarsv1.AddToScheme(scheme)
}

Takto som si do svojej schémy pridal celú skupinu starwars.okontajneroch.sk, čiže táto moja schéma bude vedieť rozpoznať Starfighter. Pamätáte si AddToScheme v registry.go a celé to okolo SchemeBuilder? Tak starwarsv1.AddToScheme(scheme) je jej praktické použitie. Takto si viem vytvoriť schému, ktorá bude podporovať nielen rôzne skupiny, ale aj skupiny v rôznych verziach.

Keď už mám schému, tak si vytvorím serializér pomocou NewSerializerWithOptions():

func main() {
   scheme := runtime.NewScheme()
   starwarsv1.AddToScheme(scheme)

   serializer := json.NewSerializerWithOptions(
      json.SimpleMetaFactory{},
      scheme,
      scheme,
      json.SerializerOptions{
         Yaml: true,
      },
   )
	
   xwing := createXWing()
   serializer.Encode(xwing, os.Stdout)
}

Ide o spoločný serialízer pre JSON a YAML. To, čo z neho robí YAML serialízer, je Yaml: true. Serializer potrebuje k svojej činnosti schému. Serializer je pripravený. Funkcia serializera Encode() prevedie môj xwing na YAML.

Keď teraz spustím môj kód, tak na výstupe dostanem YAML formu môjho objektu, čo nieje až také prekvapivé:

$ go run main.go
apiVersion: starwars/v1
kind: Starfighter
metadata:
  creationTimestamp: null
  name: x-wing-1
spec:
  faction: rebellion
  pilot: Luke Skywalker
  type: x-wing
status:
  phases: null

Zoberme si ale opačný prípad. Chcem napísať program, ktorý zoberie YAML zo štandardného vstupu a vytvorí jeho dátovú štrukturu. V podstate chcem dosiahnuť deserializáciu. Vytvorenie schémy a serializéra si ponechám, a doplnim kód, ktorý načíta dáta zo vstupu:

func main() {
   scheme := runtime.NewScheme()
   starwarsv1.AddToScheme(scheme)

   serializer := json.NewYAMLSerializer(
      json.SimpleMetaFactory{},
      scheme,
      scheme,
   )
	
   data, err := io.ReadAll(os.Stdin)
   if err != nil {
      panic(err)
   }
	
   //...
}
	

Ďalej budem pokračovať deserializáciou pomocou Decode():

   o, gvk, err := serializer.Decode(data, nil, nil)
   if err != nil {
      panic(err)
   }	

Funkcia vracia niekoľko hodnôt: všeobecnú inštanciu o typu runtime.Object. Tá sa dá pretypovať na konkrétny typ Starfighter. Potom je to informácia gvk, ktorá hovori o aký objekt ide, a samozrejme v Go už klasicky - hodnotu chyby. Ako prvé si necham vypísať gvk:

   fmt.Printf("Group: %s\n", gvk.Group)
   fmt.Printf("Version: %s\n", gvk.Version)
   fmt.Printf("Kind: %s\n\n", gvk.Kind)

Ako ďalšie si nechám vypísať spec.

   xwing := o.(*starwarsv1.Starfighter)

   fmt.Printf("Faction: %s\n", xwing.Spec.Faction)
   fmt.Printf("Type: %s\n", xwing.Spec.Type)
   fmt.Printf("Pilot: %s\n", xwing.Spec.Pilot)

Najpr som musel inštanciu o pretypovať zo všeobecného typu runtime.Object na konkrétny starwars1.Starfighter. Následne som mohol vypisať jednotlivé hodnoty spec časti.

Kód vyskúšam tak, že pošlem na štandardný vstup validný YAML:

$ go run main.go << EOF
apiVersion: starwars.okontajneroch.sk/v1
kind: Starfighter
metadata:
  name: x-wing-1
spec:
  faction: rebellion
  type: x-wing
  pilot: Luke Skywalker
status:
  phases:
    - "ready to flight"
    - "flies in space"
    - "wings to attack position"
EOF

Kód spracuje YAML, vyparsuje dáta do starwarsv1.Starfighter a nakoniec informácie vypíše na výstup:

Group: okontajneroch.io
Version: v1
Kind: Starfighter

Faction: rebellion
Type: x-wing
Pilot: Luke Skywalker

DeepCopy generator

Na záver si ešte poďme ukázať, ako si uľahčiť implementovanie DeepCopyObject(). Kubernetes komunita poskytuje aj sadu tzv. code-generator nástrojov, ktoré ako názov naznačuje, slúžia na generovanie nudnejších repetitívnych časti. Jednym z takých nástrojov je aj deepcopy-gen.

Poďme si teda vygenerovať DeepCopyObject() pre môj Starfighter. Ako prvé si nainštalujem nástoj deepcopy-gen:

go install k8s.io/code-generator/cmd/deepcopy-gen@latest

Potom si vytvorím api/v1/doc.go súbor, ktorého obsah bude len jeden komentár pre package.

// +k8s:deepcopy-gen=package
package v1

Nejde o obyčajný komentár. Ak v jazyku Go nejaký komentár začína znakom +, tak hovoríme že ide o tzv. marker. Je to spôsob, ako rozným nástrojom podhodiť akési metadáta. Javisti si to môžu predstaviť ako anotácie. Konkrétne tento marker zapína generovanie DeepCopyXXX() funkcie pre všetký typy v tomto balíčku.

V types.go ešte doplním ďalší marker. Teraz pre typ Starfighter. Tento marker zabezpečí, že pre práve pre Starfighter bude vygenerovaná metóda DeepCopyObject().

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type Starfighter struct {
   metav1.TypeMeta   `json:",inline"`
   ...
	

A teraz môžem spustiť generátor:

$ deepcopy-gen ./api/v1 --output-file zz_generated.deepcopy.go 

Generator v /api/v1 vytvori súbor zz_generated.deepcopy.go, kde budú vygenerované DeepCopyObject() metódy pre všetky typy. A nielen tie. Okrem toho vygeneruje aj DeepCopy() a DeepCopyInto() ktoré síce niesu potrebné pre implementáciu rozhrania, ale sú celkom prakticke pre ďalšie použitie.

Aby som odstránil kompilačné chyby, tak zmažem môj ručne implementovaný deepcopy.go

rm api/v1/deepcopy.go

Ja ale nechcem vždy spúštať príkaz deepcopy-gen. Možem si to zjednodušiť pomocou go generate. Príkaz vložim opäť ako špecialný komentár //go:generate do api/v1/doc.go:

// +k8s:deepcopy-gen=package
//
//go:generate deepcopy-gen . --output-file zz_generated.deepcopy.go
package v1

Teraz bude stačiť spustiť:

$ go generate ./api/v1

Toto však nieje jediný spôsob ako si zjednodušiť prácu s deepcopy-gen. Niektoré projekty maju deepcopy-gen príkaz zakomponovaný priamo v Makefile. V mojom projekte si môžem vytvoriť veľmi primitívny ./Makefile:

.PHONY: generate
generate:
	deepcopy-gen ./api/v1 --output-file zz_generated.deepcopy.go

Ten potom viem spustiť ako:

make generate

Záver

Výsledkom tohto celého snaženia je, že už viem pracovať s vlastným Kubernetes objektom v kóde.Viem ako ho definovať, viem ako ho vytvoriť a používať. Dotkol som sa aj problému GVK (GroupVersionKind) a GVR (GroupVersionResource), ku ktorému sa ešte vrátim v inom článku. Teraz ale potrebujem, aby tento môj objekt spoznal aj Kubernetes klaster pomocou Custom Resource Definition - CRD. To si už ale necham na ďalší článok.

Na úplný záver už len dotaz, že kompletný kód a projekt z tohto článku je dostupny na GitHube github.com/okontajneroch

Ak sa ti článok páčil a chceš ma podporiť, tak budem rád za lajk, zdielanie alebo followovanie na LinkedIn. Môj LinkedIn je tiež otvorený každému, kto má akukoľvek otázku k tejto téme.

<