Creating a test App
The test App is going to be a small API that provides access to some of the features of Star wars API. I picked this one because it’s open and doesn’t need authentication. The objectives of the next few articles are
- Create an API external to Kubernetes that can use the memcache instances hosted on the Kubernetes cluster
- Create a containerized version of the APP and run it on Kubernetes.
- Run serverless versions of it
- Look at the difference between timings using and not using cache and across versions
- Create an Apps Script version
- Demonstrate cache sharing across each of these platforms
The App
You’ll find it on Github. Let’s just look at the main points, as it’s a standard Express app in most respects.
index.js
const express = require('express'); const routing = require('./src/routing'); const cacher = require('./src/cacher'); const fetcher = require('./src/fetcher'); // get the env or use a default const ip = process.env.IP || "0.0.0.0"; const port = process.env.PORT || 8081; const mode = process.env.MODE || "c9"; // start express const app = express(); // initliaze cache and fetcher cacher.init(mode); fetcher.init(mode); // set up routing routing.init(app); // start the server app.listen(port, ip);
The settings – are in secrets.js, which is not on GitHub – you’ll need to make your own. It looks like this.
module.exports = ((ns) => { ns.memcached = { defExpires: 30 * 60 * 2, maxExpires: 30 * 60 * 24, c9: { host: 'xxx.xxx.xxx.xxx:11211', // temporarly exposed for testing silo: 'some secret', verbose: true, }, ku: { host: 'mycache-memcached.default.svc.cluster.local:11211', silo: 'some secret', verbose: false } }; return ns; })({});
The c9 and ku properties are to support multiple versions and are the “mode” referred to in index.js. Notice that they are different host addresses depending on whether the app mode is inside (via the cluster internal network) or outside (via the loadbalancer service) the Kubernetes cluster. I also use a silo parameter to encode my keys, and also to be able to use the same cache server for different domains of work.
routing.js
const starwars = require('./starwars'); module.exports = ((ns) => { ns.init = app => { // routing app.get('/starwars/:resource/:id', function(req, res) { starwars.get(req.params.resource, req.params.id) .then(data => res.send(data)) .catch(err => res.status(500).send(err.Error)); }); app.get('/starwars/:resource', function(req, res) { starwars.search(req.params.resource, req.query.search) .then(data => res.send(data)) .catch(err => res.status(500).send(err.Error)); }); }; return ns; })({});
The API will support only 2 kinds of endpoints for now
- A search – for example /starwars/people?search=luke
- An ID get – for example /starwars/people/4
starwars.js
const fetcher = require('./fetcher'); module.exports = ((ns) => { // star wars root url ns.base = 'http://swapi.co/api/'; // do a search ns.search = (resource, query) => fetcher.get(`${ns.base}${resource}?search=${query}`); // do a get by id ns.get = (resource, id) => fetcher.get(`${ns.base}${resource}/${id}`); return ns; })({});
fetcher.js
const axios = require('axios'); const cacher = require('./cacher'); const secrets = require('./secrets'); module.exports = ((ns) => { ns.init = (mode) => { ns.verbose = secrets.memcached[mode].verbose; return ns; }; // wrapper to try cache first ns.cacheWrapper = (url, action) => cacher.get(url) .then(r => { return r || action(url).then(result => cacher.put(url, result)); }); // first attempt will be from cache ns.get = (url) => ns.timeWrapper(url, (url) => ns.getter(url)); // have to go to the api ns.getter = (url) => axios.get(url).then(result => result.data); ns.timeWrapper = (url, action) => { const now = new Date().getTime(); return ns.cacheWrapper(url, action) .then(r => { if (ns.verbose) { console.log(new Date().getTime() - now, "ms to complete"); } return r; }); }; return ns; })({});
Note that a get will be wrapped in a timer and a cache worker. This to make logging of timings straightforward, whereas the cache wrapper will first attempt to get the result from cache. If it fails it will do a regular fetch and write the result to the cache.
cacher.js
const hasher = require("object-hash"); const memjs = require('memjs'); const secrets = require('./secrets'); module.exports = ((ns) => { // initialize ns.init = (mode) => { // possible that memcached not supported const host = secrets.memcached[mode].host; ns.client = host ? memjs.Client.create(host) : null; // to seperate caches in different environments ns.silo = secrets.memcached[mode].silo; ns.verbose = secrets.memcached[mode].verbose; return ns; }; // try to get from cache ns.get = (key) => { // cache not supported if (!ns.client) return Promise.resolve(null); // normalize the key const hashKey = ns.getKey(key); // get it return new Promise((resolve, reject) => { ns.client.get(hashKey, (err, val) => { if (err) { reject(err); } else { if (val !== null) { try { const ob = JSON.parse(val.toString()); if (ns.verbose) console.log("hit:", key, hashKey); resolve(ob.value); } catch (err) { reject(err); } } else { resolve(null); } } }); }); }; /** put to memcached * @param {string} key the key * @param {object} vob the value object to store * @param {number} expires no of secs to expire * @return {string} result from memjs (true) */ ns.put = (key, vob, expires) => { // not every environment supports memcache if (!ns.client) return Promise.resolve(null); // dont bother registering undefined or null obs if (typeof vob === typeof undefined || vob === null) return Promise.resolve(null); // normalize the key const hashKey = ns.getKey(key); // set it return new Promise((resolve, reject) => { try { ns.client.set(hashKey, JSON.stringify({ value: vob }), ns.makeExpires(expires), (err, val) => { if (err) { reject(err); } else { if (ns.verbose) console.log("cached:", key, hashKey); resolve(vob); } }); } catch (err) { reject(err); } }); }; /**stats * @return {string} result from the memcache */ ns.stats = () => { // not every environment supports memcache if (!ns.client) return Promise.resolve(null); // set it return new Promise((resolve, reject) => { ns.client.stats((err, val, stats) => { if (err) { reject(err); } else { resolve(stats); } }); }); }; /**flush * @return {string} result from the memcache */ ns.flush = () => { // not every environment supports memcache if (!ns.client) return Promise.resolve(null); // set it return new Promise((resolve, reject) => { ns.client.flush((err, val) => { if (err) { reject(err); } else { resolve(val); } }); }); }; /** remove from memcached * @param {string} key * @return {object} result from the store */ ns.remove = (key) => { // not every environment supports memcache if (!ns.client) return Promise.resolve(null); // normalize the key const hashKey = ns.getKey(key); // set it return new Promise((resolve, reject) => { ns.client.delete(hashKey, (err, val) => { if (err) { reject(err); } else { if (val) { try { const ob = JSON.parse(val.toString()); if (ns.verbose) console.log("removed:", key, hashKey); resolve(ob.value); } catch (err) { reject(err); } } else { resolve(null); } } }); }); }; ns.makeExpires = (expires) => { if (!ns.client) return Promise.resolve(null); expires = typeof expires === typeof undefined ? secrets.memcached.defExpires : expires; if (expires > secrets.memcached.maxExpires) { console.log('expires ', expires, ' reduced to ', secrets.memcached.maxExpires); expires = secrets.memcached.maxExpires; } return { expires }; }; // standardize getting a key to use ns.getKey = (url) => hasher({ silo: ns.silo, url }); return ns; })({});
There’s quite a bit of code here for use further down the line, but for the demo we only really need to look at the get and put functions. In order to obfuscate the key I’m hashing the url with the silokey from secrets.js.
Dependencies
Here’s the package.json
{ "name": "mcdemo", "version": "1.0.0", "description": "memcache kubernetes demo", "main": "index.js", "repository": { "type": "git", "url": "https://github.com/brucemcpherson/mcdemo.git" }, "dependencies": { "axios": "^0.18.0", "express": "^4.16.2", "memjs": "^1.2.0", "object-hash": "^1.3.0" }, "scripts": { "start": "node index.js" }, "author": "bruce mcpherson", "license": "MIT" }
Does it work?
searching
https://fid-xlibersion.c9users.io:8080/starwars/people?search=luke

time without cache
897 ‘ms to complete’
timing with cache
hit: http://swapi.co/api/people?search=luke 25960109ae793d5f3e351a5fb946726963a35f7c
14 ‘ms to complete’
timing with cache (after stopping and starting the app)
hit: http://swapi.co/api/people?search=luke 25960109ae793d5f3e351a5fb946726963a35f7c
29 ‘ms to complete’
by id
https://fid-xlibersion.c9users.io:8080/starwars/vehicles/14
time without cache
837 ‘ms to complete’
timing with cache
hit: http://swapi.co/api/vehicles/14 e734c2859ba4cd5c71e900ac3e99c802fca94f8c
12 ‘ms to complete’
timing with cache (after stopping and starting the app)
hit: http://swapi.co/api/vehicles/14 e734c2859ba4cd5c71e900ac3e99c802fca94f8c
27 ‘ms to complete’
Next step
So that was quite a success, even though the API is running outside the Kubernetes cluster. Next we’ll make a container so it can be run inside the cluster.