Quest'anno la principale conferenza europea su Kubernetes — KubeCon + CloudNativeCon Europe 2020 — si è svolta in formato virtuale. Tuttavia, questo cambiamento di formato non ci ha impedito di presentare la nostra lunga prevista relazione "Go? Bash! Meet the Shell-operator", dedicata al nostro progetto Open Source. .
In questo articolo, scritto sulla base della presentazione, viene presentato un approccio per semplificare il processo di creazione di operatori per Kubernetes e mostrato come, con il minimo sforzo, sia possibile realizzare il proprio utilizzando shell-operator.

Presentiamo (~23 minuti in inglese, decisamente più informativo dell'articolo) e una sintesi principale in forma testuale. Andiamo!
In "Flant" ottimizziamo e automatizziamo costantemente. Oggi parleremo di un'altra affascinante concezione. Ecco a voi: cloud-native shell-scripting!
Cominciamo con il contesto in cui tutto ciò avviene, cioè Kubernetes.
API Kubernetes e controllori
L'API in Kubernetes può essere rappresentata come un certo server di file con directory dedicate a ogni tipo di oggetto. Gli oggetti (risorse) su questo server sono rappresentati da file YAML. Inoltre, il server ha un'API di base che consente di fare tre cose:
- aggiornamenti fino ad aprile 2021. Il ramo LTS 2.22 sarà supportato fino ad aprile 2022, mentre il ramo 1.11 fino ad aprile 2020. Il supporto per il ramo 2.1 è stato interrotto. recuperare una risorsa in base al suo kind e nome;
- modificare la risorsa (in questo caso il server memorizza solo gli oggetti "corretti" – tutti quelli malformati o destinati ad altre directory vengono scartati);
- di seguire con la risorsa (in questo caso l'utente ottiene immediatamente la sua versione attuale/aggiornata).
In questo modo, Kubernetes funge da un certo tipo di server di file (per i manifest YAML) con tre metodi di base (sì, in realtà ce ne sono altri, ma per ora li tralasceremo).

Il problema è che il server è in grado solo di memorizzare informazioni. Per farla funzionare, è necessario controller – il secondo concetto più importante e fondamentale nel mondo di Kubernetes.
Si distinguono due tipi fondamentali di controller. Il primo estrae informazioni da Kubernetes, le elabora in base alla logica internamente definita e le restituisce a K8s. Il secondo estrae informazioni da Kubernetes, ma, a differenza del primo tipo, cambia lo stato di alcune risorse esterne.
Esaminiamo più nel dettaglio il processo di creazione di un Deployment in Kubernetes:
- Il Deployment Controller (che fa parte di
kube-controller-manager) riceve informazioni sul Deployment e crea il ReplicaSet. - Il ReplicaSet, sulla base di queste informazioni, crea due repliche (due pod), ma questi pod non sono ancora programmati.
- Il pianificatore organizza i pod e aggiunge informazioni sui nodi nei loro YAML.
- I kubelet apportano modifiche alle risorse esterne (ad esempio, Docker).
Poi, l'intera sequenza viene ripetuta in ordine inverso: il kubelet controlla i contenitori, calcola lo stato del pod e lo invia indietro. Il controller ReplicaSet riceve lo stato e aggiorna la condizione del set di repliche. Lo stesso avviene con il Deployment Controller, e infine l'utente riceve lo stato aggiornato (attuale).

Shell-operator
Quindi, alla base di Kubernetes c'è la collaborazione tra diversi controller (anche gli operatori Kubernetes sono controller). La domanda nasce spontanea: come creare il proprio operatore con il minimo sforzo? E qui entra in gioco il nostro . Permette agli amministratori di sistema di creare i propri operatori utilizzando metodi familiari.
Un semplice esempio: la copia dei segreti
Esaminiamo un semplice esempio.
Supponiamo di avere un cluster Kubernetes. In esso c'è uno spazio dei nomi default con un certo Secret mysecret. Inoltre, nel cluster ci sono altri spazi dei nomi. A alcuni di essi è associata una certa etichetta. Il nostro obiettivo è copiare il Secret negli spazi dei nomi con l'etichetta.
Il compito si complica poiché nel cluster possono apparire nuovi spazi dei nomi, e alcuni di essi possono avere questa etichetta. D'altra parte, quando l'etichetta viene rimossa, il Secret deve essere rimosso anch'esso. Inoltre, il Secret stesso può subire variazioni: in questo caso, il nuovo Secret deve essere copiato in tutti gli spazi dei nomi con le etichette. Se il Secret viene accidentalmente eliminato in uno spazio dei nomi, il nostro operatore deve ripristinarlo immediatamente.
Ora che il compito è formulato, è tempo di passare alla sua implementazione tramite shell-operator. Ma prima vale la pena dire alcune parole sullo shell-operator.
Principi di funzionamento dello shell-operator
Come altre workload in Kubernetes, lo shell-operator funziona nel proprio pod. In questo pod, nella directory /hooks sono memorizzati i file eseguibili. Questi possono essere script in Bash, Python, Ruby, ecc. Tali file eseguibili li chiamiamo hook (hooks).

Lo shell-operator si iscrive agli eventi di Kubernetes e attiva questi hook in risposta a quegli eventi che ci interessano.

Come fa shell-operator a sapere quale hook eseguire e quando? Il fatto è che ogni hook ha due fasi. All'avvio, shell-operator esegue tutti gli hook con l'argomento --config — questa è la fase di configurazione. Solo dopo di essa, gli hook vengono eseguiti normalmente, in risposta agli eventi a cui sono legati. In quest'ultimo caso, l'hook riceve il contesto di legame (binding context) — dati formattati in JSON, di cui parleremo più avanti.
Creiamo l'operatore in Bash
Ora siamo pronti per l'implementazione. Per questo, dobbiamo scrivere due funzioni (ecco, raccomandiamo la libreria , che semplifica notevolmente la scrittura di hook in Bash):
- la prima è necessaria per la fase di configurazione — mostra il contesto di legame;
- la seconda contiene la logica principale dell'hook.
#!/bin/bash
source /shell_lib.sh
function __config__() {
cat << EOF
configVersion: v1
# BINDING CONFIGURATION
EOF
}
function __main__() {
# THE LOGIC
}
hook::run "$@"
Il passo successivo è decidere quali oggetti ci servono. Nel nostro caso, è necessario monitorare:
- la sorgente del segreto per eventuali modifiche;
- tutti i namespace nel cluster, per sapere a quali di essi è attaccato l'etichetta;
- i segreti obiettivo, per assicurarci che siano tutti sincronizzati con la sorgente del segreto.
Ci iscriviamo alla sorgente del segreto
La configurazione di binding è piuttosto semplice. Indichiamo che siamo interessati al Secret con il nome mysecret nello spazio dei nomi default:

function __config__() {
cat << EOF
configVersion: v1
kubernetes:
- name: src_secret
apiVersion: v1
kind: Secret
nameSelector:
matchNames:
- mysecret
namespace:
nameSelector:
matchNames: ["default"]
group: main
EOF
Di conseguenza, il hook verrà attivato quando il secret di origine ({src_secret}) viene modificato e riceverà il seguente contesto di binding:src_secret) e riceverà il seguente context di binding:

Come potete vedere, contiene il nome e l'intero oggetto.
Monitoriamo gli spazi dei nomi
Ora dobbiamo iscriverti agli namespaces. Per fare ciò, indichiamo la seguente configurazione di binding:
- name: namespaces
group: main
apiVersion: v1
kind: Namespace
jqFilter: |
{
namespace: .metadata.name,
hasLabel: (
.metadata.labels // {} |
contains({"secret": "yes"})
)
}
group: main
keepFullObjectsInMemory: false
Come potete vedere, nella configurazione è apparso un nuovo campo chiamato jqFilter. Come suggerisce il suo nome, jqFilter filtra tutte le informazioni superflue e crea un nuovo oggetto JSON con i campi di nostro interesse. Un hook con una configurazione simile riceverà il seguente contesto di binding:

Contiene un array filterResults per ogni namespace nel cluster. La variabile booleana hasLabel mostra se l'etichetta è collegata a questo spazio dei nomi. Selettore keepFullObjectsInMemory: false indica che non è necessario mantenere oggetti completi in memoria.
Tracciamo i segreti obiettivo
Ci iscrivi a tutti i Secret che hanno un'annotazione impostata managed-secret: "yes" (questi sono i nostri obiettivi dst_secrets):
- name: dst_secrets
apiVersion: v1
kind: Secret
labelSelector:
matchLabels:
managed-secret: "yes"
jqFilter: |
{
"namespace":
.metadata.namespace,
"resourceVersion":
.metadata.annotations.resourceVersion
}
group: main
keepFullObjectsInMemory: false
In questo caso jqFilter filtra tutte le informazioni ad eccezione dello spazio dei nomi e del parametro resourceVersion. L'ultimo parametro è stato passato come annotazione durante la creazione del segreto: consente di confrontare le versioni dei segreti e mantenerle aggiornate.
Un hook configurato in questo modo, durante l'esecuzione, riceverà tre contesti di binding, descritti sopra. Possono essere visti come un tipo di istantanea (snapshot) del cluster.

Sulla base di tutte queste informazioni è possibile sviluppare un algoritmo di base. Esso itera su tutti gli spazi dei nomi e:
- se
hasLabelha importanzatrueper lo spazio dei nomi corrente:- confronta il segreto globale con quello locale:
- se sono identici — non fa nulla;
- se sono diversi — esegue
kubectl replaceocreate;
- confronta il segreto globale con quello locale:
- se
hasLabelha importanzafalseper lo spazio dei nomi corrente:- verifica che il Secret non sia presente in questo namespace:
- se il Secret locale è presente, lo rimuove tramite
kubectl delete; - se il Secret locale non viene trovato, non fa nulla.
- se il Secret locale è presente, lo rimuove tramite
- verifica che il Secret non sia presente in questo namespace:

puoi scaricarlo dal nostro .
Ecco come siamo riusciti a creare un semplice controller Kubernetes, usando 35 righe di configurazione YAML e circa lo stesso numero di righe di codice in Bash! Il compito dello shell-operator è quello di unirli insieme.
Tuttavia, la copia dei segreti non è l'unico campo di applicazione dello strumento. Ecco altri esempi che dimostreranno di cosa è capace.
Esempio 1: apportare modifiche al ConfigMap
Consideriamo un Deployment composto da tre pod. I pod utilizzano il ConfigMap per memorizzare alcune configurazioni. Al momento dell'avvio, il ConfigMap si trovava in uno stato specifico (chiamiamolo v.1). Di conseguenza, tutti i pod utilizzano proprio questa versione del ConfigMap.
Ora supponiamo che il ConfigMap sia cambiato (v.2). Tuttavia, i pod continueranno a utilizzare la versione precedente del ConfigMap (v.1):

Come fare in modo che passino al nuovo ConfigMap (v.2)? La risposta è semplice: usare il template. Aggiungiamo un'annotazione con il checksum nella sezione template della configurazione del Deployment:

Di conseguenza, in tutti i pod sarà registrato questo checksum, e sarà lo stesso di quello del Deployment. Ora, è sufficiente aggiornare l'annotazione ogni volta che si modifica il ConfigMap. E il shell-operator è perfetto in questo caso. Tutto ciò che serve è programmare un hook che si iscriva al ConfigMap e aggiorni il checksum.
Se un utente apporta modifiche al ConfigMap, il shell-operator le rileverà e ricalcolerà il checksum. Dopo di che, entrerà in gioco la magia di Kubernetes: l'orchestratore ucciderà il pod, ne creerà uno nuovo, aspetterà finché non diventa Pronto, e passerà al successivo. Di conseguenza, il Deployment si sincronizzerà e passerà alla nuova versione del ConfigMap.

Esempio 2: lavoro con le Custom Resource Definitions
Come è noto, Kubernetes consente di creare tipi personalizzati (kinds) di oggetti. Ad esempio, si può creare un kind MysqlDatabase. Supponiamo che questo tipo abbia due parametri di metadata: name e namespace.
apiVersion: example.com/v1alpha1
kind: MysqlDatabase
metadata:
name: foo
namespace: bar
Abbiamo un cluster Kubernetes con vari spazi dei nomi, in cui possiamo creare database MySQL. In questo caso, il shell-operator può essere utilizzato per monitorare le risorse MysqlDatabase, le loro connessioni al server MySQL e la sincronizzazione dello stato desiderato e osservato del cluster.

Esempio 3: monitoraggio della rete del cluster
Come è noto, l'uso del ping è il modo più semplice per monitorare una rete. In questo esempio mostreremo come implementare un monitoraggio simile utilizzando il shell-operator.
Prima di tutto, sarà necessario iscriversi ai nodi. Il shell-operator ha bisogno del nome e dell'indirizzo IP di ogni nodo. Con queste informazioni, pingherà questi nodi.
configVersion: v1
kubernetes:
- name: nodes
apiVersion: v1
kind: Node
jqFilter: |
{
name: .metadata.name,
ip: (
.status.addresses[] |
select(.type == "InternalIP") |
.address
)
}
group: main
keepFullObjectsInMemory: false
executeHookOnEvent: []
schedule:
- name: every_minute
group: main
crontab: "* * * * *"
Caratteristica executeHookOnEvent: [] prevenire l'esecuzione del hook in risposta a qualsiasi evento (cioè in risposta a modifiche, aggiunte, rimozioni di nodi). Tuttavia, esso verrà eseguito (e aggiornerà l'elenco dei nodi) secondo un programma — ogni minuto, come specificato dal campo schedule.
Ora sorge la domanda: come possiamo scoprire problemi come la perdita di pacchetti? Diamo un'occhiata al codice:
function __main__() {
for i in $(seq 0 "$(context::jq -r '(.snapshots.nodes | length) - 1')"); do
node_name="$(context::jq -r '.snapshots.nodes['"$i"'].filterResult.name')"
node_ip="$(context::jq -r '.snapshots.nodes['"$i"'].filterResult.ip')"
packets_lost=0
if ! ping -c 1 "$node_ip" -t 1 ; then
packets_lost=1
fi
cat >> "$METRICS_PATH" <<END
{
"name": "node_packets_lost",
"add": $packets_lost,
"labels": {
"node": "$node_name"
}
}
END
done
}
Iteriamo attraverso l'elenco dei nodi, otteniamo i loro nomi e indirizzi IP, li pinghiamo e inviamo i risultati a Prometheus. Il shell-operator può esportare metriche in Prometheus, memorizzandole in un file situato secondo il percorso specificato nella variabile d'ambiente $METRICS_PATH.
si può creare un operatore per un semplice monitoraggio di rete nel cluster.
Meccanismo delle code
Questo articolo sarebbe incompleto senza descrivere un altro meccanismo importante integrato nel shell-operator. Immagina che esegua un certo hook in risposta a un evento nel cluster.
- Cosa succede se nel cluster si verifica un altro evento?
- Il shell-operator avvierà un'altra istanza dell'hook?
- E se nel cluster si verificassero, diciamo, cinque eventi contemporaneamente?
- Il shell-operator elaborerà questi in parallelo?
- E riguardo alle risorse consumate, come memoria e CPU?
Fortunatamente, il shell-operator ha un meccanismo di queue integrato. Tutti gli eventi vengono messi in coda e elaborati in sequenza.
Illustriamo questo con alcuni esempi. Supponiamo di avere due hook. Il primo evento viene assegnato al primo hook. Dopo che la sua elaborazione è completata, la coda avanza. I tre eventi successivi vengono reindirizzati al secondo hook — vengono estratti dalla coda e consegnati in "batch". Cioè l'hook riceve un array di eventi — o, più precisamente, un array di contesti di binding.
Inoltre, questi eventi possono essere uniti in un unico grande. Questo è controllato dal parametro gruppo nella configurazione di binding.

È possibile creare un numero qualsiasi di code/hook e le loro combinazioni. Per esempio, una coda può lavorare con due hook, o viceversa.

Tutto ciò che è necessario fare è configurare correttamente il campo queue nella configurazione di binding. Se non viene specificato alcun nome per la coda, l'hook viene avviato nella coda predefinita (default). Un meccanismo di coda del genere permette di risolvere completamente tutti i problemi di gestione delle risorse quando si lavora con i hook.
Conclusione
Abbiamo spiegato cos'è il shell-operator, mostrando come utilizzarlo per creare rapidamente e senza troppi sforzi operatori Kubernetes, e fornito diversi esempi di utilizzo.
Informazioni dettagliate sul shell-operator e una guida breve sul suo utilizzo sono disponibili nel corrispondente . Non esitate a contattarci con domande: potete discuterne nel nostro (in russo) o in (in inglese).
E se vi è piaciuto, siamo sempre felici di nuovi issues/PR/stelle su GitHub, dove, a proposito, si possono trovare anche altri . Tra questi spicca , che è il "fratello maggiore" del shell-operator. Questo strumento utilizza i chart Helm per installare addon, è in grado di fornire aggiornamenti e monitorare vari parametri/valori dei chart, controlla il processo di installazione dei chart e può anche modificarli in risposta a eventi nel cluster.

Video e slide
Un video della presentazione (~23 minuti):

Presentazione della relazione:
P.S.
Leggete anche nel nostro blog:
- «»;
- «»;
- «»;
- «.
Fonte: habr.com
