Giuseppe Barbieri
on 23 March 2023
In this blog post, we are going to see when and how to migrate a ROS application currently deployed with Docker to Snap. This topic is also covered on our documentation website.
We will use the web-based joystick application developed by Husarion, (GitHub – husarion/webui-ros-joystick) as an example. This application is used to send velocity commands to a ROS robot and manage a software emergency-stop.
Docker has greatly facilitated robotics software development by providing a way to package applications and their dependencies into portable containers. However, due to its cloud-oriented design, Docker poses some difficulties for developers when it comes to deploying software on a robotic device.
If you are completely new to Snaps and want to learn how to snap robotics applications, head over to our documentation page.
When to move your Docker to Snap
Before we jump straight into work, let’s understand first when you should consider migrating from Docker to Snap.
The software lifecycle for a robotics application typically consists of four stages: development, testing, deployment, and maintenance.
- During the development and testing stage, the use of Docker containers is a good way to reproduce the application environment consistently, reducing the risk of unexpected behaviour. Just like Docker, you could do the same with other container technologies like LXD, reducing issues with missing dependencies or differences in system configurations.
- When transitioning from development to deployment and maintenance, Docker’s limitations in the robotics field become apparent. Docker lacks dedicated high-level interfaces for accessing low-level hardware. It also lacks a robust update system and state transactionality. Docker containers are also not integrated in terms of network. All of these limitations require the user to implement workarounds that can be challenging, and expose our application to security issues.
Migrating from Docker to Snap easily overcomes these challenges. Snaps are a read-only package format that encloses both the code and dependencies. They are ideal to distribute final applications. Snaps have dedicated, maintained, and secure interfaces to access the host machine and its resources, leverage semantic channels, automatic rollbacks, delta updates, and more.
If you want to learn more about Docker limitations for ROS and robotics read our whitepaper.
Let’s see how moving from Docker to Snap can help us deploy and maintain our robotic applications.
How to migrate from Docker to Snap
If you know Docker, you know that Docker images are built using Dockerfiles. These files consist of a series of commands executed in sequence to define the image of our Docker container. Snaps are built based on a recipe declared in a YAML file. This similarity will prove convenient while converting from Docker to Snap as we will see hereafter.
For our project at hand, the webui-ros-joystick Dockerfile can be found at webui-ros-joystick/Dockerfile. The image is used to create the container on which our application will be running. In this example, the container is run and the app is launched via Docker compose. Docker Compose is a tool that assists the operations between multi-container applications. It uses a YAML file to define runtime application services. For the webui-ros-joystick, docker-compose.yaml is available on Github.
Our goal is to build an app that launches the webui joystick, making it readily accessible for the user to transmit velocity commands to any generic ROS device. To achieve this, we will create a Snapcraft.yaml file based on the information that we can gather from the Docker compose and the Dockerfile available on the Husarion git page. We will adopt a top-down approach, first defining the app that we want to expose to the rest of our system and then proceed by adding the components that allow our application to build.
Exposing the application
Snaps effectively allow you to define and isolate the pieces of your application that you want to expose to the rest of the system via the app‘s tag. As already mentioned, in the example at hand, Husarion uses docker-compose.yaml to define the properties of the container that will run our app and specify the launch file to be launched at startup. This is done as follows:
services:
webui-ros-joystick:
image: husarion/webui-ros-joystick:noetic
container_name: webui-ros-joystick
network_mode: host
command: roslaunch webui-ros-joystick webui.launch
Now let’s use the information from the docker-compose.yaml to build our Snap app via Snapcraft.yaml. First we can identify the command that will launch our webui launch file and add it with the command keyword:
apps:
webui-ros-joystick:
command: opt/ros/noetic/bin/roslaunch webui-ros-joystick webui.launch
As we know, Docker uses a virtual network to manage communication between containers. In order for the network to use the host’s network namespace, we have to run our container with –network=host option as it’s done in the compose.yaml file.
Snaps offer three levels of confinement. Most Snaps operate under strict confinement, meaning they cannot access any system resources unless they request specific access through an interface. This must be determined by the user in accordance with the needs of the application. The aim is to ensure that the Snap only has access to the resources it needs to function, reducing the risk of security vulnerabilities. Let’s take a look at the list of supported interfaces.
Since we have a ROS application that communicates with other ROS components via topics, we will need the:
- network plug to grant the Snap access to the host’s network
- network-bind plug, which provides the Snap with the ability to bind to a specific IP address and port as required for ROS communication.
This offers a more granular set of network permissions with respect to the network option in Docker.
Another compelling example is the need for X11 forwarding required to run some popular ROS applications like RViz in Docker. In traditional setups, you would have to specify the X11 flags each time you run the container (instructions in the ROS wiki available here). However, with Snaps, you can simply define the X11 interface in your application, streamlining the process.
Our webui-ros-joystick app will now look like this:
apps:
webui-ros-joystick:
command: opt/ros/noetic/bin/roslaunch webui-ros-joystick webui.launch
plugs: [network, network-bind]
Before launching an application in ROS, the environment and ROS have to be sourced. This is done in the Dockerfile with the following commands:
RUN echo "source /opt/ros/$ROS_DISTRO/setup.bash" >> ~/.bashrc && \
echo "source /ros_ws/devel/setup.bash" >> ~/.bashrc
Moreover, the workspace is always sourced at container runtime via the ros_entrypoint script.
In Snapcraft sourcing and ROS-specific environment variable are automatically handled by the ros1-noetic extension, which we can add to our app as follows:
apps:
webui-ros-joystick:
command: opt/ros/noetic/bin/roslaunch webui-ros-joystick webui.launch
plugs: [network, network-bind]
extensions: [ros1-noetic]
If we were developing an application for a ROS_DISTRO that lacks the required extension, we would have utilized the build-environment keyword to add the necessary ROS variables as shown in this example.
Finally, our Husarion package uses rospack. Rospack uses caching to speed up its performance when searching for packages and their dependencies, and does so by using ROS_HOME. Since most snaps use strict confinement to isolate both their execution environments and their data from your system, the home folder is only accessible via an interface and that does not allow access to hidden folders like .ros. To ensure that rospack operates as expected we can set the ROS_HOME environment variable to the SNAP_USER_DATA directory. This directory is unique to each user and provides a location where rospack can store persistent data such as package information and configuration files.
apps:
webui-ros-joystick:
command: opt/ros/noetic/bin/roslaunch webui-ros-joystick webui.launch
plugs: [network, network-bind]
extensions: [ros1-noetic]
environment:
ROS_HOME: $SNAP_USER_DATA/ros
Now that our app executable is defined, let us proceed to define the components required to build it.
Build environment
Docker allows users to create images based on existing parent images that provide the required environment for the application. This is useful to avoid having to set up the same core libraries everytime. Similarly, Snaps provide base snaps.
Both base Snaps and parent Docker images serve as the foundation for building our application. The Husarion Dockerfile reveals the use of two parent images:
- ros-noetic-ros-base for building the package and its dependencies;
- ros-noetic-ros-core for the final production OCI image.
This is done because Docker allows multi-stage build to overcome the fact that each RUN, COPY, and ADD instruction present in a Dockerfile adds a layer to the image. This can cause the size of the image to grow in size and be polluted with a lot of unnecessary files and dependencies. Therefore, the user is responsible for remembering to clean up all unnecessary artifacts in the final layer. Using shell tricks (e.g. apt clean, autoremove) and multi-layer images can help keep layers as small as possible.
In our snapcraft we’ve already seen that the ROS environment is set up and sourced automatically by the ros-extension. Hence, we don’t need to worry about that. Since our app is developed in ROS noetic which runs on Ubuntu 20.04, we will select the Core20 base snap by adding this line to our snapcraft.yaml:
base: core20
This line provides a minimal, stable and maintained Ubuntu 20.04 runtime environment for our application. ROS is thus installed on top of this as part of building our application as we’re going to see now.
Building the application
When writing a Dockerfile, you think of the bash commands that would run on the host to install an application and all of its dependencies correctly. For a generic ROS application these commands would install ROS, install the project dependencies using rosdep, compile your ROS package and source it.
In Snaps this is achieved via parts, which are the building blocks of our application. They are used to declare pieces of code that compose our packages and that have to be pulled into our Snap package. In our example we can identify three main building blocks for our application:
- ROS Noetic: the ROS distro that was used to build our application
- The source code for our package
- NodeJS: the framework used to develop our application’s frontend
Previously, we have see how adding the ros1-noetic extension to our application takes care of setting up the ROS environment. The great thing about Snap extensions is that they allow us to easily create a blueprint for common requirements. Since the definition of a ROS part is always the same, this is also handled by the extension. Hence, the extensions:
- Adds the ROS apt repository
- Sets up the GPG keys
- Installs the required debian.
In case the extension is not available for a specific ROS_DISTRO, we made an example that shows how the ROS part should be implemented.
That’s it for building ROS, let’s now jump into building our ROS package.
Clearly our application needs its source code and its dependencies to run. In our Dockerfile these are pulled into the container with the following commands:
# Create and initialize ROS workspace
COPY ./webui-ros-joystick ./src/webui-ros-joystick
RUN source /opt/ros/$ROS_DISTRO/setup.bash && \
rosdep update --rosdistro $ROS_DISTRO && \
rosdep install -i --from-path src --rosdistro $ROS_DISTRO -y && \
cd src/webui-ros-joystick/nodejs && \
npm install [email protected] [email protected] [email protected] [email protected] &&
npm install && \
cd /ros_ws && \
catkin_make
Let’s extract from here the elements required to build our Snap part. Snap parts are recipes to build a piece of software and are driven via plugins. Our ROS application is compiled with catkin. We can search the snapcraft plugins page to select the required plugin.
The catkin_plugin automatically handles pulling the ROS dependencies defined in the package.xml and compiling our workspace. Hence, let’s add it to our part as follows:
parts:
webui-ros-joystick:
plugin: catkin
Then we need to provide the plugin with information on the source it has to build. The source can be a folder on your host or the link to a git repository. You can learn more about parts, how to set up a specific branch or a subfolder in the Snapcraft parts metadata documentation. In our case we can point the plugin directly to the Husarion git repository as follows:
parts:
webui-ros-joystick:
plugin: catkin
source: https://github.com/husarion/webui-ros-joystick.git
source-branch: master
The Dockerfile reveals that our webui-ros-joystick relies on certain NodeJS modules, which we install in our workspace via npm. Hence we can identify npm as a build dependency of our snap. Therefore, we can make the part pull it from apt at run time by using the build-packages keyword:
parts:
webui-ros-joystick:
plugin: catkin
source: https://github.com/husarion/webui-ros-joystick.git
source-branch: master
build-packages:
- npm
The other NodeJS modules cannot be installed via apt, but have to be installed via npm. Snapcraft provides a npm plugin. This plugin automatically pulls nodejs and reads a package.json file to install all the required modules.
In our case though, the package.json file is missing. Fear not! Snap offers the necessary flexibility to customize all the Snap processes with overrides. Let’s add the override-build keyword to our part as follows:
parts:
webui-ros-joystick:
plugin: catkin
source: https://github.com/husarion/webui-ros-joystick.git
source-branch: master
build-packages:
- npm
override-build: |
snapcraftctl build
npm install --prefix ${SNAPCRAFT_PART_INSTALL} [email protected] [email protected] [email protected] [email protected]
Here we are simply telling the build stage to build the Snap with the catkin plugin as it would normally do, but at the end we will ask it to install the required NodeJS modules, similarly as it is done in the Dockerfile.
Our application requires the roslaunch, geometry-msgs, std-msgs and std-srvs ros packages to launch the application and those are not included as a run_dependency in the package.xml file of our package, therefore we need to also include it in the part by using the stage-packages keyword as follows:
parts:
webui-ros-joystick:
plugin: catkin
source: https://github.com/husarion/webui-ros-joystick.git
source-branch: master
build-packages:
- npm
override-build: |
snapcraftctl build
npm install --prefix ${SNAPCRAFT_PART_INSTALL} [email protected] [email protected] [email protected] [email protected]
stage-packages:
- ros-noetic-roslaunch
- ros-noetic-geometry-msgs
- ros-noetic-std-msgs
- ros-noetic-std-srvs
It is always recommended to follow the correct standards for dependencies and install rules. This will ease your Snap development, and also your Dockerfile development and setup.
As we’ve seen, our application also requires NodeJS, let’s see how to include it in our Snap. In the production environment of our Dockerfile, NodeJS is installed with the following command:
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash - && \
apt install -y nodejs
This is a script that sets up all the required ppa for NodeJS and the GPG keys. NodeJs is available as a Snap. Hence we can create a new part that uses the stage-snaps keyword to install it as follows:
node:
plugin: nil
stage-snaps:
- node/18/stable
stage-packages:
- libc6
This installs NodeJS snap v10, which is the version installed in the Docker container. For this part we set the plugin to nil since we are not pulling any source code.
With this syntax, updating and testing NodeJS is effortless as you can quickly switch to a different NodeJs Snap version. For example, to use the latest version of NodeJS, you only need to modify the stage-snap to node/18/stable and rebuild your snap.
Installing and running the Snap
After we’ve defined a Dockerfile we need to build the image via the Docker build command. Similarly, we use Snapcraft to build our Snap with the following command:
$ snapcraft
Starting Snapcraft
Created snap package
Once the build is complete we can install it with:
$ sudo snap install webui-ros-joystick_0.1_amd64.snap --dangerous
Using the “–dangerous” argument will result in the installation of a local snap without undergoing verification of its signatures from the store. You can read more about Snaps installation modes at: Snap install modes.
This is it, now we can run the webui-ros-joystick Snap with:
$ webui-ros-joystick
Since we’re running a launch file we might want to pass parameters to it. As we can see, the launch file allows the user to adjust some parameters for our joystick. With the snap we can just pass this parameters at run time as shown below:
$ webui-ros-joystick e_stop:=true max_lin_vel:=2.0
Done! The migration from Docker to Snap is complete! Now our snap can communicate with any ROS device that exposes the /cmd_vel topic and it’s running on my host network. This can also be a development Docker container running a simulated robot.
- It’s worth mentioning in this section that for autonomous robots, it is preferable to have applications automatically starting at boot. Snapcraft offers high-level interfaces to turn your snap command into services and daemons, wth the simplicity of always accessing automatically the required resources avoiding setting up specific Docker flags at runtime. This feature is really useful when there is the need of orchestrate multiple ROS applications, as we explored in our blog ROS orchestration with snaps | Ubuntu.
Updates mechanism
Docker images are committed and stored in DockerHub. Each image can be defined by a tag that can follow the software releases tagging. In this way other developers and clients can download the latest image and set up an up-to-date container.
However, in order to update my Docker image, as a user, I need to delete my container, pull the new image and create a new one. This process comes with downtime and loss of my container state. This is ok in a development environment, but has many limitations in a production environment, in which a robust update process is key in order to guarantee the security of the robot or the device.
Snaps can be made available to users via publication on the Snap Store. Additionally, Snaps offer an automated update mechanism, constantly monitoring the Store for any available updates. Since snaps are effectively mounted on the filesystem, the updates take advantage of the mount system in Linux being atomic. This makes updates and rollbacks very precise, frequent and without downtime. Moreover, during the update the old version is kept and the writable space of the snap is backed up, this guarantees consistency not only for the binaries but also of the persistent state. Moreover, snap offers deltas updates which compress and transmit only the changed data during updates. This is particularly beneficial for robotics applications that have limited internet access, as they help minimise bandwidth usage and reduce the cost associated with updates. You can read more about delta updates in our whitepaper.
Additionally, utilizing CircleCI, you can establish a CI/CD pipeline for your Snap. In this way, every time a modification is made to the main branch, the process of constructing and deploying your Snap is automated.
More information on how to release your application to the Store can be found at: Releasing your app | Snapcraft documentation.
Security notifications
As we mentioned, in the context of robotic applications, keeping software packages up-to-date and secure is crucial. Attackers may exploit security vulnerabilities introduced by outdated packages. Out of the box, Docker doesn’t offer a method for users to verify the security of installed packages or libraries that our application depends on, including whether they are up-to-date. This is another reason why you should consider moving from Docker to Snap.
On the other hand, the snapcraft tool can automatically detect vulnerabilities in the installed packages and libraries that compose our snap.
In our example, we use npm to install some outdated NodeJS packages that our application requires. Therefore, when build our snap, snapcraft flags any vulnerability that might affect our application as shown in the screenshot below:
As we can see, snapcraft found 9 vulnerabilities in the outdated node packages that we are installing. In this way, it’s easy for developers to proactively address potential vulnerabilities in their applications.
Conclusions of migrating Docker to Snap
In this post, we’ve seen how to move your application from Docker to Snap.
This topic is also covered on our documentation website.
We’ve analyzed how Snap simplifies some of the deployment processes. From the definition of the application that we want to expose to our robot or clients, to the high level interfaces that allows the user to define at build time which resources our app should be able to access. Moreover the seamless regular update system reduces the possibility of attacks with a seamless regular update system. Docker is primarily designed for running services and as such it excels in providing the necessary elasticity for these types of use-cases. However, its design limits its ability to run device applications, as the container cannot easily interact with devices, graphics and network which is crucial for robotics applications.
If you want to learn more about Snaps:
- Visit our documentation page
- Follow this online course in The Construct
- Read our whitepaper about Docker & ROS: When all you have is a hammer, everything looks like a nail