17 Apr

Eureka!


Az elmúlt pár cikkben igyekeztem meglovagolni én is azt a hype-ot, ami a microservice architektúrával kapcsolatos, de eddig eléggé elméleti megközelítése volt a dolgoknak, így gondoltam most kicsit váltok és egy saját kis példát dobok össze, hogy szemléltessem a dolgot.

Amire a cikkben lévő példához szükség lesz:

  • docker (erről itt olvashattok)

  • némi kakaó a gépben, amin csináljátok, mert pár szolgáltatást feldobunk rá (nem, most nem AWS példa lesz, de igény szerint majd csinálok azt is), név szerint egy Eureka fog futni, 3x3 darab egyszerűbb Node alkalmazás, 3 key-value store, meg 3 MySQL (sima, nem cluster).

  • Node és npm.

  • Egy shell, ahol fel tudod tolni a szükséges initial SQL szerkezetet és dummy tartalmat (vagy myadmin, ha az jobban tetszik :) )

  • Ha lusta vagy gépelni, akkor példakód letöltéséhez git :)


    microservices-aggregator-1024x528

    Forrás: http://blog.arungupta.me/microservice-design-patterns/



Akkor az első amire szükségünk lesz, hogy összetartsuk a fenti rendszert, az az Eureka lesz, aminek a telepítésével és hasonlókkal most minimálisan fogunk foglalkozni, mert Dockerben fogjuk azt futtatni, amivel a beröffentése leegyszerűsödik egy szimpla
docker run -d --name eureka-server -p 32784:8080 netflixoss/eureka:1.1.142

-ra.
Akinek nem lenne tiszta mi is ez az Eureka: Ez egy ún. service registry, ahova a különböző service-eink beregisztrálnak, aztán meghatározott időközönként küldenek életjelet magukról. Az eureka lesz az, amit aztán kérdezgetnek a service-ek, ha valamelyik másik service hol- és mibenlétéről akarnak infót. Így elkerülhető az, hogy a service-einkbe beleégessük a többiek elérését, csupán a nevükre van szükség, ami alapján az eureka-tól ki tudja annak elérését kérni.

A fenti parancs lehúzza a dockerhubról az eureka image-ét, elindítja azt eureka-server néven, mégpedig daemonként a háttérben és a 8080-as belső portra a docker0 interface 32784-es portja fog mutatni. Kell neki egy kis idő mire észhez tér, de kb. 1 perc múlva már le tudjuk tesztelni. Ha nem tudjuk, hogy hol is találhato a docker0 interface, akkor az ifconfig-al elő tudjuk halászni. Ha megvan, akkor nézzük meg, hogy működik-e a dolog. Indítsunk egy GET kérést az /eureka/v2/apps endpointra. A válaszban a következőt kellene kapjuk:
<applications>
 <versions__delta>1</versions__delta>
 <apps__hashcode></apps__hashcode>
</applications>

Üres, lévén még semmi sincs felregisztrálva oda.

Akkor jöjjenek a MySQL-ek:
docker run -d -p 12805:3306 --name accounts_mysql -e MYSQL_ROOT_PASSWORD=password mysql:latest
docker run -d -p 12806:3306 --name products_mysql -e MYSQL_ROOT_PASSWORD=password mysql:latest
docker run -d -p 12807:3306 --name orders_mysql -e MYSQL_ROOT_PASSWORD=password mysql:latest

A fenti három konténerben futnak majd elkülönítve a három külön service-t kiszolgáló MySQL-ek. Alapból expose-olják a 3306-os portot, amivel docker style össze is linkelhetnénk őket a service-ekkel, de most ennyire nem mennék bele. A lényeg, hogy ezek is a 12805-12807-es portokon elérhetőek lesznek a docker0 interface-en.

Töltsük is fel dummy adatokkal őket! A szükséges SQL dumpok megtalálhatóak a repo-ban.
cat accounts.sql | mysql --host={DOCKER0_IF} --port=12805 --password=password
cat products.sql | mysql --host={DOCKER0_IF} --port=12806 --password=password
cat orders.sql | mysql --host={DOCKER0_IF} --port=12807 --password=password

Akkor most jöjjön a cache réteg:
docker run -d -p 10564:6379 --name accounts-redis redis
docker run -d -p 10565:6379 --name products-redis redis
docker run -d -p 10566:6379 --name orders-redis redis

Persze azt mindig tartsuk észben, hogy ezek nem kötelezően egyazon gépen helyezkednek el, most csak a példa kedvéért van így

Akkor mostmár a data source réteg rendben, jöhetnek az alkalmazások, amik ezt használják!

