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.
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.
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.
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.
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.
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.
{ "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.
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.
(Autorem všech obrázků je Martin Koníček.)