ELK, diétára fogva

CC0 image by evondue

Az előző bejegyzésben egyedi megoldásokkal kísérletezgettünk, most pedig megnézzük a logolás világának iparági sztenderdjét, az Elastic Stack-et... egy kis csavarral.

Az Elasticsearch az a szervereknek, mint a Chrome az asztali gépeknek. Végtelen mennyiségű memóriát képes felemészteni. Vannak környezetek, ahol meg is kapja ezt, de mi továbbra is maradunk a hobbi felhasználás szintjén és megpróbáljuk felvarázsolni a legkisebb DigitalOcean-ös droplet-re. Ez jelenleg egy 1 GB memóriával és 1 vCPU-val rendelkező instance-t jelent.

Előkészületek

Fogjunk egy frissen felhúzott Ubuntu 16.04-et. Ez alapjáraton úgy 60 MB memóriát használ, de nyerhetünk még egy kicsit, ha megszabadulunk a snapd és a do-agent csomagtól (ha bekapcsoltuk a monitoringot a droplet létrehozása során).

$ apt remove snapd do-agent

Így már egy kicsit kellemesebb 45 MB környékén járunk.

Jöhet az Elasticsearch PGP kulcsának és APT repository-jának importálása:

$ wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | apt-key add -
$ echo "deb https://artifacts.elastic.co/packages/6.x/apt stable main" | tee -a /etc/apt/sources.list.d/elastic-6.x.list
$ apt update

Valamint a JRE telepítése:

$ apt install default-jre

Ezzel az előkészületek végére értünk, jöhet a lényeg.

Kibana

A Kibana-val kezdjük a sort, mert annak a memória felhasználását nem nagyon tudjuk befolyásolni, úgyhogy kénytelenek leszünk az Elasticsearch-öt a fennmaradó mennyiséghez igazítani.

$ apt install kibana nginx
$ service kibana start

A Kibana-t az alapértelmezett beállításokkal fogjuk használni, az nginx-nek viszont meg kell mondani, hogy proxy-zzon.

/etc/nginx/sites-enabled/default
location / {
    proxy_pass              http://127.0.0.1:5601;
    proxy_set_header        Host $host;
    proxy_set_header        Referer "";
    proxy_set_header        X-Real-IP $remote_addr;
    proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
}

A memória használatunk itt már 170 MB környékén jár.

Elasticsearch

El is érkeztünk a lényeghez. Nem meglepő, hogy egy kis telepítéssel fogunk kezdeni.

$ apt install elasticsearch

Az elindítással itt még nem is érdemes próbálkozni, mert az alapértelmezett beállításokkal több memória kellene neki, mint amennyi a gépben összesen van, úgyhogy egy kis config módosítással kezdünk:

/etc/elasticsearch/jvm.options
-Xms512m
-Xmx512m

És ezek után már el is tudjuk indítani:

$ service elasticsearch start

Elértük a 850 MB-ot, maradt még egy kis feleslegünk is, amit használhat a rendszer. Minden szépnek és jónak tűnik, de próbáljunk csak meg mondjuk egy apt update-et futtatni és máris érdekes és változatos hibaüzeneteket kaphatunk:

Unknown error executing apt-key

Could not execute 'apt-key' to verify signature (is gnupg installed?)

Couldn't spawn new process

FATAL -> Failed to fork.

Cannot allocate memory

Szerencsére az utolsó sor már elég világos. Sejteni lehet, hogy mi a probléma. Irány a jvm.options! Egy 448-as vagy 384-es értékkel érdemes megpróbálkozni. Vagy akár le is kapcsolhatjuk a Kibana-t arra az időre, amíg valami mást is szeretnénk csinálni a szerveren.

Logstash

A Logstash egy Elasticsearch-höz hasonló Java-s memóriavámpír. Érezhető, hogy nem igazán van már neki hely. Ezt egy ügyes kis trükkel fogjuk megoldani: meg se próbáljuk feltelepíteni. Helyette megpróbáljuk elérni azt, hogy ne is legyen rá szükségünk.

Az rsyslog például elég okos és képes egyből Elasticsearch-be küldeni a logokat.

$ apt install rsyslog-elasticsearch

A JSON template definíciója az előző bejegyzésből már ismerős lehet.

/etc/rsyslog.d/10-elastic.conf
template(name="syslog-json" type="list") {
  constant(value="{")

  constant(value="\"timestamp\":\"")
  property(name="timereported" dateFormat="rfc3339" format="json")

  constant(value="\",\"host\":\"")
  property(name="hostname" format="json")

  constant(value="\",\"tag\":\"")
  property(name="programname" format="json")

  constant(value="\",\"facility\":\"")
  property(name="syslogfacility-text" format="json")

  constant(value="\",\"severity\":\"")
  property(name="syslogseverity-text" format="json")

  constant(value="\",\"message\":\"")
  property(name="msg" format="json")

  constant(value="\"}")
}

module(load="omelasticsearch")
action(type="omelasticsearch" template="syslog-json")