Node.js-t fogunk használni az egyszerűség kedvéért, amihez ugye szükség lesz egy express-re, egy eureka, mysql, redis kliensre és indulhat is a menet!

Package.json tartalma:
{
  "name": "hello-eureka",
  "description": "Eureka test app",
  "version": "0.0.1",
  "private": true,
  "dependencies": {
    "express": "3.12.0",
    "eureka-js-client" : "2.4.0",
    "md5" : "2.*",
    "redis" : "2.6.0-1",
    "mysql" : "2.10.*"
  }
}

Ezzel már le is tudjuk húzni azt a pár függőséget, ami nekünk kell egy-egy service működéséhez. Az aggregátort és a load balancert majd külön tárgyaljuk. Az egyszerűség kedvéért, most egy igen egyszerű node app-ot rakunk össze, ami parancssori paraméter alapján dönti el, hogy a 3 sql/redis duóból melyikből is fog kiszolgálni, valamint itt lesz a load-balancer és a view aggregator is, de értelemszerűen ennyire nem egyszerű a helyzet a való életben :)

Service.js:
// a service típusa

var serviceType = process.argv.pop();

var port = 5000;

var express = require('express');
// eureka kliensünk

var eureka = require("eureka-js-client").Eureka;
// csak az egyedi hostname miatt

var md5 = require("md5");

// redis

var redis = require("redis");
var mysql = require("mysql");

// config

var config = require("./config").config[serviceType];
// eureka configja

var euConfig = require("./config").config.eureka;


var redisClient = redis.createClient({
    "host" : config.redis.host,
    "port" : config.redis.port
});

var connection = mysql.createConnection({
    port    : config.mysql.port,
    host     : config.mysql.host,
    user     : config.mysql.username,
    password : config.mysql.password,
    database : config.mysql.database
});

connection.connect(function(err) {
    if (err) {
        console.error('error connecting: ' + err.stack);
        return;
    }
    console.log('connected as id ' + connection.threadId);
});

var app = express();

var euClient = new eureka({
    // application instance information

    instance: {
        app: config.serviceName,
        hostName: md5(Date.now()),
        ipAddr: process.env.DOCKER_HOST,
        port: process.env.DOCKER_PORT,
        vipAddress: 'jq.test.something.com',
        dataCenterInfo: {
            name: 'MyOwn'
        }
    },
    eureka: {
        // eureka server host / port

        host: euConfig.host,
        port: euConfig.port
    }
});
// beregisztrálunk az eurekára és küldjük az életjeleket

euClient.start();

// ne vegyünk példát a szerkezetről, az anonymous function-öket kerüljük máskor

app.get('/', function (req, res) {
    // meglessük a cache-t

    redisClient.get("accounts", function(err, reply) {
        if (err != null || reply == null) {
    // nem visszük túlzásba a dolgokat, csak a példa kedvéért

            connection.query('SELECT * FROM ' + config.mysql.table, function(err, rows) {
                if (err) {
                    console.error('error connecting: ' + err.stack);
                    res.end();
                }
                console.log("sql-ből");
                res.end(JSON.stringify(rows));
                redisClient.set("accounts", JSON.stringify(rows));
            });
            return;
        }
        console.log("redis-ből");
        res.end(reply);
    });
});

app.listen(port);

module.exports = app;

Na most a fenti alkalmazásunkat akkor elemezgessük csak végig! Behúzzuk a szükséges függőségeket és konfigurációt, aztán felregisztrálunk az eurekára. Itt környezeti változókat fogunk majd használni, mert habár az app a konténeren belül az 5000-es porton csücsül, kívülről másik porton és címen fogjuk elérni. Ezeket a változókat majd a docker-el fogjuk átadni. Sajnos még nem találtam megoldást, hogy a docker átadná-e a dolgokat a konténernek, amivel megkönnyíthetné az életemet, de ha valaki tud ilyenről, az szóljon!

Nos ha beérkezik egy kérés a figyelt portra, akkor a hozzánk rendelt redis-ből megpróbáljuk előhalászni az adatokat, ha nem sikerült, akkor pedig szimplán lekérjük az összeset SQL-ből és elmentjük a cache-be, későbbi használatra. Pretty simple.

Önmagában nem is a service lesz itt a lényeg, szóval ezt próbáltam a lehető legegyszerűbbre hagyni.

A használt konfig (config.js):
var docker0 = "172.17.42.1"; // mivel minden a docker0-on csücsül, így egyszerűbb


