Knižnica client-go

V tomto článku predstavíme knižnicu client-go, zameriame sa na základy: načítanie konfigurácie, vytvorenie REST klienta a REST mapper. Tieto základy nám poslúžia pre pokročilejšie témy, ako sú Clientsety a Informery, v ďalšom článku.

Ak ste čítali predchádzajúce články, určite už viete, ako vytvoriť vlastný objekt v klastri pomocou CRD a jeho reprezentáciu – typ v Go. Doteraz však tieto dva svety neboli prepojené. Je čas prepojiť tento Go typ s objektom v klastri. Kubernetes na tento účel poskytuje knižnicu client-go, ktorá sa postará o celú komunikáciu s klastrom a prináša aj užitočné funkcie, ako je caching a rate limiting.

Konfigurácia

Spojenie s Kubernetes API nie je úplne jednoduché a vyžaduje riešenie certifikátov a ďalších detailov. Našťastie knižnica client-go ponúka nástroje, ktoré tento proces výrazne zjednodušujú.

K tomu sa zvyčajne používa súbor KUBECONFIG. Tento YAML súbor obsahuje konfigurácie pre klastre (ako endpointy a CA certifikáty), používateľov (certifikáty, tokeny) a kontexty, ktoré spájajú klaster s používateľom. Vďaka tomu môžeme bezpečne pristupovať k rôznym klastrom s rôznymi právami. Každý z nás, čo používa kubectl, tak nepriamo používa tento súbor. Štandardne je umiestnený v $HOME/.kube/config.

Ale vráťme sa ku knižnici. Client-go mi umožňuje viacero spôsobov, ako k tejto konfigurácii pristupovať. Tu rozlišujeme také dva základné scenáre:

In-Cluster

Používa sa, keď moja aplikácia (napr. operátor alebo controller) beží priamo v Kubernetes klastri. V tomto prípade Kubernetes automaticky injektuje potrebné prístupové údaje do každého podu do /var/run/secrets/kubernetes.io/serviceaccount. Túto konfiguráciu v mojej aplikácii načítam veľmi jednoducho:

config, err := rest.InClusterConfig()

Out-Of-Cluster

Tento prístup sa využíva pri nástrojoch, ktoré bežia mimo klastra, alebo pri vývoji aplikácií, ktoré vo finále budú bežať v klastri. Pre tento scenár client-go poskytuje niekoľko možností. Môžem celú konfiguráciu vytvoriť programovo ručne. Môžem ju načítať z pamäte alebo z rôznych neštandardných súborov na neštandardných cestách. Ja ale použijem typický spôsob, ktorý používa napríklad aj kubectl. Na to slúži práve NewNonInteractiveDeferredLoadingClientConfig:

config, _ := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
    clientcmd.NewDefaultClientConfigLoadingRules(),
    nil,
).ClientConfig()
config.APIPath = "/apis"

Táto funkcia najprv kontroluje existenciu a obsah premennej prostredia KUBECONFIG. Ak táto premenná nie je nastavená, automaticky sa hľadá konfiguračný súbor na štandardnej ceste $HOME/.kube/config.

Pozorné oko si všimlo, že robím niečo navyše. Okrem načítania konfigurácie ešte nastavujem APIPath. Ten totiž štandardne nie je súčasťou KUBECONFIG. Bez tohto nastavenia by mi však nefungovala komunikácia, pretože by sa mi generovali neplatné URL.

Hoci ďalej v článku budem preferovať práve tento prístup, tak je dobrým zvykom, ak aplikácia podporuje oba scenáre. Najprv sa pokúsi o in-cluster konfiguráciu, a ak zlyhá (nie som v klastri), prejde na out-of-cluster konfiguráciu pomocou KUBECONFIG. Takto bude aplikácia flexibilná a použiteľná v rôznych prostrediach.

REST Client

Ak už mám načítanú konfiguráciu a certifikáty, môžem pristúpiť k komunikácii s Kubernetes klastrom. Na to využijem RESTClient z knižnice client-go, ktorý mi umožňuje jednoducho vytvoriť klienta pomocou funkcie rest.RESTClientFor(), ktorá akceptuje konfiguráciu.