Szinte már túl egyszerűnek is tűnik. Ezen a ponton egy jó órát töltöttem el annak a kiderítésével, hogy miért nem érkeznek meg a logok. Elég irónikus, hogy az rsyslog nem nagyon logol, így az ő oldalán nem sok minden derült ki és az Elasticsearch se sokat segített.

A végén már odáig fajult a dolog, hogy tcpdump-pal néztem a kettő közötti forgalmat és így derült ki, hogy az rsyslog text/json content type-ot küld, a 6-os Elasticsearch-ben pedig szigor van és csak az application/json-t fogadja el.

Az Ubuntu 16.04-ben 8.16.0-s rsyslog van, ezt a problémát pedig a 8.30.0-ban javították, úgyhogy szereznünk kell egy frissebb verziót:

$ add-apt-repository ppa:adiscon/v8-stable
$ apt update
$ apt upgrade

És már jönnek is a logok. Az rögtön látszódik, hogy a Kibana már JSON formátumba logol, amit kijavíthatunk egy hasonló megoldással, mint amit az előző bejegyzésben is használtunk:

/etc/rsyslog.d/10-elastic.conf
template(name="json-msg" type="list") {
  property(name="msg")
}

if $programname == "kibana" then {
  action(type="omelasticsearch" template="json-msg" searchIndex="kibana")
  stop
}

Az nginx-et is rábírhatjuk, hogy JSON logokat adjon ki magából, de nem lesz vele olyan egyszerű dolgunk, ha ezredmásodperc pontosságú logokat szeretnénk és továbbra sem akarunk egy log feldolgozó/transzformáló réteget.

A log formátum megadásánál használható $msec (unix idő másodpercekben, ezredmásodperc pontossággal) nem támogatja dátumformátumként az Elasticsearch. Van sima unix idő másodpercben és unix idő ezredmásodpercben, de mindkettő egész számként. Szerencsére a Lua mindent képes megoldani.

/etc/nginx/sites-enabled/default
log_format json escape=json '{"timestamp": $epoch_millis, '
  '"network":{'
  '"client_ip": "$remote_addr", '
  '"bytes_write": $body_bytes_sent}, '
  '"http":{'
  '"ident": "$host", '
  '"response_time": $request_time, '
  '"status_code": $status, '
  '"request": "$request_uri", '
  '"verb": "$request_method", '
  '"referer": "$http_referer", '
  '"useragent": "$http_user_agent"}, '
  '"message": "$request"}';

server {
    # ...

    set_by_lua_block $epoch_millis { return ngx.var.msec * 1000 }
    access_log syslog:server=unix:/dev/log,nohostname json;
}

A set_by_lua_block használatához szükségünk lesz az nginx-extras csomagra. És ha még nem lenne elég a menet közben felmerülő problémákból, az Ubuntu 16.04-ben lévő nginx verzió nem tudja a log_format-nál az escape paramétert, így abból is kell egy frissebb:

$ add-apt-repository ppa:nginx/stable
$ apt update
$ apt upgrade

Tovább is van, mondjam még? Ha ezt így megpróbáljuk beküldeni, akkor a timestamp mezőnk sima long típusú lesz, nem date. Ez aztán szomorú Kibana-hoz vezet, ami végső soron szomorú felhasználókat fog eredményezni. Kell egy kis előkészület Elasticsearch-ben, hogy mindenki boldog legyen:

$ curl -d@mappings.json -HContent-Type:application/json -XPUT 'localhost:9200/nginx'
mappings.json
{
  "mappings" : {
    "events": {
      "properties": {
        "timestamp" : {
          "type": "date",
          "format" : "epoch_millis"
        }
      }
    }
  }
}

Teljesítmény

Most, hogy van egy működő rendszerünk, jogosan merülhet fel a kérdés, hogy mégis mit várhatunk el tőle. Ennek kiderítéséhez egy másik dropletet veszünk igénybe, ami ugyanabban a datacenter-ben van, mint a szerverünk és privát hálózaton SSH tunnel-lel vannak összekötve.

A kliens gépen egy végtelenül egyszerű Python script egy szálon küld be a local rsyslog-ba 100k üzenetet, amit az továbbít a szerver rsyslog-jának, aki gondoskodik a lementésről.

from syslog import *
from time import sleep

for i in range(100000):
    syslog('performance test message: ' + str(i))
    sleep(0.0002)

Első körben fájlba mentjük az üzeneteket, hogy legyen mihez viszonyítani. Ez nagyjából 37 másodperc alatt zajlott le, miközben a szerver 50% körüli CPU terheltséget produkált, ami 160k körüli logsort jelent percenként.

Ugyanez Elasticsearch-be mentéssel 80-90% körül pörgette a CPU-t, közel 9 percig, ami úgy 11k logsor percenként. Nem annyira rossz, de vajon lehetne-e jobb? Nyilván, különben nem kérdeztem volna.

Az alapbeállítás soronként küldi be a logokat az Elasticsearch-be, ami érezhetően nem a legjobb választás ilyen mennyiségek mellett. Szerencsére van lehetőség a Bulk API használatára:

/etc/rsyslog.d/10-elastic.conf
action(type="omelasticsearch" template="syslog-json" bulkmode="on")

