No more pet build servers!
Have you ever spent several days setting up a build server? I’ve done this on quite a few projects and I can’t say I’ve ever particularly enjoyed the experience. I think there are a number of reasons for this, chief among them being:
- It takes quite a while to install some of the tooling, e.g. compilers
- If a tool doesn’t function correctly, I’m never quite sure if everything is cleanly removed when I uninstall
- Different tools sometimes clash and special configuration is needed to get them to live together on the same host
- I try to be a good boy and document everything I do, but sometimes I forget, or simply note something down wrong
All of this contributes to something I like to call “pet build server syndrome”. The phrase comes from the idea that a server that has been created and maintained over time without declarative configuration is a “pet”, whereas a server that can be recreated easily is just “cattle”. Check out the Puss in Boots slide in this article.
It’s not a nice feeling to know that your build server is crucial to your project and yet can’t be easily recreated if something goes wrong, or cloned if contributions to your project increase and you need more compute power. What we really need is some way to turn our build servers into cattle. Enter Concourse!
We’ve been using Concourse for several months now and have found it liberating to be able to recreate a build server from scratch in a matter of minutes and store our CI definition along with the code it builds and deploys. We had a few teething problems along the way, both because we were on a learning curve and also because we started using Concourse prior to version 1.0, but overall it has been a great experience. I’d find it hard to go back to non-declarative CI!
What is Concourse?
Concourse is a simple and scalable way to declare your continuous integration as code. It’s compelling because that means that the configuration of your build server is known and repeatable without manual intervention. You can tear down your CI infrastructure and recreate it in a matter of minutes rather than hours or days.
All of this is made possible by the magic of containers. The tooling installed on actual CI host machines should be restricted to Concourse itself. Anything else is wrapped up in images, e.g. compiler, unit test framework, Git. So rather than installing things on the build server, we encapsulate our tools in images and store them somewhere else, e.g. DockerHub.
A “traditional” build server might look something like this, with the build system installed onto the same file system as the tooling needed to actually do the builds:
In contrast, a build server running Concourse runs the tooling in containers, which have their own separate file systems.
There’s no overlap and creation of the build server for us involves putting a single binary on a VM, configuring it, running it and then sending build pipelines.
Concourse “pipelines” are YAML files that declare resources to use, e.g. Git repos or Docker images, and contain a set of jobs to execute. In turn, jobs are sub-divided into tasks and each task runs in a container. Some readers may be surprised to learn that Docker and containerisation aren’t synonymous. Concourse uses Garden containers and when a task needs Docker, e.g. to build a Docker image, Docker runs in a Garden container!
The Concourse documentation is very good and there’s an excellent tutorial by Stark & Wayne to get you started here.
Our use case
In this blog post, I want to focus on the very positive experience we’ve had using Concourse for continuous deployment of a microservice-based application written in F# into AWS using Docker Swarm. The application is in a set of repositories under the BristechSRM GitHub organisation, here.
Very briefly, Bristech is a not-for-profit organisation whose mission is to share knowledge within the Bristol tech community, where I work. I help to organise the monthly meetups and we currently use this application to manage our speaker pipeline. I don’t want to go into the details of the application, since it is currently only for use by the Bristech organisers, but I will refer to code in the public repositories.
The important thing to note is that microservice repositories such as sessions or comms have a concourse
directory so that their CI pipeline has its home in the same place as the F# code. Resources shared by microservice builds, such as the Dockerfile for the F# compiler tooling are stored in the infrastructure repository.
Our use case makes an interesting case study because we use images that come out of the box with Concourse and our own custom images both for resources and for tasks. In the case of resources, we use Git repositories and DockerHub repositories out of the box and a Docker Swarm resource that we have written ourselves. In the case of tasks, we use the Busybox image for simple file manipulation and a custom image for our F# compiler tooling.
Continuous deployment pipeline
We currently only have a single job for a typical microservice. The definition looks like this:
jobs:
- name: build
public: true
serial: true
plan:
- get: code
trigger: true
- task: build
file: code/concourse/build.yml
- task: create-context
file: code/concourse/create-context.yml
- put: image
params:
build: context
- put: swarm
One of the nice things about Concourse is just how readable this is. It says “Get the code whenever there’s a change, build it, wrap it up in a Docker image, push it to DockerHub and then tell Docker Swarm to deploy the new code”. There are more details in the task YAML files and their associated shell scripts, but this pipeline file gives the overall gist.
In the Concourse UI, the pipeline looks like this:
If you want to check out a (much) larger pipeline, then you’ll be pleased to hear that Concourse eats its own dog food. Concourse builds with Concourse and its pipeline is here.
In the following sections, I’ll describe the resources and tasks in the pipeline in the order in which they run.
Getting the code
The plan for our job says
- get: code
trigger: true
In turn the code
resource is defined in the pipeline file as:
- name: code
type: git
source:
uri: https://github.com/BristechSRM/comms.git
Git repositories are a standard resource type.
Because we set trigger
to true
, Concourse polls the Git repository for new commits on master. When it finds some, it pulls the repository. Any subsequent task in the pipeline that requests code
as an input then has the directory containing the repository mapped in as a volume in the task container. The code appears as a code
directory to shell scripts.
Building the code
Our microservices are written in F#. They use Nuget for package management and FAKE for build.
Our compiler tooling is wrapped up in a custom Docker image, which is created using this Dockerfile:
FROM ubuntu:14.04
RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF && \
echo "deb http://download.mono-project.com/repo/debian wheezy main" | sudo tee /etc/apt/sources.list.d/mono-xamarin.list && \
apt-get update && \
apt-get -y install mono-devel fsharp wget
RUN mkdir -p code/.nuget && cd code/.nuget && wget https://dist.nuget.org/win-x86-commandline/latest/nuget.exe && cd .. && \
mono .nuget/nuget.exe install "FAKE" -OutputDirectory packages/ -ExcludeVersion
Here’s what this does:
- We add a key for Mono’s package repository and register the repository in sources for the image
- We then download packages for Mono development, F# and also wget for the next step
- Nuget isn’t in a Debian package, so we get it from a URL
- Then we use Nuget to get FAKE
Putting tooling in images allows us to compile our code and be left with just the binaries. The container itself is destroyed once the binaries have been created. Nothing is installed on the build server.
Here’s the task that does that:
platform: linux
image: docker:///bristechsrm/build-fsharp
inputs:
- name: code
outputs:
- name: binaries
run:
path: code/concourse/build.sh
In brief, it says “spin up a container with F# build tooling, map the code in, run a shell script and map the binaries out”. Once that is done, the tooling can be removed from the machine, leaving a clean build server with nice shiny new binaries!
The shell script looks like this:
#!/bin/sh
set -e
cd code
cp -R /code/.nuget/ .
cp -R /code/packages/ .
mono packages/FAKE/tools/FAKE.exe build.fsx
cd ..
cp code/build/output/* binaries/
The essential thing to note here is that container volumes for code
and binaries
are created automatically by Concourse. code
contains the repository and the script compiles the code and copies the binaries out into a volume that persists when the container is removed.
The binaries are then used as input for the next step, which wraps them up into a Docker image with the Mono runtime.
Creating a docker build context
Creating a Docker image for deployment starts by creating a directory for our docker build
context. We take a standard Dockerfile from our code and transfer it to a directory along with the previously built binaries. Running docker build
in the next step will wrap up our binaries with the Mono runtime, ready for deployment.
The task looks like this:
platform: linux
image: docker:///busybox
inputs:
- name: code
- name: binaries
outputs:
- name: context
run:
path: code/concourse/create-context.sh
Since we are only copying files with the shell script, we only need limited shell functionality, which is supplied in a small standard image by Busybox.
Building and pushing the deployment image
We are using one of Concourse’s standard images (containing Docker), to build a Docker image containing our code and push it to DockerHub! (Yes, that is mind bending when you first hear about it.)
This is accomplished very simply by the following directive in the pipeline file:
- put: image
params:
build: context
Note that context
is an output from the previous task.
In turn, image
is defined as a standard Docker image (docker-image) resource:
- name: image
type: docker-image
source:
email: {{docker-hub-email}}
username: bristechsrm
password: {{docker-hub-password}}
repository: bristechsrm/sessions
We need credentials to push an image to a DockerHub repository. How these credentials get onto the build server is described after we look at the final stage in our continuous deployment pipeline - kicking Docker Swarm.
Kicking Docker Swarm
The Git repository that we watch for code changes and the DockerHub repository where we push our microservice image are standard resources supplied by Concourse out of the box. However, there isn’t a resource for a Docker Swarm, so we wrote our own.
A Concourse resource is a Docker image containing three executables:
- check
- out
- in
For something like a Git repository, check
looks for new commits on a given branch, in
corresponds to git pull
and out
corresponds to git push
. In the case of Docker Swarm, we haven’t implemented check
and in
, but have written a shell script for out
, in order to tell the swarm master to update a particular running microservice. The script looks like this:
#!/bin/sh
exec 3>&1 # make stdout available as fd 3 for the result
exec 1>&2 # redirect all output to stderr for logging
buffer=$(cat)
#Extract variables from json
serviceName=$(echo $buffer | jq -r '.source.serviceName')
nodeName=$(echo $buffer | jq -r '.source.nodeName')
swarmMasterIp=$(echo $buffer | jq -r '.source.swarmMasterIp')
repository=$(echo $buffer | jq -r '.source.repository')
overlay=$(echo $buffer | jq -r '.source.overlay')
accessKeyId=$(echo $buffer | jq -r '.source.accessKeyId')
secretAccessKey=$(echo $buffer | jq -r '.source.secretAccessKey')
export DOCKER_HOST="tcp://${swarmMasterIp}:3376"
docker stop ${serviceName}
docker rm ${serviceName}
docker pull ${repository}
docker run -d -p 8080:8080 --restart=always --name=${serviceName} --net=${overlay} --env=constraint:node==${nodeName} --env AWS_ACCESS_KEY_ID=${accessKeyId} --env AWS_SECRET_ACCESS_KEY=${secretAccessKey} ${repository}
echo '{ "version": { "ref": "'$BUILD_ID'" } }' >&3
There is some boilerplate reassignment of file descriptors at the start, which Concourse currently requires. The rest of the script takes configuration from STDIN and uses it to talk to the swarm master.
This script is built in to an image using this Dockerfile:
FROM concourse/docker-image-resource
COPY ./resource /opt/resource
RUN chmod +x /opt/resource/*
We piggy-back on Concourse’s base resource for Docker images, meaning that Docker and jq (an executable that can parse and filter JSON) are available to our script.
Creating a Concourse rig
We run our Concourse rig in the cloud on AWS, with web server, work scheduler, build workers and build database on separate VMs. There’s a blog in the pipeline (no pun intended!) describing how we set that up, so I won’t go into it here.
If you want to experiment with Concourse, the best way to get started is to bring up a local instance using Vagrant, as described here. You can send pipelines to a local instance in exactly the same way as you would to a full rig in the cloud. If you want to use a microservice pipeline from Bristech SRM, then you will be able to pull the code and build it easily, but of course you won’t be able to push to the DockerHub repo, or contact our swarm master!
In fact, being able to run a local build machine in this way is another very powerful feature of Concourse. If the build breaks and you’re not sure why, you can experiment locally rather than pushing speculative commits to the Git repo and waiting for remote builds to complete. When it builds locally, it will build on a remote rig because everything is in containers.
Sending pipelines
Pipelines are sent to a Concourse instance with the Fly command line tool. Fly can be downloaded from a running Concourse instance. If the instance has no pipelines, you will be presented with a home screen prompting you to do this.
If you want to download Fly from an instance with pipelines already configured, you can use the small icons in the bottom right corner of the home page.
Fly needs to be targeted at a Concourse instance. For an exploratory rig running on a local VM with Vagrant, this might look something like:
fly --target local login --concourse-url http://192.168.100.4:8080
Once a target has been set, pipelines can be sent to it using commands such as
fly --target local set-pipeline --config pipeline.yml --pipeline my-pipeline
In this example, the pipeline.yml file is sent to the build server and will show up in the UI with the name my-pipeline.
Sending secrets
Build servers often need quite a few secrets to do their magic. In our case, we have database passwords, DockerHub credentials and AWS access keys, among other things.
These get onto the build server by executing set-pipeline with a –load-vars-from flag that references a credentials.yml file that we don’t commit to source control.
For instance, here’s the part of our pipeline that declares a DockerHub repo for our comms microservice:
- name: image
type: docker-image
source:
email: {{docker-hub-email}}
username: bristechsrm
password: {{docker-hub-password}}
repository: bristechsrm/comms
The credentials are parameterised. The credentials.yml file would have entries like this:
docker-hub-email: me@example.com
docker-hub-password: Pa33w0rd
Fly replaces the {{}} parameters in the pipeline with values from the credentials.yml file and sends the resulting definition over a secure connection to the Concourse instance. Once the pipeline is on the instance, there is no way to view its YAML definition.
In conclusion
Thanks for reading this far! I’ve tried to get across how Concourse provides a powerful CI solution by allowing your pipelines to be fully declarative and separating concerns into:
- An overall pipeline definition that should be a readable representation of your CI
- Resources used by the pipeline, e.g. Git repos, DockerHub repos, S3 buckets, …
- Custom resources (e.g. a Docker swarm) with executables to check for changes, and push and pull the resource
- Dockerfiles to declare images for tooling
- Individual tasks declaring the image to run, inputs and outputs and …
- … a shell script to execute the task
I’d encourage you to explore Concourse and use it on your projects. There are many more possibilities than those I’ve had space and time to discuss here, e.g. running the tasks in a job in parallel rather than series. There’s a vibrant community at the moment and I was particularly impressed with the help I received on the Slack channel when I was first setting up our rig.