client, _ := rest.RESTClientFor(&config)

Tu však musím zmieniť jeden dôležitý fakt. Platnosť RESTClient je obmedzená na skupinu a verziu. V minulých článkoch som si vytvoril Go typy a CRD pre starwars.okontajneroch.sk/v1. Ide o Group a Version. A práve to je platnosť klienta. Čiže ak by som chcel pracovať napríklad s Deployment, tak potrebujem vytvoriť klienta pre apps/v1.

Ako teda vytvoriť klienta platného pre starwars.okontajneroch.sk/v1? Celé to bude vyzerať trošku komplikovanejšie:

starwarsConfig := *config // kopia hlavnej konfiguracie
starwarsConfig.GroupVersion = &schema.GroupVersion{
    Group: "starwars.okontajneroch.sk", 
    Version: "v1"
}

// potrebujem schemu a serializer
scheme := runtime.NewScheme()
starwarsv1.AddToScheme(scheme)
starwarsConfig.NegotiatedSerializer = serializer.NewCodecFactory(scheme)

// vytvorenie REST clienta
starwarsClient, _ := rest.RESTClientFor(&starwarsConfig)

Najprv si vytvorím kópiu mojej “hlavnej” konfigurácie. Následne nastavím GroupVersion, čím určím platnosť. Ešte musím nastaviť tzv. NegotiatedSerializer, aby klient vedel serializovať a deserializovať dáta z/do mojej štruktúry starwarsv1.Starfighter. Z článku o API Machinery už viem, že na to potrebujem schému.

Takto som sa dostal do stavu, že mám pripraveného klienta starwarsClient pre moju skupinu starwars.okontajneroch.sk/v1. A môžem ho začať používať. Ako prvé si skúsim vytvoriť objekt v klastri. To znamená, odošlem ho POSTom:

// vytvorim si novy Starfighter objekt
xwing := &starwarsv1.Starfighter{}
xwing.Name = "x-wing-1"
xwing.Spec.Pilot = "Luke Skywalker"
xwing.Spec.Type = "X-Wing"
xwing.Spec.Faction = starwarsv1.Rebellion

// poslem objekt do k8s pomocou POST requestu
result := &starwarsv1.Starfighter{}
err := starwarsClient.
    Post().
    Namespace("default").
    Resource("starfighters").
    Name("x-wing-1").
    Body(xwint).
    Do(context.TODO()).
    Into(result)
		
// spracujem error alebo vysledok
if err != nil {
    fmt.Printf("Error: %s\n", err)
    return
}

REST Client používa tzv. fluent API prístup. Volanie je teda vyskladané cez funkcie a bodky. Taký vláčik.

Tento klient pracuje s GVR (GroupVersionResource) namiesto GVK (GroupVersionKind), čo môže byť drobný, ale dôležitý detail. V praxi to znamená, že pri volaniach musíte špecifikovať Resource ako množné číslo (napr. Resource("starfighters")), nie Kind.

Potom nasleduje do(), v ktorom sa realizuje volanie. To nielen odošle dáta môjho objektu, ale taktiež vracia vytvorený objekt a naplní ho do result. Táto návratová verzia objektu bude obsahovať navyše napr. UID alebo aj Status. Na záver môjho veľmi naivného programu teda vypíšem novovytvorené UID:

fmt.Printf("Created Starfighter: %s \n", result.Name, result.UID)

Celý spustiteľný kód aj s CRD je dostupný na GitHube. Ak na svoj Kubernetes klaster aplikujem CRD a spustím tento mini program, tak v klastri by sa mal vytvoriť objekt s menom x-wing-1.

$ go run ./examples/rest-client
Updated Starfighter: x-wing-1 b3336a2b-f792-4848-9e11-5e73c955c897
$ kubectl get starfighters
NAME       AGE
x-wing-1   23s

Poďme ale na ďalšie operácie. Predstavme si, že chcem zmeniť napr. meno pilota v objekte. Na to budem potrebovať aktuálny stav objektu a vykonať PUT, a.k.a. update.

