Vaibhav Shende Vaibhav Shende

ROS 2 Launch Files: Part 2 - ROS-Specific Actions & Node Management

Deep dive into Node, LifecycleNode, ComposableNodeContainer, and the ROS 2-specific actions that actually launch your robots.

ROS 2

ROS 2 Launch Files: Part 2 - ROS-Specific Actions & Node Management

Introduction: Where the Magic Happens

Part 1 covered the infrastructure. Now we cover what you actually came here for: launching ROS 2 nodes.

If you skipped Part 1, go back and read the section on “Actions.” This part assumes you understand that Node is an Action, not a ROS node itself.


Part 1 Quick Recap

LaunchDescription = Container
Action = Thing that gets executed
Node = Action that launches a ROS node

Your launch file generates actions.
The launch manager executes them.

1. Node: The Workhorse

Node is the action that launches an actual ROS 2 node. You’ll use this constantly.

Basic Structure

from launch_ros.actions import Node
from launch import LaunchConfiguration
 
def generate_launch_description():
    
    my_node = Node(
        package='my_package',          # ROS package name
        executable='my_executable',    # Executable name (not the node name!)
        name='my_node_name',           # Name in the ROS graph
        namespace='my_namespace',      # Namespace (optional)
        output='screen',               # Where output goes
    )
    
    return LaunchDescription([my_node])

Crucial distinction:

  • package + executable = “Find and run this program”
  • name = “What to call it in the ROS graph”
# Your launch file says:
Node(package='nav2_bringup', executable='nav2_node', name='nav2')
 
# This creates a ROS node named `/nav2`
# If you add namespace='robot':
Node(..., namespace='robot')
# Creates `/robot/nav2`

Full Node Configuration

Node(
    package='pkg',
    executable='exe',
    
    # Identification
    name='node_name',
    namespace='namespace',
    
    # Where to find files
    cwd='directory',                    # Working directory
    
    # Output & Debugging
    output='screen',                    # 'screen', 'log', None
    emulate_tty=True,                   # Better formatting
    prefix='xacro',                     # Prefix for the command
    shell=True,                         # Run through shell
    
    # Parameters
    parameters=[
        {'param1': 'value1'},
        '/path/to/params.yaml',
        LaunchConfiguration('param2'),
    ],
    
    # Topic/Service Remapping
    remappings=[
        ('/old_topic', '/new_topic'),
        ('service', 'new_service'),
    ],
    
    # Environment Variables
    env={
        'MY_VAR': 'value',
        'ROS_LOG_LEVEL': 'debug',
    },
    
    # Lifecycle Control
    respawn=False,                      # Restart if it dies
    respawn_delay=0.0,                  # Wait before restart
    
    # Conditional Launch
    condition=IfCondition('true'),      # Conditional execution
    
    # Cleanup
    on_exit=LogInfo(msg='Node exited'), # What to do when it stops
)

Real-World Example: Multi-Robot Setup

from launch import (
    LaunchDescription,
    DeclareLaunchArgument,
    LaunchConfiguration,
)
from launch_ros.actions import Node
from launch.conditions import IfCondition
 
def generate_launch_description():
    
    # Arguments
    robot1_name = DeclareLaunchArgument('robot1', default_value='robot_1')
    robot2_name = DeclareLaunchArgument('robot2', default_value='robot_2')
    use_nav = DeclareLaunchArgument('nav', default_value='true')
    
    robot1_cfg = LaunchConfiguration('robot1')
    robot2_cfg = LaunchConfiguration('robot2')
    nav_cfg = LaunchConfiguration('nav')
    
    # Robot 1
    robot1_motor = Node(
        package='motor_controller',
        executable='motor_node',
        name='motors',
        namespace=robot1_cfg,
        output='screen',
        parameters=[{
            'max_velocity': 1.5,
            'wheel_radius': 0.05,
        }],
    )
    
    robot1_lidar = Node(
        package='lidar_driver',
        executable='lidar_node',
        name='lidar',
        namespace=robot1_cfg,
        remappings=[
            ('/scan', [robot1_cfg, '/scan']),  # Namespace the topic
        ],
    )
    
    # Robot 2
    robot2_motor = Node(
        package='motor_controller',
        executable='motor_node',
        name='motors',
        namespace=robot2_cfg,
        output='screen',
    )
    
    robot2_lidar = Node(
        package='lidar_driver',
        executable='lidar_node',
        name='lidar',
        namespace=robot2_cfg,
        remappings=[
            ('/scan', [robot2_cfg, '/scan']),
        ],
    )
    
    # Navigation (optional)
    nav2 = Node(
        package='nav2_bringup',
        executable='nav2_node',
        name='nav2',
        condition=IfCondition(nav_cfg),
    )
    
    return LaunchDescription([
        robot1_name,
        robot2_name,
        use_nav,
        robot1_motor,
        robot1_lidar,
        robot2_motor,
        robot2_lidar,
        nav2,
    ])

Execution:

# Launch with defaults
ros2 launch my_pkg multi_robot.py
 
