Píšeme si vlastní exporter pro Prometheus v Kubernetes, příklad s ČNB

13. 9. 2023
Doba čtení: 10 minut

Sdílet

 Autor: Martin Koníček
Použijeme nástroje Prometheus a Grafana pro načítání a zobrazování volně dostupných informací o měnách z webu ČNB. Napíšeme si k tomu vlastní exporter dat a ukážeme si, jak to celé nasadit a zprovoznit.

Doba se mění

Když jsem před dvaceti lety začínal kariéru v informačních technologiích, pokud jste potřebovali něco monitorovat, první volbou byl vždy Nagios občas doplněný grafy s RRDTool. Tehdy se vše ještě instalovalo na klasický „bare metal“ s Linuxem, a v dobrém rozpoložení se sem tam něco vložilo do CVS či SVN, Git byl teprve v plenkách.

Dnešní doba je již jiná, za hlavní průkopníky monitorovacích technologiích se považuje Grafana a Prometheus, a správa a instalace technologií se zpravidla provádí pomocí metodologie DevOps, CI/CD pipeline na Kubernetes, holt časy se mění, a tak mě napadlo seznámit i čtenáře s tím, jak to funguje více v dnešní době na jednoduchém příkladě.

Nástroj Grafana

Grafana je nástroj, jak už z názvu vyplývá, na vytváření různých grafů. Pomůže vám vytvořit graf využití procesoru, diskového prostoru i vlastní „dashboard“. Jako zdroj dat využívá vždy nějakou databázi (Grafana jenom vykresluje), kterou může být třeba monitorovací nástroj Prometheus, nebo nějaká databáze typu InfluxDB.

Ukázka dashboardu v nástroji Grafana

Nástroj Prometheus

Prometheus je databáze dat, která se stahuje z různých zdrojů a je načítána a vykreslována Grafanou. Obvykle to funguje tak, že nějaký server nebo služba má takzvaný „exporter“, což je vlastně webový server, který na nějakém portu zveřejňuje textový soubor s daty. Ty jsou v pravidelných intervalech nástrojem Prometheus stahovány a ukládány do interní databáze, a pokud se k nástroji Prometheus připojí například front-end Grafana, mohou být i zobrazeny.

Ukázka nastavení nástroje Prometheus

Nástroj Prometheus má i integrovaný alert manager, který vám umožňuje zasílat při různých podmínkách různá upozornění, například pokud nejede web, dokáže vám poslat správu na Slack, e-mail, nebo například využít mou oblíbenou notifikační službu SNS na AWS.

Diagram nástroje Prometheus ve spojení s exporterem a Grafanou

Samotný projekt

Protože držím investice ve více měnách, zajímají mě jejich pohyby, a mám doma takový domácí dashboard v monitorovacím systému Grafana – který třeba monitoruje dostupnost mých webů, a rychlost internetu, napadlo mě, co si tam přidat kurzy měn.

Ukázka zobrazení měn

V případě, že jste si už s nástrojem Grafana již trošku hráli, jistě víte, že umí načítat data z nástroje Prometheus, který ve stanovený interval stahuje data z tzv. exporterů, které je publikují pomocí webového serveru, příklad exporteru vypadá takto:

exchange_rate{currency="australie-dolar"} 14.706
exchange_rate{currency="brazilie-real"} 4.469
exchange_rate{currency="bulharsko-lev"} 12.079
exchange_rate{currency="cina-zen-min-pi"} 3.087

Pro mě tedy otázkou bylo, jak vytvořit takovýto soubor z volně dostupných informací o měnách z webu ČNB, které vypadají takto:

05.06.2023 #107
země|měna|množství|kód|kurz
Austrálie|dolar|1|AUD|14,539
Brazílie|real|1|BRL|4,459
Bulharsko|lev|1|BGN|12,051

Kód samotného exporteru jsem se rozhodl napsat v NodeJS, protože to je technologie, v které se učím. Samotnou kostru mi napsal ChatGPT, ale kód jsem několikrát upravoval sám. Ze začátku jsem narazil na problém s tím, že pokud jsem zobrazoval pouze měnu – třeba dolar, tak v daném souboru bylo několik „stejných“ měn – americký dolar, australský dolar, takže jsem musel přidat i rozlišení podle země.

Dále jsem narazil na problém s češtinou, která se v monitorovacích nástrojích nezobrazovala správně, vyřešil jsem to pomocí nepříliš elegantního kódu, který odstraňuje diakritiku, a budu snad i rád, pokud se najde nějaký čtenář, který poradí elegantnější metodu.

