CI and CD
(Source)
Continuous deployment (CD) refers to the process of frequently delivering software updates through automated deployments and usually goes hand in hand with continuous integration (CI). The input to a CI pipeline (e.g. running on GitLab CI, GitHub Actions, Jenkins, TeamCity or GoCD) is raw source code, while the output usually is a well-tested, self-contained build artifact, that contains your application. This could be a binary executable (common with compiled languages like C, C++, Go), a JAR or WAR file (in the case of JVM languages), a DEB or RPM package, a Python wheel, and, among all these other options, also a Docker image. Let us focus on the latter, as this is one of the most common and most flexible approaches nowadays. So, after having been built and stored in some Docker library (e.g. publicly on DockerHub or in a private container registry like GitLab, Artifactory or Nexus), the Docker image must still somehow find its way to the server, where the application shall be deployed - at least in the case of web applications. This is where CD comes to play.
Docker + Caddy (+ GitLab CI)
After a new Docker image was built, you somehow need to tell the host machine’s Docker daemon to pull and run that new image. A common practice is to use tools like Watchtower, which can be configured to either regularly poll a registry for new image tags or react to webhooks. However, I didn’t want to run another extra software tool solely for the purpose of updating my Docker container. As a consequence, I came up with a minimalist, custom-built solution involving nothing but Caddy (with the caddy-exec plugin) – which runs on my host machine as a web server and reverse proxy anyway – and a shell script.
Conceptually, Caddy spawns a webhook endpoint, that is called as part of the last step of my CI pipeline and then runs a few simple bash commands to update my container. Here is how to set everything up.
Get a Caddy release containing the
caddy-exec
plugin from their downloads page. I assume you have worked with Caddy before, know how to run and use it and are familiar withCaddyfile
.Allow the
caddy
user to access the Docker daemon by adding it to thedocker
group:1
$ sudo usermod -a G docker caddy
You might need to reboot or re-login afterwards, in order for the changes to take effect.
If you’re concerned about security, you might want to come up with a more elaborate solution here, e.g. involving authorization or so.
Optional: log in to private container registry and make credentials accessible by
caddy
user. If you’re concerned about security, you might want come up with a cleaner solution here.1
2
3
4$ docker login registry.gitlab.com
$ chown -R $USER:caddy $HOME/.docker/
$ chmod 770 $HOME/docker
$ chmod 640 $HOME/.docker/config.jsonCreate a new bash script, e.g.
update_app.sh
, that contains the logic for updating your container.1
2
3
4
5
6
7
8
9
10
11
12
13
# Replace your-username with your actual user or leave out `--config` when using public registry
DC="/home/your-username/.docker"
# Pull image `registry.gitlab.com/your-group/your-project` from non-public GitLab container registry
docker --config $DC pull registry.gitlab.com/your-group/your-project
# Stop old container
docker --config $DC stop your-app-1 && docker --config $DC rm your-app-1
# Run new container (e.g. some web app listeing on port 8080)
docker --config $DC run -d -p 127.0.0.1:8080:8080 --name your-app-1 registry.gitlab.com/your-group/your-project:latestMake the file executable by
caddy
user:1
2$ sudo chown caddy:caddy update_app.sh
$ sudo chmod+x update_app.shEdit your Caddyfile to look like so:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22your-app.example.org {
# Assuming you also use Caddy as a reverse proxy to your app, not required
reverse_proxy http://localhost:8080
# Request matcher, based on query param and token header field
@your-app_push {
query project=your-app
# A "pre-shared key" to "authenticate" against this endpoint
# Replace this by some random string
header X-Token o47iUJnhq3vZZIMT
}
route /push-hook* {
exec @your-app_push {
command bash
args /home/your-username/update_app.sh
timeout 120s
}
}
}Restart (or reload) Caddy for changes to take effect. Now, you should be able to call the endpoint to kick off your update script:
1
$ curl -X GET -H "X-TOKEN: o47iUJnhq3vZZIMT" https://your-app.example.org/push-hook?project=your-app
Include this webhook request to your pipeline. Here, I’m using GitLab CI, you would need to adapt this logic to the syntax of whatever CI server you use. This example is a minimal pipeline definition for building a Docker image (using Kaniko), pushing it to GitLab container registry and eventually calling the above webhook endpoint.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28# .gitlab-ci.yml
stages:
- publish
- notify
workflow:
rules:
# Run pipeline only for tags
- if: $CI_COMMIT_TAG
build-publish-docker:
stage: publish
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [ "" ]
script:
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
- /kaniko/executor --context $CI_PROJECT_DIR --cache=true
--dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG --destination $CI_REGISTRY_IMAGE:latest
notify-webhook:
stage: notify
allow_failure: true
# DEPLOY_TOKEN must be defined as a CI variable under Settings -> CI/CD -> Variables
script:
- 'curl -H "X-Token: $DEPLOY_TOKEN" https://your-app.example.org/push-hook?project=$CI_PROJECT_NAME'