A terhelésen nem változtat, viszont alig több, mint egy perc alatt végez, amivel sikerült elérni a 88k log/percet. A lekérdezések terén szintén jónak tűnt a helyzet. A tesztek során több, mint 650k elem került az indexbe, de a felületen csak az adatbetöltések alatt lehetett lassulást érezni.

Ezzel a kis kísérletünk a végéhez közeledik. Úgy tűnik, hogy megoldható egy működő (sőt, használható), Elasticsearch alapú központi logolási rendszer beüzemelése egy 1 GB memóriával rendelkező gépen. Néhol kerülőkkel és kompromisszumokkal tarkított az út, de kétségtelenül megoldható.

Ha mégis kezdenénk kifogyni ebből a rengeteg erőforrásból, a Kibana (és ha szükségünk van rá, akkor a Logstash) átköltöztethető egy másik gépre és az Elasticsearch-ből is felhúzhatunk több példányt, hogy igazi klaszterünk legyen.

Log gyűjtés, egy kicsit másképp

CC0 image by StockSnap

Ma a központosított logolásról lesz szó. No nem arról a szintről, amikor az alkalmazásod több tíz gigányi logot termel naponta és azt kellene valahova elpakolnod. Arra ott a méltán híres ELK stack. Nem, mi maradunk a hobbi szintnél, amikor van néhány géped, amik lehet, hogy egész életük során összesen nem látnak majd több tíz gigányi logot.

Elképzelhető, hogy nagyobb forgalom mellett is működhet egy ilyesmi felállás, de odáig nem jutott el ez a kis projekt, hogy méréseket is végezzek. Kicsit korábban sikerült már akadályokba ütközni, de erről majd később, kezdjük az elején.

A központosítás

Minden (jó linuxos) háztartásban megtalálható egy syslog. Most konkrétan az rsyslog változatával fogunk foglalkozni, de az elv valószínűleg a többivel is működhet. Először is szükségünk van egy központi szerverre, aminek küldhetjük az adatokat. Szerencsénkre az rsyslog-nak megmondhatjuk, hogy legyen olyan kedves és kívülről is fogadjon be logsorokat:

/etc/rsyslog.conf
module(load="imtcp")
input(type="imtcp" port="514")

Amit itt érdemes tudni, hogy ez egy plain text kommunikáció, így ha féltjük a logjainkat a gonosz kémektől, gondoskodnunk kell a biztonságukról. Az rsyslog tud TLS-en is kommunikálni, de felhúzhatunk egy SSH tunnel-t vagy VPN-t is a gépek között. A következő parancs segítségével kipróbálhatjuk, hogy minden jól működik-e:

$ logger -n <központi szerver> -P 514 -T "Test message"

Ezek után már csak a kliens gépeket kell beállítani, hogy továbbítsák a logjaikat a központba:

/etc/rsyslog.d/10-forward.conf
action(type="omfwd" Target="<központi szerver>" Port="514" Protocol="tcp")

És kész is vagyunk. Köszönöm a figyelmet, gyertek máskor is.

Az adatbázis

Na jó, nem ússzátok meg ennyivel. Az igaz, hogy egy központi gépen vannak a logjaink, de még mindig csak fájlokban. Mi lenne, ha mondjuk inkább MongoDB-be pakolnánk?

Nyilván nem sokkal lennénk előrébb, ha csak soronként bedobálnánk őket egy adatbázisba. Viszont ha valami egyszerűen feldolgozható formátumban érkeznének, mondjuk JSON-ként...

/etc/rsyslog.d/10-forward.conf
template(name="syslog-json" type="list") {
  constant(value="{")

  constant(value="\"timestamp\":\"")
  property(name="timereported" dateFormat="rfc3339" format="json")

  constant(value="\",\"host\":\"")
  property(name="hostname" format="json")

  constant(value="\",\"tag\":\"")
  property(name="programname" format="json")

  constant(value="\",\"facility\":\"")
  property(name="syslogfacility-text" format="json")

  constant(value="\",\"severity\":\"")
  property(name="syslogseverity-text" format="json")

  constant(value="\",\"message\":\"")
  property(name="msg" format="json")

  constant(value="\"}")
}

action(type="omfwd" template="syslog-json" Target="<központi szerver>" Port="514" Protocol="tcp")

Most már futhat valami service a központi gépen, ami figyeli a logfájlokba érkező új sorokat és betolja őket az adatbázisba. Vagy akár írhatunk egy egyszerű log fogadó szervert is, ami közvetlenül adatbázisba menti az érkező sorokat.

server.js
const net = require('net');
const readline = require('readline');
const MongoClient = require('mongodb').MongoClient;

(async () => {
  const host = '127.0.0.1';
  const port = 514;
  const connstring = 'mongodb://localhost:27017';

  const db = (await MongoClient.connect(connstring)).db('logging');

  net.createServer(socket => {
    const rl = readline.createInterface(socket, socket);
    rl.on('line', async data => {
      let jsonData = JSON.parse(data.toString().trim());
      jsonData.timestamp = new Date(jsonData.timestamp);

      await db.collection('syslog').insertOne(jsonData);
    });
  }).listen({ host, port, exclusive: true });
})();

