Deploy Docker containers from a snap

As with any other snap-enabled environment, Ubuntu Core can install and deploy Docker containers from the command line, first by installing the Docker container runtime snap, and then running either docker run or docker compose. This approach is useful for testing, but it’s difficult to scale.

Ubuntu Core has been designed to boot into a production state without manual interaction. This is accomplished by building a custom Ubuntu Core image for your specific application and device deployment.

To build such an image to deploy Docker with your own configuration requires the creation of your own snap, the creation of which is described in this How-to. Our example will deploy RabbitMQ, an industry-grade message broker implementing protocols such as AMQP and MQTT, but it can easily be adapted to deploy any other Docker application.

Basic familiarity with snaps isn’t required but paves the way for a smooth experience. We suggest going though the tutorial on creating a snap or another similar guide.

Create the snap

Start by initializing a snap project in a clean directory:

snapcraft init

Open the newly created snap/snapcraft.yaml file with your favourite editor. Modify and make it technically equivalent to the following. Choose suitable metadata if you plan to publish this snap.

name: rabbitmq-docker-guide
base: core22
version: 'demo' 
summary: RabbitMQ Docker companion snap demo
description: This snap manages the RabbitMQ Docker container, via the Docker Engine.

grade: devel
confinement: strict

environment:
  # Add the docker bin to PATH globally.
  # We need this for the app as well as the hooks.
  PATH: $SNAP/docker/bin:$PATH

plugs:
  # For using Docker executables from the Docker snap.
  # Once connected, several directories will be bind mounted under the target path.
  # We are interested in the Docker CLI at: $SNAP/docker/bin/docker
  docker-executables:
    interface: content
    content: docker-executables
    target: $SNAP/docker
    default-provider: docker
  # For accessing the Docker daemon via the unix socket
  docker:
    label: Access to the Docker Engine's unix socket
    default-provider: docker

parts:
  # This part adds our local files
  rabbitmq:
    plugin: dump
    source: snap/local

apps:
  rabbitmq:
    command: bin/run.sh
    daemon: simple
    environment:
      # This is the Docker image tag associated with this revision of the snap
      DOCKER_IMAGE_TAG: 3.13-management

The above file is self-described; read the inline comments.
This is the definition of our snap, including the metadata, interfaces to access external resources, build, as well as the runtime logic.

Note that the global manipulation of PATH results in deviations from the default built behavior. If you plan to add Debian packages or use plugins, it is best to set it to $SNAP/docker/bin:$SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH instead.

Now, we need to add some scripts to implement the container management operations.

Create a script at snap/local/bin/run.sh with the following content:

#!/bin/bash -eu

container_name="$SNAP_INSTANCE_NAME"
image_name="rabbitmq:$DOCKER_IMAGE_TAG"

if [ ! "$(docker ps --all --quiet --filter name="$container_name")" ]; then
    echo "Container does not exist. Creating ..."
    docker create \
        --name "$container_name" \
        --publish 5672:5672 \
        --publish 15672:15672 \
        --log-driver none \
        "$image_name "   
fi

echo "Starting container ..."
docker start --attach "$container_name"

Then, make it executable: chmod +x snap/local/bin/run.sh.

This script is responsible for (creating and) starting the Docker container.

It is important to disable the log driver (by setting it to none) so that we don’t store the logs twice. Docker Engine uses the json-file driver by default. Snaps write logs to Journal.
With the given configuration, the service’s standard output will be written to Journal only.

The snap is almost ready but we need to ensure that we can update the container image too, after the deployment. There are a few ways to do this, but we choose to rely on a publisher-managed delivery via the snap. In other words, we update the snap which in turn updates the docker image and the container instance.

To realize that, we leverage the post-refresh hook which is a script executed whenever the snap has updated. All this script needs to do is to remove the container, so a new one gets created using the Docker tag specified in the snapcraft.yaml file.

Add the following script at snap/hooks/post-refresh:

#!/bin/bash -eu

container_name="$SNAP_INSTANCE_NAME"