Co byl také trošku problém bylo pravidelné stahování dat z webu ČNB, tak aby fungovalo správně. Zde musím zdůraznit, že nepoužívám cache, i když by to bylo záhodno, rád se zde obrátím na zkušenější čtenáře, kteří by na tomto otevřeném kódu chtěli udělat změny.

Zcela nepochybuji, že jsou lidé, kteří to dokáží napsat lépe než já (pracuji v DevOps), a velmi uvítám jejich pull requesty v mém GitHub repozitáři.

const axios = require('axios');
const fs = require('fs');
const express = require('express');

const app = express();
const port = 8080;
const filePath = 'exchange_rates.txt';
const fileUrl = 'https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.txt'

function replaceSpacesAndDiacritics(input) {
  // Replace spaces with dashes
  var replacedSpaces = input.replace(/ /g, '-');

  // Replace Czech diacritics with normal characters
  var replacedDiacritics = replacedSpaces
    .replace(/[áäÁÄ]/g, 'a')
    .replace(/[čČ]/g, 'c')
    .replace(/[ďĎ]/g, 'd')
    .replace(/[éěÉĚ]/g, 'e')
    .replace(/[íÍ]/g, 'i')
    .replace(/[ľĺĽĹ]/g, 'l')
    .replace(/[ňŇ]/g, 'n')
    .replace(/[óôÓÔ]/g, 'o')
    .replace(/[ŕřŔŘ]/g, 'r')
    .replace(/[šŠ]/g, 's')
    .replace(/[ťŤ]/g, 't')
    .replace(/[úůÚŮ]/g, 'u')
    .replace(/[ýÝ]/g, 'y')
    .replace(/[žŽ]/g, 'z');

  return replacedDiacritics.toLowerCase();
}

function parseData(){
  // Read the downloaded file
  const fileContent = fs.readFileSync(filePath, { encoding: 'utf8', flag: 'r' });

  // Split the content by new lines to get individual rows
  const rows = fileContent.split('\n');

  // Initialize an empty array to store the currency data
  const currencyData = [];

  // Extract currency and exchange rate from each row
  for (let i = 2; i < rows.length; i++) {
    const row = rows[i].split('|');
    if(row.length == 5){
      const currency = replaceSpacesAndDiacritics(`${row[0]}-${row[1]}`);
      const exchangeRate = parseFloat(row[4].replace(',', '.'));

      // Add currency and exchange rate as an object to the array
      currencyData.push({ currency, exchangeRate });
    }
  }
  return currencyData;
}

async function downloadCurrencies(){
  await axios.get(fileUrl, { responseType: 'stream' })
    .then(response => {
      const outputStream = fs.createWriteStream(filePath);
      response.data.pipe(outputStream);

      return new Promise(resolve => outputStream.on('finish', () => {
        console.log(`File downloaded successfully: ${filePath}`);
        console.log(`Data written to ${filePath} successfully.`);
        resolve();
      }));
    })
    .catch(error => {
      console.error('Error occurred while fetching data:', error);
    });
  return parseData();
}



  // Create a Prometheus exporter endpoint
  app.get('/metrics', (req, res) => {
    // Generate Prometheus metrics format
    let metrics = '';
    downloadCurrencies()
      .then(result => {
        result.forEach(data => {
          metrics += `exchange_rate{currency="${data.currency}"} ${data.exchangeRate}\n`;
        });
        return metrics;
      })
      .then(metrics => {
        res.setHeader('Content-Type', 'text/plain');
        res.send(metrics);
      });
  });

  // Start the server
  app.listen(port, () => {
    console.log(`Prometheus exporter is running on port ${port}`);
  });

Další samostatnou prací bylo vytvoření Dockerfile. Není to nějaká věda, opravdu to nejzákladnější používající běžný image Node.

FROM node:20

# Create app directory
WORKDIR /usr/src/app

# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available (npm@5+)
COPY package*.json ./

RUN npm install
# If you are building your code for production
# RUN npm ci --omit=dev

# Bundle app source
COPY . .

EXPOSE 8080
CMD [ "node", "exporter.js" ]

Načež jsem se rozhodl, že si na GitHubu udělám vlastní CI/CD workflow pomocí GitHub actions. Workflow dělám téměř vždy, i když se jedná třeba i o jednorázové utility, protože když se v budoucnu rozhodnu pro úpravy kódu, je to velmi jednoduché, a pokud si v Kubernetes specifikujete imagePullPolicy: Always, automaticky vám to vždy stáhne nejnovější kód přímo z GitHubu. Výhoda je také přechod na nejnovější verze „base image“, například když se zvedne verze NodeJS.