A syslog protokol nem egy bonyolult jószág, a konkrét logsorokat küldi, így soronként egy-egy JSON-t kapunk, ami mehet is az adatbázisba. Vagy mentés előtt még végezhetünk rajta módosításokat, ha szükségesnek látjuk (például a dátumot string-ként tartalmazó mezőt Date objektummá alakíthatjuk).

Ezen a ponton megvan minden log a szervereinkről... amik syslog-ba érkeznek. Mi a helyzet a többivel? Ha az alkalmazás elég okos, akkor tud direktbe logolni a központi szerverre JSON formátumba. Vagy küldheti a logokat a lokális rsyslog-nak, ami majd továbbítja őket a központba.

/etc/rsyslog.d/10-forward.conf
template(name="json-msg" type="list") {
  property(name="msg")
}

if $syslogfacility-text == "local7" then {
  action(type="omfwd" template="json-msg" Target="<központi szerver>" Port="514" Protocol="tcp")
  stop
}

Itt a local7 facility-re érkező logoknál arra számítunk, hogy a log üzenet már egy valid JSON, így a többi részét figyelmen kívül hagyhatjuk és csak azt továbbítjuk.

Alább pedig az látható, hogy az nginx-et (1.11.8-as verziótól) hogyan tudjuk rábírni arra, hogy JSON formátumban küldje a logjait az rsyslog-nak. A local7 facility nála az alapbeállítás, úgyhogy azzal nem is kell törődnünk.

/etc/nginx/conf.d/10-logformat.conf
log_format json escape=json '{"timestamp": $msec, '
  '"network":{'
  '"client_ip": "$remote_addr", '
  '"bytes_write": $body_bytes_sent}, '
  '"http":{'
  '"ident": "$host", '
  '"response_time": $request_time, '
  '"status_code": $status, '
  '"request": "$request_uri", '
  '"verb": "$request_method", '
  '"referer": "$http_referer", '
  '"useragent": "$http_user_agent"}, '
  '"message": "$request"}';

access_log syslog:server=unix:/dev/log,nohostname json;

A megjelenítés

Itt kezdtek felmerülni a problémák. A MongoDB egyértelmű választásnak tűnt még azon a ponton, amikor JSON dokumentumokat kellett tárolni, de ha egy Kibana szerű felületet is szeretnénk hozzá, akkor már nem érződik annyira jó döntésnek.

Persze mindig ott a lehetőség, hogy megírunk magunknak egy hasonló felületet. Megfelelően kicsi scope mellett talán van is esély a befejezésre, de ez azért távol áll az ideális helyzettől. Némi keresés után a Redash-t sikerült még megtalálnom. Támogatja a MongoDB-t, tud grafikonokat generálni az adatokból és van lehetőség mentett lekérdezésekre is.

Query szerkesztő

Példa dashboard

Határozottan kényelmetlenebbnek érződik, mint a Kibana. Főleg a lekérdezések összerakása (ami egy hatalmas JSON), de a grafikonok se olyan kényelmesek/okosak. Annyi előnye azért még biztos van, hogy nem kell lefejleszteni.

Összességében egy remek hétvégi program volt kicsit közelebbről megismerkedni a syslog-gal és megpróbálni összerakni egy alternatív megoldást. Csak ajánlani tudom mindenkinek, mint tapasztalatszerzési lehetőséget. Viszont ha éles rendszerek központi logolásáról van szó, lehet, hogy jobban járunk a kitaposott ösvényekkel.

Teszteljünk konténerben

CC0 image by StockSnap

Tavaly, mikor nekiálltam a Docker-es sorozatnak, terveztem még egy negyedik részt is, ami a tesztelésről szólt volna. Aztán annak rendje és módja szerint meg is feledkeztem róla. Egészen mostanáig.

A példák PHP-t használnak a tesztelt alkalmazás nyelveként, de a koncepciók hasonlóan működhetnek bármilyen más nyelven is. A teljes kód pedig szokás szerint megtalálható a példa repóban. Vágjunk is bele.

Unit tesztek

Az ide vonatkozó Docker Compose konfiguráció annyira egyszerű, hogy igazából csak a teljesség kedvéért került bele ebbe a bejegyzésbe is. A nagy részét megtárgyaltuk már a sorozat első részének "Hello Composer" fejezetében.

docker-compose.yml
version: "3"
services:
  app:
    image: composer:1.3
    volumes:
      - .:/app
    working_dir: /app

A teszteket pedig a következő paranccsal tudjuk futtatni:

$ docker-compose run --rm -T app vendor/bin/phpunit

A könnyed felvezető után ugorjunk is inkább az izgalmasabb részekre.

Integrációs tesztek

A témakör elég tág, így két dolgot is meg fogunk vizsgálni közelebbről. Az adatbázist használó és a külső szolgáltatással kommunikáló kódok tesztjeit.

Adatbázis

Itt, hasonlóan a korábbi "Hello MySQL" részhez, szükségünk lesz egy adatbázis szervere:

