Continuous Deployment

Continuous Deployment is the practice of automatically releasing your code or project to the end users, provided that it passes all tests. As mentioned, normally it is a good idea to deploy to a staging environment (e.g. port 8051) before deploying to production, so that a human in the loop can do one final check that everything is working as expected. However the key point of this process is to help ensure the latest components are available to other users in production as quickly as possible and in an automated way to reduce errors.

After going through this module, students should be able to:

  • Set up a GitHub Action for building and pushing a Docker image to the GitHub Container Registry

  • Trigger the GitHub Action by pushing a new tag to GitHub

  • Use Ansible to deploy a version of the application on a remote server independent of the code base

Build an Image for the GitHub Container Registry

Rather than commit to GitHub AND push to a container registry like Docker Hub each time you want to release a new version of code, you can set up an integration between the two services that automates it. The key benefit is you only have to commit to one place (GitHub), and you can be sure the image in your container registry will always be in sync.

Note

The content below works similarly on Docker Hub, or the GitHub Container Registry. If setting up this integration for Docker Hub, you will need to add a step to inject Docker Hub credentials into the workflow via secrets. For example, see this link.

Consider the following workflow, which may be written to .github/workflows/push-to-registry.yml:

 1name: Publish Container Image
 2
 3on:
 4  push:
 5    tags:
 6      - "*"
 7
 8jobs:
 9  push-to-registry:
10    runs-on: ubuntu-latest
11    permissions:
12      contents: read
13      packages: write
14      attestations: write
15      id-token: write
16
17    steps:
18      - name: Check out the repo
19        uses: actions/checkout@v4
20
21      - name: Log in to the Container registry
22        uses: docker/login-action@v4
23        with:
24          registry: ghcr.io
25          username: ${{ github.actor }}
26          password: ${{ secrets.GITHUB_TOKEN }}
27
28      - name: Extract metadata (tags, labels) for container
29        id: meta-data
30        uses: docker/metadata-action@v5
31        with:
32          images: ghcr.io/${{ github.repository }}
33
34      - name: Build and push image
35        uses: docker/build-push-action@v5
36        with:
37          context: .
38          push: true
39          file: ./Dockerfile
40          tags: ${{ steps.meta-data.outputs.tags }}
41          labels: ${{ steps.meta-data.outputs.labels }}

This workflow is triggered when a new tag is pushed (tags: - '*'). In contrast to testing on every push, it makes sense to build and tag containers more selectively, because we would prefer if tagged containers can be traced back to specific tagged versions of code.

This workflow sets a few permissions near the beginning which are required for building an image. As in the previous workflow, this one also runs on an ubuntu-latest environment.

Then, among the fours steps, it uses the docker/login-action to log in to GitHub Container Registry (GHCR) Hub on the command line. The username and password are taken out of the environment. Certain variables, including secrets.GITHUB_TOKEN are automatically part of the environment for every GitHub Action Workflow.

Finally, the workflow uses the docker/metadata-action to extract tags and the repository name to assign to the name of the container image, and uses docker/build-push-action to build and push the container to the GHCR.

Tip

Don’t re-invent the wheel when performing GitHub Actions. There is likely an existing action that already does what you’re trying to do.

Trigger the Integration

To trigger the build in a real-world scenario, make some changes to your source code, push your modified code to GitHub and tag the release as X.Y.Z (whatever new tag is appropriate) to trigger another automated build:

[coe332-vm]$ git add *
[coe332-vm]$ git commit -m "added a new feature to do something"
[coe332-vm]$ git push
[coe332-vm]$ git tag -a 0.1.1 -m "release version 0.1.1"
[coe332-vm]$ git push origin 0.1.1

By default, the git push command does not transfer tags, so we are explicitly telling git to push the tag we created (0.1.1) to GitHub (origin).

Now, check the online GitHub repo to make sure your change / tag is there, and check the Actions tab to monitor the status of your build.

../_images/ghcr_result.png

New tag automatically pushed.

