A common theme I hear when talking to operations people, and developers a like: continuous integration & continuous delivery (CI/CD). It’s too hard to get started. Too many resources are required to run it. The famous “I’ll get to it later” conversations begin.
Passionate devs will paint a picture of their favorite tools, while those not accustomed to the benefits of CI/CD will almost always tell me all of the things they need to do before they can “get” to CI. It’s 2017: if you’re not using CI to build your tools, and instead rely on manual means, you’re doing it wrong.
This blog post hopes to show you just how quickly you can have a professional grade configuration in no time. After reading this, you’ll have every reason in the world to continue leveraging CI/CD to make better quality code.
Requirements
- Docker
- a repository you can access (Github or Bitbucket is fine!) & put our
Jenkinsfile
- Some example code. We’re going to use a silly bit of go code, but you can substitute your own.
Download and Install Jenkins
We want a quick and easy installation of Jenkins. While the code we’ll write today will be professional grade, this Jenkins instance will be far from it. At the very end I’ll include some things you can do to make it a little more permanent. I expect Docker is already installed. If it’s not, please stop and check out Docker documentation before tackling this.
- Download Jenkins with Blue Ocean and run it
WARNING We’re binding the Docker socket of the host computer to the Docker container so we can launch containers to build on. This is super dangerous! Don’t trust me, don’t trust anything but your own code. When Docker has access to the Docker daemon it can run all kinds of nasty code on your machine.
# Run in daemon mode -d
docker run -d --name blue-ocean -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock jenkinsci/blueocean:latest
The power of Docker! This is the offial container provided by the nice folks from Jenkins.io it just works. We are running it in -d
daemon mode so it’ll detach from the terminal. It’ll bind to port 8080
of the localhost
, and we mounted the Docker socket for our host’s Docker daemon to the same path for the container. We’re going to use that later when we specify a Docker image to run our tests on.
The Code
Our deployment scripts are non-existant, so we don’t have a deploy/
or scripts/
subdirectory. Normally you should include the code you depend on for deployment in your repository.
We’re using Go for this example. See my other blog posts on why I like it so much. It’s a great use case for this blog post because it doesn’t natively generate junit
format .xml
files. We’ll see later how to overcome this.
main.go
package helloworld
import "fmt"
func main() {
fmt.Println(hello())
}
// hello *should return Hello World!"
func hello() string {
return "Goodbye Moon"
}
main_tests.go
package helloworld
import (
"testing"
)
func TestHello(t *testing.T) {
result := hello()
if result != "Hello World!" {
t.Fatalf("hello() didn't output \"Hello World!\", got:\n%q", result)
}
}
If we ran this in the console we’d see our problem:
$ go test -v
=== RUN TestHello
--- FAIL: TestHello (0.00s)
helloworld_test.go:10: hello() didn't output "Hello World!", got:
"Goodbye Moon"
FAIL
exit status 1
FAIL _/home/bobby/Projects/go-with-tests 0.001s
This is important for day to day coding. Little bugs like this are not likely to get past a local git-hook to run tests before pushing, but we want to demonstrate one aspect of a CI pipeline: historical build data. We’ll generate the test results and display them in our UI.
We’re not solving the Universe’s great mysteries here, but we are going to see CI in action!
With the current code we should not expect it to work. Just based on the test case alone, the string doesn’t match. We will get our build defined in a Jenkinsfile
and fix the code to pass with flying colors
The Jenkinsfile
Jenkins changed their course from using straight groovy code to a neat Domain Specific Language (DSL) in order to get people onboarded with Jenkinsfile
faster.
Let’s look at our example. I slapped it together using the official Jenkinsfile page.
pipeline {
agent {
docker { image 'golang:1.9.2-stretch'}
}
stages {
stage('Build'){
steps{
sh 'go get -u github.com/jstemmer/go-junit-report'
sh 'go build .'
}
}
stage('Test'){
steps{
sh 'go test -v 2>&1 | go-junit-report > report.xml'
}
}
stage('Deploy'){
steps{
echo "Tiny bubbles\nIn my beer\nMakes me happy\nAnd full of cheer"
}
}
}
// Required to view our test results in the UI
post {
always {
junit 'report.xml'
}
}
}
This is just a simple build/test/deploy. We were very specific, requesting this job to be run on an official golang container. This gives us a “sealed” build environment each and every time. It’ll also give us a known quantity to work with: all of the tools, paths, everything is the same every time.
Build
In this step we arn’t really building much of anything. Our example is a Hello World!
, so there’s not much to build. To reflect reality for everyone else, this is where you’d run go build .
Test
This is pretty cool! Jenkins needs an .xml
file in junit
format to understand the results of the tests. This is great because as long as we can provide this file, we can run our tests and use the UI to determine the results. We downloaded a tool during the build called jstemmer/go-junit-report. It takes the output of go test -v
and will output the XML that Jenkins understands. For us that means instead of
go test -v
We now do:
go test -v 2>&1 | go-junit-report > report.xml
By specifying the post
to always
artifact the file we create, we’re telling Jenkins to take the text file report.xml
in the working directory and store it in the Jenkins server as an “artifact”. See tests-and-artifacts for more information. Jenkins is smart enough to know what to do with this file, and Blue Ocean is smart enough to include it under the “Tests” page of each build. We will also automatically mark builds with passed tests as stable, and failing tests as “unstable”.
Deploy
We are not deploying this binary anywhere. Generally a binary or library would be uploaded to an Artifact server, or perhaps in the build we would create a native OS package that our Deploy script would upload into a repository. For today we’ll echo a familiar cadence.
Now You’re Ready
At this point we have a repository that contains our code, our tests, and a Jenkinsfile
. The Jenkinsfile
tells Jenkins how this all works, including how to store test results. We can even track the environment we do this all in within the Jenkinsfile
.
If you’re feeling brave: You can stop and remove the Docker container, restart a new instance with no previous information, and point it to the same repository we’ve been using. It will immediately attempt to download the container we build in, and immediately know our entire build process: build, test, deploy.
# Stop the instance and remove it completely
# THIS IS DESTRUCTIVE!!!
docker stop blue-ocean | xargs docker rm
This is incredibly powerful! While we will lose the build history, that could be prevented if we maintain state better. See the bottom Appendix on how to make this more production worthy. For now, you know for a fact that you can build your binary on any Jenkins 2.0 instance.
You now have a build you can run practically anywhere, and with Docker backing your build, you can start with something as simple as this, and build it into a robust build that will test, cross compile, auto deploy, and maybe even send you a message across IRC, Slack, or email. It doesn’t take much to start, but it will require you to start with this stuff. There’s no point waiting until the end to do any of this. As you build different tools and products, you’ll amass a library of Jenkinsfiles
that you can draw from, and quickly build from the start.
Appendix A: Beef Up Your Jenkins Instance
This is a single container running with its state held locally. That means when the image is deleted, so is your configuration. Hopefully this blog post demonstrated when you put your configuration in a repository, it’s almost trivial to get back to a running state.
- Create a volume for
/var/lib/jenkins
. This is the home directory of Jenkins and where the state for the image is stored. - Configure Jenkins to restart on failure. We want it running all of the time and Docker makes it easy:
--restart=always
- Consider running Jenkins on a host running all of the time. Maybe it’s a private Digital Ocean droplet (with a firewall!) or a raspberry pi running at home.
- Isolate your worker agents from your CI master. This way they’r even more trivial, and less likely to take down your CI server. A full disk from a runaway build won’t ruin your CI master if it’s not the same machine.
- Monitor your work! Now that you’re relying on Jenkins, why not look into some basic monitoring to make sure you know it’s up.
- Bonus points if you add some alerting.
Appendix B: Beefing Up Your Build
- Considering we know we’re going to build from the official Go image and always adding the tool to export
go test
into an.xml
file that Jenkins & Blue Ocean can parse.
- You should save yourself a few seconds by building your own test image that includes it automatically.
- You should definitely build it in a pipeline, so when there’s an update you can simply rebuild and your jobs can all take advantage of the new release.
- Configure your job to build on push. Continuous Integration means we’re constantly building our code to reflect the latest changes to test for the now, not the maybe future.