Objekt získam pomocou chronicky známej operácie GET:

// ziskam povodny objekt 'x-wing-1` priamo z k8s clustra
result := &starwarsv1.Starfighter{}
err := starwarsClient.
    Get().
    Namespace("default").
    Resource("starfighters").
    Name("x-wing-1").
    Do(context.TODO()).
    Into(result)

if err != nil {
    fmt.Printf("Error: %s\n", err)
    return
}

Prečo potrebujem získať aktuálny stav objektu? Je to kvôli UID. Ten slúži ako tzv. pasívny zámok. Čiže ak by medzitým niekto zmenil objekt v klastri, objekt bude mať nové UID a môj PUT tak neprejde.

Teraz môžem zmeniť hodnotu alebo hodnoty v získanom objekte a vykonať PUT:

// zmena dat
result.Spec.Pilot = "Wedge Antilles"

// vykonam update objektu 'x-wing-1' v k8s clustri pomocou PUT requestu
err = starwarsClient.
    Put().
    Namespace("default").
    Resource("starfighters").
    Name("x-wing-1").
    Body(result).
    Do(context.TODO()).
    Into(result)
	
fmt.Printf("Updated Starfighter: %s \n", result.Name, result.UID)

Opäť aj PUT vracia objekt, tento však bude mať nielen zmenu v dátach, ktorú som chcel, ale tiež bude zmenené UID.

Opäť, celý kód je dostupný na GitHube. Ak teraz spustím môj príkaz, tak dostanem na výstupe nové UID pre môj objekt:

$ go run ./examples/rest-client-update
Updated Starfighter: x-wing-1 b3336a2b-f792-4848-9e11-5e73c955c897

A v klastri sa zmeni hodnota pilot na Wedge Antilles:

$ kubectl get starfighter x-wing-1 -o yaml
apiVersion: starwars.okontajneroch.sk/v1
kind: Starfighter
metadata:
  creationTimestamp: "2024-12-05T23:05:52Z"
  generation: 2
  name: x-wing-1
  namespace: default
  resourceVersion: "583"
  uid: b3336a2b-f792-4848-9e11-5e73c955c897
spec:
  faction: rebellion
  pilot: Wedge Antilles
  type: X-Wing
status: {}

Takýmto spôsobom poskytuje REST Client aj ostatné operácie, ako DELETE, ale aj WATCH. Druhý zmieňovaný je dôležitý práve pre písanie programov, ktoré budú sledovať objekty a reagovať na ich zmeny. Ale to si nechám na neskôr.

REST Mapper

Predtým, ako tento článok ukončím, ešte spomeniem jeden dôležitý koncept - REST Mapper.

REST Mapper je nástroj, ktorý konvertuje medzi GVK (GroupVersionKind) a GVR (GroupVersionResource), čo je dôležité pri komunikácii s Kubernetes API. Pri práci s objektmi v Go používame GVK, ale pri posielaní požiadaviek na API musíme použiť GVR. Navyše, niektoré komponenty client-go používajú GroupKind a iné GroupVersion, čo môže byť zdrojom zmätku.

Ale vráťme sa k veci. Často potrebujeme získať GVR z GVK alebo naopak. Je to presne v momente, keď sa generuje URL pre RESTovské volania. Prípadne naopak, keď sa robí naspäť deserializácia do Go typov. A práve to je úlohou tzv. REST Mapperov – poskytnúť túto konverziu medzi GVR a GVK.

REST Mapper je v podstate súčasťou API Machinery. Prečo ho ale spomínam práve v článku o client-go? API Machinery definuje len samotný interface pre REST Mappery. Ich konkrétne implementácie sú už súčasťou client-go. Prečo?

Je niekoľko mapperov. Asi najprimitívnejšia implementácia je taká, ktorá má natvrdo nakódovanú konverziu. Bežne sa ale používa trošku sofistikovanejší mapper - Discovery REST Mapper. Ten robí mapovanie na základe toho, čo je k dispozícii v konkrétnom klastri, aké sú tam CRD. Tento mapper je užitočný, ak pracujem s objektom, kde síce poznám skupinu a Kind, ale nepoznám verziu. Ako jeho názov hovorí, mapper na to používa tzv. Discovery API klastra.