exports.config = {
    eureka : {
        host : docker0,
        port : 32784
    },
    aggregator : {
        serviceName : "aggregatorService"
    },
    accounts : {
        serviceName : "accountService",
        mysql : {
            host : docker0,
            port : 12805,
            table : "accounts",
            database : "account",
            "username" : "root",
            "password" : "password"
        },
        redis : {
            host : docker0,
            port : 10564
        }
    },
    orders : {
        serviceName : "orderService",
        mysql : {
            host : docker0,
            port : 12807,
            table : "orders",
            database : "order",
            "username" : "root",
            "password" : "password"
        },
        redis : {
            host : docker0,
            port : 10565
        }
    },
    products : {
        serviceName : "productService",
        mysql : {
            host : docker0,
            port : 12806,
            table : "products",
            database : "product",
            "username" : "root",
            "password" : "password"
        },
        redis : {
            host : docker0,
            port : 10566
        }
    }
};

Szépen feldarabolva az egyes service-ekhez tartozó konfigurációk. Alapból mindenhova így adnánk meg az elérést, de most a service registry miatt nem kell annyira belemennünk.

Most, hogy a service-ek megvannak, nem árt beletennünk őket egy-egy konténerbe. Ehhez szükségünk lesz egy Dockerfile-ra:
FROM node:latest
MAINTAINER fejlesztes@letscode.hu
# set default workdir

WORKDIR /usr/src
# Add package.json to allow for caching

COPY package.json /usr/src/package.json
# Install app dependencies

RUN npm install
# Bundle app source and config

COPY config.js /usr/src/
COPY service.js /usr/src/
COPY load-balancer.js /usr/src/
COPY aggregator.js /usr/src/

# user to non-privileged user


USER nobody
# Expose the application port and run application

EXPOSE 5000
# Itt van az initial command

CMD ["node","service.js", "products"]

A fenti fájlt majd szükséges lesz módosítanunk az egyes buildek előtt, mert a service.js-nek átadott paraméter változni fog majd, de most írjuk be a következő parancsot:
docker build -t product-service .

Majd írjuk át a CMD sor utolsó paraméterét orders-re és futtassuk újra:
docker build -t order-service .

Ezután a paramétert írjuk át accounts-ra és újra:
docker build -t account-service .

Most, hogy a három kis kiszolgáló kész van, jöjjön az, ami egybevarázsolja a dolgokat, a view aggregátor. Ez is egy eléggé fapados cucc lesz, csak szemléltetni az aggregator.js:
var express = require('express');
// eureka kliensünk

var eureka = require("eureka-js-client").Eureka;
// csak az egyedi hostname miatt

var md5 = require("md5");
var config = require("./config").config.aggregator;

// eureka configja

var euConfig = require("./config").config.eureka;

var http = require("http");

var app = express();

var hostName = md5(Date.now());
var euClient = new eureka({
    // application instance information

    instance: {
        app: config.serviceName,
        hostName: hostName,
        ipAddr: process.env.DOCKER_HOST,
        port: process.env.DOCKER_PORT,
        vipAddress: 'jq.test.something.com',
        dataCenterInfo: {
            name: 'MyOwn'
        }
    },
    eureka: {
        // eureka server host / port

        host: euConfig.host,
        port: euConfig.port
    }
});

euClient.start();

// a kezdőindex

var i = 0;
// ez fog kidobni nekünk egy instance-t a service-ből

function getWorkingInstance(name) {
    var instances = euClient.getInstancesByAppId(name);
    var ret = [];

    if (instances) {
        // előszűrűnk, hogy csak a működőek legyenek benne

        instances.forEach(function(instance) {
            if (instance.status !== "UP") {
                return;
            }
            ret.push({
                // csak a host és a port érdekel minket

                "host" : instance.ipAddr,
                "port" : instance.port.$
            });
        });
    }
    i = (i >= ret.length -1) ? 0 : (i + 1);
    return ret[i];
}

function getPromiseWithData(hostPortConfig, fieldName) {
    return new Promise(function(resolve, reject) {
        var request = http.get({
            "host":  hostPortConfig.host,
            "port": hostPortConfig.port,
            "path": "/"
        }, function(response) {
            var data = "";
            response.on("data", function(chunk) {
                data += chunk;
            });
            response.on("end", function() {
                var response = {
                    "key" : fieldName,
                    "data" :JSON.parse(data)
                };
                resolve(response);
            });
        });
        request.on("error", function(err) {
            reject(err);
        });

        request.end();
    });
}

