Quest'anno, la principale conferenza europea su Kubernetes — KubeCon + CloudNativeCon Europe 2020 — si è tenuta in modalità virtuale. Tuttavia, questo cambio di formato non ci ha impedito di tenere un intervento da tempo pianificato, intitolato "Go? Bash! Meet the Shell-operator", dedicato al nostro progetto Open Source. .
Questo articolo, basato sulla presentazione, presenta un approccio per semplificare il processo di creazione di operatori per Kubernetes e mostra come è possibile crearne di propri con il minimo sforzo utilizzando shell-operator.

Introduzione (~23 minuti in inglese, molto più informativo dell'articolo) e il riassunto principale in formato testo. Andiamo!
In Flant ottimizziamo e automatizziamo costantemente tutto. Oggi parleremo di un altro concetto entusiasmante. Vi presentiamo: scripting shell nativo del cloud!
Ma cominciamo dal contesto in cui tutto questo accade: Kubernetes.
API e controller Kubernetes
L'API di Kubernetes può essere considerata come un file server con directory per ogni tipo di oggetto. Gli oggetti (risorse) su questo server sono rappresentati da file YAML. Inoltre, il server dispone di un'API di base che consente di eseguire tre operazioni:
- per ricevere risorsa in base al suo genere e al suo nome;
- cambiare risorsa (in questo caso, il server memorizza solo gli oggetti "corretti" - tutti quelli formati in modo errato o quelli destinati ad altre directory vengono scartati);
- seguire per la risorsa (in questo caso, l'utente riceve immediatamente la sua versione corrente/aggiornata).
Kubernetes agisce quindi come una sorta di file server (per i manifesti YAML) con tre metodi di base (sì, in realtà ce ne sono altri, ma per ora li salteremo).

Il problema è che il server può solo memorizzare informazioni. Per farlo funzionare, è necessario controllore — il secondo concetto più importante e fondamentale nel mondo Kubernetes.
Esistono due tipi principali di controller. Il primo prende le informazioni da Kubernetes, le elabora secondo la logica annidata e le restituisce a K8s. Il secondo prende le informazioni da Kubernetes, ma, a differenza del primo tipo, modifica lo stato di alcune risorse esterne.
Diamo un'occhiata più da vicino al processo di creazione di una distribuzione in Kubernetes:
- Controller di distribuzione (incluso in
kube-controller-manager) ottiene informazioni sulla distribuzione e crea un ReplicaSet. - ReplicaSet crea due repliche (due pod) in base a queste informazioni, ma questi pod non sono ancora pianificati.
- Lo scheduler pianifica i pod e aggiunge informazioni sui nodi ai loro YAML.
- I kubelet apportano modifiche a una risorsa esterna (ad esempio, Docker).
Quindi l'intera sequenza viene ripetuta al contrario: kubelet controlla i container, calcola lo stato del pod e lo invia. ReplicaSet Controller riceve lo stato e aggiorna lo stato del replica set. Lo stesso accade con Deployment Controller e l'utente riceve infine lo stato aggiornato (corrente).

Operatore di shell
A quanto pare, Kubernetes si basa sul lavoro congiunto di diversi controller (anche gli operatori di Kubernetes sono controller). La domanda sorge spontanea: come creare un proprio operatore con il minimo sforzo? Ed ecco che ci viene in soccorso quello che abbiamo sviluppato. Consente agli amministratori di sistema di creare i propri operatori utilizzando metodi familiari.
Esempio semplice: copia dei segreti
Diamo un'occhiata a un semplice esempio.
Supponiamo di avere un cluster Kubernetes. Ha uno spazio dei nomi default con qualche segreto mysecretInoltre, nel cluster sono presenti altri namespace. Ad alcuni di essi è associata un'etichetta specifica. Il nostro obiettivo è copiare Secret nei namespace con l'etichetta.
Il compito è complicato dal fatto che nuovi namespace potrebbero apparire nel cluster, e alcuni di essi potrebbero avere questa etichetta. D'altra parte, quando l'etichetta viene eliminata, anche il Segreto deve essere eliminato. Inoltre, anche il Segreto stesso potrebbe cambiare: in questo caso, il nuovo Segreto deve essere copiato in tutti i namespace con etichette. Se un Segreto viene eliminato accidentalmente in un qualsiasi namespace, il nostro operatore deve ripristinarlo immediatamente.
Ora che il compito è stato formulato, è il momento di iniziare a implementarlo utilizzando shell-operator. Ma prima, qualche parola su shell-operator stesso.
Come funziona l'operatore shell
Come altri carichi di lavoro Kubernetes, shell-operator viene eseguito nel proprio pod. In quel pod, nella directory /hooks vengono memorizzati file eseguibili. Possono essere script in Bash, Python, Ruby, ecc. Chiamiamo tali file eseguibili hook (ganci).

Shell-operator si iscrive agli eventi Kubernetes ed esegue questi hook in risposta agli eventi di cui abbiamo bisogno.

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. Successivamente, gli hook vengono lanciati nel modo consueto, in risposta agli eventi a cui sono vincolati. In quest'ultimo caso, l'hook riceve un contesto di vincolo (contesto vincolante) - dati in formato JSON, di cui parleremo più dettagliatamente di seguito.
Creazione di un operatore in Bash
Ora siamo pronti per l'implementazione. Per farlo, dovremo scrivere due funzioni (a proposito, consigliamo biblioteca , che semplifica notevolmente la scrittura di hook in Bash):
- il primo è necessario per la fase di configurazione: restituisce il contesto di binding;
- Il secondo contiene la logica principale del gancio.
#!/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 di quali oggetti abbiamo bisogno. Nel nostro caso, dobbiamo tracciare:
- segreto di origine per le modifiche;
- tutti gli spazi dei nomi nel cluster, per sapere a quali è associata un'etichetta;
- segreti di destinazione per garantire che siano tutti sincronizzati con il segreto di origine.
Iscriviti alla fonte segreta
La configurazione del binding è piuttosto semplice. Specifichiamo che siamo interessati a 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 gancio verrà attivato quando cambia il segreto sorgente (src_secret) e ottenere il seguente contesto di binding:

Come puoi vedere, contiene il nome e l'intero oggetto.
Tenere traccia degli spazi dei nomi
Ora dobbiamo sottoscrivere gli spazi dei nomi. Per farlo, specifichiamo 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 puoi vedere, nella configurazione è apparso un nuovo campo denominato jqFilter. Come suggerisce il nome, jqFilter Filtra tutte le informazioni non necessarie e crea un nuovo oggetto JSON con i campi di nostro interesse. Un hook con questa configurazione riceverà il seguente contesto di binding:

Contiene una matrice filterResults per ogni namespace nel cluster. Variabile booleana hasLabel indica se l'etichetta è associata allo spazio dei nomi specificato. Selettore keepFullObjectsInMemory: false indica che non è necessario mantenere oggetti completi in memoria.
Tracciamento di obiettivi segreti
Ci iscriviamo a tutti i Segreti che hanno un set di annotazioni 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 tranne lo spazio dei nomi e il parametro resourceVersionL'ultimo parametro è stato passato all'annotazione quando è stato creato il segreto: consente di confrontare le versioni dei segreti e di mantenerle aggiornate.
Un hook configurato in questo modo, una volta eseguito, riceverà i tre contesti di binding descritti sopra. Puoi considerarli come una sorta di snapshot (istantanea) grappolo.

Sulla base di tutte queste informazioni, è possibile sviluppare un algoritmo di base. Questo algoritmo itera attraverso tutti gli spazi dei nomi e:
- se
hasLabelquestionitrueper lo spazio dei nomi corrente:- confronta il segreto globale con quello locale:
- se sono uguali - non fa nulla;
- se differiscono - esegue
kubectl replaceocreate;
- confronta il segreto globale con quello locale:
- se
hasLabelquestionifalseper lo spazio dei nomi corrente:- assicura che Secret non si trovi nello spazio dei nomi specificato:
- se è presente un segreto locale, lo elimina utilizzando
kubectl delete; - se il segreto locale non viene trovato, non fa nulla.
- se è presente un segreto locale, lo elimina utilizzando
- assicura che Secret non si trovi nello spazio dei nomi specificato:

puoi scaricarlo dal nostro .
Ecco come siamo riusciti a creare un semplice controller Kubernetes utilizzando 35 righe di configurazioni YAML e circa la stessa quantità di codice Bash! Il compito dell'operatore shell è quello di collegarli tra loro.
Tuttavia, la copia dei segreti non è l'unico ambito di applicazione dell'utilità. Ecco alcuni altri esempi che mostreranno le sue potenzialità.
Esempio 1: apportare modifiche a ConfigMap
Consideriamo un Deployment composto da tre pod. I pod utilizzano una ConfigMap per memorizzare alcune configurazioni. All'avvio dei pod, la ConfigMap si trovava in uno stato (chiamiamolo v.1). Di conseguenza, tutti i pod utilizzano questa versione della ConfigMap.
Supponiamo ora che la ConfigMap sia cambiata (v.2). Tuttavia, i pod continueranno a utilizzare la vecchia versione della ConfigMap (v.1):

Come possiamo farli passare alla nuova ConfigMap (v.2)? La risposta è semplice: usare un modello. Aggiungiamo un'annotazione di checksum alla sezione template Configurazioni di distribuzione:

Di conseguenza, tutti i pod avranno questo checksum, che sarà lo stesso del Deployment. Ora non vi resta che aggiornare l'annotazione quando cambia la ConfigMap. E l'operatore shell è molto utile in questo caso. Tutto ciò che dovete fare è programmare un hook che si iscriverà a ConfigMap e aggiornerà il checksum.
Se l'utente apporta modifiche al ConfigMap, l'operatore shell le noterà e ricalcolerà il checksum. Dopodiché, la magia di Kubernetes entrerà in gioco: l'orchestratore eliminerà il pod, ne creerà uno nuovo e attenderà che diventi Readye passa a quello successivo. Di conseguenza, la distribuzione verrà sincronizzata e spostata alla nuova versione di ConfigMap.

Esempio 2: lavorare con definizioni di risorse personalizzate
Come sapete, Kubernetes consente di creare tipi (generi) personalizzati di oggetti. Ad esempio, è possibile creare un tipo MysqlDatabaseSupponiamo che questo tipo abbia due parametri di metadati: name и namespace.
apiVersion: example.com/v1alpha1
kind: MysqlDatabase
metadata:
name: foo
namespace: bar
Abbiamo un cluster Kubernetes con diversi namespace in cui possiamo creare database MySQL. In questo caso, l'operatore shell può essere utilizzato per monitorare le risorse. MysqlDatabase, la loro connessione al server MySQL e la sincronizzazione degli stati desiderati e osservati del cluster.

Esempio 3: Monitoraggio di una rete di cluster
Come sapete, usare il ping è il modo più semplice per monitorare una rete. In questo esempio, mostreremo come implementare tale monitoraggio utilizzando l'operatore shell.
Per prima cosa, è necessario sottoscrivere i nodi. L'operatore shell necessita del nome e dell'indirizzo IP di ciascun nodo. Li userà per effettuare il ping di tali 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: "* * * * *"
Parametro executeHookOnEvent: [] impedisce che l'hook venga attivato in risposta a qualsiasi evento (ad esempio in risposta a nodi modificati, aggiunti o rimossi). Tuttavia, sarà lanciato (e aggiornare l'elenco dei nodi) In programma - ogni minuto, come prescrive il campo schedule.
Ora sorge spontanea la domanda: come facciamo a sapere esattamente di 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
}
Esaminiamo l'elenco dei nodi, ne otteniamo i nomi e gli indirizzi IP, li pingiamo e inviamo i risultati a Prometheus. L'operatore Shell può esportare le metriche su Prometheus, salvandoli in un file posizionato secondo il percorso specificato nella variabile d'ambiente $METRICS_PATH.
È possibile creare un operatore per il monitoraggio semplice della rete in un cluster.
Meccanismo di coda
Questo articolo non sarebbe completo senza descrivere un altro importante meccanismo integrato in shell-operator. Immaginate che esegua un hook in risposta a un evento nel cluster.
- Cosa succede se contemporaneamente si verifica un problema nel cluster? un'altra cosa evento?
- Shell-operator eseguirà un'altra istanza dell'hook?
- Cosa succederebbe se, per esempio, in un cluster si verificassero contemporaneamente cinque eventi?
- L'operatore shell li elaborerà in parallelo?
- E che dire delle risorse consumate, come memoria e CPU?
Fortunatamente, shell-operator ha un meccanismo di accodamento integrato. Tutti gli eventi vengono accodati ed elaborati in sequenza.
Illustriamolo con degli esempi. Supponiamo di avere due hook. Il primo evento viene inoltrato al primo hook. Una volta completata l'elaborazione, la coda avanza. I tre eventi successivi vengono inoltrati al secondo hook: vengono estratti dalla coda e inviati ad esso in un "batch". In altre parole, l'hook riceve una serie di eventi — o, più precisamente, una serie di contesti vincolanti.
Anche questi gli eventi possono essere combinati in uno grandeIl parametro è responsabile di questo. group nella configurazione di rilegatura.

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

