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 a companion 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
# This is the Docker image tag associated with this revision of the snap
DOCKER_IMAGE_TAG: 3.13-management
plugs:
# Configure the interface for using Docker executables from the Docker snap.
# Once connected, several directories will bind mount 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
parts:
# This part adds our snap-specific files to the package
local:
plugin: dump
source: snap/local
apps:
rabbitmq:
command: bin/run.sh
daemon: simple
plugs:
- docker-executables
- docker
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
│ ├── post-refresh
│ └── remove
├── local
│ └── bin
│ └── run.sh
└── snapcraft.yaml
5 directories, 4 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
├── [email protected]
└── 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.
A nice way to enable configuration of snaps is via snap configuration option. To make use of this configuration mechanism, we need to implement a script which reads the snap configuration options and then writes equivalent environment variables into a file. We leverage the snap’s configure hook for this purpose so that we perform the mapping every time the user sets snap configurations.
To write environment variables into a file whenever the user sets a snap configuration option, 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 above script adds a mapping for overriding the default username and password. You can extend it by adding other mappings.
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"
Note that the configurations are passed during the container creation. So for them to be effective, they need to be set before starting the service for the first time.
To test:
- Rebuild the snap
- Remove and re-install it
- Connect the interfaces
- Set the configuration with
sudo snap set rabbitmq-docker-guide default-user=admin default-pass=<secret>
- Start the service
Then open the web GUI and login with 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
It is possible to tweak this method and support changing of configurations without having to re-create the container via re-install the snap. That is however beyond the scope of this guide. Alternatively, you may consider supplying a configuration file via a mounted volume.
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:
- Add the file under
snap/local/conf/rabbitmq.conf
- Set the following argument for the
docker create
command insiderun.sh
:--volume $SNAP/conf/rabbitmq.conf:/etc/rabbitmq/conf.d/rabbitmq.conf:ro
- Rebuild the snap
- 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
Allow container creation configuration
In the earlier steps we hardcoded some of the container configurations, passed to the docker create
command in the run.sh
script.
For example, we exposed the service ports with the same number on the host:
if [ ! "$(docker ps --all --quiet --filter name="$container_name")" ]; then
...
docker create \
...
--publish 5672:5672 \
--publish 15672:15672 \
...
...
This would work if those ports are available on the host. However, it is generally risky to make strong deployment-specific assumptions at packaging time.
We can make the port mapping configurable using snap configuration options. For example, to make RabbitMQ’s AMQP listener port configurable, but falling back to the default:
# Read the value set via snap config option
amqp_port="$(snapctl get amqp-port)"
# If not set, use default
if [[ -z "$amqp_port" ]] ; then
amqp_port="5672"
echo "AMQP port not set, using $amqp_port"
fi
if [ ! "$(docker ps --all --quiet --filter name="$container_name")" ]; then
...
docker create \
...
--publish "$amqp_port":5672 \
--publish 15672:15672 \
...
...
To verify that this works:
- Re-build the snap
- Re-install it
- Set the AMQP port:
sudo snap set rabbitmq-docker-guide amqp-port=8072
- Start the service
- Execute
docker ps
and look for0.0.0.0:8072->5672/tcp
For setting defaults, a more maintainable approach would be to set them in the install hook.
As an example, create an executable script at snap/hooks/install
with the following content:
#!/bin/bash -eu
snapctl set amqp-port=5672
This way, the user can also query and see the default via snap get <snap>
, offering better visibility.
Keep in mind that the install hook is called only when installing the snap from scratch, not when re-installing or refreshing.
Version the snap
In the snapcraft.yaml
file, we set the version of the snap to demo
. That isn’t really helpful because it makes it hard to know which version of the Docker image is installed with this snap.
It’s better to set the version to something that reflects the snap’s content. Using the RabbitMQ Docker image tag is a good option.
The version can be set manually on every upgrade, together with the existing environment variable for the tag:
...
version: '3.13-management'
...
environment:
...
DOCKER_IMAGE_TAG: '3.13-management'
We can eliminate version repetition by using a YAML anchor and alias. Let’s define an anchor called TAG
the first time we set the version and then use it as alias further below:
...
version: &TAG '3.13-management'
...
environment:
...
DOCKER_IMAGE_TAG: *TAG
Build the snap:
snapcraft -v
This will create: rabbitmq_3.13-management_amd64.snap
, using the Docker image tag as the snap’s version!
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:
- Shipping the Docker image inside the snap for better reproducibility and deployment in air-gapped environments; see Package Docker images in a snap.
- 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
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.