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:
- Rebuild the snap
- Re-install it
- 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 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:
- 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
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.