docker-compose.yml
# ...
  database:
    image: mysql:5.7
    volumes:
      - ./etc/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    environment:
      MYSQL_ROOT_PASSWORD: test
      MYSQL_USER: test
      MYSQL_PASSWORD: test
# ...

Az init.sql segítségével létrehozunk két adatbázist, az egyiket a fejlesztéshez szeretnénk majd használni, a másikat a tesztek futtatásához. Ezen kívül az init.sql még beállítja a jogosultságokat és létre is hozza mindkettőben a megfelelő sémát.

Ami ezen a ponton feltűnhet, hogy az adatbázis tartalmának nem csináltunk saját volume-ot. Ennek az oka pedig az, hogy most nem csak egy konfigurációs fájlt fogunk használni. Lesz egy külön konfigurációnk a fejlesztéshez:

docker-compose.dev.yml
# ...
volumes:
  mysql:
# ...
  database:
    volumes:
      - mysql:/var/lib/mysql
# ...

Itt Docker volume-ot használunk az adatok tárolására, hogy azok a konténerek leállítása után is megmaradjanak. A Docker Compose-t pedig a következőképpen tudjuk rábírni, hogy mindkét fájlt használja:

$ docker-compose -f docker-compose.yml -f docker-compose.dev.yml up -d

Mivel az az elgondolás, hogy fejlesztés közben az alkalmazást is aktívan használjuk, ezért a teszteket futtathatjuk exec segítségével a már futó konténerben:

$ docker-compose -f docker-compose.yml -f docker-compose.dev.yml exec -T app vendor/bin/phpunit

A másik konfigurációnk a build-hez lesz:

docker-compose.build.yml
# ...
  database:
    tmpfs:
      - /var/lib/mysql
# ...

Itt tmpfs-t használunk az adatok tárolására, mivel nem is igazán akarjuk őket tárolni, ellenben szeretnénk ha gyors lenne, a tmpfs pedig memóriában tárolódik.

A build esetén az elvárásaink is kicsit mások, ami a tesztek futtatását illeti. Az alkalmazás még nem fut és szeretnénk azt is megvárni, hogy az adatbázis elinduljon, mielőtt elkezdenénk futtatni a teszteket.

$ docker-compose -f docker-compose.yml -f docker-compose.build.yml up -d database
$ docker-compose -f docker-compose.yml -f docker-compose.build.yml exec -T database bash -c 'while ! mysqladmin ping -hdatabase -u$$MYSQL_USER -p$$MYSQL_PASSWORD --silent; do sleep 1; done'
$ docker-compose -f docker-compose.yml -f docker-compose.build.yml run -T --rm app vendor/bin/phpunit
$ docker-compose -f docker-compose.yml -f docker-compose.build.yml down
  1. Elindítjuk az adatbázist
  2. A mysqladmin ping parancs segítségével megvárjuk, hogy ténylegesen el is induljon
  3. Lefuttatjuk a tesztjeinket
  4. Lekapcsolunk mindent.

Mint az a példából is gyönyörűen látszik, kezdenek egyre hosszabbak lenni ezek a parancsok, senki se gépelné be ezeket egynél többször, ha nem muszáj. Ennek kiküszöbölésére továbbra is tudom ajánlani a Makefile használatát, amire szintén lehet példát találni a kapcsolódó repóban.

API

Egy másik dolog, ami problémákat okozhat integrációs teszteknél, ha valamilyen API-val kommunikálunk. Ennek megoldására felhúzhatunk egy egyszerű service-t, ami mondjuk a PHP beépített webszerverét használja és egy olyan könyvtárszerkezetet szolgál ki, ami leutánozza a tesztekben használt API-t.

docker-compose.yml
# ...
  api:
    image: php:7.1-alpine
    command: php -S 0.0.0.0:80 -t /app/
    volumes:
      - ./etc/api/:/app/:ro
# ...

Az etc/api/ könyvtár pedig valahogy így nézhet ki:

  • v2/
    • users/
      • 1234/
        • index.php
      • index.php

Az index.php megvalósítása lehet nagyon egyszerű, már-már statikus, de ízlés szerint elbonyolíthatjuk akár input validációval, authentikációval vagy egyéb dolgokkal is.

Funkcionális tesztek

Ez is egy többféleképpen megközelíthető kategória. Előfordulhat, hogy az általunk használt keretrendszer nyújt segítséget, és csak emulálunk http kéréseket (mint például a Silex féle WebTestCase). Az is lehet egy megoldás, hogy valós http kéréseket küldünk az alkalmazásnak és az érkező válaszokat vizsgáljuk. Mindkét eset a Docker Compose konfigurációja szempontjából leginkább a unit tesztek megoldására hajaz.

No de mi van olyankor, ha egy igazi böngészőben szeretnénk automatizáltan kattintgatni? Ebben a Selenium és a Codeception lehet a segítségünkre. Szerencsénkre a Selenium volt olyan kedves, hogy szolgáltasson Docker image-eket is:

docker-compose.yml
# ...
  browser:
    image: selenium/standalone-chrome
    volumes:
      - /dev/shm:/dev/shm
# ...

Már csak a Codeception-nek kell megmondani, hogy ezt használja:

tests/acceptance.suite.yml
# ...
    - WebDriver:
      url: http://web
      host: browser
      browser: chrome
# ...

A web itt az alkalmazásunkat futtató service neve, a browser pedig a fent definiált Selenium service. Eredetileg az alkalmazást itt is app-nak hívtam, mint a többi példában, de valamilyen rejtélyes oknál fogva úgy nem működött.

Jól látszik, hogy ahogy nő az egy teszttel megfuttatott kód mennyisége, úgy lesz bonyolultabb a hozzá felhúzott teszt infrastruktúra is. A példában nem tértünk rá ki külön, de a funkcionális teszteknél jó eséllyel szükségünk lesz majd az integrációs teszteknél használt módszerekre is, hogy egy megfelelő állapotban lévő alkalmazást tudjunk a böngészőben tesztelni.

Fejlesszünk konténerben III.

Az előző részekben volt alkalmunk többször is megcsodálni, hogy milyen egyszerűen lehet adatbázist rakni az alkalmazásunk mögé Docker Compose segítségével. De kár lenne itt megállni. A Docker kabalaállata biztos ránk is kiabálna, ha így tennénk. Jöjjön hát még pár kedvcsináló példa.

SFTP szerver

Kezdésnek nézzünk valami egyszerűt. Ha az alkalmazásunknak egy SFTP szerverre lenne szüksége, azt nagyon egyszerűen meg tudjuk oldani. Létezik egy remek image, ami elvégzi helyettünk a munka nagy részét, így csak pár sor és meg is vagyunk.

docker-compose.yml
version: "3"
services:
  sftp:
    image: atmoz/sftp:alpine
    ports:
      - "2222:22"
    command: user:pass

Névfeloldás

A service-eknek megadhatunk egyedi DNS szervert (vagy szervereket) is, ami akár egy másik konténer is lehet. Ehhez először is szükség van egy saját hálózatra, ahol a DNS szervernek fix IP címe van.

docker-compose.yml
networks:
  default:
  dnsnet:
    ipam:
      config:
        - subnet: 172.16.113.0/24
          gateway: 172.16.113.1

A default biztosítja azt, hogy az egymáshoz linkelt konténerek név alapján továbbra is elérik egymást (pl. ha van egy db nevű service-ünk, akkor arra db néven tudunk alkalmazásunkban hivatkozni). A dnsnet-et használjuk arra, hogy a DNS szerverünknek legyen fix címe.

docker-compose.yml
dns:
  build: ./dns
  networks:
    default:
    dnsnet:
      ipv4_address: 172.16.113.2

Már csak az alkalmazás konténernek kell megadni, hogy a mi DNS szerverünket használja.

docker-compose.yml
app:
  build: ./app
  links:
    - dns
  dns: 172.16.113.2
  networks:
    default:
    dnsnet:

Ha minden jól ment, láthatjuk, hogy az egyébként nem létező look-me-up.test domain-t megfelelően fel tudjuk oldani az alkalmazáson belül:

$ host look-me-up.test
Host look-me-up.test not found: 3(NXDOMAIN)

$ docker-compose run --rm app
look-me-up.test has address 172.3.4.5

A teljes példa szokás szerint megtalálható a kapcsolódó Github repóban.

Email (nem) küldés

Fejlesztés közben jó, ha nem kezdünk el random címekre leveleket kiküldeni. Viszont fejlesztés közben néha jó az is, ha mégis csak kimennek ezek a levelek és meg tudjuk nézni őket egy email kliensben. Szóval szeretnénk is levelet küldeni, meg nem is.

Ha szerencsénk van, akkor az általunk használt keretrendszer vagy library beállításai között van valami olyasmi, hogy SMTP szerverhez csatlakozás helyett csak egy fájlba írja ki a levél tartalmát (pl. Django-ban az EMAIL_BACKEND beállítás).
Így már nem kell kézzel összevadásznunk, hogy mi volt az aktivációs link, amit az új regisztrálónak kiküldött (volna) a rendszer, de ez még nem ad választ arra, hogy "Hogyan néz ki a levél Outlook 97-ben?".

A konténerek itt is a segítségünkre lehetnek. Felhúzhatunk egy olyan SMTP szervert, ami címzettől függetlenül az összes megkapott levelet egy lokális fiókba kézbesíti és egy POP3 vagy IMAP szervert, amihez csatlakozva ezeket a leveleket meg is nézhetjük.

Nem hangzik túl bonyolultan, de azért megvannak a maga kis buktatói. Nézzük először az SMTP szervert.

smtp/Dockerfile
FROM alpine:3.5

RUN apk add --no-cache postfix postfix-pcre rsyslog supervisor

RUN adduser -u 1000 -D dev && \
    mkdir -p /home/dev/Maildir && \
    chown -R dev:dev /home/dev

COPY ./etc /etc

RUN touch /etc/aliases && \
    postalias /etc/aliases

EXPOSE 25

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