# Remove the container so it gets recreated upon snap's service startup
# This results in creating the container based on the updated configuration
# such as image name/tag, volumes, and ports.
docker rm "$container_name"

Then, make the script executable: chmod +x snap/hooks/post-refresh

Here, we could add additional logic such as removing the old image, or preparing the application for this upgrade.

For completeness, let’s also create a remove hook, to remove the container when the snap is uninstalled. This prevents container name conflicts if the snap is re-installed.

Create the following script at snap/hooks/remove:

#!/bin/bash -eu

container_name="$SNAP_INSTANCE_NAME"

docker rm -f "$container_name"

Make it executable: chmod +x snap/hooks/remove

This is also a good place to remove obsolete images to save disk space, but we leave that out of this documentation.

Finally, verify the location of created files:

$ tree
.
└── snap
    ├── hooks
    │   ├── configure
    │   ├── post-refresh
    │   └── remove
    ├── local
    │   └── bin
    │       └── run.sh
    └── snapcraft.yaml

5 directories, 5 files

At this point, we are ready to build the snap:

snapcraft -v

This will create: rabbitmq-docker-guide_demo_amd64.snap

You could use --destructive-mode flag to build it natively, instead of using LXD.
This speeds up the build and is safe because building this snap makes no changes on the host and doesn’t link against any of the host shared libraries (e.g. by using build/stage packages).

Install the snap

sudo snap install --dangerous ./rabbitmq-docker-guide_demo_amd64.snap

The --dangerous flag is set because this is a locally built, unsigned snap.

Connect the following interfacess:

# For access to Docker Daemon
sudo snap connect rabbitmq-docker-guide:docker docker:docker-daemon
# For access to Docker CLI
sudo snap connect rabbitmq-docker-guide:docker-executables docker:docker-executables

Start the service:

sudo snap start rabbitmq-docker-guide

Verify that everything is working and setup correctly by looking into the following:

  • Snap logs: snap logs -n 100 rabbitmq-docker-guide
  • Docker containers: docker ps

You should be able to access the RabbitMQ’s management dashboard via a web browser.
By default, it is available at http://localhost:15672 (username/password: guest).

Persist the application data

By looking into the logs we can identify that the container uses the following path to write its data:

home dir       : /var/lib/rabbitmq
data dir       : /var/lib/rabbitmq/mnesia/rabbit@b3c91694093c

First we need to set a hostname for the container so that the data directory name doesn’t change with the container. Docker generate a random hostname by default. Set the hostname to demo by setting --hostname demo argument for the docker create command inside run.sh. You may need to revisit this if you plan to run a RabbitMQ cluster.

The Docker image specifies a volume at /var/lib/rabbitmq and the Docker engine maintains it internally. However, the volume will be lost if the container is removed, for example during an upgrade.
We will mount SNAP_DATA/data as a volume inside the container, so that the container data gets persisted as snap’s own data and copied from one version of the snap to the next. Read more about snap’s data locations and snapshots.

This can be achieved by adding the following argument to the docker create command inside the run.sh script: --volume $SNAP_DATA/data:/var/lib/rabbitmq.

Now, rebuild the snap and re-install it, then verify the location of container data:

$ tree -L 2 /var/snap/rabbitmq-docker-guide/current/data/
/var/snap/rabbitmq-docker-guide/current/data/
└── mnesia
    ├── rabbit@demo
    ├── rabbit@demo-feature_flags
    ├── rabbit@demo.pid
    └── rabbit@demo-plugins-expand

4 directories, 2 files

Change configuration via environment variables

There are many ways to pass configuration to a Docker container on Ubuntu Core. This also heavily depends on the application inside the container.

For adding configuration support to our RabbitMQ companion snap, we will use environment variables. But to make that work we have to implement a mapping from snap configuration option.

In order to pass environment variables to the container without having to recreate it, we use an environment file, which we could dynamically add key values into. The container would pick up the values upon restart.

The goal is to write KEY=<value> into the file whenever the user sets a snap configuration option. When using the Snap CLI, the command to set it would be: snap set <snap> key=<value>.