# Custom names
ros2 launch my_pkg multi_robot.py robot1:=bot1 robot2:=bot2 nav:=false
 
# Check available arguments
ros2 launch my_pkg multi_robot.py --show-args

2. LifecycleNode: Nodes with Lifecycle Management

Regular Node actions have no control over node state. LifecycleNode gives you explicit state management: configuring → activating → running → deactivating → cleaningup.

from launch_ros.actions import LifecycleNode
 
def generate_launch_description():
    
    # A lifecycle node (must be written to support lifecycle)
    lifecycle_node = LifecycleNode(
        package='my_pkg',
        executable='my_lifecycle_node',
        name='my_node',
        namespace='robot',
        output='screen',
        
        # Lifecycle-specific
        emulate_tty=True,
        parameters=[
            {'param1': 'value'}
        ],
        remappings=[
            ('/old', '/new'),
        ],
    )
    
    return LaunchDescription([lifecycle_node])

The Lifecycle State Machine:

[Unconfigured]
     |
     | ros2 lifecycle set <node> configure
     v
[Inactive] (configured, not running)
     |
     | ros2 lifecycle set <node> activate
     v
[Active] (running)
     |
     | ros2 lifecycle set <node> deactivate
     v
[Inactive]
     |
     | ros2 lifecycle set <node> cleanup
     v
[Unconfigured]

When to use: For nodes that need initialization, hardware setup, or graceful shutdown. Navigation stack, hardware drivers, etc.

When NOT to use: Simple processing nodes that don’t need special setup.


3. ComposableNodeContainer & LoadComposableNodes

This is for advanced scenarios where you want to load multiple nodes into a single process instead of spawning separate processes.

Why? Performance. Inter-process communication has overhead. Composable nodes communicate in-process.

from launch_ros.actions import ComposableNodeContainer
from launch_ros.descriptions import ComposableNode
 
def generate_launch_description():
    
    # Create a container
    container = ComposableNodeContainer(
        name='my_container',
        namespace='robot',
        package='rclcpp_components',
        executable='component_container',
        composable_node_descriptions=[
            # Nodes run INSIDE this container
            ComposableNode(
                package='sensor_drivers',
                plugin='sensor_drivers::LidarDriver',
                name='lidar',
            ),
            ComposableNode(
                package='sensor_fusion',
                plugin='sensor_fusion::PointCloudProcessor',
                name='processor',
            ),
        ],
        output='screen',
    )
    
    return LaunchDescription([container])

Result:

