Una persona che probabilmente mi odia mi ha chiesto: "Conosci Fluent Bit?".
Al che mi sono venute in mente le ore sprecate per cercare di capire come funzionasse Fluentd, il famoso data collector. Ricordai giustappunto il tempo utilizzato per capire i vari moduli per la lettura, la raccolta dei Log in Kubernetes, la loro trasformazione, e l'invio a destinazione.
Ingenuamente rispondo: "FluentD?".
"No, no, Fluent BIT, diciamo che è il fratello minore di Fluentd ma ottimizzato per lavorare con Container e tutta quella roba lì che piace a te. Ma come fai a non conoscerlo?".
Ok, confesso l'ignoranza. prima di quel dialogo non sapevo neanche dell'esistenza di Fluent Bit eppure scopro che c'è da anni! E' forse il momento di provarlo sperando di non sprecare ore e ore per farlo funzionare?
Fluent Bit
Fluent Bit potrebbe definirsi il fratello minore di Fluentd. Ottimizzato per l'uso nei Containers ma soprattutto ha il pregio di avere un consumo molto inferiore di risorse. Dalla documentazione ufficiale sembra che il suo eseguibile consumi all'incirca 650KB invece dei 40MB di Fluentd. A parte questi dettagli come funzionano questi Tool? In modo molto semplice: leggono i Log delle applicazioni più importanti (sono presenti Plug-in per la lettura automatica dei Log dei più famosi database per esempio, e dopo eventuali filtri e trasformazioni, questi Log possono essere inviati a Tool che memorizzano queste informazioni, ad altri database e servizi come Splunk. In questo schema il riassunto di quanto ho provato a spiegare (presa dal sito ufficiale):
- Input: di base Fluent Bit accetta più di venti Input come riportato da questo link. Per la demo che presenterà in questo post userò Docker, perché i Pod all'interno di Kubernetes, almeno nella versione ancora utilizzata, è compatibile. E' ci dovrebbe essere già il modulo per leggere in Input i Container in formato CRI, ottimo.
- Parser: questo modulo (non obbligatorio) permette il parsing delle informazioni da Input che possono essere in qualsiasi formato (testuale, json, etc...).
- Filter: questo modulo serve per filtrare il documento, per aggiungere e eliminare informazioni eventualmente ricevuti dai moduli precedenti.
- Buffer: questo modulo viene utilizzare solo per la memorizzazione delle informazioni elaborate dai moduli precedenti prima dell'invio al modulo per il Routing.
- Routing: alla base delle trasmissioni di dati tra i vari moduli. Ogni dato elaborato dal modulo Input ha un suo Tag che permette il suo riconoscimento e la trattazione per i moduli successivi.
- Output: questo modulo invia quanto elaborato dai moduli precedenti al servizio prescelto per la memorizzazione. Qui l'elenco ufficiale dei Plug-in supportati.
Ora non rimane che metterlo alla prova con qualcosa di semplice.
Primo test in locale
Prima di passare ad AWS provo un test il locale con la versione di Kubernetes presente nell'installazione di Docker per Windows 10. Dalla documentazione leggo che il modo più semplice per avviare Fluent Bit è con Helm:
helm install fluent-bit fluent/fluent-bit
Ma siccome voglio fare da subito alcune prove sovrascrivo il contenuto della configurazione prendendo i valori di default da questo file:
## https://docs.fluentbit.io/manual/administration/configuring-fluent-bit/configuration-file config: service: | [SERVICE] Daemon Off Flush {{ .Values.flush }} Log_Level {{ .Values.logLevel }} Parsers_File parsers.conf Parsers_File custom_parsers.conf HTTP_Server On HTTP_Listen 0.0.0.0 HTTP_Port {{ .Values.metricsPort }} Health_Check On inputs: | [INPUT] Name cpu Tag my_cpu filters: | [FILTER] Name record_modifier Match my_cpu Allowlist_key cpu_p Allowlist_key user_p outputs: | [OUTPUT] Name stdout Match * customParsers: |
E' arrivato il momento di avviare Fluent Bit con Helm e questi parametri:
helm install fluent-bit -f value.yaml fluent/fluent-bit
Prima di vedere il risultato alcune parole sul mio test. Fluent Bit mette a disposizione nativamente dei Plug-in per la lettura delle informazioni sulla macchina in cui gira, tra cui l'utilizzo della memoria, della cpu etc... Qui sopra ho inserito in [INPUT] cpu in modo da avere i valori attuali della cpu. Nella sezione di [FILTER] ho inserito un filtro per avere solo due valori. Infine in [OUTPUT] ho messo la console. Punto importante è l'uso di Tag e Match per collegare i passi della Pipeline che Fluent Bit deve seguire per le spefiche informazioni. Avendo in [INPUT] utilizzato il tag my_cpu, il filtro e l'output successivo saranno collegati ad esso con il Match. Potrei creare più sezioni di Input per la CPU assegnando ad ognuna di esse un Tag specifico per elaborazioni e filtri particolari.
Ora controllando il Log del Pod di Fluent Bit trovo le informazioni volute:
[0] my_cpu: [1658425188.449414500, {"cpu_p"=>5.750000, "user_p"=>2.500000}] [0] my_cpu: [1658425189.449495900, {"cpu_p"=>3.000000, "user_p"=>2.000000}] [0] my_cpu: [1658425190.449405000, {"cpu_p"=>5.000000, "user_p"=>3.500000}] [0] my_cpu: [1658425191.449414200, {"cpu_p"=>8.000000, "user_p"=>5.250000}] ...
Se non avessi inserito quel filtro avrei avuto questo contenuto su una CPU Quad-core:
[0] my_cpu: [1658425050.449336300, {"cpu_p"=>5.500000, "user_p"=>3.000000, "system_p"=>2.500000, "cpu0.p_cpu"=>3.000000, "cpu0.p_user"=>3.000000, "cpu0.p_system"=>0.000000, "cpu1.p_cpu"=>4.000000, "cpu1.p_user"=>2.000000, "cpu1.p_system"=>2.000000, "cpu2.p_cpu"=>7.000000, "cpu2.p_user"=>1.000000, "cpu2.p_system"=>6.000000, "cpu3.p_cpu"=>8.000000, "cpu3.p_user"=>6.000000, "cpu3.p_system"=>2.000000}] [0] my_cpu: [1658425051.449415400, {"cpu_p"=>3.750000, "user_p"=>2.500000, "system_p"=>1.250000, "cpu0.p_cpu"=>3.000000, "cpu0.p_user"=>2.000000, "cpu0.p_system"=>1.000000, "cpu1.p_cpu"=>2.000000, "cpu1.p_user"=>1.000000, "cpu1.p_system"=>1.000000, "cpu2.p_cpu"=>5.000000, "cpu2.p_user"=>4.000000, "cpu2.p_system"=>1.000000, "cpu3.p_cpu"=>5.000000, "cpu3.p_user"=>3.000000, "cpu3.p_system"=>2.000000}]
Kubernetes e Fluent Bit, primo tentativo
Avendo un cluster Kubernetes avviato in AWS trovo questi link in cui viene spiegata l'installazione di Fluent Bit che invia i Log raccolti a Cloud Watch. Perfetto! L'esempio che mi serviva. Cosa può andare storto? Il link è preso dalla documentazione ufficiale di AWS! Inizio a seguire la guida pedissequamente fino all'ultimo passo, fino a quando devo verificare il setup di Fluent Bit. Entro nella console di AWS e apro Cloud Watch. Subito l'amara sorpresa: nella documentazione descrive la presenza di tre Log Groups, ma nel mio caso non vedo nulla. Aspetto qualche minuto, forzo il refresh della pagina, ma non appare niente. Mi viene il dubbio di aver dimenticato qualcosa e, tornando all'inizio di quella pagina, controllo con maggiore scrupolo i passaggi fatti. La mia fiducia è quasi scomparsa, e i file YAML prima installati senza nemmeno essere controllati, vengono aperti e controllati - gran brutte cose la pigrizia e la fiducia: insieme fanno solo danni.
Il succo di tutto è un Pod avviato con all'interno l'immagine Docker di Fluent Bit. Inizio a preoccuparmi. Nella mia mente si fa largo un sospetto. Solo per il primi minuti cerco di evitarlo e lo metto da parte, ma più esamino il codice più questo mi sembra fondato. Trovo il codice per la definizione delle RBAC in Kubernetes. "E' una cosa normale", penso, "perché se deve prelevare le informazioni dagli altri Pod e solo con i giusti permessi si può fare". Ma nella catena delle azioni che deve fare Fluent Bit mi soffermo un attivo sull'ultimo step: "Ma come invia le informazioni a Cloud Watch con un normale Service Account?" - questo argomento l'ho trattato nel post precedente.
Eccolo lì il problema. Per esserne certo controllo il Log di quel Pod:
[2022/06/11 17:50:29] [ info] [output:cloudwatch_logs:cloudwatch_logs.1] Creating log stream ip-10-0-58-61.eu-south-1.compute.internal-dataplane.systemd.kubelet.service in log group /aws/containerinsights/K8sDemo-blog-cloud-watch/dataplane [2022/06/11 17:50:29] [error] [output:cloudwatch_logs:cloudwatch_logs.1] CreateLogStream API responded with error='AccessDeniedException' [2022/06/11 17:50:29] [error] [output:cloudwatch_logs:cloudwatch_logs.1] Failed to create log stream
Forse mi sono perso qualcosa in quel tutorial... non me ne importa niente. Vedo che l'User Account creato è fluent-bit e il Namespace utilizzato è amazon-cloudwatch. Mi ero segnato questo comando da terminale per la creazione dell'utente in AWS collegato ad un Service Account, lo provo con questo utente:
eksctl create iamserviceaccount --region eu-south-1 --name fluent-bit --namespace amazon-cloudwatch --cluster K8sDemo-blog-cloud-watch --attach-policy-arn arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy --override-existing-serviceaccounts --approve
Ma preparo il comando per la sua cancellazione perché la mia fiducia è scesa in sciopero:
eksctl delete iamserviceaccount --region eu-south-1 --name fluent-bit --namespace amazon-cloudwatch --cluster K8sDemo-blog-cloud-watch
Quindi forzo la cancellazione del Pod di Fluent Bit in modo che venga ricreato in automatico. Con pessimismo motivato aspetto qualche minuto e controllo Cloud Watch:
Ha funzionato! Un po' di fortuna...
Figurarsi se Terraform poteva mancare
Prova a fare il Deploy di quanto ho fatto finora con Terraform. I file con gli script per la creazione di VPC, EKS e le altre risorse necessarie sono sempre quelli. Devo personalizzare la creazione del Service Account per Fluent Bit. Utilizzerò quanto spiegato nel post precedente:
data "aws_iam_policy_document" "oidc_assume_role_policy" { statement { actions = ["sts:AssumeRoleWithWebIdentity"] effect = "Allow" condition { test = "StringEquals" variable = "${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" values = [ "system:serviceaccount:${kubernetes_namespace.amazon-cloudwatch-ns.metadata[0].name}:fluent-bit" ] } principals { identifiers = [aws_iam_openid_connect_provider.eks.arn] type = "Federated" } } } resource "aws_iam_role" "oidc_cw" { assume_role_policy = data.aws_iam_policy_document.oidc_assume_role_policy.json name = "oidc_cw" } resource "aws_iam_role_policy_attachment" "role_attach_cw" { role = aws_iam_role.oidc_cw.name policy_arn = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy" }
Non devo creare nessuna Policy ma utilizzerò quella già presente: CloudWatchAgentServerPolicy. Creo il Service Account:
resource "kubernetes_service_account" "aws-sa-cw" { metadata { name = "fluent-bit" namespace = kubernetes_namespace.amazon-cloudwatch-ns.metadata[0].name annotations = { "eks.amazonaws.com/role-arn" = aws_iam_role.oidc_cw.arn } } }
Quindo, come spiegato in quel tutorial in AWS, creo un ConfigMap con le stesse chiavi/valori:
resource "kubernetes_config_map" "fluent-bit-cluster-info" { metadata { name = "fluent-bit-cluster-info" namespace = kubernetes_namespace.amazon-cloudwatch-ns.metadata[0].name } data = { "cluster.name" = var.cluster_name "http.server" = "On" "http.port" = 2020 "read.head" = "Off" "read.tail" = "On" "logs.region" = var.aws_region } }
Sempre dalla documentazione veniva scaricato e avviata la creazione delle risorse definite in questo Yaml file:
kubectl apply -f https://raw.githubusercontent.com/aws-samples/amazon-cloudwatch-container-insights/latest/k8s-deployment-manifest-templates/deployment-mode/daemonset/container-insights-monitoring/fluent-bit/fluent-bit.yaml
Trasformo questo file nel formato di Terraform. Faccio una modifica solo nella sezione ConfigMap dove, in quel file, è definita la configurazione di Fluent Bit, che io sposto in file esterni per una più veloce e facile gestione:
resource "kubernetes_config_map" "fluent_bit_config" { metadata { name = "fluent-bit-config" namespace = kubernetes_namespace.amazon-cloudwatch-ns.metadata[0].name labels = { k8s-app = "fluent-bit" } } data = { "application-log.conf" = file("${path.module}/fluent-bit-config/8.1-application-log.conf") "dataplane-log.conf" = file("${path.module}/fluent-bit-config/8.2-dataplane-log.conf") "host-log.conf" = file("${path.module}/fluent-bit-config/8.3-host-log.conf") "parsers.conf" = file("${path.module}/fluent-bit-config/8.4-parsers.conf") "application-custom.conf" = file("${path.module}/fluent-bit-config/8.5-application-custom.conf") "fluent-bit.conf" = file("${path.module}/fluent-bit-config/8.6-fluent-bit.conf") } }
Ho creato una directory apposita dove ho inserito i file già presenti e ne ho aggiunto uno mio - application-custom.conf - dove ho inserito la mia configurazione personalizzata. Sì, perché i file di default raccolgono un coacervo di informazioni di cui non me ne importa niente (per il momento o per sempre?), io voglio inserire in Cloud Watch le informazioni che voglio io dai Pod che voglio io!
Personalizzare Fluent Bit
Per i miei test ho bisogno di Pod. Nella mia demo ho avviato il classo Pod con Nginx quindi altri due Pod che inviano messaggi a intervalli regolari. Solo per curiosità ecco il codice usato per questi due ultimi Pod che, al limite del ridicolo, non fanno altro che scrivere un messaggio nella console ogni tre secondi:
apiVersion: v1 kind: Pod metadata: name: static-web1 spec: containers: - name: bash image: nginx:1.14.2 command: ["/bin/bash", "-c", "--" ] args: ["while :; do echo '12345,abcde,miao,cipcip,1234567890'; sleep 3; done"] --- apiVersion: v1 kind: Pod metadata: name: static-web2 spec: containers: - name: bash image: nginx:1.14.2 command: ["/bin/bash", "-c", "--" ] args: ["while :; do echo 'qwerty'; sleep 3; done"]
Ora devo configurare Fluent Bit perché prenda questi messaggi e li salvi in Cloud Front. Dal file utilizzato precedentemente per AWS faccio una sola aggiunta al file fluent-bit.conf:
[SERVICE] Flush 5 Log_Level info Daemon off Parsers_File parsers.conf HTTP_Server ${HTTP_SERVER} HTTP_Listen 0.0.0.0 HTTP_Port ${HTTP_PORT} storage.path /var/fluent-bit/state/flb-storage/ storage.sync normal storage.checksum off storage.backlog.mem_limit 5M @INCLUDE application-log.conf @INCLUDE dataplane-log.conf @INCLUDE host-log.conf @INCLUDE application-custom.conf # <- riga aggiunta
Le tre righe precedenti creano i Log Group visti prima. Se si volesse evitare la loro creazione è sufficiente rimuovere queste righe da questo file. Ma ecco qualche dettaglio del mio file. Innanzitutto definisco i dati in input:
[INPUT] Name tail Path /var/log/containers/*.log multiline.parser docker Tag kube.* Mem_Buf_Limit 5MB Skip_Long_Lines On
Uso come parser Tail e faccio e come Path inserisco il percorso dove sono salvati i Log file all'interno dei Pod di Kuberntes. Anche qui ho definito un Tag: kube.*, che sarà utilizzato come base per la creazione del Tag che potrà essere utilizzato dagli altri moduli di Fluent Bit. Ora definisco i filtri per i tre Pod creati precedentemente:
[FILTER] Name kubernetes Match kube.var.log.containers.static-web1_* Merge_Log On Keep_Log Off K8S-Logging.Parser On K8S-Logging.Exclude On
I log vengono salvati nel Path visto prova e hanno come nome il nome stesso del Pod. Nell'esempio mostrato prima il primo Pod ha il nome static-web1. Ora utilizzo questo nome in Match per fare in modo che questo filtro prenda solo da Input il contenuto dei file con questo nome: kube.var.log.containers.static-web1_* (il Tag inizia con kube. come visto prima, e in automatico Kubernetes ha aggiunto il path completo del Log del Pod ed ha aggiunto un sequenza di caratteri casuali che risolvo con la Wildcard *).
Se inserissi il contenuto da questo filtro otterrei questo output:
{ "log": "qwerty\n", "stream": "stdout", "kubernetes": { "pod_name": "static-web2", "namespace_name": "default", "pod_id": "a9b2dacf-6cf5-44ea-bf34-600a30094122", "host": "ip-10-0-150-58.eu-south-1.compute.internal", "container_name": "bash", "docker_id": "4558aa182399991deaf6afacc130e4f40f6d347f78ec6a171d2e6fa84987ae07", "container_hash": "nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", "container_image": "nginx:1.14.2" } }
Per ora non mi interessano tutte queste informazioni, e soprattutto non voglio per ora il JSON come output. Devo utilizzare un altro filtro per sistemare questo output:
[FILTER] Name nest Match kube.var.log.containers.static-web1_* Operation lift Nested_under kubernetes Add_prefix kubernetes_
Con il filtro di tipo Nest con l'opzione Lift posso prendere il contenuto presente in altri Key e portarlo al primo livello. Nell'output precedente voglio prendere il contenuto della Key Kubernetes e portarli al primo livello. Con il filtro precedente ora avrò come output:
{ "log": "qwerty\n", "stream": "stdout", "kubernetes_pod_name": "static-web2", "kubernetes_namespace_name": "default", "kubernetes_pod_id": "a9b2dacf-6cf5-44ea-bf34-600a30094122", "kubernetes_host": "ip-10-0-150-58.eu-south-1.compute.internal", "kubernetes_container_name": "bash", "kubernetes_docker_id": "4558aa182399991deaf6afacc130e4f40f6d347f78ec6a171d2e6fa84987ae07", "kubernetes_container_hash": "nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d", "kubernetes_container_image": "nginx:1.14.2" }
Non mi servono tutte queste informazioni, quindi uso un altri Filtro per avere come output solo le Key che mi interessano:
[FILTER] Name record_modifier Match kube.var.log.containers.static-web1_* Allowlist_key log Allowlist_key time Allowlist_key kubernetes_pod_name
Ora avrò come output finale:
{ "log": "qwerty\n", "time": "2022-07-19T18:31:31.531664336Z", "kubernetes_pod_name": "static-web2" }
Non rimane che inviare il tutto a Cloud Watch:
[OUTPUT] Name cloudwatch_logs Match kube.var.log.containers.static-web1_* region ${AWS_REGION} log_group_name /aws/containerinsights/${CLUSTER_NAME}/web_all log_stream_name web1 auto_create_group true extra_user_agent container-insights
Nel quale specifico, tra gli altri, la Region, il Log Group Name e il Log Stream Name. Anche se mi ripeto notare che in link di collegamento tra l'input, tutti i filtri utilizzati e l'output finale è dato dall'opzione Match.
Ora farò lo stesso anche per il secondo Pod che invia il messaggio e Nginx. Nel modulo di output utilizzerò lo stesso Log Group Name in modo che questi tre Pod inviino i Log allo stesso gruppo. Solo il Log Stream name sarà differente. Alla fine otterrò questo risultato in Cloud Watch:
Nel dettaglio in nginx:
Che riporto qui in formato testuale:
{ "log": "127.0.0.1 - - [19/Jul/2022:18:31:26 +0000] \"GET / HTTP/1.1\" 200 612 \"-\" \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0\" \"-\"\n", "time": "2022-07-19T18:31:26.536651573Z", "kubernetes_pod_name": "nginx-deployment-6947dd7485-wtn4q" }
Ed ecco un esempio di Log per il Pod che invia del testo a intervalli regolari:
Conclusioni
L'esempio qui riportato è molto semplice. Più interessate è l'utilizzo con moduli di output più interessanti come Elastich Search o Splunk per un esame del log con strumenti più adatti che Cloud Watch e del suo Logs Insights.
Personalmente ho trovato il numero di Plug-in per i vari moduli di input, filter e output abbondante per le mie necessità. L'unico difetto che ho trovato in Fluent Bit è stata nella documentazione che ho trovato personalmente incompleta - per capire alcune opzioni ho dovuto cercare esempi funzionanti in Internet.
Ecco il link con il codice utilizzato in questa demo. Nella directory principale è presente il classico codice in Terraform da avviare con i classici tre comandi:
terraform init terraform plan terraform apply -auto-approve
Dopo l'esecuzione di questo codice è possibile configurazione l'autenticazione sulla propria macchina EKS con questo comando:
aws eks --region eu-south-1 update-kubeconfig --name K8sDemo-blog-cloud-watch
Nella stessa directory è presente il file nginx.yaml dove sono presente i tre Pod utilizzati per i test. Per avviarli:
kubectl apply -f nginx.yaml
Alla fine si elimina il tutto con:
terraform destroy -auto-approve
Per inserire un commento, devi avere un account.
Fai il login e torna a questa pagina, oppure registrati alla nostra community.
- AWS, EKS, OIDC: accedere alla risorse di AWS da Kubernetes, il 15 luglio 2022 alle 19:53
- AWS, EKS, gestione domini e TLS con Ingress, l'8 luglio 2022 alle 20:00
- Gestione dei domini e certificati in AWS con Terraform, il 25 giugno 2022 alle 09:54
- Terraform, Vue.js, Lambda in Net6 e Aws CloudFront, il 3 giugno 2022 alle 10:56
- Terraform, Vue.js e Aws CloudFront, il 26 maggio 2022 alle 19:46