AWS, EKS e Fluent Bit

di Andrea Zani, in terraform,

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
Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese