Trying ROS2: pub/sub within a single container


Welcome to the next pikoTutorial !

I’ve worked with ROS 1 on various projects, but with its end-of-life approaching in 2025, it felt for me like the perfect time to dive into ROS 2. In this article you’ll read about:

  • setting up a typical ROS publisher and subscriber written in C++ using ROS 2
  • defining a custom interface with .msg file
  • building everything as a Docker image
  • running both nodes within a single container using a launch file which now can be written in Python

Project structure

Let’s first define a project structure to understand what am I aiming at:

project
├── launch
    ├── run_pub_sub.py
├── src
    ├── cpp_publisher
        ├── CMakeLists.txt
        ├── main.cpp
        ├── package.xml
    ├── cpp_subscriber
        ├── CMakeLists.txt
        ├── main.cpp
        ├── package.xml
    ├── interfaces
        ├── msg
            ├── Data.msg
        ├── CMakeLists.txt
        ├── package.xml
├── Dockerfile
├── ros_entrypoint.sh

Create packages

I will put publisher, subscriber and the message definitions into separate ROS packages, so let’s first create them using the following commands:

ros2 pkg create --build-type ament_cmake cpp_publisher
ros2 pkg create --build-type ament_cmake cpp_subscriber
ros2 pkg create --build-type ament_cmake interfaces

Defining a custom message type

To define a custom message type called Data, I create a Data.msg file in src/interfaces/msg folder. Remember that message definitions must be placed in msg folder and their names must be camel case. Otherwise, you’ll get the following build error:

rosidl_adapter.parser.InvalidResourceName: 'data' is an invalid message
name.  It should have the pattern '^[A-Z][A-Za-z0-9]*$'

The src/interfaces/msg/Data.msg file looks like below:

int32 value1
int32 value2
int32 value3

To generate a C++ code out of such message definition, we need to use rosidl_generate_interfaces function in src/interfaces/CMakeLists.txt file:

cmake_minimum_required(VERSION 3.8)
project(interfaces)

find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)

rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/Data.msg"
)

ament_package()

And add proper entries to the src/interfaces/package.xml file:

<buildtool_depend>rosidl_default_generators</buildtool_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>

Writing a C++ ROS2 publisher

Below you can find the implementation of the C++ publisher using ROS2 with a bit of additional utilities like overloaded stream operator for easy Data message printing. Below is the content of the src/cpp_publisher/main.cpp file.

// ROS client library for C++ header
#include "rclcpp/rclcpp.hpp"
// Data message header
#include "interfaces/msg/data.hpp"
// short alias for Data message type
using DataMsg = interfaces::msg::Data;
// constants
static constexpr const char* kNodeName {"cpp_publisher"};
static constexpr const char* kTopicName {"/data"};
static constexpr std::size_t kQueueSize {5U};
// overloaded stream operator for easy message printing
std::ostream& operator<<(std::ostream& os, const DataMsg& data)
{
    os << "value1 = " << data.value1
       << ", value2 = " << data.value2
       << ", value3 = " << data.value3;
    return os;
}
// publisher class
class CppPublisher : public rclcpp::Node
{
public:
    CppPublisher()
    : Node(kNodeName),
      publisher_{create_publisher<DataMsg>(kTopicName, kQueueSize)}
    {}

    void Publish()
    {
        // create a message and populate it with values
        DataMsg msg;

        msg.value1 = 12;
        msg.value2 = 24;
        msg.value3 = 36;

        std::cout << "[PUBLISHER] Sending data: " << msg << std::endl;
        // send message
        publisher_->publish(msg);
    }

private:
    rclcpp::Publisher<DataMsg>::SharedPtr publisher_;
};
// main function
int main(int argc, char * argv[])
{
    rclcpp::init(argc, argv);
    // create a node and loop it in a separate thread
    std::shared_ptr<CppPublisher> node = std::make_shared<CppPublisher>();
    auto future = std::async([node](){
        while (true) {
            node->Publish();
            std::this_thread::sleep_for(std::chrono::seconds(1));
        }
    }); 

    rclcpp::spin(node);
    rclcpp::shutdown();
}

I kept src/cpp_publisher/CMakeLists.txt file in its most minimalistic form, so it contains the executable depending on our previously defined interfaces package:

cmake_minimum_required(VERSION 3.5)
project(cpp_publisher)

find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(interfaces REQUIRED)

add_executable(${PROJECT_NAME} main.cpp)
ament_target_dependencies(${PROJECT_NAME}
    rclcpp
    interfaces
)

install(TARGETS ${PROJECT_NAME}
        DESTINATION lib/${PROJECT_NAME})

ament_package()

We must also remember about adding interfaces dependency to the src/cpp_publisher/package.xml file. Otherwise, the dependency build order will not be preserved and colcon may try to build e.g. cpp_publisher package before interfaces package (on which cpp_publisher depends) has been built.

<depend>interfaces</depend>

Writing a C++ ROS2 subscriber

Now it’s time to create src/cpp_subscriber/main.cpp file:

