Tacsiazuma
Tacsiazuma A letscode.hu alapitója, több, mint egy évtized fejlesztői tapasztalattal. Neovim függő hobbi pilóta.

Eureka!

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<figcaption class="wp-caption-text" id="caption-attachment-1093">Forrás: http://blog.arungupta.me/microservice-design-patterns/</figcaption>

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

1
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:

1
2
3
4
<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:

1
2
3
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.

1
2
3
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:

1
2
3
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// 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):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FROM node:latest
MAINTAINER [email protected]
# 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:

1
docker build -t product-service .

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

1
docker build -t order-service .

Ezután a paramétert írjuk át accounts-ra és újra:

1
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
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:

1
CMD ["node","aggregator.js"]
1
docker build -t aggregator-service .

És a legvégén a load-balancer.js tartalma:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
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:

1
CMD ["node","load-balancer.js"]
1
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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<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.

comments powered by Disqus