saluki_env/workload/collectors/
cgroups.rs

1use std::time::Duration;
2
3use async_trait::async_trait;
4use saluki_common::{
5    collections::{FastHashMap, FastHashSet},
6    sync::shutdown::ShutdownHandle,
7};
8use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
9use saluki_core::health::Health;
10use saluki_error::{generic_error, ErrorContext as _, GenericError};
11use saluki_metrics::{static_metrics, Counter};
12use stringtheory::{interning::GenericMapInterner, MetaString};
13use tokio::{pin, select, sync::mpsc};
14use tracing::{debug, warn};
15
16use super::MetadataCollector;
17use crate::workload::{
18    entity::EntityId,
19    helpers::cgroups::{CgroupsConfiguration, CgroupsReader},
20    metadata::MetadataOperation,
21};
22
23#[static_metrics(prefix = cgroups_metadata_collector)]
24#[derive(Clone)]
25struct Telemetry {
26    /// Traversals that covered the entire hierarchy, and so ran a removal pass.
27    ///
28    /// Together with `traversals_incomplete_total`, this accounts for every traversal, so the two can be summed for a
29    /// total, or compared against each other to see how often traversals are being cut short.
30    traversals_complete_total: Counter,
31
32    /// Traversals that could not cover the entire hierarchy, and so did not run a removal pass.
33    traversals_incomplete_total: Counter,
34
35    /// Individual paths skipped during traversal because they could not be read.
36    traversal_paths_skipped_total: Counter,
37}
38
39/// A metadata collector that observes Linux "Control Groups" (cgroups).
40///
41/// This collector specifically tracks cgroup controllers attached to container workloads using simple regex-based
42/// matching against the controller path, and maintains a mapping of controller inodes to the container ID extracted
43/// from the controller path.
44///
45/// This is specifically used to support client-based Origin Detection in DogStatsD, where clients will either send
46/// their detected container ID _or_ the inode of their cgroup controller. A canonical container ID must always be used
47/// for origin enrichment, so this mapping allows resolving controller inodes to their canonical container ID.
48pub struct CgroupsMetadataCollector {
49    reader: CgroupsReader,
50    health: Health,
51}
52
53impl CgroupsMetadataCollector {
54    /// Creates a new `CgroupsMetadataCollector` from the given cgroups configuration.
55    ///
56    /// # Errors
57    ///
58    /// If a valid cgroups hierarchy can not be located at the configured path, an error will be returned.
59    pub async fn new(
60        cgroups_config: &CgroupsConfiguration, health: Health, interner: GenericMapInterner,
61    ) -> Result<Self, GenericError> {
62        let reader = match CgroupsReader::try_from_config(cgroups_config, interner)? {
63            Some(reader) => reader,
64            None => {
65                return Err(generic_error!("Failed to detect any cgroups v1/v2 hierarchy. "));
66            }
67        };
68
69        Ok(Self { reader, health })
70    }
71}
72
73#[async_trait]
74impl MetadataCollector for CgroupsMetadataCollector {
75    fn name(&self) -> &'static str {
76        "cgroups"
77    }
78
79    async fn watch(&mut self, operations_tx: &mut mpsc::Sender<MetadataOperation>) -> Result<(), GenericError> {
80        self.health.mark_ready();
81
82        // Drive a blocking background task that polls the cgroups hierarchy on a regular interval, and sends metadata
83        // updates when cgroups are created or deleted. We do this in a blocking task since all of the I/O operations
84        // are synchronous.
85        let mut cgroups_manager = SynchronousCgroupsManager::from_reader(self.reader.clone());
86        let operations_tx = operations_tx.clone();
87
88        // We hold on to the shutdown coordinator here (even though we never call it) so that it only triggers on drop,
89        // ensuring we don't leak the blocking poller task if this collector is dropped before it completes.
90        let (_shutdown_coordinator, shutdown_handle) = ShutdownHandle::paired();
91        let poller_handle = tokio::task::spawn_blocking(move || cgroups_manager.poll(operations_tx, shutdown_handle));
92        pin!(poller_handle);
93
94        debug!("Spawned cgroups background poller task.");
95
96        let final_result = loop {
97            select! {
98                _ = self.health.live() => {},
99                result = &mut poller_handle => match result {
100                    Ok(Ok(())) => break Ok(()),
101                    Ok(Err(e)) => break Err(e).error_context("Cgroups background poller task encountered an error."),
102                    Err(e) => break Err(e).error_context("Cgroups background poller task panicked."),
103                }
104            }
105        };
106
107        self.health.mark_not_ready();
108
109        final_result
110    }
111}
112
113impl MemoryBounds for CgroupsMetadataCollector {
114    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
115        builder
116            .minimum()
117            // Pre-allocated operation batch buffer. This is only the minimum, as it could grow larger.
118            .with_array::<MetadataOperation>("metadata operations", 64);
119        // TODO: Kind of a throwaway calculation because nothing about the reader can really be bounded at the moment.
120        //
121        // Specifically, we don't know the number of cgroups that will be present... and we have both a map that holds
122        // the active cgroups _and_ a map to track the cgroups seen during a single traversal, which we need to
123        // determine which cgroups have been removed. This means we might end up with like 3 copies of the same cgroup
124        // times however many cgroups there are at peak.
125        builder.firm().with_single_value::<Self>("component struct");
126    }
127}
128
129struct SynchronousCgroupsManager {
130    reader: CgroupsReader,
131    active_cgroups: FastHashMap<u64, MetaString>,
132    operations: Vec<MetadataOperation>,
133    telemetry: Telemetry,
134}
135
136impl SynchronousCgroupsManager {
137    fn from_reader(reader: CgroupsReader) -> Self {
138        Self {
139            reader,
140            active_cgroups: FastHashMap::default(),
141            operations: Vec::with_capacity(64),
142            telemetry: Telemetry::new(),
143        }
144    }
145
146    fn poll(
147        &mut self, operations_tx: mpsc::Sender<MetadataOperation>, shutdown_handle: ShutdownHandle,
148    ) -> Result<(), GenericError> {
149        let mut traversed_cgroups = FastHashSet::default();
150        let mut cgroups_to_delete = Vec::new();
151
152        loop {
153            // Make sure we should still be running.
154            if operations_tx.is_closed() || shutdown_handle.is_triggered() {
155                return Ok(());
156            }
157
158            traversed_cgroups.clear();
159
160            let start = std::time::Instant::now();
161
162            // Traverse the cgroups hierarchy and collect all child cgroups that we can find that are attached to a
163            // container and have a controller inode for us to attach an alias to.
164            let traversal = self.reader.get_child_cgroups();
165            let traversal_complete = traversal.is_complete();
166            let skipped = traversal.skipped();
167
168            if skipped > 0 {
169                self.telemetry.traversal_paths_skipped_total().increment(skipped as u64);
170            }
171
172            let child_cgroups = traversal.into_cgroups();
173            let child_cgroups_len = child_cgroups.len();
174
175            for child_cgroup in child_cgroups {
176                if let Some(cgroup_inode) = child_cgroup.inode() {
177                    traversed_cgroups.insert(cgroup_inode);
178
179                    // If we haven't seen this cgroup before, start tracking it.
180                    if !self.active_cgroups.contains_key(&cgroup_inode) {
181                        let container_id = child_cgroup.into_container_id();
182                        debug!(%cgroup_inode, %container_id, "Found new container-based cgroup.");
183
184                        self.active_cgroups.insert(cgroup_inode, container_id.clone());
185
186                        // Emit an operation to add an alias between the cgroup inode and the container ID.
187                        let entity_id = EntityId::ContainerInode(cgroup_inode);
188                        let ancestor_entity_id = EntityId::Container(container_id);
189
190                        let operation = MetadataOperation::add_alias(entity_id, ancestor_entity_id);
191                        self.operations.push(operation);
192                    }
193                } else {
194                    // If the cgroup has no inode, we can't track it.
195                    let container_id = child_cgroup.into_container_id();
196                    warn!(%container_id, "Encountered cgroup without controller inode during metadata traversal. This is unexpected.");
197                    continue;
198                }
199            }
200
201            // Figure out which cgroups are no longer active and mark them for deletion.
202            //
203            // We can only conclude "absent means removed" from a traversal that would have seen the cgroup if it still
204            // existed. When the traversal reports otherwise, a cgroup we didn't see may well still be live, and
205            // dropping its alias would break origin enrichment for it. Holding on to a stale alias for another poll
206            // interval is the cheaper mistake.
207            if traversal_complete {
208                self.telemetry.traversals_complete_total().increment(1);
209
210                for cgroup_inode in self.active_cgroups.keys() {
211                    if !traversed_cgroups.contains(cgroup_inode) {
212                        // This cgroup is no longer present, so we need to delete it.
213                        cgroups_to_delete.push(*cgroup_inode);
214                    }
215                }
216            } else {
217                self.telemetry.traversals_incomplete_total().increment(1);
218                debug!(
219                    skipped,
220                    "Cgroups hierarchy traversal was incomplete. Skipping removal of cgroups that were not seen."
221                );
222            }
223
224            // Process the deletions.
225            for cgroup_inode in cgroups_to_delete.drain(..) {
226                if let Some(container_id) = self.active_cgroups.remove(&cgroup_inode) {
227                    debug!(%cgroup_inode, %container_id, "Removing old container-based cgroup.");
228
229                    // Emit a metadata operation to remove the alias between the cgroup inode and the container ID.
230                    let entity_id = EntityId::ContainerInode(cgroup_inode);
231                    let ancestor_entity_id = EntityId::Container(container_id);
232
233                    let operation = MetadataOperation::remove_alias(entity_id, ancestor_entity_id);
234                    self.operations.push(operation);
235                } else {
236                    warn!(%cgroup_inode, "Tried to remove a cgroup that was not in the active set.");
237                }
238            }
239
240            let elapsed = start.elapsed();
241            debug!(elapsed = ?elapsed, child_cgroups_len, "Traversed cgroups.");
242
243            // Send all collected operations to the channel.
244            for operation in self.operations.drain(..) {
245                if operations_tx.blocking_send(operation).is_err() {
246                    return Err(GenericError::msg("Operations channel unexpectedly closed."));
247                }
248            }
249
250            // Check again if we should shutdown before we go to sleep.
251            if operations_tx.is_closed() || shutdown_handle.is_triggered() {
252                return Ok(());
253            }
254
255            std::thread::sleep(Duration::from_secs(2));
256        }
257    }
258}