Creating a test app for memcache on Kubernetes

Cloud Platform (intermediate level) posted on 15th August 2018


This is a part of the series of posts on Getting memcache up and running on Kubernetes which explained how to create your first cluster and Installing memcache with Kubernetes which installed some memcache instances on your cluster and Exposing a memcache loadbalancer which makes it available externally.

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 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 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.









Why not join our forum, follow the blog or follow me on twitter to ensure you get updates when they are available.
Comments