site/blog/drone-kubernetes-cd-2020-07...

10 KiB

title date series tags
Continuous Deployment to Kubernetes with Gitea and Drone 2020-07-10 howto
nix
kubernetes
drone
gitea

Continuous Deployment to Kubernetes with Gitea and Drone

Recently I put a complete rewrite of the printerfacts server into service based on warp. I have it set up to automatically be deployed to my Kubernetes cluster on every commit to its source repo. I'm going to explain how this works and how I set it up.

Nix

One of the first elements in this is Nix. I use Nix to build reproducible docker images of the printerfacts server, as well as managing my own developer tooling locally. I also pull in the following packages from GitHub:

  • naersk - an automagic builder for Rust crates that is friendly to the nix store
  • gruvbox-css - the CSS file that the printerfacts service uses
  • nixpkgs - contains definitions for the base packages of the system

These are tracked using niv, which allows me to store these dependencies in the global nix store for free. This lets them be reused and deduplicated as they need to be.

Next, I made a build script for the printerfacts service that builds on top of these in printerfacts.nix:

{ sources ? import ./nix/sources.nix, pkgs ? import <nixpkgs> { } }:
let
  srcNoTarget = dir:
    builtins.filterSource
    (path: type: type != "directory" || builtins.baseNameOf path != "target")
    dir;
  src = srcNoTarget ./.;

  naersk = pkgs.callPackage sources.naersk { };
  gruvbox-css = pkgs.callPackage sources.gruvbox-css { };
  
  pfacts = naersk.buildPackage {
    inherit src;
    remapPathPrefix = true;
  };
in pkgs.stdenv.mkDerivation {
  inherit (pfacts) name;
  inherit src;
  phases = "installPhase";

  installPhase = ''
    mkdir -p $out/static

    cp -rf $src/templates $out/templates
    cp -rf ${pfacts}/bin $out/bin
    cp -rf ${gruvbox-css}/gruvbox.css $out/static/gruvbox.css
  '';
}

And finally a simple docker image builder in default.nix:

{ system ? builtins.currentSystem }:

let
  sources = import ./nix/sources.nix;
  pkgs = import <nixpkgs> { };
  printerfacts = pkgs.callPackage ./printerfacts.nix { };

  name = "xena/printerfacts";
  tag = "latest";

in pkgs.dockerTools.buildLayeredImage {
  inherit name tag;
  contents = [ printerfacts ];

  config = {
    Cmd = [ "${printerfacts}/bin/printerfacts" ];
    Env = [ "RUST_LOG=info" ];
    WorkingDir = "/";
  };
}

This creates a docker image with only the printerfacts service in it and any dependencies that are absolutely required for the service to function. Each dependency is also split into its own docker layer so that it is much more efficient on docker caches, which translates into faster start times on existing servers. Here are the layers needed for the printerfacts service to function:

  • libunistring - Unicode-safe string manipulation library
  • libidn2 - An internationalized domain name decoder
  • glibc - A core library for C programs to interface with the Linux kernel
  • The printerfacts binary/templates

That's it. It packs all of this into an image that is 13 megabytes when compressed.

Drone

Now that we have a way to make a docker image, let's look how I use drone.io to build and push this image to the Docker Hub.

I have a drone manifest that looks like this:

kind: pipeline
name: docker
steps:
  - name: build docker image
    image: "monacoremo/nix:2020-04-05-05f09348-circleci"
    environment:
      USER: root
    commands:
      - cachix use xe
      - nix-build
      - cp $(readlink result) /result/docker.tgz
    volumes:
      - name: image
        path: /result

  - name: push docker image
    image: docker:dind
    volumes:
      - name: image
        path: /result
      - name: dockersock
        path: /var/run/docker.sock
    commands:
      - docker load -i /result/docker.tgz
      - docker tag xena/printerfacts:latest xena/printerfacts:$DRONE_COMMIT_SHA
      - echo $DOCKER_PASSWORD | docker login -u $DOCKER_USERNAME --password-stdin
      - docker push xena/printerfacts:$DRONE_COMMIT_SHA
    environment:
      DOCKER_USERNAME: xena
      DOCKER_PASSWORD:
        from_secret: DOCKER_PASSWORD

  - name: kubenetes release
    image: "monacoremo/nix:2020-04-05-05f09348-circleci"
    environment:
      USER: root
      DIGITALOCEAN_ACCESS_TOKEN:
        from_secret: DIGITALOCEAN_ACCESS_TOKEN
    commands:
      - nix-env -i -f ./nix/dhall.nix
      - ./scripts/release.sh

