sys/sysmod/
health.rs

1//! 定期ヘルスチェック機能。
2
3use super::SystemModule;
4use crate::taskserver::Control;
5use crate::{config, taskserver};
6use anyhow::{Result, anyhow, ensure};
7use bitflags::bitflags;
8use chrono::{DateTime, Local, NaiveTime};
9use log::info;
10use serde::{Deserialize, Serialize};
11use std::collections::VecDeque;
12use tokio::{process::Command, select};
13
14/// [Health::history] の最大サイズ。
15///
16/// 60 * 24 = 1440 /day
17const HISTORY_QUEUE_SIZE: usize = 60 * 1024 * 2;
18
19/// ヘルスチェック設定データ。toml 設定に対応する。
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21pub struct HealthConfig {
22    /// ヘルスチェック機能を有効化する。
23    enabled: bool,
24    /// 起動時に1回だけタイムライン確認タスクを起動する。デバッグ用。
25    debug_exec_once: bool,
26}
27
28/// ヘルスチェックシステムモジュール。
29pub struct Health {
30    /// 設定データ。
31    config: HealthConfig,
32    /// 定期実行の時刻リスト。
33    wakeup_list_check: Vec<NaiveTime>,
34    /// 定期実行の時刻リスト。
35    wakeup_list_tweet: Vec<NaiveTime>,
36    /// 測定データの履歴。最大サイズは [HISTORY_QUEUE_SIZE]。
37    history: VecDeque<HistoryEntry>,
38}
39
40impl Health {
41    /// コンストラクタ。
42    ///
43    /// 設定の読み込みのみ行い、async task の初期化は [Self::on_start] で行う。
44    pub fn new(
45        wakeup_list_check: Vec<NaiveTime>,
46        wakeup_list_tweet: Vec<NaiveTime>,
47    ) -> Result<Self> {
48        info!("[health] initialize");
49
50        let config: HealthConfig = config::get(|cfg| cfg.health.clone());
51
52        Ok(Health {
53            config,
54            wakeup_list_check,
55            wakeup_list_tweet,
56            history: VecDeque::with_capacity(HISTORY_QUEUE_SIZE),
57        })
58    }
59
60    /// 測定タスク。
61    /// [Self::history] に最新データを追加する。
62    async fn check_task(&mut self, _ctrl: &Control) -> Result<()> {
63        let cpu_info = get_cpu_info().await?;
64        let mem_info = get_mem_info().await?;
65        let disk_info = get_disk_info().await?;
66
67        let timestamp = Local::now();
68        let enrty = HistoryEntry {
69            timestamp,
70            cpu_info,
71            mem_info,
72            disk_info,
73        };
74
75        debug_assert!(self.history.len() <= HISTORY_QUEUE_SIZE);
76        // サイズがいっぱいなら一番古いものを消す
77        while self.history.len() >= HISTORY_QUEUE_SIZE {
78            self.history.pop_front();
79        }
80        // 今回の分を追加
81        self.history.push_back(enrty);
82
83        Ok(())
84    }
85
86    /// ツイートタスク。
87    /// [Self::history] の最新データが存在すればツイートする。
88    async fn tweet_task(&self, ctrl: &Control) -> Result<()> {
89        if let Some(entry) = self.history.back() {
90            let HistoryEntry {
91                cpu_info,
92                mem_info,
93                disk_info,
94                ..
95            } = entry;
96
97            let mut text = String::new();
98
99            text.push_str(&format!("CPU: {:.1}%", cpu_info.cpu_percent_total));
100
101            if let Some(temp) = cpu_info.temp {
102                text.push_str(&format!("\nCPU Temp: {temp:.1}'C"));
103            }
104
105            text.push_str(&format!(
106                "\nMemory: {:.1}/{:.1} MB Avail ({:.1}%)",
107                mem_info.avail_mib,
108                mem_info.total_mib,
109                100.0 * mem_info.avail_mib / mem_info.total_mib,
110            ));
111
112            text.push_str(&format!(
113                "\nDisk: {:.1}/{:.1} GB Avail ({:.1}%)",
114                disk_info.avail_gib,
115                disk_info.total_gib,
116                100.0 * disk_info.avail_gib / disk_info.total_gib,
117            ));
118
119            let mut twitter = ctrl.sysmods().twitter.lock().await;
120            twitter.tweet(&text).await?;
121        }
122
123        Ok(())
124    }
125
126    /// [Self::check_task] のエントリ関数。
127    /// モジュールをロックしてメソッド呼び出しを行う。
128    async fn check_task_entry(ctrl: Control) -> Result<()> {
129        // wlock
130        let mut health = ctrl.sysmods().health.lock().await;
131        health.check_task(&ctrl).await
132        // unlock
133    }
134
135    /// [Self::tweet_task] のエントリ関数。
136    /// モジュールをロックしてメソッド呼び出しを行う。
137    async fn tweet_task_entry(ctrl: Control) -> Result<()> {
138        // check_task を先に実行する (可能性を高める) ために遅延させる
139        select! {
140            _ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {}
141            _ = ctrl.wait_cancel_rx() => {
142                info!("[health-tweet] task cancel");
143                return Ok(());
144            }
145        }
146
147        // rlock
148        let health = ctrl.sysmods().health.lock().await;
149        health.tweet_task(&ctrl).await
150        // unlock
151    }
152}
153
154impl SystemModule for Health {
155    fn on_start(&mut self, ctrl: &Control) {
156        info!("[health] on_start");
157        if self.config.enabled {
158            if self.config.debug_exec_once {
159                taskserver::spawn_oneshot_task(ctrl, "health-check", Health::check_task_entry);
160                taskserver::spawn_oneshot_task(ctrl, "health-tweet", Health::tweet_task_entry);
161            } else {
162                taskserver::spawn_periodic_task(
163                    ctrl,
164                    "health-check",
165                    &self.wakeup_list_check,
166                    Health::check_task_entry,
167                );
168                taskserver::spawn_periodic_task(
169                    ctrl,
170                    "health-tweet",
171                    &self.wakeup_list_tweet,
172                    Health::tweet_task_entry,
173                );
174            }
175        }
176    }
177}
178
179/// 履歴データのエントリ。
180#[derive(Debug, Clone)]
181struct HistoryEntry {
182    /// タイムスタンプ。
183    #[allow(dead_code)]
184    timestamp: DateTime<Local>,
185    /// CPU 使用率。
186    cpu_info: CpuInfo,
187    /// メモリ使用率。
188    mem_info: MemInfo,
189    /// ディスク使用率。
190    disk_info: DiskInfo,
191}
192
193/// CPU 情報。
194#[derive(Debug, Clone, Copy)]
195pub struct CpuInfo {
196    /// 全コア合計の使用率。
197    pub cpu_percent_total: f64,
198    /// CPU 温度 (℃)。
199    /// 取得できなかった場合は [None]。
200    pub temp: Option<f64>,
201}
202
203/// メモリ使用率。
204#[derive(Debug, Clone, Copy)]
205pub struct MemInfo {
206    /// メモリ総量 (MiB)。
207    total_mib: f64,
208    /// 利用可能メモリ量 (MiB)。
209    avail_mib: f64,
210}
211
212/// ディスク使用率。
213#[derive(Debug, Clone, Copy)]
214pub struct DiskInfo {
215    /// ディスク総量 (GiB)。
216    total_gib: f64,
217    /// 利用可能ディスクサイズ (GiB)。
218    avail_gib: f64,
219}
220
221/// [CpuInfo] を計測する。
222///
223/// _/proc/stat_ による。
224pub async fn get_cpu_info() -> Result<CpuInfo> {
225    let buf = tokio::fs::read("/proc/stat").await?;
226    let text = String::from_utf8_lossy(&buf);
227
228    // See `man proc`
229    // user   (1) Time spent in user mode.
230    // nice   (2) Time spent in user mode with low priority (nice).
231    // system (3) Time spent in system mode.
232    // idle   (4) Time spent in the idle task.  This value should be USER_HZ times the second entry in the /proc/uptime pseudo-file.
233    // iowait (since Linux 2.5.41)
234    //        (5) Time waiting for I/O to complete.  This value is not reliable, for the following reasons:
235    // irq (since Linux 2.6.0-test4)
236    //        (6) Time servicing interrupts.
237    // softirq (since Linux 2.6.0-test4)
238    //        (7) Time servicing softirqs.
239    // steal (since Linux 2.6.11)
240    //        (8)  Stolen  time,  which  is the time spent in other operating systems when running in a virtualized environment
241    // guest (since Linux 2.6.24)
242    //        (9) Time spent running a virtual CPU for guest operating systems under the control of the Linux kernel.
243    // guest_nice (since Linux 2.6.33)
244    //        (10)  Time spent running a niced guest (virtual CPU for guest operating systems under the
245    //        control of the Linux kernel).
246    let mut cpu_percent_total = None;
247    let mut cpu_percent_list = vec![];
248    for line in text.lines() {
249        let mut name = None;
250        let mut user = None;
251        let mut nice = None;
252        let mut system = None;
253        let mut idle = None;
254        for (col_no, elem) in line.split_ascii_whitespace().enumerate() {
255            match col_no {
256                0 => name = Some(elem),
257                1 => user = Some(elem),
258                2 => nice = Some(elem),
259                3 => system = Some(elem),
260                4 => idle = Some(elem),
261                _ => (),
262            }
263        }
264        // cpu or cpu%d の行を取り出す
265        if name.is_none() || !name.unwrap().starts_with("cpu") {
266            continue;
267        }
268
269        let user: u64 = user.ok_or_else(|| anyhow!("parse error"))?.parse()?;
270        let nice: u64 = nice.ok_or_else(|| anyhow!("parse error"))?.parse()?;
271        let system: u64 = system.ok_or_else(|| anyhow!("parse error"))?.parse()?;
272        let idle: u64 = idle.ok_or_else(|| anyhow!("parse error"))?.parse()?;
273        let total = user + nice + system + idle;
274        let value = (total - idle) as f64 / total as f64;
275        if name == Some("cpu") {
276            cpu_percent_total = Some(value);
277        } else {
278            cpu_percent_list.push(value);
279        }
280    }
281
282    ensure!(cpu_percent_total.is_some());
283    ensure!(!cpu_percent_list.is_empty());
284    let cpu_percent_total = cpu_percent_total.unwrap();
285
286    let temp = get_cpu_temp().await?;
287
288    Ok(CpuInfo {
289        cpu_percent_total,
290        temp,
291    })
292}
293
294/// [MemInfo] を計測する。
295///
296/// `free` コマンドによる。
297pub async fn get_mem_info() -> Result<MemInfo> {
298    let mut cmd = Command::new("free");
299    let output = cmd.output().await?;
300    ensure!(output.status.success(), "free command failed");
301
302    // sample
303    //                total        used        free      shared  buff/cache   available
304    // Mem:        13034888     4119272     5561960          68     3353656     8609008
305    // Swap:        4194304           0     4194304
306    let stdout = String::from_utf8_lossy(&output.stdout);
307    let mut total = None;
308    let mut avail = None;
309    for (line_no, line) in stdout.lines().enumerate() {
310        if line_no != 1 {
311            continue;
312        }
313        for (col_no, elem) in line.split_ascii_whitespace().enumerate() {
314            match col_no {
315                1 => total = Some(elem),
316                6 => avail = Some(elem),
317                _ => (),
318            }
319        }
320        break;
321    }
322    let total = total.ok_or_else(|| anyhow!("parse error"))?;
323    let avail = avail.ok_or_else(|| anyhow!("parse error"))?;
324    let total_mib = total.parse::<u64>()? as f64 / 1024.0;
325    let avail_mib = avail.parse::<u64>()? as f64 / 1024.0;
326
327    Ok(MemInfo {
328        total_mib,
329        avail_mib,
330    })
331}
332
333/// [DiskInfo] を計測する。
334///
335/// `df` コマンドによる。
336pub async fn get_disk_info() -> Result<DiskInfo> {
337    let mut cmd = Command::new("df");
338    let output = cmd.output().await?;
339    ensure!(output.status.success(), "df command failed");
340
341    // sample
342    // ファイルシス   1K-ブロック     使用    使用可 使用% マウント位置
343    // /dev/root        122621412 12964620 104641120   12% /
344    // devtmpfs           1800568        0   1800568    0% /dev
345    // tmpfs              1965432        0   1965432    0% /dev/shm
346    // tmpfs              1965432    17116   1948316    1% /run
347    // tmpfs                 5120        4      5116    1% /run/lock
348    // tmpfs              1965432        0   1965432    0% /sys/fs/cgroup
349    // /dev/mmcblk0p1      258095    49324    208772   20% /boot
350    // /dev/sda1         59280316 57109344         0  100% /media/usbbkup
351    let stdout = String::from_utf8_lossy(&output.stdout);
352    let mut total = None;
353    let mut avail = None;
354    for line in stdout.lines().skip(1) {
355        let mut total_tmp = None;
356        let mut avail_tmp = None;
357        let mut mp_tmp = None;
358        for (col_no, elem) in line.split_ascii_whitespace().enumerate() {
359            match col_no {
360                1 => total_tmp = Some(elem),
361                3 => avail_tmp = Some(elem),
362                5 => mp_tmp = Some(elem),
363                _ => (),
364            }
365        }
366        if let Some(mp) = mp_tmp
367            && mp == "/"
368        {
369            total = total_tmp;
370            avail = avail_tmp;
371        }
372    }
373    let total = total.ok_or_else(|| anyhow!("parse error"))?;
374    let avail = avail.ok_or_else(|| anyhow!("parse error"))?;
375    let total_gib = total.parse::<u64>()? as f64 / 1024.0 / 1024.0;
376    let avail_gib = avail.parse::<u64>()? as f64 / 1024.0 / 1024.0;
377
378    Ok(DiskInfo {
379        total_gib,
380        avail_gib,
381    })
382}
383
384/// CPU 温度 (正確には違うかもしれない。ボード上の何らかの温度センサの値。) を取得する。
385///
386/// _/sys/class/thermal/thermal_zone0/temp_ による。
387/// デバイスファイルが存在しない場合は [None] を返して成功扱いとする。
388/// Linux 汎用のようだが少なくとも WSL2 では存在しない。
389/// RasPi only で `vcgencmd measure_temp` という手もあるが、
390/// 人が読みやすい代わりにパースが難しくなるのでデバイスファイルの方を使う。
391pub async fn get_cpu_temp() -> Result<Option<f64>> {
392    let result = tokio::fs::read("/sys/class/thermal/thermal_zone0/temp").await;
393    match result {
394        Ok(buf) => {
395            let text = String::from_utf8_lossy(&buf);
396            // 'C を 1000 倍した整数が得られるので変換する
397            let temp = text.trim().parse::<f64>()? / 1000.0;
398
399            Ok(Some(temp))
400        }
401        Err(e) => {
402            if e.kind() == std::io::ErrorKind::NotFound {
403                // NotFound は None を返して成功扱い
404                Ok(None)
405            } else {
406                // その他のエラーはそのまま返す
407                Err(anyhow::Error::from(e))
408            }
409        }
410    }
411}
412
413/// CPU 論理コア数を取得する。
414///
415/// nproc コマンドを使用する。
416pub async fn get_cpu_cores() -> Result<u32> {
417    let output = Command::new("nproc").output().await?;
418    ensure!(output.status.success(), "nproc command failed");
419
420    let stdout = String::from_utf8_lossy(&output.stdout);
421
422    Ok(stdout.trim().parse()?)
423}
424
425/// CPU クロック周波数を取得する。
426///
427/// Raspberry Pi vcgencmd コマンドを使用する。
428/// 存在しない環境ではエラーではなく None を返す。
429pub async fn get_current_freq() -> Result<Option<u64>> {
430    let result = Command::new("vcgencmd")
431        .arg("measure_clock ")
432        .arg("arm")
433        .output()
434        .await;
435    let output = match result {
436        Ok(output) => output,
437        Err(e) => {
438            if e.kind() == std::io::ErrorKind::NotFound {
439                // NotFound は None を返して成功扱い
440                return Ok(None);
441            } else {
442                // その他のエラーはそのまま返す
443                return Err(anyhow::Error::from(e));
444            }
445        }
446    };
447    ensure!(output.status.success(), "vcgencmd measure_clock failed");
448
449    let stdout = String::from_utf8_lossy(&output.stdout);
450    let actual = if let Some((_le, ri)) = stdout.trim().split_once('=') {
451        ri.parse::<u64>()?
452    } else {
453        return Err(anyhow!("Parse error"));
454    };
455
456    Ok(Some(actual))
457}
458
459/// CPU クロック周波数の設定値を取得する。
460/// 実際の周波数は発熱によるスロットリングによりこれより低くなる可能性がある。
461///
462/// Raspberry Pi vcgencmd コマンドを使用する。
463/// 存在しない環境ではエラーではなく None を返す。
464pub async fn get_freq_conf() -> Result<Option<u64>> {
465    let result = Command::new("vcgencmd")
466        .arg("get_config")
467        .arg("arm_freq")
468        .output()
469        .await;
470    let output = match result {
471        Ok(output) => output,
472        Err(e) => {
473            if e.kind() == std::io::ErrorKind::NotFound {
474                // NotFound は None を返して成功扱い
475                return Ok(None);
476            } else {
477                // その他のエラーはそのまま返す
478                return Err(anyhow::Error::from(e));
479            }
480        }
481    };
482    ensure!(output.status.success(), "vcgencmd get_config failed");
483
484    let stdout = String::from_utf8_lossy(&output.stdout);
485    let conf = if let Some((_le, ri)) = stdout.trim().split_once('=') {
486        // MHz => Hz
487        ri.parse::<u64>()? * 1000 * 1000
488    } else {
489        return Err(anyhow!("Parse error"));
490    };
491
492    Ok(Some(conf))
493}
494
495bitflags! {
496    /// vcgencmd get_throttled bit flags
497    #[derive(Default)]
498    pub struct ThrottleFlags: u32 {
499        /// 0: Under-voltage detected
500        const UNDER_VOLTAGE = 0x1;
501        /// 1: Arm frequency capped
502        const ARM_FREQ_CAPPED = 0x2;
503        /// 2: Currently throttled
504        const THROTTLED = 0x4;
505        /// 3: Soft temperature limit active
506        const SOFT_TEMP_LIMIT = 0x8;
507        /// 16: Under-voltage has occurred
508        const PAST_UNDER_VOLTAGE = 0x10000;
509        /// 17: Arm frequency capping has occurred
510        const PAST_ARM_FREQ_CAPPED = 0x20000;
511        /// 18: Throttling has occurred
512        const PAST_THROTTLED = 0x40000;
513        /// 19: Soft temperature limit has occurred
514        const PAST_SOFT_TEMP_LIMIT = 0x80000;
515    }
516}
517
518/// CPU スロットリング状態を取得する。
519///
520/// Raspberry Pi vcgencmd コマンドを使用する。
521/// 存在しない環境ではエラーではなく None を返す。
522pub async fn get_throttle_status() -> Result<Option<ThrottleFlags>> {
523    let result = Command::new("vcgencmd").arg("get_throttled").output().await;
524    let output = match result {
525        Ok(output) => output,
526        Err(e) => {
527            if e.kind() == std::io::ErrorKind::NotFound {
528                // NotFound は None を返して成功扱い
529                return Ok(None);
530            } else {
531                // その他のエラーはそのまま返す
532                return Err(anyhow::Error::from(e));
533            }
534        }
535    };
536    ensure!(output.status.success(), "vcgencmd get_throttled failed");
537
538    let stdout = String::from_utf8_lossy(&output.stdout);
539    let bits = if let Some((_le, ri)) = stdout.trim().split_once("=0x") {
540        u32::from_str_radix(ri, 16)?
541    } else {
542        return Err(anyhow!("Parse error"));
543    };
544    let status = ThrottleFlags::from_bits(bits).ok_or_else(|| anyhow!("Invalid bitflags"))?;
545
546    Ok(Some(status))
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[tokio::test]
554    async fn cpu_info() {
555        let info = get_cpu_info().await.unwrap();
556
557        assert!((0.0..=100.0).contains(&info.cpu_percent_total));
558
559        let temp = info.temp;
560        if cfg!(any(target_arch = "arm", target_arch = "aarch64")) {
561            let temp = temp.unwrap();
562            assert!(
563                (30.0..=100.0).contains(&temp),
564                "strange temperature: {temp}"
565            );
566        } else {
567            assert!(temp.is_none());
568        }
569    }
570
571    #[tokio::test]
572    async fn mem_info() {
573        let info = get_mem_info().await.unwrap();
574
575        assert!(info.avail_mib <= info.total_mib);
576    }
577
578    #[tokio::test]
579    async fn disk_info() {
580        let info = get_disk_info().await.unwrap();
581
582        assert!(info.avail_gib <= info.total_gib);
583    }
584
585    #[tokio::test]
586    async fn cpu_cores() {
587        let cores = get_cpu_cores().await.unwrap();
588        assert!((1..=256).contains(&cores), "CPU cores: {cores}");
589    }
590
591    #[tokio::test]
592    async fn cpu_freq() {
593        let cur = get_current_freq().await.unwrap();
594        let conf = get_freq_conf().await.unwrap();
595        if cfg!(any(target_arch = "arm", target_arch = "aarch64")) {
596            let cur = cur.unwrap();
597            let conf = conf.unwrap();
598            // 100MHz - 10GHz
599            assert!(
600                (100_000_000..10_000_000_000).contains(&cur),
601                "CPU frequency: {cur} Hz"
602            );
603            assert!(
604                (100_000_000..10_000_000_000).contains(&conf),
605                "CPU frequency: {conf} Hz"
606            );
607        } else {
608            assert!(cur.is_none());
609            assert!(conf.is_none());
610        }
611    }
612
613    #[tokio::test]
614    async fn throttle_status() {
615        let flags = get_throttle_status().await.unwrap();
616        if cfg!(any(target_arch = "arm", target_arch = "aarch64")) {
617            // enum に変換できれば OK
618            assert!(flags.is_some());
619        } else {
620            assert!(flags.is_none());
621        }
622    }
623}