In order to handle snap configurations, we need to create a configure hook. Create a script at snap/hooks/configure with the following content:

#!/bin/bash -eu

env_file="$SNAP_COMMON/conf.env"

# Clear the file
> "$env_file"

default_user="$(snapctl get default-user)"
if [[ -n "$default_user" ]] ; then
    echo "RABBITMQ_DEFAULT_USER=$default_user" >> "$env_file"
fi

default_pass="$(snapctl get default-pass)"
if [[ -n "$default_pass" ]] ; then
    echo "RABBITMQ_DEFAULT_PASS=$default_pass" >> "$env_file"
fi

Make the script executable: chmod +x snap/hooks/configure

The scripts adds a mapping for overriding default username and password.

Now, modify the run.sh script and add the following argument to the docker create command to pass the environment file: --env-file $SNAP_COMMON/conf.env

The script should now look like:

#!/bin/bash -eu

container_name="$SNAP_INSTANCE_NAME"
image_name="rabbitmq:$DOCKER_IMAGE_TAG"

if [ ! "$(docker ps --all --quiet --filter name="$container_name")" ]; then
    echo "Container does not exist. Creating ..."
    docker create \
        --name "$container_name" \
        --publish 5672:5672 \
        --publish 15672:15672 \
        --log-driver none \
        --hostname demo \
        --volume "$SNAP_DATA/data:/var/lib/rabbitmq" \
        --env-file "$SNAP_COMMON/conf.env" \
        "$image_name"    
fi


echo "Starting container ..."
docker start --attach "$container_name"

To test:

  1. Rebuild the snap
  2. Re-install it
  3. Set the configuration with sudo snap set rabbitmq-docker-guide default-user=admin default-pass=<secret>
  4. Start the service

Then open the web GUI and login the new credentials: admin/<secret>.

To debug possible issues:

  • Look at the container logs
  • Check the environment file at /var/snap/rabbitmq-docker-guide/common/conf.env

Supply a configuration file

We could provide an additional configuration file to the container via the snap. This is useful for static configurations that can be shipped to every deployment. Alternatively, we could supply the configuration file from another path on host, a removable media, or a configuration provider snap (a snap providing configuration via a content interface).

To provide a configuration file from the snap to the Docker container, we need to add the file to the snap and a mount during runtime as a volume inside the container.

For example:

  1. Add the file under snap/local/conf/rabbitmq.conf
  2. Set the following argument for the docker create command inside run.sh: --volume $SNAP/conf/rabbitmq.conf:/etc/rabbitmq/conf.d/rabbitmq.conf:ro
  3. Rebuild the snap
  4. Re-install the snap

This will make the file deployed at /snap/rabbitmq-docker-guide/current/conf/rabbitmq.conf available inside the container.

To verify this setup:

  • Look at the container logs
  • Check the deployed read-only configuration file at /snap/rabbitmq-docker-guide/conf/rabbitmq.conf
  • Check the mounted file inside the container with docker exec rabbitmq-docker-guide cat /etc/rabbitmq/conf.d/rabbitmq.conf

Next steps

In this guide, we created a RabbitMQ companion snap that made it possible to deploy, configure, and maintain the RabbitMQ Docker image on Ubuntu Core.

The companion snap can be extended to take care of many other requirements including:

  • Allowing runtime configuration of the container creation (ports, hostname, etc)
  • Shipping the Docker image inside the snap for better reproducibility and deployment in air-gapped environments
  • Pruning dangling images to save disk space after upgrades
  • Using Docker compose; although this isn’t recommended because it will encapsulate several containers into a single service
  • Snap versioning integrated with the Docker image

Apart from installing the snap, we performed a few manual operations on target such as for interface connections and setting snap configuration options. This went against our initial goal of deploying the application without manual interaction. Ubuntu Core leverages other tools and services to automate such operations. These include Ubuntu Core image creation with custom snaps, gadget snaps for adding default configurations and connections into the image, and dedicated snap stores with deployment-specific interface auto-connection assertions.

The created snap and its future revisions can be published to a snap store to allow remote deployment and OTA updates of the Docker container.

2 Likes