img

Ale poďme k veci – ukázať si, ako s takýmto mapperom pracovať. Mám k dispozícii môj klaster, kde som si nasadil už chronicky známe CRD - starwars.okontajneroch.sk.

$ kubectl api-resources | grep "starfighters"
starfighters   starwars.okontajneroch.sk/v1   true   Starfighter

Ako môžem vidieť, klaster má všetky potrebné informácie – verzia, skupina, kind, ale aj resource. A teraz – mám kód, kde poznám len skupinu a kind, a potrebujem mapper, aby som získal aj verziu a resource.

Môj kód začne už klasicky – načítaním konfigurácie:

config, _ := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
    clientcmd.NewDefaultClientConfigLoadingRules(),
    nil,
).ClientConfig()

Teraz môžem vytvoriť Discovery REST Mapper:

discoveryClient, _ := discovery.NewDiscoveryClientForConfig(config)
mapper := restmapper.NewDeferredDiscoveryRESTMapper(  
    memory.NewMemCacheClient(discoveryClient),  
)

Ako som už spomínal, mapper využíva tzv. Discovery API, čo je tiež len klasické Kubernetes API. Preto si ako prvé vytvorím špeciálneho klienta, ktorý pozná schému atď. Následne si už môžem vytvoriť Discovery REST Mapper. Ja som zvolil tzv. Deferred implementáciu s MemCacheClient. To mi zabezpečí aj tzv. lazy loading, aby som Kubernetes API nebombardoval zbytočnými REST volaniami.

Takto vytvorený mapper je schopný vrátiť konkrétny mapping pre to, čo poznám – čiže skupinu a kind. Odtiaľ už viem vyčítať chýbajúce informácie, ako verzia a resource:

mapping, _ := mapper.RESTMapping(schema.GroupKind{
    Group: "starwars.okontajneroch.sk",
    Kind:  "Starfighter",
})

fmt.Printf("Version: %s\n", mapping.GroupVersionKind.Version)
fmt.Printf("Resource: %s\n", mapping.Resource.Resource)

Keď môj program skompilujem a spustím voči môjmu klastru, na výstupe uvidím:

$ go run ./examples/rest-mapping
Version: v1
Resource: starfighters

Dobre. Ale čo s tým? V predchádzajúcej ukážke, keď som vytváral starwarsConfig, som natvrdo špecifikoval skupinu a verziu. To teraz môžem zmeniť:

starwarsConfig := *config // kopia hlavnej konfigurace  
starwarsConfig.GroupVersion = &schema.GroupVersion{
    Group: mapping.GroupVersionKind.Group, 
    Version: mapping.GroupVersionKind.Version, 
}
starwarsConfig.NegotiatedSerializer = serializer.NewCodecFactory(scheme)

Taktiež, keď som používal volania ako get(), post() alebo put(), musel som špecifikovať resource - Resource("starfighters"). Tu tiež namiesto natvrdo nastaveného starfighters môžem využiť mapper:

result := &starwarsv1.Starfighter{}
err := starwarsClient.
    Post(). 
    Namespace("default"). 
    Resource(mapping.Resource.Resource). // Pouzitie mappera 
    Name("x-wing-1").
    Do(context.TODO()).
    Into(result)

Záver

Po tomto všetkom už viem vytvoriť REST klienta, prepojiť ho s mojím typom v Go, vykonať základné operácie nad reálnym objektom v klastri. Taktiež si už viem dopomôcť REST mapperom. Stále však ide o dosť low-level programovanie a pomerne dosť veľa kódu. Preto sa v ďalšom článku budem venovať tzv. Clientsetom, ako ich tvoriť a používať. No a aby to nebolo málo, tak si ešte vytvorím Informer, ktorý mi zase pomôže so sledovaním objektov v klastri. Takže sa je na čo tešiť.

Tak ako aj v minulom článku, kompletný kód a projekt je dostupny na GitHube

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.

<