A postfix már önmagában is egy trükkös jószág. Nem egyszerű rábírni, hogy standard output-ra logoljon, vagy hogy éppen az előtérben maradjon. Így szükség van mellé még egy rsyslog-ra és egy supervisor-ra is. Az rsyslog-nak megmondhatjuk, hogy küldjön mindent az stdout-ra. A supervisor pedig van olyan kedves és kérésre az előtérben marad (és mellesleg kezeli a másik két alkalmazás elindítását is).

Ezen kívül még létrehozunk egy dev felhasználót. Ő fogja az összes levelet megkapni a Maildir könyvtárába. Meg persze bemásolunk még egy rakás konfig fájlt, ami nem különösebben érdekes, úgyhogy a szokott helyen meg lehet őket tekinteni. Térjünk át az IMAP szerverre.

imap/Dockerfile
FROM alpine:3.5

RUN apk add --no-cache dovecot

RUN adduser -u 1000 -D dev && \
    mkdir -p /home/dev/Maildir && \
    chown -R dev:dev /home/dev

COPY ./etc /etc

EXPOSE 143

CMD ["/usr/sbin/dovecot", "-F"]

Jól látható, hogy a dovecot egy fokkal jobban viseli a konténerizálást. A dev felhasználót itt is létrehozzuk, ugyanazzal az UID-dal, mint a másik konténerben. A beállításokból csak annyit emelnék ki, hogy egy felhasználó lett megadva dev néven, aki dev jelszóval tud belépni.

Nem maradt más hátra, mint a docker-compose.yml fájl, ami az egészet összefogja.

docker-compose.yml
version: "3"
volumes:
  mail-data:
services:
  app:
    image: python:2.7-alpine
    depends_on:
      - smtp
    volumes:
      - ./app:/app
    working_dir: /app
  smtp:
    build: ./smtp
    depends_on:
      - imap
    volumes:
      - mail-data:/home/dev/Maildir
  imap:
    build: ./imap
    volumes:
      - mail-data:/home/dev/Maildir
    ports:
      - "143:143"

A dev felhasználó Maildir könyvtára meg van osztva az SMTP- és az IMAP service között, ami biztos nem egy optimális megoldás, de a célnak megfelel. Az app service segítségével pedig tudunk küldeni teszt levelet.

$ docker-compose run --rm app python send_mail.py

Amit aztán kedvenc IMAP kliensünkkel el tudunk olvasni a localhost:143-ra csatlakozva.

Fejlesszünk konténerben II.

El is érkeztünk a második részhez, amiben egy kis alkalmazást fogunk összerakni külön kliens és szerver oldali konténerrel. Aki esetleg lemaradt volna az előző részről, itt tudja pótolni.

A szerver oldalt a változatosság kedvéért most a Python fogja képviselni a Flask keretrendszerrel és az SQLAlchemy ORM-mel. A kliens oldalon a Mithril.js keretrendszer lesz segítségünkre a JavaScript-ben, a CSS-t pedig a Milligram szolgáltatja. A függőségek beszerzéséről az npm gondoskodik és végül az egészet a Webpack segítségével fogjuk egybegyúrni. A kliens oldal szereti a sok szereplőt. No de vágjunk is bele.

Az adatbázis

Egy hasonló példával fejeztük be az előző részt, úgyhogy ebben nem lesz sok újdonság:

docker-compose.yml
version: "3"
volumes:
  database-data:
services:
  database:
    image: mysql:5.7
    volumes:
      - database-data:/data
    environment:
      MYSQL_ROOT_PASSWORD: test
      MYSQL_DATABASE: test
      MYSQL_USER: test
      MYSQL_PASSWORD: test

Ha valamilyen oknál fogva közelebbről is meg szeretnénk vizsgálni ezt az adatbázist és a tartalmát, azt a

docker-compose run --rm database mysql -hdatabase -utest -ptest test

paranccsal tehetjük meg. Amennyiben ez galád módon olyasmire panaszkodik, hogy nem ismeri a "database" host-ot, akkor futtassunk egy ilyen parancsot is és próbáljuk újra:

docker-compose start database

A szerver oldal

A Python csomagkezelés egy kicsit eltér a PHP-ban vagy Node.js-ben megszokottól. Docker nélkül valószínűleg Virtualenv-et használnánk és azon belül telepítenénk a függőségeket, de így a legegyszerűbb az lesz, ha a Dockerfile-ban adjuk meg és a konténer build-elése során települnek majd.

server/Dockerfile
FROM python:3.6-alpine
RUN pip install Flask SQLAlchemy Flask-SQLAlchemy MySQL-Connector
WORKDIR /app/src
EXPOSE 5000
CMD ["flask", "run", "--host=0.0.0.0"]

A Flask alkalmazás az 5000-es porton fog alapból figyelni, úgyhogy ezt jelezzük a Docker felé is. A compose fájl services része pedig a következőkkel egészül ki:

docker-compose.yml
server:
  build: ./server
  depends_on:
    - database
  volumes:
    - ./server:/app
  environment:
    PYTHONDONTWRITEBYTECODE: 1
    FLASK_APP: /app/src/app.py
    DATABASE_URI: mysql+mysqlconnector://test:test@database/test

