In a recent project I was tasked with creating a local testing environment for a Kubernetes cluster the client was actively working on. The main requirements were:
- Cross platform: It should work on Linux and macOS.
- Ease of use: The setup should be easy to use.
- Suitable for CI integration: It should be possible to use the setup as part of a CI pipeline.
Two options came to mind, both of which had to be dismissed:
- Minikube: Minikube is a well established tool for local Kubernetes clusters but its reliance on a hypervisor rules it out.
- NixOS/QEMU: NixOS makes it easy to build and start arbitrary configurations via QEMU but this obviously is not cross-platform and thus not an option.
Instead we’ll use kind, which:
- Depends on Docker only
- Works on Linux, macOS and even Windows
- Supports multi-node (including HA) clusters
In the following I will guide you through an example project which will illustrate how kind can be combined with KubeNix to develop correct, easy to maintain, and easy to test Kubernetes deployments. This will include:
- implementing and nixifying a simple NodeJS service,
- using Nix tooling to build minimal Docker images,
- using kind to boot up a Kubernetes cluster with minimal effort,
- using KubeNix to catch common configuration errors early, eschew copypasta and make configurations composable,
- deploying the configuration created by KubeNix to the kind cluster.
Note: What I am presenting is for motivational purposes and you should certainly put more thought into your setup if you want to take this approach to production. The full source code of this project is available on GitHub.
Implementing a simple nodejs service
The first thing we need is a service to actually deploy to Kubernetes. The service itself is mostly irrelevant for our purposes so
it will just be an Express based JavaScript app that returns “Hello World” on a port that can be configured via the environment
variable APP_PORT
:
#!/usr/bin/env node
const express = require("express")
const app = express()
const port = process.env.APP_PORT ? process.env.APP_PORT : 3000
app.get("/", (req, res) => res.send("Hello World"))
app.listen(port, () => console.log(`Listening on port ${port}`))
Nixifying the service
In order to nixify the little hello-app
I am going to
use yarn2nix:
pkgs.yarn2nix.mkYarnPackage {
name = "hello-app";
src = ./.;
packageJson = ./package.json;
yarnLock = ./yarn.lock;
}
I made sure to add "bin": "index.js"
to package.json
so that mkYarnPackage
will put index.js
in the bin
path of the resulting output.
Thanks to the shebang #!/usr/bin/env node
in index.js
Nix is able to figure out that Node is a runtime dependency of hello-app
all by its own.
Using Nix to build Docker images
Kubernetes runs Docker images, so the little Express service has to be dockerized. The traditional way to achieve this would be to write
a Dockerfile
. Nix, however, provides more convenient and declarative tooling for building Docker images.
The snippet below shows how to use dockerTools.buildLayeredImage
to build a minimal docker image containing our app and nodejs:
pkgs.dockerTools.buildLayeredImage {
name = "hello-app";
tag = "latest";
config.Cmd = [ "${helloApp}/bin/hello-app" ];
}
The snippet above generates a Docker archive - a .tar.gz
file which could be loaded into the Docker daemon using docker load
. Notice
how the only arguments given are the image name, the image tag and command to be executed in the container. There is no need to
provide a sequence of commands to populate the container and no explicit contents configuration either. Instead, the contents can
be automatically derived from config.Cmd
: The image will include everything that is required to execute hello-app
- i.e the
closure of the helloApp
derivation. This also explains why there is no need to specify a base image (FROM node:10
in a Dockerfile):
The helloApp
derivation brings along nodejs as runtime dependency.
Much more could be said about the benefits of this way to build Docker images but it would go beyond the scope of this blog post. Suffice to say that with 4 lines of code, no additional tooling and no reliance on external base images I’m able to create a Docker image that contains only exactly what is needed.
kind: Kubernetes clusters in Docker
kind is a portable (Linux, macOS and Windows) solution to running Kubernetes clusters locally, in a Docker container. The project is still young but it is getting a lot of support and works very well already:
$ kind create cluster
Creating cluster "kind" ...
✓ Ensuring node image (kindest/node:v1.13.3) 🖼
✓ [control-plane] Creating node container 📦
✓ [control-plane] Fixing mounts 🗻
✓ [control-plane] Configuring proxy 🐋
✓ [control-plane] Starting systemd 🖥
✓ [control-plane] Waiting for docker to be ready 🐋
✓ [control-plane] Pre-loading images 🐋
✓ [control-plane] Creating the kubeadm config file ⛵
✓ [control-plane] Starting Kubernetes (this may take a minute) ☸
Cluster creation complete. You can now use the cluster with:
export KUBECONFIG="$(kind get kubeconfig-path --name="kind")"
kubectl cluster-info
The above command takes roughly 35sec on my Dell XPS laptop. After that, the cluster is up and running and you can interact with it
via kubectl
once you set the KUBECONFIG
as described in the output above. Clusters can of course also be deleted again using
kind delete cluster
. This is almost everything that needs to be said about kind at this point - Later on I will also mention the
ability to preload Docker images using kind load
but this isn’t yet relevant.
What is important is the fact that creating, deleting and interacting with Kubernetes clusters via kind is trivial, has no dependencies beyond Docker and thus works on Linux, macOS and Windows.
KubeNix: Validation for free and no YAML in sight either
The KubeNix parses a Kubernetes configuration in Nix and validates it against the official Swagger specification of the designated Kubernetes version. Furthermore it changes the way in which you can work with, and organize your deployment configuration.
With deployments configured in JSON or YAML, files are the smallest unit to work with. There is little that can be done in terms of reuse, composition or abstraction in general. This leads to a lot of redundancy and often big files that are error-prone to work with. I don’t like repeating myself and I also hate typos in labels breaking references between services and pods. Using Nix I can avoid this or at the very least turn runtime errors into compile-time errors:
{ type ? "dev" }:
let
kubeVersion = "1.11";
helloApp = rec {
label = "hello";
port = 3000;
cpu = if type == "dev" then "100m" else "1000m";
imagePolicy = if type == "dev" then "Never" else "IfNotPresent";
env = [{ name = "APP_PORT"; value = "${toString port}"; }];
};
in
{
kubernetes.version = kubeVersion;
kubernetes.resources.deployments."${helloApp.label}" = {
metadata.labels.app = helloApp.label;
spec = {
replicas = 1;
selector.matchLabels.app = helloApp.label;
template = {
metadata.labels.app = helloApp.label;
spec.containers."${helloApp.label}" = {
name = "${helloApp.label}";
image = "hello-app:latest";
imagePullPolicy = helloApp.imagePolicy;
env = helloApp.env;
resources.requests.cpu = helloApp.cpu;
ports."${toString helloApp.port}" = {};
};
};
};
};
kubernetes.resources.services."${helloApp.label}" = {
spec.selector.app = "${helloApp.label}";
spec.ports."${toString helloApp.port}".targetPort = helloApp.port;
};
}
configuration.nix
actually contains a function that takes a type
argument which is used for augmenting the requested resources of
the deployment. This is just a motivating example, but it would also be possible to split bigger configurations into
production.nix
and development.nix
, which both import settings from generic.nix
. The best solution is the one that works best
for your setup and requirements. The very fact that there are now different options to pick from is an advantage over being restricted
to a bunch of YAML files.
Applying a configuration
Despite all the benefits of using Nix to describe deployments that were outlined above, kubectl
still only consumes JSON or YAML input.
The default.nix in the root of the example project defines a
function that uses kubenix.buildResources
to generate schema-validated JSON output that can be fed into kubectl
:
{
buildConfig = t: kubenix.buildResources { configuration = import ./configuration.nix { type = t; }; };
}
The deploy-to-kind script uses the output
of buildConfig "dev"
and pipes it into kubectl
. You can try it by entering the Nix shell at the root of the project:
$ nix-shell
$ deploy-to-kind
One thing worth mentioning about this: The dockerized hello
service is a Docker archive, a local .tar.gz
archive. When Kubernetes
is asked to apply a hello-app:latest
image it will try to fetch it from somewhere. To avoid that from happening we have to do two things:
- Tell Kubernetes to never pull the image: configuration.nix
- Make the image available using
kind load image-archive
: nix/deploy-to-kind.nix
With that in place the deployment will work just fine.
Finishing Up
The default.nix
of the project exposes the following attributes:
app
: The nodejs service. It can be build vianix-build -A app
.deploy-to-kind
: A script that starts a kind cluster and deploysconfiguration.nix
.test-deployment
: A script that implements a very simplistic smoke test to check if our app is up and working.deploy-and-test
: Running this shell vianix-shell -A deploy-and-test default.nix
will deploy, wait for the deployment and finally test it.shell
: Started vianix-shell
this shell provides all required inputs for manually deploying and testing.
Conclusions
- kind is an easy to use, cross-platform solution for running local Kubernetes clusters with minimal runtime dependencies.
- KubeNix makes working with Kubernetes configuration files more convenient and safer at the same time (more errors are caught early).
- Nix is a powerful framework with which all the moving parts of a project can be orchestrated. From the provisoning of all required run and build-time dependencies to the streamliend creation of Docker images, Nix is the underlying concept making KubeNix possible in the first place.
Notes:
- The version of
kind
used in this project is built from the master revision at the time of writing. The latest release doesn’t include thekind load
functionality. - KubeNix currently doesn’t have any documentation but a major overhaul with great features is in the works. Follow KubeNix refactoring for details.
- I used wait-for-deployment - a nice little bash script - to wait for the completion of the deployment.
- yarn2nix might be removed from Nixpkgs soon. Once that happens there are still various ways to continue using it, but the code presented here is not going to work as-is anymore.