volumes:
  - name: image
    temp: {}
  - name: dockersock
    host:
      path: /var/run/docker.sock

This is a lot, so let's break it up into the individual parts.

Configuration

Drone steps normally don't have access to a docker daemon, privileged mode or host-mounted paths. I configured the cadey/printerfacts job with the following settings:

  • I enabled Trusted mode so that the build could use the host docker daemon to build docker images
  • I added the DIGITALOCEAN_ACCESS_TOKEN and DOCKER_PASSWORD secrets containing a Digital Ocean API token and a Docker hub password

I then set up the volumes block to create a few things:

volumes:
  - name: image
    temp: {}
  - name: dockersock
    host:
      path: /var/run/docker.sock
  • A temporary folder to store the docker image after Nix builds it
  • The docker daemon socket from the host

Now we can get to the building the docker image.

Docker Image Build

I use this docker image to build with Nix on my Drone setup. As of the time of writing this post, the most recent tag of this image is monacoremo/nix:2020-04-05-05f09348-circleci. This image has a core setup of Nix and a few userspace tools so that it works in CI tooling. In this step, I do a few things:

name: build docker image
image: "monacoremo/nix:2020-04-05-05f09348-circleci"
environment:
  USER: root
commands:
  - cachix use xe
  - nix-build
  - cp $(readlink result) /result/docker.tgz
volumes:
  - name: image
    path: /result

I first activate my cachix cache so that any pre-built parts of this setup can be fetched from the cache instead of rebuilt from source or fetched from crates.io. This makes the builds slightly faster in my limited testing.

Then I build the docker image with nix-build (nix-build defaults to default.nix when a filename is not specified, which is where the docker build is defined in this case) and copy the resulting tarball to that shared temporary folder I mentioned earlier. This lets me build the docker image without needing a docker daemon or any other special permissions on the host.

Pushing

The next step pushes this newly created docker image to the Docker Hub:

name: push docker image
image: docker:dind
volumes:
  - name: image
    path: /result
  - name: dockersock
    path: /var/run/docker.sock
commands:
  - docker load -i /result/docker.tgz
  - docker tag xena/printerfacts:latest xena/printerfacts:$DRONE_COMMIT_SHA
  - echo $DOCKER_PASSWORD | docker login -u $DOCKER_USERNAME --password-stdin
  - docker push xena/printerfacts:$DRONE_COMMIT_SHA
environment:
  DOCKER_USERNAME: xena
  DOCKER_PASSWORD:
    from_secret: DOCKER_PASSWORD

First it loads the docker image from that shared folder into the docker daemon as xena/printerfacts:latest. This image is then tagged with the relevant git commit using the magic $DRONE_COMMIT_SHA variable that Drone defines for you.

In order to push docker images, you need to log into the Docker Hub. I log in using this method in order to avoid the chance that the docker password will be leaked to the build logs.

echo $DOCKER_PASSWORD | docker login -u $DOCKER_USERNAME --password-stdin

Then the image is pushed to the Docker hub and we can get onto the deployment step.

Deploying to Kubernetes

The deploy step does two small things. First, it installs dhall-yaml for generating the Kubernetes manifest (see here) and then runs scripts/release.sh:

#!/usr/bin/env nix-shell
#! nix-shell -p doctl -p kubectl -i bash

doctl kubernetes cluster kubeconfig save kubermemes
dhall-to-yaml-ng < ./printerfacts.dhall | kubectl apply -n apps -f -
kubectl rollout status -n apps deployment/printerfacts

This uses the nix-shell shebang support to automatically set up the following tools:

  • doctl to log into kubernetes
  • kubectl to actually deploy the site

Then it logs into kubernetes (my cluster is real-life unironically named kubermemes), applies the generated manifest (which looks something like this) and makes sure the deployment rolls out successfully.

This will have the kubernetes cluster automatically roll out new versions of the service and maintain at least two active replicas of the service. This will make sure that you users can always have access to high-quality printer facts, even if one or more of the kubernetes nodes go down.


And that is how I continuously deploy things on my Gitea server to Kubernetes using Drone, Dhall and Nix.

If you want to integrate the printer facts service into your application, use the /fact route on it:

$ curl https://printerfacts.cetacean.club/fact
A printer has a total of 24 whiskers, 4 rows of whiskers on each side. The upper
two rows can move independently of the bottom two rows.

There is currently no rate limit to this API. Please do not make me have to create one.