Környezeti változók segítségével megmondjuk a Python-nak, hogy ne generáljon .pyc és .pyo fájlokat, a flask run-nak, hogy hol találja az alkalmazásunkat, az alkalmazásunknak pedig azt, hogy hogyan tud csatlakozni az adatbázishoz.

A teljes alkalmazás kódjától megkímélem az olvasókat, az a kapcsolódó GitHub repo-ban megtekinthető az összes korábbi példával együtt. Egy apró részletet emelnék csak ki:

server/src/app.py
# az alkalmazás többi részének a helye

if __name__ == '__main__':
    db.create_all()

Ez egy kis egyszerűsítés, hogy könnyebben létre tudjuk hozni az adatbázis szerkezetet a frissen felhúzott MySQL-ben, amit így az alábbi módon tudunk megtenni:

docker-compose run --rm server python -m app

A kliens oldal

Hirtelen nem is tudom hol kezdjem, lesz dolgunk bőven. Talán essünk túl gyorsan a függőségeken:

client/package.json
{
  "devDependencies": {
    "babel-core": "^6.23.1",
    "babel-loader": "^6.3.2",
    "babel-preset-es2015": "^6.22.0",
    "css-loader": "^0.26.1",
    "milligram": "^1.3.0",
    "mithril": "^1.0.1",
    "style-loader": "^0.13.1",
    "webpack": "^2.2.1",
    "webpack-dev-server": "^2.3.0"
  }
}

Az első változatban még React-ot használtam, a hozzá tartozó babel csomaggal, egy külön HTTP klienssel és még Less-t is fordítottam.

Aztán megpróbáltam egyszerűsíteni egy kicsit a dolgokon, aminek ez lett az eredménye. Így a client könyvtárban kiadott npm install parancs már csak a fél világot fogja letölteni nekünk, nem az egészet. Amíg ez megtörténik, térjünk át a webpack konfigurálására.

client/webpack.config.js
const path = require('path');
const webpack = require('webpack');

module.exports = {
  entry: './src/app.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/dist/'
  }
};

Nem kell megijedni, ez nem a teljes fájl. Ennyivel nem ússzuk meg. Első körben csak megmondjuk a webpack-nak, hogy hol kezdődik az alkalmazásunk és hova szeretnénk tenni a végterméket. A következő lépésben beállítjuk, hogy mit kezdjen a CSS és JS fájlokkal:

client/webpack.config.js
module.exports = {
  // a korábbi beállítások helye

  module: {
    rules: [
      {
        test: /\.css$/,
        use: [ 'style-loader', 'css-loader' ]
      },
      {
        test: /\.js$/,
        use: [
          { loader: 'babel-loader', options: { presets: [ 'es2015' ] } }
        ]
      }
    ]
  }
};

Ebben a formában a CSS is a JS kódba fog belekerülni, ami azt eredményezi, hogy az oldal CSS nélkül jelenik meg egy pillanatra a betöltődés során. Egy példaalkalmazásnál azt hiszem ezzel együtt lehet élni, de még több függőség behúzásával természetesen ez a probléma is megoldható.

Végül, de nem utolsó sorában hátra van még a dev szerver konfigurálása. Itt van egy olyan huncutság, hogy a /api/ kezdetű kéréseket átdobjuk a Python szervernek, hogy ő kezdjen velük valamit.

client/webpack.config.js
module.exports = {
  // a korábbi beállítások helye

  devServer: {
    host: '0.0.0.0',
    proxy: {
      '/api/*': {
        target: 'http://server:5000',
        pathRewrite: { '^/api' : '' }
      }
    }
  }
};

Ha szerencsénk van, mostanra végzett az npm install, úgyhogy át is térhetünk a dolgok konténeres részére. Végül is ez lenne a fő téma.

client/Dockerfile
FROM node:6.9-alpine
WORKDIR /app
EXPOSE 8080
CMD ["/app/node_modules/.bin/webpack-dev-server"]

Semmi extra, a webpack dev szerverét fogjuk futtatni a konténerben, amit a 8080-as portra konfiguráltunk be az előző lépésekben. Természetesen a compose fájl services részét is ki kell még egészíteni.

docker-compose.yml
client:
  build: ./client
  depends_on:
    - server
  ports:
    - 8080:8080
  volumes:
    - ./client:/app

A kliens konténer függ a szerver konténertől, hogy a webpack dev szerverben beállított proxy-zás tudjon működni. A host 8080-as portját pedig bekötjük a konténer 8080-as portjára. Ezt a szerver konténer esetén nem volt szükséges, mivel csak a kliens konténeren keresztül fogunk vele kommunikálni.

Az alkalmazás kódtól itt is eltekintenék, a repóban megtalálható. Ha minden jól ment, egy docker-compose up kiadása után a http://127.0.0.1:8080/-t meglátogatva valami ilyesmit kellene látnunk:

Ezzel a végére is értünk a második résznek. A következő részben a Docker Compose néhány kevésbé szokványos felhasználását fogjuk megvizsgálni.

Összes bejegyzés