If successful, the resulting container images can be found by navigating to your GitHub Profile and clicking the Packages tab near the top center. That image can be pulled using the Docker commandline interface:

[mbs337-vm]$ docker pull ghcr.io/USERNAME/IMAGE:TAG
# e.g.:
[mbs337-vm]$ docker pull ghcr.io/wjallen/dash-test:0.1.0

With container images stored in a web-accessible container registry, you can now deploy code and projects independent of the codebase itself. This is great for arbitrary cloud deployments orchestrated with tools like Kubernetes or Ansible.

Ansible

Ansible is a great tool for automating complex or repetetive tasks anywhere - even on remote virtual machines. For example, imagine you have a web dashboard consisting of multiple containerized components. Docker compose is great for orchestrating the containers - but there are many other considerations when working in a brand new virtual machine. How will you make sure Docker is even installed? And the necessary containers or source code are available to the machine? And the ports are correctly proxied so that the dashboard is visible to the outside world? And all of the other latest versions of packages and security updates are installed so your virtual machine is secure?

Ansible enables us to write a “playbook” for launching our web apps from start to finish. It runs consistently everywhere, can very easily be set up to support staging and production environments, and it is self-documenting, much like a Dockerfile.

Install Ansible

Simply:

[mbs337-vm]$ pip3 install ansible

Consider that ansible is agentless, meaning it can communicate and perform actions on remote virtual machines without having to install any applications or services. So in practice, you may find that you ultimately install ansible on your own laptop, and manage your virtualized dashboards on remote VMs from there.

[local]$ pip3 install ansible

The next steps will assume you are running ansible from your local laptop, and your class virtual machine is the remote host (you need to know the IP).

Create an Inventory

The inventory is a list of hosts - typically IP addresses - for virtual machines that you have provisioned and you have access to. You should have prepared:

  1. The IP address of the host

  2. The username you use to log in to the host

  3. SSH key authentication for logging in to the host

Write the inventory into a inventory.ini file:

[myhosts]
129.114.123.456

Then try using the ansible CLI to ping the hosts in your inventory:

[local]$ ansible -m ping -i inventory.ini -u ubuntu --key-file ~/.ssh/id_ed25519 myhosts
129.114.123.456 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.12"
    },
    "changed": false,
    "ping": "pong"
}

The command line options are:

  • ansible -m ping: run the ansible ping module

  • -i inventory.ini: a pointer to your local inventory file

  • -u ubuntu: the username you use to log in to your host

  • --key-file ~/.ssh/id_ed25519: the private key you use to log in to your host

  • myhosts: the group name from your inventory file

A SUCCESS message above means ansible is able to reach your host and will be able to manage it.

Write a Playbook

A very simple first playbook from the ansible documentation is as follows:

- name: My first play
hosts: myhosts
tasks:
 - name: Ping my hosts
   ansible.builtin.ping:

 - name: Print message
   ansible.builtin.debug:
     msg: Hello world

Save the above into a file called “playbook.yaml”. It performs two steps on your hosts labeled “myhosts” in your inventory. Step one is a ping (as we saw previously), and step two is to echo a message to standard out - “Hello world”. Ansible uses builtin macros for almost every function or command one could imagine - from making a folder to cloning a git repo to starting containers and anything in between. The Ansible documentation has an extensive library of macros.

To run the playbook, execute:

[localhost]$ ansible-playbook -i inventory.ini -u ubuntu --key-file ~/.ssh/id_ed25519 playbook.yaml

Another key concept of ansible is idempotence. In computer science, this is the property whereby an action can be applied multiple times (e.g. an ansible playbook), but it won’t have any additional effect on the system beyond the first time. In other words, you can play a playbook with ansible to start your containers the first time. If you call the exact same playbook again, it will see that the containers are already started and will make no change. In this way, ansible can be used not only to manage your services, but to verify that they are working as expected.

Next Steps

  1. Update the playbook to clone your Git repository and use docker compose to start your dashboard container(s)

  2. Modify the scheme to support staging and production environments

Additional Resources