Single Process (my_container)
|- lidar (LidarDriver plugin)
`- processor (PointCloudProcessor plugin)

All communication happens in-process = fast

Alternative: Load Composable Nodes Dynamically

from launch_ros.actions import LoadComposableNodes
from launch_ros.descriptions import ComposableNode
 
def generate_launch_description():
    
    # Container must already be running!
    load_nodes = LoadComposableNodes(
        target_container='my_container',
        composable_node_descriptions=[
            ComposableNode(
                package='my_pkg',
                plugin='my_pkg::MyPlugin',
                name='my_node',
            ),
        ],
    )
    
    return LaunchDescription([load_nodes])

Reality check: This is advanced. Most projects don’t need composable nodes. If you’re not sure, use regular Node.


4. PushROSNamespace: Namespace Scoping

Apply a namespace to a group of nodes.

from launch.actions import GroupAction
from launch_ros.actions import PushROSNamespace, Node
 
def generate_launch_description():
    
    # Everything inside this group gets '/robot' namespace
    robot_group = GroupAction(
        actions=[
            PushROSNamespace('robot'),
            
            # This becomes /robot/motors
            Node(package='driver', executable='motor_node', name='motors'),
            
            # This becomes /robot/lidar
            Node(package='driver', executable='lidar_node', name='lidar'),
        ]
    )
    
    return LaunchDescription([robot_group])

Equivalent to:

Node(..., name='motors', namespace='robot'),
Node(..., name='lidar', namespace='robot'),

When to use: When you have many nodes that should share a namespace. Cleaner than repeating namespace= for each node.


5. SetParameter & SetParametersFromFile: Pre-Node Configuration

Parameters available to nodes before they start.

from launch.actions import SetParameter, SetParametersFromFile
from launch.substitutions import FindPackageShare, PathJoinSubstitution
 
def generate_launch_description():
    
    # Set individual parameters
    param1 = SetParameter(name='my_param', value=42)
    param2 = SetParameter(name='debug_mode', value=True)
    
    # Load parameters from YAML
    pkg = FindPackageShare('my_pkg')
    params_file = PathJoinSubstitution([
        pkg,
        'config',
        'robot_params.yaml'
    ])
    
    load_params = SetParametersFromFile(parameter_file=params_file)
    
    return LaunchDescription([
        param1,
        param2,
        load_params,
    ])

YAML format:

# robot_params.yaml
robot_controller:
  ros__parameters:
    wheel_radius: 0.033
    wheel_separation: 0.16
    max_velocity: 1.5

Timing is important:

SetParameter (parameters set)
     ↓
Node (node starts, sees parameters)

If you set parameters AFTER a node starts, the node might not see them.


Part 2 Summary: Launching ROS Nodes

You now understand:

  1. Node - Basic ROS node launching (90% of use cases)
  2. LifecycleNode - Nodes with state management
  3. ComposableNodeContainer - In-process node composition
  4. PushROSNamespace - Namespace scoping
  5. SetParameter - Pre-node parameter setting

The Reality of Node Launching

Here is what actually happens when you launch a node:

1. Launch file declares: Node(package='pkg', executable='exe', name='node_name')

2. Launch manager:
   a) Finds the executable in 'pkg'
   b) Prepares command: /opt/ros/humble/lib/pkg/exe
   c) Sets up ROS environment
   d) Spawns new process

3. Child process:
   a) Initializes rclcpp
   b) Creates node named 'node_name'
   c) Starts spinning (ROS callbacks)

4. Launch file:
   a) Tracks the child process
   b) Shows output (if output='screen')
   c) Waits for shutdown signal

Common Pitfalls

Pitfall 1: Node Won’t Start

# ❌ Wrong package or executable name
Node(package='sensor_driver', executable='lidar_node')  # Can't find it
 
# ✅ Verify first
$ ros2 pkg prefix sensor_drivers
$ ls /opt/ros/humble/lib/sensor_drivers/

Pitfall 2: Parameters Not Set

# ❌ Node starts before parameters are set
my_node = Node(package='pkg', executable='exe')
my_param = SetParameter(name='param', value=100)
 
LaunchDescription([my_node, my_param])  # Wrong order!
 
# ✅ Parameters before node
LaunchDescription([my_param, my_node])  # Right order

Pitfall 3: Namespace Confusion

# ❌ What's the actual node name?
Node(package='pkg', executable='exe', name='node', namespace='robot')
# Answer: /robot/node
 
# ❌ But remappings might override it:
Node(
    package='pkg',
    executable='exe',
    name='node',
    namespace='robot',
    remappings=[('/node', '/other_node')]
)
# The remapping is relative to the node, so: /robot/node → /robot/other_node
# Not /node → /other_node
 
# ✅ Check with ros2 node list
ros2 node list

Pitfall 4: Respawn Loops

# ❌ Node crashes and respawns forever
Node(
    package='buggy_pkg',
    executable='buggy_node',
    respawn=True,  # Will restart forever!
)
 
# ✅ Only use respawn for stable nodes
# Only use on nodes that are known to be stable

Troubleshooting Launch

# 1. List available launch files
ros2 launch my_pkg --help
 
# 2. Show launch arguments
ros2 launch my_pkg my_launch.py --show-args
 
# 3. Show full launch description (what will be executed)
ros2 launch my_pkg my_launch.py --show
 
# 4. Launch with debug output
ros2 launch my_pkg my_launch.py -v
 
# 5. Monitor running nodes
ros2 node list
ros2 topic list
ros2 service list
ros2 param list
 
# 6. Check a specific node's parameters
ros2 param list /my_node
ros2 param get /my_node my_param
 
# 7. Check connections
ros2 topic info /my_topic
ros2 service info /my_service

Advanced: Custom Node Wrappers

For maximum reusability, wrap complex node configurations:

def create_lidar_driver(robot_name: str, use_sim: bool) -> Node:
    """Factory function for lidar driver node"""
    return Node(
        package='lidar_driver',
        executable='lidar_node',
        name='lidar',
        namespace=robot_name,
        output='screen',
        parameters=[{
            'frame_id': f'{robot_name}/laser',
            'use_sim_time': use_sim,
        }],
        remappings=[
            ('/scan', f'/{robot_name}/scan'),
        ],
    )
 
def generate_launch_description():
    
    robot1 = create_lidar_driver('robot_1', use_sim=True)
    robot2 = create_lidar_driver('robot_2', use_sim=True)
    
    return LaunchDescription([robot1, robot2])

This reduces code duplication when you have multiple instances of the same node.


Quick Reference

# Minimal Node
Node(package='pkg', executable='exe')
 
# Node with namespace
Node(package='pkg', executable='exe', name='node', namespace='robot')
 
# Node with parameters
Node(
    package='pkg',
    executable='exe',
    parameters=[{'param': value}]
)
 
# Node with remapping
Node(
    package='pkg',
    executable='exe',
    remappings=[('/old', '/new')]
)
 
# Node with environment
Node(
    package='pkg',
    executable='exe',
    env={'MY_VAR': 'value'}
)
 
# Lifecycle node
LifecycleNode(package='pkg', executable='exe', name='node')
 
# Composable node container
ComposableNodeContainer(
    name='container',
    package='rclcpp_components',
    executable='component_container',
    composable_node_descriptions=[
        ComposableNode(package='pkg', plugin='pkg::Plugin'),
    ]
)

Next: Part 3: Substitutions, Conditions & Advanced Patterns →