Celý image nahrávám do Docker.io, které je k dispozici opět zdarma, takže nikde za nic neplatím, i když by mi principiálně asi nevadilo využít řešení třeba jako AWS.

name: Docker Image CI

on:
  push:
    branches: ["dev"]
  pull_request:
    branches: ["dev"]

jobs:
  build-docker-image:
    runs-on: ubuntu-latest
    steps:
      - name: Docker login
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Checkout
        uses: actions/checkout@v3

      - name: Build docker image
        run: cd Kubernetes/cnb-prometheus-exporter && docker build . --file Dockerfile --tag ${{ secrets.DOCKERHUB_USERNAME }}/cnb-prometheus-exporter:$(date +%s) --tag ${{ secrets.DOCKERHUB_USERNAME }}/cnb-prometheus-exporter:dev && docker push ${{ secrets.DOCKERHUB_USERNAME }}/cnb-prometheus-exporter --all-tags

Co dál bylo třeba vytvořit byl soubor s deploymentem a službou pro Kubernetes. Na svém vlastním lokálním serveru používám Ubuntu s MicroK8s. Když jsem vytvářel soubor s deploymentem, chtěl jsem se před čtenáři trošku „ukázat“, a tak jsem nakonec dodělal „best practices“ jako jsou nastavené požadavky na paměť (requests), tak liveness a readiness probe, i když tyto věci na neprodukčním serveru bych klasicky dodělával asi nejspíše normálně ve chvíli, až kdybych zjistil, že se něco děje.

Klasicky požadavky na paměť, jsou důležité na produkčním serveru z toho důvodu, že když si nějaká služba vezme příliš mnoho paměti, pustí se OOM killer, a začne „zabíjet“, a může to s dostupností celého „node“ skončit špatně. V domácích podmínkách asi přežijete, že vám nejede NAS a nějaké grafy, navíc na serveru provozuji několik starších služeb z doby kdy jsem s Kubernetes začínal, a ještě se „nestalo“, nicméně dá se říci, že se jedná o dobrou praktiku, a nějaký linter třeba ve Visual Studio Code vás na to určitě upozorní.

Readiness a Liveness sondy zase zjišťují, jestli je kontejner dostupný. Opět, jedná se o best practices, a vzhledem k tomu, že si službu zobrazujete v Grafana, asi byste přišli na to, že máte problém, ale je dobrým zvykem toto kontrolovat, a pokud se budete prací s Kubernetes zabývat profesionálně, asi si kamarádského poklepání po ramenou nezasloužíte, pokud tuto část psaní deploymentu vynecháte.

Možná že vás zaskočí, že u zjišťování zdraví kontejneru je poměrně dlouhý interval – jedna hodina, je to ovšem dáno tím, že i službu přes Prometheus kontroluji jednou za hodinu, a nechci zbytečně vytěžovat servery ČNB, navíc když to není vůbec potřeba, neboť data se mění jednou za den.

Zde bych chtěl předem opět říci, že se to možná dalo napsat lépe, a budu velmi vděčný za veškeré rady i připomínky v komentářích, případně za pull request do mého repozitáře či otevřenou issue na GitHubu – GitHub – ČNB Prometheus Exporter.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cnb-exporter
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cnb-exporter
  template:
    metadata:
      labels:
        app: cnb-exporter
    spec:
      containers:
        - name: cnb-exporter
          image: krab55/cnb-prometheus-exporter:latest
          imagePullPolicy: Always
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: 25m
              memory: 32Mi
            limits:
              cpu: 100m
              memory: 256Mi
          readinessProbe:
            httpGet:
              path: /metrics
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 3600
          livenessProbe:
            exec:
              command:
                - sh
                - -c
                - >
                  if ! curl -s http://localhost:8080/metrics | grep -q 'exchange_rate{currency="emu-euro"} [0-9.]*$'; then
                    exit 1;
                  fi
            initialDelaySeconds: 10
            periodSeconds: 3600
---
apiVersion: v1
kind: Service
metadata:
  name: cnb-exporter-service
spec:
  selector:
    app: cnb-exporter
  ports:
    - protocol: TCP
      port: 9100
      targetPort: 8080
  type: ClusterIP

Nastavení Promethea