Tutto quello che devi fare è impostare il campo di conseguenza. queue nella configurazione del binding. Se non viene specificato alcun nome di coda, l'hook viene eseguito sulla coda predefinita (default). Un tale meccanismo di coda consente di risolvere completamente tutti i problemi di gestione delle risorse quando si lavora con gli hook.
conclusione
Abbiamo spiegato cos'è shell-operator, mostrato come può essere utilizzato per creare rapidamente e facilmente operatori Kubernetes e fornito diversi esempi del suo utilizzo.
Informazioni dettagliate sull'operatore shell, nonché una guida rapida su come utilizzarlo, sono disponibili nel corrispondente Non esitate a contattarci per qualsiasi domanda: potrete discuterne in un apposito spazio (in russo) o in (in inglese).
E se ti è piaciuto, siamo sempre felici di vedere nuovi problemi/PR/stelle su GitHub, dove, tra l'altro, puoi trovarne altri Tra questi, vale la pena sottolineare , che è il fratello maggiore di shell-operatorQuesta utility utilizza i grafici Helm per installare componenti aggiuntivi, può fornire aggiornamenti e monitorare vari parametri/valori dei grafici, controlla il processo di installazione dei grafici e può anche modificarli in risposta agli eventi nel cluster.

Video e diapositive
Video dell'esibizione (~23 minuti):

Presentazione del rapporto:
PS
Leggi anche sul nostro blog:
- «";
- «";
- «";
- «.
Fonte: habr.com