// ROS client library for C++ header
#include "rclcpp/rclcpp.hpp"
// Data message header
#include "interfaces/msg/data.hpp"
// short alias for Data message type
using DataMsg = interfaces::msg::Data;
// constants
static constexpr const char* kNodeName {"cpp_subscriber"};
static constexpr const char* kTopicName {"/data"};
static constexpr std::size_t kQueueSize {5U};
// overloaded stream operator for easy message printing
std::ostream& operator<<(std::ostream& os, const DataMsg& data)
{
    os << "value1 = " << data.value1
       << ", value2 = " << data.value2
       << ", value3 = " << data.value3;
    return os;
}
// subscriber class
class CppSubscriber : public rclcpp::Node
{
public:
    CppSubscriber()
    : Node(kNodeName)
    {
        // subscription creation with a on message received callback
        subscription_ = create_subscription<DataMsg>(kTopicName, kQueueSize, [](const DataMsg &msg){
            std::cout << "[SUBSCRIBER] Received data: " << msg << std::endl;
        });
    }

private:
    rclcpp::Subscription<DataMsg>::SharedPtr subscription_;
};
// main function
int main(int argc, char * argv[])
{
  rclcpp::init(argc, argv);
  rclcpp::spin(std::make_shared<CppSubscriber>());
  rclcpp::shutdown();
}

The implementations of src/cpp_subscriber/CMakeLists.txt and src/cpp_subscriber/package.xml files are analogous to cpp_publisher, so I’ll skip writing about them here.

Writing a ROS2 launch file

After we have publisher and subscriber ready to be ran, it’s time to define a Python launch file launch/run_pub_sub.py :

from launch import LaunchDescription
from launch_ros.actions import Node

def generate_launch_description():
    return LaunchDescription([
        Node(
            package='cpp_publisher',
            executable='cpp_publisher',
            output='screen',
        ),
        Node(
            package='cpp_subscriber',
            executable='cpp_subscriber',
            output='screen',
        ),
    ])

This file can be provided to ros2 launch command, but because we will run it inside the docker container environment which will require sourcing the ROS underlay and the workspace overlay anyway, let’s put all of these commands to a ros_entrypoint.sh file:

#!/bin/bash
set -e

source /opt/ros/rolling/setup.bash
source /app/src/install/setup.bash

ros2 launch launch/run_pub_sub.py

If you want to learn more about the ROS underlays and overlays, you can read more about them in this pikoTutorial.

Alternatively, if you don’t want to use ros2 launch, this script could look like this:

#!/bin/bash
set -e

source /opt/ros/rolling/setup.bash
source /app/src/install/setup.bash

ros2 run cpp_publisher cpp_publisher &
ros2 run cpp_subscriber cpp_subscriber

Writing a Dockerfile

To create a Docker image, I’ll use osrf/ros:rolling-desktop as a base image. It’s because on my Ubuntu 24.04 I use Rolling Ridley ROS 2 distro, so I want my container environment to be the same as my local one.

# use ROS Rolling Ridley image as the base
FROM osrf/ros:rolling-desktop
# specify bash as shell to use source command
SHELL ["/bin/bash", "-c"]
# change working directory to /app
WORKDIR /app
# copy all the ROS workspace files
COPY src src/
COPY launch launch/
COPY ros_entrypoint.sh /app
# build workspace
RUN cd src && \
    source /opt/ros/rolling/setup.bash && \
    colcon build
# assign permission to the entrypoint script
RUN chmod +x /app/ros_entrypoint.sh
# run entrypoint script
ENTRYPOINT ["/app/ros_entrypoint.sh"]

Putting all together

At this point we have all the parts, but before we start working with the container, let’s check locally if the workspace build at all with the following command:

source /opt/ros/rolling/setup.bash
colcon build

If everything succeeded, you should see the output similar to:

Starting >>> interfaces
Finished <<< interfaces [5.48s]                     
Starting >>> cpp_publisher
Starting >>> cpp_subscriber
Finished <<< cpp_publisher [6.49s]                                               
Finished <<< cpp_subscriber [9.73s]                      
Summary: 3 packages finished [15.4s]

Now as we know that ROS workspace can be successfully built, it’s time to build a docker image:

docker build -t ros_pub_sub .

Note for beginners: if you’re struggling with missing Docker permissions, check this pikoTutorial out.

To run the container basing on this image, run:

docker run --rm --name pub_sub ros_pub_sub

Examining nodes’ behavior inside the container

After running the container, you will see the following logs:

[PUBLISHER] Sending data: value1 = 12, value2 = 24, value3 = 36
[SUBSCRIBER] Received data: value1 = 12, value2 = 24, value3 = 36
[PUBLISHER] Sending data: value1 = 12, value2 = 24, value3 = 36
[SUBSCRIBER] Received data: value1 = 12, value2 = 24, value3 = 36
[PUBLISHER] Sending data: value1 = 12, value2 = 24, value3 = 36
[SUBSCRIBER] Received data: value1 = 12, value2 = 24, value3 = 36

You may want to examine further what’s going on in the container, so let’s go inside our container using:

docker container exec -it pub_sub /bin/bash

The first thing to check is whether our topic /data is on the list of active topics:

ros2 topic list

This should give the output:

/data
/parameter_events
/rosout

Let’s then check the properties of that topic:

ros2 topic info /data

As expected, we see that there is 1 publisher and 1 subscriber on that topic:

Publisher count: 1
Subscription count: 1

Last thing to do is to sniff the actual data being transferred via /data topic:

ros2 topic echo /data

Everything worked correctly because the output shows:

value1: 12
value2: 24
value3: 36

If you get an error “The message type ‘interfaces/msg/Data’ is invalid”, you can find the reason and the solution in this pikoTutorial.