Nastavení scraperu v Prometheus není žádná věda, a je níže. Jako target stačí zadat jméno service v Kubernetes (pokud je service i Prometheus ve stejném namespace) a začít stahovat. Osobně jsem pro instalaci prometheus využil Helm repozitáře prometheus-community/prometheus, který je k vidění na GitHubu.

 scrape_configs:
      - job_name: 'cnb'
        scrape_interval: 1h
        static_configs:
          - targets: ['cnb-exporter-service:9100']

Nastavení Grafany

Nastavení Grafany není nijak složité, stejně jako Prometheus jsem jí nainstaloval pomocí balíčku Helm. U Grafany pozor, abyste nastavili PVC (Persistent Volume Claim) jinak se vám její nastavení nebude ukládat.

První věc, kterou si projdete u Grafany je propojení s Prometheem. Já mám vše na lokální síti a v síti Kubernetes clusteru nepoužívám žádnou autentizaci. Jak je toto bezpečné se můžete podělit v komentářích. Rozhodně bych ale toto nastavení nedoporučoval v korporátní síti.

Nastavení Prometheus v Grafana

Nakonec si nastavuji jednotlivý panel, to není nijak obtížné, a vidíte to na obrázku níže. Ještě jsem pro jistotu vyexportoval celý JSON nastavení z Grafany, kdybyste chtěli nějaké detaily.

Nastavení zobrazení měn

{
  "datasource": {
    "type": "prometheus",
    "uid": "yTVXipTVz"
  },
  "fieldConfig": {
    "defaults": {
      "color": {
        "fixedColor": "light-orange",
        "mode": "palette-classic"
      },
      "mappings": [],
      "thresholds": {
        "mode": "absolute",
        "steps": [
          {
            "color": "green",
            "value": null
          },
          {
            "color": "red",
            "value": 80
          }
        ]
      },
      "unit": "currencyCZK"
    },
    "overrides": []
  },
  "gridPos": {
    "h": 8,
    "w": 7,
    "x": 0,
    "y": 23
  },
  "id": 24,
  "options": {
    "colorMode": "background",
    "graphMode": "none",
    "justifyMode": "auto",
    "orientation": "auto",
    "reduceOptions": {
      "calcs": [
        "lastNotNull"
      ],
      "fields": "",
      "values": false
    },
    "textMode": "auto"
  },
  "pluginVersion": "9.3.1",
  "targets": [
    {
      "datasource": {
        "type": "prometheus",
        "uid": "yTVXipTVz"
      },
      "editorMode": "builder",
      "expr": "exchange_rate{currency=\"emu-euro\"}",
      "legendFormat": "{{currency}}",
      "range": true,
      "refId": "A"
    },
    {
      "datasource": {
        "type": "prometheus",
        "uid": "yTVXipTVz"
      },
      "editorMode": "builder",
      "expr": "exchange_rate{currency=\"usa-dolar\"}",
      "hide": false,
      "legendFormat": "{{currency}}",
      "range": true,
      "refId": "B"
    }
  ],
  "title": "Euro / Dolar",
  "type": "stat"
},

Na domácí hraní

Celé řešení je samozřejmě zejména amatérská ukázka „na doma“, a velmi ocením veškeré připomínky, rady i případně pull requesty na GitHubu od čtenářů. Myslím, že hodně lidí toto může využít i jako takovou kostru, pokud by si chtěli sami napsat nějaký vlastní prometheus exporter do Kubernetes, a chtějí třeba příklady CI/CD pipeline, kódu, apod. a chtějí si upravit jen detaily – osobně zvažuji ještě udělání obdobné ukázky pro fond SP500.

Sám mám naimplementováno několik řešení, které zobrazují třeba spotřebu zásuvek TP100 od TP-Linku, případně teploty a vlhkosti v místnostech, či naměřenou rychlost internetu pomocí SpeedTestu. Pokud uvidím, že by byla dostatečná poptávka o jejich zveřejnění, rád se v některém z dalších článků podělím.

bitcoin školení listopad 24

Mnou navržené řešení neberte jako mantru, že by to nešlo udělat lépe – určitě šlo. Ještě jednou opakuji, u mého autorského účtu i na GitHubu je zveřejněný web s formulářem na kontakt, velmi rád si s vámi o tom popovídám.

Takto vypadá můj domácí dashboard

(Autorem všech obrázků je Martin Koníček.)

Autor článku

V oboru informačních technologií se pohybuje přes 20 let. V současné době pracuje jako kontraktor pro DevOps.