app.get("/", function(req, res) {
    var productService = getWorkingInstance("productService");
    var orderService = getWorkingInstance("orderService");
    var accountService = getWorkingInstance("accountService");

    var product = getPromiseWithData(productService, "products");
    var account = getPromiseWithData(accountService, "accounts");
    var order = getPromiseWithData(orderService, "orders");

    Promise.all([product, account, order]).then(function(values) {
        var responseObj = {};
        values.forEach(function(item) {
            responseObj.hostId = hostName;
            responseObj[item.key] = item.data;
        });
        res.end(JSON.stringify(responseObj));
    }, function(err) {
        console.log(err);
        res.end(JSON.stringify({
            "error" : "Sorry, we cant fulfill your request!"
        }));
    });
});

app.listen(5000);

module.exports = app;

Szintén az 5000-es porton ül belül. Feliratkozik az Eurekára, mint a többiek. Ha beérkezik egy lekérdezés, akkor meghívja az Eureka-t és kikéri a három service példányait. Azoknak egy-egy lekérést indít és a végén ezeket bevárva visszaad egy választ. Ha valamelyik hibára fut, akkor hibaüzenettel tér vissza (igen, egy fokkal szofisztikáltabb megoldás lenne, ha csak annak az egy service-nek az adatai hiányoznának a válaszból, tudom).

Ezután a paramétert írjuk át az utolsó sort így és újra:
CMD ["node","aggregator.js"]

docker build -t aggregator-service .

És a legvégén a load-balancer.js tartalma:
var http = require('http');
// eureka kliensünk

var eureka = require("eureka-js-client").Eureka;
// csak az egyedi hostname miatt

var md5 = require("md5");
// eureka configja

var euConfig = require("./config").config.eureka;

var euClient = new eureka({
    // application instance information

    instance: {
        app: "balancer",
        hostName: md5(Date.now()),
        ipAddr: '127.0.0.1',
        port: 5000,
        vipAddress: 'jq.test.something.com',
        dataCenterInfo: {
            name: 'MyOwn'
        }
    },
    eureka: {
        // eureka server host / port

        host: euConfig.host,
        port: euConfig.port
    }
});

euClient.start();

// a kezdőindex

var i = 0;
// ez fog kidobni nekünk egy instance-t a service-ből

function getWorkingInstance(name) {
    var instances = euClient.getInstancesByAppId(name);
    var ret = [];

    if (instances) {
        // előszűrűnk, hogy csak a működőek legyenek benne

        instances.forEach(function(instance) {
            if (instance.status !== "UP") {
                return;
            }
            ret.push({
                // csak a host és a port érdekel minket

                "host" : instance.ipAddr,
                "port" : instance.port.$
            });
        });
    }
    i = (i >= ret.length -1 ) ? 0 : (i + 1);
    return ret[i];
}

http.createServer(function (req, res) {
    var instance = getWorkingInstance("aggregatorService");
    var request = http.get({
        "host" : instance.host,
        "port" : instance.port
    },function(response) {
        response.pipe(res);
    });
    request.on("error", function() {
        res.end(JSON.stringify({
            "error" : "Unable to serve your request!"
        }));
    });

    request.end();
}).listen(5000);

és a hozzá tartozó buildfile módosítás:
CMD ["node","load-balancer.js"]

docker build -t balancer-service .

A fenti parancsok beleégetik az aktuális parancsok kimenetét egy image-be és azt megtag-elik a -t paraméterrel. Bizonyára megfigyeltétek a DOCKER_HOST és DOCKER_PORT környezeti változót. Ezt majd a run parancs során kell átadnunk a konténernek, ami alapján helyes elérési úttal regisztrál be az Eurekába.
Update: Erre a célra szolgál a start.sh a projekt gyökerében, hogy ne kelljen egyesével beírkálni a dolgokat.

Na de most nézzük, hogy mi is történik mikor beröffentjük azt?

Elindul 3 product, 3 order és 3 account service. Most az adatok mibenlétére ne térjünk ki, csak random belepakoltam ezt-azt a táblákba. Ezen felül van 2 view aggregator, ami a fenti 3 service közül hívogatja azokat, amik épp futnak. Aztán a hármas válaszát összesítve tér vissza. Valamint lesz még egy balancer, ami a két aggregátor közül választja ki az épp futót. Azért Node.JS van itt használva mert a két aggregátor is változó címen lehet, így Eureka-ból kéri le azokat is. Ez persze megoldható lenne Nginx dinamikus rekonfigurálásával, de az megint egy másik sztori :)

Viszont az az érzésem, hogy aki nem sűrűn foglalkozott ilyesmivel az kezdi elveszteni a fonalat, így jöjjön egy ábra az egészről:

