ROS 2 Lifecycle Nodes & Composition
Plain nodes start running the instant they exist, in whatever order happens to win the race. Learn lifecycle nodes for deterministic startup and shutdown, and composition for running many nodes in a single process.
Every node you’ve written so far follows the same pattern: it comes up, immediately creates its publishers and subscribers, and starts doing its job the instant rclpy.spin() runs. That’s fine for a single node, but it falls apart at scale. If your camera driver starts publishing before your image processing node has finished loading a calibration file, those first frames get silently dropped. If a sensor node needs to re-read a config file after a crash, your only option is to kill and restart the whole process — there’s no “pause and reconfigure” button.
ROS 2 solves the first problem with lifecycle nodes: nodes with an explicit, inspectable state machine instead of an implicit “exists = running” model. It solves a related but different problem — process overhead — with composition: running many nodes inside one process instead of one-process-per-node. This week covers both.
What You’ll Learn
- The four states of a lifecycle node and the five transitions between them
- How to write a lifecycle node in Python with
rclpy.lifecycle.Node - How to drive transitions from the command line and understand who normally drives them in a real system
- What composition is, why it exists, and how to compose nodes in a launch file
- The common pitfalls that trip up beginners on both topics
Why “Exists” Isn’t the Same as “Ready”
A plain rclpy.node.Node has one implicit state: as soon as its constructor returns, its publishers are live and its callbacks can fire. There’s no way to ask it “are you actually ready to do useful work?” — from the outside, a node that’s still loading a 500 MB map file looks identical to one that’s fully initialized.
A lifecycle node (also called a “managed node”) makes readiness explicit. It exposes its current state, refuses to do real work until it’s told to activate, and lets an external supervisor drive it through configuration, activation, deactivation, and shutdown in a controlled order. This is exactly how Nav2 brings up a robot: the Nav2 stack you launched in Week 18 is built almost entirely out of lifecycle nodes, coordinated by a lifecycle_manager that configures and activates the planner, controller, and costmaps in a specific sequence — so the controller never starts issuing velocity commands before the costmap has a map to plan against.
The Lifecycle State Machine
Every lifecycle node has four primary states and moves between them through five transitions:
- Unconfigured — the node exists, but hasn’t allocated resources yet.
- Inactive — configured and ready, but its publishers and callbacks are dormant. This is a safe state to sit in while you wait for the rest of the system.
- Active — fully running: publishers actually publish, callbacks actually fire.
- Finalized — terminal. The node is being destroyed.
Writing a Lifecycle Node in Python
rclpy gives you a Node subclass in the rclpy.lifecycle module. Instead of doing your setup in __init__, you override one callback per transition:
# battery_monitor_lifecycle.py
import rclpy
from rclpy.lifecycle import Node, State, TransitionCallbackReturn
from std_msgs.msg import Float64
class BatteryMonitor(Node):
def __init__(self):
super().__init__('battery_monitor')
self._pub = None
self._timer = None
def on_configure(self, state: State) -> TransitionCallbackReturn:
self._pub = self.create_lifecycle_publisher(Float64, 'battery_voltage', 10)
self.get_logger().info('Configured: publisher created')
return TransitionCallbackReturn.SUCCESS
def on_activate(self, state: State) -> TransitionCallbackReturn:
self._timer = self.create_timer(1.0, self._publish_voltage)
self.get_logger().info('Activated: publishing every 1s')
return super().on_activate(state)
def on_deactivate(self, state: State) -> TransitionCallbackReturn:
self.destroy_timer(self._timer)
self.get_logger().info('Deactivated: stopped publishing')
return super().on_deactivate(state)
def on_cleanup(self, state: State) -> TransitionCallbackReturn:
self.destroy_publisher(self._pub)
self._pub = None
self.get_logger().info('Cleaned up: publisher destroyed')
return TransitionCallbackReturn.SUCCESS
def on_shutdown(self, state: State) -> TransitionCallbackReturn:
self.get_logger().info('Shutting down')
return TransitionCallbackReturn.SUCCESS
def _publish_voltage(self):
msg = Float64()
msg.data = 12.4
self._pub.publish(msg)
def main():
rclpy.init()
rclpy.spin(BatteryMonitor())
rclpy.shutdown()
A few things to notice:
create_lifecycle_publisher(not the plaincreate_publisher) creates a publisher that’s automatically silenced outside the Active state — even if something calls.publish()on it while Inactive, nothing goes out on the wire.- Each callback returns a
TransitionCallbackReturn:SUCCESSto proceed,FAILUREto abort the transition and stay put, orERRORto jump straight to a cleanup path. Callingsuper().on_activate(state)/super().on_deactivate(state)runs the base class logic that actually flips the publisher’s internal switch on or off — skip it and your publisher never turns on even after activation succeeds. - Resources are allocated in
on_configureand released inon_cleanup, so a node can be reconfigured (cleaned up, then configured again) without a full process restart.
Driving Transitions
Nothing transitions automatically — something external has to ask. For testing, the CLI does the asking:
ros2 lifecycle get /battery_monitor # what state is it in right now?
ros2 lifecycle set /battery_monitor configure # Unconfigured -> Inactive
ros2 lifecycle set /battery_monitor activate # Inactive -> Active
ros2 lifecycle list /battery_monitor # valid transitions from here
In a real system, a small supervisor node — often literally called a lifecycle manager — calls the same change_state service that the CLI calls under the hood, in whatever order matters for your robot (for example: configure and activate the sensor drivers before configuring the nodes that consume their data). That’s the role Nav2’s lifecycle_manager plays for the navigation stack you used in Week 18.
What Is Composition?
Composition is a different tool for a different problem. Every node you’ve run so far is its own OS process, and every message between two nodes on different machines — or even different processes on the same machine — is serialized, copied, and sent over a socket. That’s fine for a handful of nodes, but a robot with forty nodes all passing large images and point clouds around pays a real cost in serialization and copying.
Composition loads multiple nodes into a single process (a “container”) as plugins, instead of launching each as its own executable. Nodes in the same container can pass data through shared memory instead of the network stack, which matters a lot for high-bandwidth data like camera frames and point clouds.
| Separate processes | Composed in one container | |
|---|---|---|
| Startup | One executable per node | One container executable hosts many |
| Communication | Always serialized, over sockets/shared memory transport | Can use true intra-process, zero-copy delivery |
| Overhead | Higher — one process, one DDS participant per node | Lower — shared process and DDS participant |
| Crash isolation | A crashing node doesn’t take others down | A crash can take the whole container down |
Composing Nodes with a Launch File
Composition is currently a C++-only feature — a composable node is a C++ class registered with RCLCPP_COMPONENTS_REGISTER_NODE, and only rclcpp nodes can be loaded into a container this way. There’s no Python equivalent of loading a plain rclpy node into a shared-process container. You still write the launch file in Python, though:
# launch/perception_container.launch.py
from launch import LaunchDescription
from launch_ros.actions import ComposableNodeContainer
from launch_ros.descriptions import ComposableNode
def generate_launch_description():
container = ComposableNodeContainer(
name='perception_container',
namespace='',
package='rclcpp_components',
executable='component_container_mt',
composable_node_descriptions=[
ComposableNode(
package='image_proc',
plugin='image_proc::RectifyNode',
name='rectify',
),
ComposableNode(
package='my_robot_perception',
plugin='my_robot_perception::ObjectDetector',
name='object_detector',
),
],
output='screen',
)
return LaunchDescription([container])
executable='component_container' gives you a single-threaded container; component_container_mt gives you a multi-threaded one so the nodes inside it can actually run concurrently instead of taking turns.
You can also load and unload components into an already-running container from the command line, without editing a launch file at all:
ros2 component list # see running containers + loaded nodes
ros2 component load /perception_container image_proc image_proc::RectifyNode
ros2 component unload /perception_container <unique_id>
Common Pitfalls
Using create_publisher instead of create_lifecycle_publisher. A regular publisher created inside a lifecycle node’s on_configure will happily publish in every state, including Unconfigured — completely defeating the point. Always use create_lifecycle_publisher inside a lifecycle node.
Forgetting to call super().on_activate() / super().on_deactivate(). These base-class calls are what actually flip a lifecycle publisher’s internal enabled switch. Override the callback for your own setup, but still chain to the parent implementation (or return its result directly, as in the example above).
Expecting a lifecycle node to activate itself. A freshly launched lifecycle node sits in Unconfigured forever unless something calls configure and then activate — either the CLI, a service call, or (in production) a lifecycle manager node. If your node “isn’t doing anything,” check its state before you check your code.
Trying to write a composable node in pure Python. If you’re hunting for a Python decorator or base class that lets ros2 component load pick up an rclpy node, it doesn’t exist yet — composition’s plugin-loading mechanism is C++-only. Python nodes still run fine standalone or under a MultiThreadedExecutor; they just can’t join a component container.
Picking component_container when you needed component_container_mt. The single-threaded container runs every composed node’s callbacks on one thread, one at a time — a slow callback in one node stalls every other node sharing that container. Reach for component_container_mt any time your composed nodes need to run concurrently.
Recap
| What you want | How to do it |
|---|---|
| Explicit, inspectable node readiness | Lifecycle node: rclpy.lifecycle.Node + on_configure/on_activate/… |
| A publisher that respects lifecycle state | create_lifecycle_publisher, not create_publisher |
| Trigger a transition manually | ros2 lifecycle set /node <transition> |
| Coordinate many lifecycle nodes automatically | A lifecycle manager node calling change_state on each |
| Run many nodes in one process | ComposableNodeContainer + ComposableNode (C++ nodes only) |
| Concurrent execution inside a container | executable='component_container_mt' |
| Inspect/modify a running container | ros2 component list / load / unload |
Lifecycle nodes give you control over when a node does its job; composition gives you control over how much it costs to run many of them together. Both show up constantly in production ROS 2 stacks like Nav2, even though a simple ros2 run never needs either.
Next week, we look at capturing and replaying exactly what your robot saw: Recording & Replaying Data with rosbag2 — how to record every topic your robot publishes to a single file, and play it back later without touching real hardware.