servicek

Látható, hogy a rendszer pár része fix címen van, míg egy része dinamikus. Ez utóbbiak miatt van szükség az Eurekára igazából. Akkor most, hogy mindenki lehúzta a repóból a ccucot, nézzük mi is történik, ha az SQL-ek beoktrojálása után elindítjuk azt a bizonyos start.sh-t!

Lebuildelődnek a docker image-ek, aztán pedig el is indulnak a példányok. Ezután ha mindent jól csináltunk és nem dobott valami hibát a rendszer, akkor ha megnyitjuk a böngészőnkben a localhost:13344-et, akkor kihányja elénk a view aggregate tartalmát, vagyis működik a dolog!

Na de mi alapján is dolgozik mindez?

Ha rálesünk a http://172.17.42.1:32784/eureka/v2/apps/aggregatorservice -ra, akkor láthatjuk, hogy szépen itt van XML-ben, minden info az aggregatorservice-ről. Az Eureka REST API-ja sok infót tud nekünk szolgáltatni és a kliensek is ezt használják értelemszerűen. Látható hogy hány példány van, milyen IP-n, porton, milyen státusszal, de aki többet szeretne erről megtudni, annak itt a komplett doksi.
<application>
 <name>AGGREGATORSERVICE</name>
 <instance>
 <hostName>dd2a1d15f95e7b9c6026b5a70fcd07d3</hostName>
 <app>AGGREGATORSERVICE</app>
 <ipAddr>172.17.42.1</ipAddr>
 <status>UP</status>
 <overriddenstatus>UNKNOWN</overriddenstatus>
 <port enabled="true">13343</port>
 <securePort enabled="false">7002</securePort>
 <countryId>1</countryId>
 <dataCenterInfo class="com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo">
 <name>MyOwn</name>
 
 </dataCenterInfo>
 <leaseInfo>
 <renewalIntervalInSecs>30</renewalIntervalInSecs>
 <durationInSecs>90</durationInSecs>
 <registrationTimestamp>1460892987340</registrationTimestamp>
 <lastRenewalTimestamp>1460893497976</lastRenewalTimestamp>
 <evictionTimestamp>0</evictionTimestamp>
 <serviceUpTimestamp>1460892987238</serviceUpTimestamp>
 
 </leaseInfo>
 <metadata class="java.util.Collections$EmptyMap"/>
 <vipAddress>jq.test.something.com</vipAddress>
 <isCoordinatingDiscoveryServer>false</isCoordinatingDiscoveryServer>
 <lastUpdatedTimestamp>1460892987340</lastUpdatedTimestamp>
 <lastDirtyTimestamp>1460892987237</lastDirtyTimestamp>
 <actionType>ADDED</actionType>
 
 </instance>
 <instance>
 <hostName>d1e74f559e3a1766bab29d011fc3614e</hostName>
 <app>AGGREGATORSERVICE</app>
 <ipAddr>172.17.42.1</ipAddr>
 <status>UP</status>
 <overriddenstatus>UNKNOWN</overriddenstatus>
 <port enabled="true">13342</port>
 <securePort enabled="false">7002</securePort>
 <countryId>1</countryId>
 <dataCenterInfo class="com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo">
 <name>MyOwn</name>
 
 </dataCenterInfo>
 <leaseInfo>
 <renewalIntervalInSecs>30</renewalIntervalInSecs>
 <durationInSecs>90</durationInSecs>
 <registrationTimestamp>1460892986340</registrationTimestamp>
 <lastRenewalTimestamp>1460893496975</lastRenewalTimestamp>
 <evictionTimestamp>0</evictionTimestamp>
 <serviceUpTimestamp>1460892986312</serviceUpTimestamp>
 
 </leaseInfo>
 <metadata class="java.util.Collections$EmptyMap"/>
 <vipAddress>jq.test.something.com</vipAddress>
 <isCoordinatingDiscoveryServer>false</isCoordinatingDiscoveryServer>
 <lastUpdatedTimestamp>1460892986340</lastUpdatedTimestamp>
 <lastDirtyTimestamp>1460892986311</lastDirtyTimestamp>
 <actionType>ADDED</actionType>
 
 </instance>
</application>

Persze az Eureka önmagában még nem jelent semmit, mert jön az, ami az ilyen rendszerek lényege.. Mi van akkor, ha lehalnak a service-eink?:)  Na de erről majd legközelebb!
A példában szereplő fájlok megtalálhatóak githubon.

Hozzászólások betöltése
2014-2018 © Letscode.hu. Minden jog fenntartva. Build verzió: 1.2.11