AEGLOSLAB ROS 2 Jazzy 대전

제6부 하드웨어 인터페이스

6.9 진단 — diagnostic_updater와 상태 보고

6.9.1 이 장에서 배우는 것 / 선수 지식

이 책은 여러 장에서 같은 말을 반복했다 — "카운터를 진단으로 내보내라." 실시간 경로의 로깅 대신(1.11.7), tf 조회 실패율을(5.2.8), 하드웨어 통신 오류율을(6.2.10), 드라이버 상태를(6.7.9), 제어 주기 초과를(6.6.8). 이 장이 그 "진단"의 실체다.

진단이 중요한 이유는 명확하다. 로봇은 조용히 악화된다. 케이블이 마모되고, 센서가 더러워지고, 통신 오류율이 서서히 오른다. 완전히 멈추기 전까지는 아무도 모르고, 멈춘 뒤에는 원인을 찾기 어렵다. 진단은 그 사이를 메운다.

배우는 것:

  • 진단 메시지 구조 — DiagnosticArray와 상태 레벨.
  • diagnostic_updater — 태스크 기반 구현.
  • 표준 태스크 — 주파수·타임스탬프 감시.
  • 집계기 — 많은 항목을 계층으로 정리.
  • 설계 원칙 — 무엇을 어느 레벨로 보고할 것인가.
  • 실시간 경로와의 연결 — 카운터 패턴 완성.
  • 운영 활용 — 시각화·경보·기록.
  • 워치독으로서의 진단(6.9.14) — systemd가 못 보는 실패를 검출하는 법과 그 조건.

선수 지식: 1.11(로깅·카운터 패턴), 6.2.10·6.6.8·6.7.9(진단 항목들), 1.4(토픽), 2.9(원자 연산).

정직: diagnostic_updater API는 ROS 1에서 이어져 안정적인 편이다. Updater의 갱신 주기 결정 규칙과 진단 이름 형식은 Jazzy에서 직접 컴파일·실행해 확인했다(6.9.12). 집계기(diagnostic_aggregator) 설정 형식은 확인하지 않았으므로 패키지 문서를 정본으로 보라(목차 3).

6.9.2 진단 메시지 구조

DiagnosticArray header (stamp) status[] : DiagnosticStatus level: OK/WARN/ ERROR/STALE name: '드라이버: 통신' message: '오류율 0.3%' hardware_id: 'arm_ctrl_01' values[] : KeyValue

레벨 네 가지:

레벨값의미운영 대응
OK0정상없음
WARN1비정상이지만 동작 중주의·추적
ERROR2기능 실패조치 필요
STALE3보고가 끊김노드 확인

STALE이 특별하다. 이것은 노드가 보고한 것이 아니라 집계기가 판단하는 것이다 — 일정 시간 갱신이 없으면 STALE로 표시된다. 즉 "진단을 보내던 노드가 종료됐다"를 감지한다.

견해: STALE이 진단 체계의 숨은 가치다. 노드가 크래시하면 그 노드는 아무것도 보고하지 못하는데, 집계기가 그 침묵을 감지해 알려 준다. AUTOMATIC liveliness로는 검출되지 않는 상태(1.5.3.6)도 여기서 드러난다.

values가 실제 데이터다:

name: "모터 드라이버: 통신"
level: WARN
message: "오류율 상승"
hardware_id: "arm_ctrl_01"
values:
  - {key: "오류율(%)",        value: "2.4"}
  - {key: "최근 1분 오류 수", value: "144"}
  - {key: "평균 왕복(ms)",    value: "1.8"}
  - {key: "최대 왕복(ms)",    value: "12.3"}
  - {key: "재연결 횟수",      value: "3"}

견해: message에는 사람이 읽을 요약을, values에는 수치를 넣으라. 운영자는 message를 보고 판단하고, 엔지니어는 values를 보고 진단한다. 둘을 섞으면 양쪽 모두에게 불편해진다.

6.9.3 diagnostic_updater 기본

#include "diagnostic_updater/diagnostic_updater.hpp"
#include "diagnostic_updater/publisher.hpp"

class MyDriverNode : public rclcpp::Node
{
public:
  MyDriverNode() : Node("my_driver"), updater_(this)
  {
    updater_.setHardwareID("arm_ctrl_01");

    // 태스크 등록 — 주기적으로 호출된다
    updater_.add("통신", this, &MyDriverNode::diagnose_communication);
    updater_.add("모터 상태", this, &MyDriverNode::diagnose_motors);
    updater_.add("온도", this, &MyDriverNode::diagnose_temperature);

    // 갱신 주기 (기본 1 Hz — 파라미터 diagnostic_updater.period 로도 설정 가능)
    updater_.setPeriod(1.0);
  }

private:
  void diagnose_communication(diagnostic_updater::DiagnosticStatusWrapper & stat)
  {
    // 실시간 경로가 갱신한 원자 카운터를 읽는다 (6.9.6)
    const auto errors = comm_errors_.exchange(0);
    const auto total = comm_total_.exchange(0);
    const double rate = total > 0 ? 100.0 * errors / total : 0.0;

    if (total == 0) {
      stat.summary(diagnostic_msgs::msg::DiagnosticStatus::ERROR, "통신 없음");
    } else if (rate > 5.0) {
      stat.summary(diagnostic_msgs::msg::DiagnosticStatus::ERROR,
                   "통신 오류율 심각");
    } else if (rate > 0.5) {
      stat.summary(diagnostic_msgs::msg::DiagnosticStatus::WARN,
                   "통신 오류율 상승");
    } else {
      stat.summary(diagnostic_msgs::msg::DiagnosticStatus::OK, "정상");
    }

    stat.add("오류율(%)", rate);
    stat.add("최근 오류 수", errors);
    stat.add("최근 시도 수", total);
    stat.add("평균 왕복(ms)", rtt_avg_ms_.load());
    stat.add("최대 왕복(ms)", rtt_max_ms_.exchange(0.0));
    stat.add("재연결 횟수", reconnects_.load());
  }

  diagnostic_updater::Updater updater_;
  std::atomic<uint64_t> comm_errors_{0}, comm_total_{0}, reconnects_{0};
  std::atomic<double> rtt_avg_ms_{0.0}, rtt_max_ms_{0.0};
};

동작: Updater가 타이머로 등록된 태스크들을 호출하고, 결과를 모아 /diagnostics에 발행한다.

요소역할
setHardwareID물리 장치 식별(여러 대 운영 시 중요)
add(이름, 콜백)태스크 등록
stat.summary(레벨, 메시지)레벨·요약 설정
stat.add(키, 값)수치 추가
setPeriod갱신 주기

함정: Updater를 멤버로 보관하지 않으면 소멸해 동작하지 않는다. 5.1.3(tf 리스너)·2.8.8(rclpy 구독)과 같은 성질의 실수다.

함정: 태스크 콜백은 executor 스레드에서 실행된다. 여기서 블로킹하면 다른 콜백이 지연된다(2.2.9). 하드웨어를 직접 읽지 말고, 이미 수집된 값을 보고하는 데 그쳐야 한다.

6.9.4 표준 태스크

직접 만들지 않아도 되는 흔한 감시들이다.

6.9.4.1 주파수 감시

#include "diagnostic_updater/update_functions.hpp"

double min_freq = 9.0, max_freq = 11.0;
freq_status_ = std::make_unique<diagnostic_updater::FrequencyStatus>(
    diagnostic_updater::FrequencyStatusParam(&min_freq, &max_freq, 0.1, 10));
updater_.add(*freq_status_);

// 발행할 때마다 호출
void publish_scan(...) {
  pub_->publish(msg);
  freq_status_->tick();
}

센서가 기대 주기로 오는지 감시한다. 카메라가 30 Hz여야 하는데 12 Hz면 WARN·ERROR가 출력된다 — USB 대역 부족·CPU 과부하·센서 고장의 조기 신호다.

6.9.4.2 타임스탬프 감시

double min_delay = -1.0, max_delay = 0.5;    // 초
stamp_status_ = std::make_unique<diagnostic_updater::TimeStampStatus>(
    diagnostic_updater::TimeStampStatusParam(min_delay, max_delay));
updater_.add(*stamp_status_);

void on_message(const Msg::SharedPtr msg) {
  stamp_status_->tick(msg->header.stamp);
}

데이터의 나이를 감시한다. 1.10.7의 "데이터 나이 검사"와 5.2.4의 "시계 불일치 탐지"가 여기서 자동화된다 — 스탬프가 미래면 시계 문제, 너무 과거면 지연 문제다.

6.9.4.3 발행자 결합

#include "diagnostic_updater/publisher.hpp"

diagnostic_updater::DiagnosedPublisher<sensor_msgs::msg::LaserScan> diag_pub_(
    node->create_publisher<sensor_msgs::msg::LaserScan>("scan", 10),
    updater_,
    diagnostic_updater::FrequencyStatusParam(&min_freq, &max_freq, 0.1, 10),
    diagnostic_updater::TimeStampStatusParam(-1, 0.5));

// 발행하면 자동으로 주파수·스탬프가 감시된다
diag_pub_.publish(msg);

견해: 센서 드라이버에는 DiagnosedPublisher를 기본으로 쓰라. 코드 몇 줄로 주파수·타임스탬프 감시가 붙고, 이 둘이 센서 문제의 상당 부분을 조기에 검출한다. "카메라가 가끔 느려진다"를 사후가 아니라 발생 시점에 알 수 있다.

6.9.5 집계기

노드가 많아지면 진단 항목이 수십~수백 개가 된다. 그대로는 볼 수 없다.

/diagnostics (원시, 수십 항목) diagnostic_aggregator /diagnostics_agg (계층 구조) 로봇 구동계 센서 연산 좌측 모터 우측 모터 라이다 카메라
# config/diagnostics.yaml
analyzers:
  ros__parameters:
    path: 로봇

    drive:
      type: diagnostic_aggregator/GenericAnalyzer
      path: 구동계
      contains: ['모터', 'wheel']
      timeout: 5.0

    sensors:
      type: diagnostic_aggregator/GenericAnalyzer
      path: 센서
      startswith: ['lidar', 'camera', 'imu']
      timeout: 5.0
      num_items: 3            # 이만큼 없으면 STALE

    compute:
      type: diagnostic_aggregator/GenericAnalyzer
      path: 연산
      contains: ['CPU', '메모리', '온도']
      timeout: 5.0

집계기가 하는 일:

기능설명
계층화경로 기반으로 트리 구성
최악값 전파하위에 ERROR가 있으면 상위도 ERROR
STALE 판정timeout 안에 갱신 없으면
필수 항목 검사num_items로 누락 탐지

견해: num_items가 과소평가된 기능이다. 센서 3개가 있어야 하는데 2개만 보고하면, 나머지 하나의 노드가 전혀 기동되지 않았다는 뜻이다. "없는 것"을 감지하는 것이 "이상 상태"을 감지하는 것만큼 중요하다.

6.9.6 실시간 경로와의 연결

이 책 전체에서 반복한 패턴의 완성이다.

실시간 경로 (read/ update/write) 진단 태스크 (executor 스레드, 1 Hz) 오류 발생 원자적 카운터 증가 fetch_add(relaxed) 카운터 읽기 + 리셋 exchange(0) 비율 계산 · 레벨 판정 DiagnosticStatus 발행
// 실시간 경로 (6.2.7 · 6.4.4 · 6.6.3)
hardware_interface::return_type MyHardware::read(
    const rclcpp::Time &, const rclcpp::Duration & period)
{
  ++comm_total_;                                     // 원자적, relaxed면 충분
  if (!communicate()) {
    ++comm_errors_;
    return hardware_interface::return_type::OK;      // 임계 전까지는 OK
  }

  // 주기 초과 감시 (6.6.8)
  if (period.seconds() > period_limit_) ++overruns_;

  // 최대값 갱신 — CAS 루프 없이 근사해도 충분
  const double rtt = measure_rtt_ms();
  double prev = rtt_max_ms_.load(std::memory_order_relaxed);
  if (rtt > prev) rtt_max_ms_.store(rtt, std::memory_order_relaxed);

  return hardware_interface::return_type::OK;
}

왜 이 구조인가:

이유설명
실시간 경로에 로깅·할당이 없다원자적 정수 연산만(6.6.3)
진단 태스크는 비실시간문자열·발행 자유
카운터 리셋으로 구간 통계누적값보다 유용
레벨 판정을 한 곳에서임계 관리 용이

견해: exchange(0)로 구간 통계를 만드는 것이 누적 카운터보다 낫다. "총 오류 12345개"는 정보가 적지만 "최근 1초에 3개"는 즉시 판단 가능하다. 추세를 보려면 시계열로 기록하면 되고(11.3), 그것도 구간 값이 있어야 한다.

함정: memory_order_relaxed로 충분한지 판단하라. 단순 카운터는 relaxed로 충분하지만(2.9.4), 여러 값 사이의 일관성이 필요하면 다르다. 진단은 근사값이어도 되는 경우가 대부분이므로 relaxed가 적절하다.

6.9.7 설계 원칙 — 무엇을 어느 레벨로

레벨 판단 기준:

레벨기준예
OK정상 범위오류율 0.1%, 온도 45℃
WARN추세가 악화됐거나 여유가 줄었다오류율 2%, 온도 70℃, 배터리 20%
ERROR기능이 실패했거나 곧 실패한다통신 두절, 온도 85℃, 배터리 5%

핵심: WARN은 "아직 동작하지만 조치가 필요하다" 를 의미한다. 이것이 예방 정비의 근거가 된다.

견해: WARN 임계를 의미 있게 설정하는 것이 진단의 가치를 좌우한다. 너무 민감하면 상시 노란색이라 아무도 안 보고, 너무 둔감하면 ERROR와 동시에 출력된다. 정상 운영 데이터를 수집해 통계적으로 정하는 것이 좋다 — 평소 오류율이 0.1%라면 1%가 의미 있는 WARN이다.

보고할 항목 선정:

범주항목
통신오류율·재연결·왕복 시간(6.7.9)
타이밍주기 지터·초과 횟수(6.6.6)
하드웨어온도·전압·전류·모터 오류 코드
데이터 흐름발행 주파수·데이터 나이(6.9.4)
처리큐 길이·드롭 수·처리 지연
알고리즘tf 실패율(5.2.8)·측위 신뢰도·계획 실패율
자원CPU·메모리·디스크(1.11.3의 로그 디스크)
안전비상 정지 상태·한계 도달·워치독

보고하지 말아야 할 것:

항목이유
고빈도 원시 데이터진단은 요약이지 데이터 스트림이 아니다
비밀 값진단은 기록·전송된다(1.11.8)
매번 바뀌는 무의미한 값노이즈

견해: 진단은 "이 노드가 건강한가"에 답해야 한다. 내부 상태를 전부 노출하는 것이 아니라, 운영자가 조치를 결정할 수 있는 정보를 주는 것이 목적이다. 상세 디버깅 정보는 로그·별도 토픽으로 분리한다.

계층적 요약:

void diagnose_overall(diagnostic_updater::DiagnosticStatusWrapper & stat)
{
  // 하위 상태를 종합한 한 줄 — 운영자가 처음 보는 것
  if (!comm_ok_)          stat.summary(ERROR, "통신 두절 — 즉시 점검");
  else if (temp_ > 80.0)  stat.summary(ERROR, "과열 — 정지 권장");
  else if (error_rate_ > 2.0) stat.summary(WARN, "통신 불안정 — 케이블 점검");
  else                    stat.summary(OK, "정상 동작");
}

견해: 요약 메시지에 "무엇을 해야 하는가"를 넣으라. "온도 85℃"보다 "과열 — 정지 권장"이 낫다. 현장 운영자는 엔지니어가 아니며, 조치를 지시하는 메시지가 실제로 도움이 된다(1.11.8의 "ERROR는 행동을 시사해야 한다"와 같은 원칙).

6.9.8 운영 활용

시각화:

# GUI 모니터 (계층 표시)
ros2 run rqt_robot_monitor rqt_robot_monitor

# 원시 확인
ros2 topic echo /diagnostics
ros2 topic echo /diagnostics_agg

# 특정 항목만
ros2 topic echo /diagnostics --field status[0].message

기록:

# 진단을 bag에 함께 기록 (11.3)
ros2 bag record /diagnostics /diagnostics_agg /rosout /tf ...

견해: /diagnostics를 bag에 항상 포함시키라. 1.11.9에서 /rosout을 함께 기록하라고 한 것과 같은 이유다 — 사후 분석에서 "그 시점에 시스템이 어떤 상태였나"를 데이터와 나란히 볼 수 있다. 저빈도·소형이라 비용도 낮다.

경보 연동:

# 진단을 감시해 외부로 알리는 노드 (개념)
class DiagnosticAlerter(Node):
    def __init__(self):
        super().__init__('diagnostic_alerter')
        self.sub = self.create_subscription(
            DiagnosticArray, '/diagnostics_agg', self.on_diag, 10)
        self.last_level = {}

    def on_diag(self, msg):
        for status in msg.status:
            prev = self.last_level.get(status.name, 0)
            # 레벨이 높아진 순간에만 알림 (상시 알림 방지)
            if status.level > prev and status.level >= DiagnosticStatus.ERROR:
                self.send_alert(status)
            self.last_level[status.name] = status.level

함정: 매 진단 메시지마다 알림을 보내면 알림 폭주가 된다. 1 Hz로 발행되므로 ERROR 상태가 1분 지속되면 60번 알린다. 상태 전이 시점에만 알리고, 지속되면 주기적 요약으로 전환하는 것이 실용적이다.

플릿 운영(14.9):

활용설명
중앙 대시보드여러 로봇의 진단을 한 화면에
예방 정비WARN 추세로 부품 교체 시점 판단
통계 분석어떤 부품이 자주 문제인가
가용성 지표ERROR 시간 비율

6.9.9 진단 없는 시스템의 증상

진단이 왜 필요한지를 반대로 보면 명확하다.

증상진단이 있었다면
"로봇이 가끔 멈춘다"통신 오류율 추세로 케이블 문제 조기 발견
"왜 느려졌는지 모르겠다"주기 초과·CPU 지표
"센서가 언제부터 이상했나"주파수 감시 기록
"이 노드가 종료된 것을 몰랐다"STALE 감지
"현장에서 원인을 못 찾는다"하드웨어 오류 코드 노출
"예방 정비 시점을 모른다"WARN 추세

견해: 진단은 개발 막바지에 추가하는 것이 아니라 드라이버·컨트롤러를 쓸 때 함께 만드는 것이다. 나중에 추가하려면 실시간 경로에 카운터를 추가하고 구조를 바꿔야 한다. 6.2·6.4·6.7에서 카운터를 미리 두라고 한 이유가 이것이며, 그 카운터들이 이 장에서 값을 한다.

6.9.12 Updater의 주기와 이름 — 실측

앞 절의 예제가 setPeriod(1.0)과 setHardwareID(...)를 호출했다. 그 값들이 실제로 무엇보다 후순위이고 무엇보다 우선하는지를 Jazzy에서 컴파일해 확인했다(실행 확인). 둘 다 "코드에 썼는데 적용되지 않는다"가 나오는 자리다.

6.9.12.1 갱신 주기는 네 곳에서 정해진다

Updater는 갱신 주기를 ROS 파라미터로도 받는다 — 이름은 diagnostic_updater.period다. 그래서 우선순위가 생긴다. 실제로 측정해 본 결과는 이렇다:

상황getPeriod() 결과
아무것도 안 함1.0초(기본값)
생성자 인자만 5.05.0초
파라미터 0.2 + 생성자 인자 5.00.2초 — 파라미터가 우선한다
위 상태에서 setPeriod(3.0) 호출3.0초 — 명시 호출이 우선한다

정리하면 setPeriod() 호출 > diagnostic_updater.period 파라미터 > 생성자 인자 > 기본값 1.0초다.

함정: 생성자 인자는 파라미터에 진다. Updater updater(node, 5.0);이라고 써 두고 YAML에 diagnostic_updater.period: 0.1이 있으면, 진단이 초당 열 번 나간다. 코드만 읽어서는 이유를 알 수 없고, 헤더 주석에만 적혀 있다. 주기를 코드로 못 박고 싶으면 생성자 인자가 아니라 setPeriod()를 호출하라 — 그것만이 파라미터에 우선한다.

견해: 반대로, 주기를 파라미터로 두는 편이 대체로 낫다. 진단 주기는 현장에서 조정하고 싶어지는 값이고(대역이 빠듯하면 늘리고, 문제를 쫓을 땐 줄인다), 이 설계는 그것을 코드 수정 없이 가능하게 하려는 의도로 보인다. 코드로 명시하는 것은 주기가 안전 논리의 일부일 때로 한정하라.

6.9.12.2 진단 이름에 네임스페이스가 안 들어간다 — 다중 로봇의 함정

같은 파라미터 그룹에 diagnostic_updater.use_fqn이 하나 더 있다. 진단 항목의 name 필드 형식을 정한다. 네임스페이스 /robot 아래 probe 노드에서 배터리 항목을 발행해 실제 메시지를 받아 봤다:

use_fqn발행되는 name
false(기본값)probe: 배터리
true/robot/probe: 배터리

기본값이 문제다. 로봇 여러 대를 네임스페이스로 나눠 운영하면(1.1.7) /robot1/probe와 /robot2/probe가 둘 다 probe: 배터리라는 같은 이름으로 진단을 낸다. 집계 화면에서 어느 로봇의 배터리인지 구분되지 않고, 두 로봇의 상태가 한 줄로 겹쳐 보인다.

# 다중 로봇이면 켜라
/**:
  ros__parameters:
    diagnostic_updater:
      use_fqn: true

함정: setHardwareID로 대신하려 하지 마라. hardware_id는 DiagnosticStatus의 별도 필드이고, 대시보드·집계기는 보통 name으로 항목을 식별한다. 하드웨어 ID를 잘 넣어 두어도 이름이 겹치면 화면에서는 여전히 겹친다. 둘은 보완재지 대체재가 아니다.

함정: use_fqn을 나중에 켜면 이름이 전부 바뀐다. 진단 이름을 키로 삼아 만든 알림 규칙·대시보드 위젯·집계기 설정이 한꺼번에 어긋난다. 다중 로봇이 될 가능성이 있다면 한 대일 때 미리 켜 두는 것이 비용이 낮다.

6.9.14 진단을 워치독으로 써 봤다 — 실측

14.8.13.2은 "systemd가 볼 수 없는 실패가 있으니 상태 감시를 따로 두라"로 끝났다. 그 상태 감시를 진단 체계로 만들 수 있는지 확인했다(실행 확인, Jazzy — diagnostic_updater·diagnostic_aggregator 4.2.7).

구성: 노드가 diagnostic_updater로 자기 라이프사이클 상태를 보고하고, diagnostic_aggregator의 GenericAnalyzer가 그것을 묶는다. 세 상황에서 /diagnostics_agg의 최상위 판정을 봤다.

6.9.14.1 결과 — 두 종류의 실패를 모두 검출한다

상황개별 항목/Robot/Arm/Robot(최상위)
정상OK · activeOKOK
노드가 종료돼 침묵active(마지막 값 그대로)StaleStale
실행 중이나 finalized 보고Error · finalized — 복구 불가ErrorError

6.9.2의 주장이 확인된다 — "STALE은 노드가 보고한 것이 아니라 집계기가 판단하는 것"이 그대로다. 노드를 강제 종료하자 timeout(3초) 뒤 상위가 Stale로 바뀌었다.

그리고 14.8.13.2의 감시 공백이 메워진다.

systemd가 못 보는 실패진단 체계는조건
프로세스가 종료됐는데 on-failure가 안 잡음(SIGTERM)Stale로 검출한다진단 발행이 끊기므로 자동
finalized 노드가 실행 중(1.12.10.3)Error로 검출한다노드가 상태를 보고해야 한다

둘째 줄이 이 절의 요점이다. finalized 노드는 계속 살아서 진단도 계속 발행한다 — 그러면 집계기는 Stale을 못 낸다. 라이프사이클 상태를 진단에 실어 나르지 않으면 그 실패는 여기서도 안 보인다.

그래서 관리형 노드의 진단 태스크에는 자기 상태를 넣어야 한다.

up.add("lifecycle", [this](DiagnosticStatusWrapper & s) {
  auto st = get_current_state().label();          // active / finalized / …
  if (st == "active")            s.summary(s.OK,    st);
  else if (st == "unconfigured") s.summary(s.WARN,  st);
  else                           s.summary(s.ERROR, st + " — 복구 불가");
  s.add("state", st);
});

6.9.14.2 개별 항목만 보면 종료된 것을 모른다

위 표의 둘째 줄에서 개별 항목의 message는 여전히 active다 — 마지막으로 받은 값이 그대로 남고, 상위 그룹만 Stale로 바뀐다.

함정: 대시보드나 스크립트가 개별 항목만 읽으면 종료된 노드를 정상으로 본다. 판정은 그룹 수준(/Robot·/Robot/Arm)에서 읽어야 한다. 6.9.5의 계층 구조가 장식이 아니라 판정의 단위인 이유다.

6.9.14.3 분석기가 아무것도 검출하지 못하면 그것이 장애로 보인다

startswith를 잘못 적어 분석기가 한 항목도 매칭하지 못한 상태로 실행해 봤다.

항목결과
잘못 설정한 분석기 /Robot/ArmStale
최상위 /RobotStale
실제 진단은 catch-all(/Robot/Other)로 흘러가OK

빈 분석기가 Stale이 되고 최상위까지 끌어내린다. 진단은 정상적으로 오고 있는데 설정 오류가 시스템 장애처럼 보인다.

진단 이름의 형식이 여기서 중요하다. 원시 진단의 이름은 <노드 이름>: <태스크 이름> 이다 — 이 실험에서는 diagnode: lifecycle이었다(6.9.12의 이름 규칙). startswith: ["lifecycle"]처럼 태스크 이름으로 적으면 안 맞는다. startswith: ["diagnode"]처럼 노드 이름으로 시작해야 한다.

증상이 혼동을 일으킨다 — "집계기를 켰더니 전체가 Stale"이면 노드를 의심하기 전에 분석기 매칭을 보라. /diagnostics(원시)에 항목이 있는데 /diagnostics_agg에서 Other로 빠져 있다면 매칭 실패다.

6.9.14.4 부수로 확인한 것 — 헤더 경로

diagnostic_updater의 헤더는 include/diagnostic_updater/diagnostic_updater.hpp 로, 패키지 이름이 한 번만 나온다. 대부분의 ROS 2 패키지가 include/<pkg>/<pkg>/… 로 두 번 중첩되는 것과 다르다.

# 대부분:            include/rclcpp/rclcpp/…
# diagnostic_updater: include/diagnostic_updater/diagnostic_updater.hpp

직접 g++로 빌드할 때 -I<prefix>/include(기본 경로)가 필요하다. ament_cmake를 쓰면 드러나지 않지만, 도구를 만들거나 수동 빌드할 때 걸린다(4.2).

6.9.14.5 확인하지 못한 것

  • timeout 값에 따른 Stale 전환 지연은 정밀하게 측정하지 않았다 — 3초로 두고 "몇 초 뒤 바뀐다" 수준만 봤다(확인 필요).
  • AnalyzerGroup의 중첩 계층(그룹 안 그룹)은 시험하지 않았다.
  • 진단을 받아 실제로 프로세스를 강제 종료하는 워치독 노드는 만들지 않았다 — 이 실험은 판정까지다(확인 필요: 조치 자동화).

6.9.10 함정 카탈로그

#함정증상대처
1Updater를 멤버로 미보관진단 미발행멤버 보관
2태스크에서 하드웨어 직접 읽기executor 블로킹수집된 값만 보고
3실시간 경로에서 진단 발행지터(6.6.3)카운터 + 별도 태스크
4누적 카운터만 보고판단 어려움exchange(0) 구간 통계
5WARN 임계가 부적절상시 노란색·무시됨운영 데이터로 설정
6요약에 조치가 없음운영자가 판단 못 함"무엇을 하라"를 명시
7hardware_id 미설정여러 대 중 어느 것인지 모름설정
8집계기 num_items 미사용없는 노드를 못 잡음필수 항목 선언
9진단을 bag에 미포함사후 분석 불가항상 기록
10매 메시지마다 알림알림 폭주상태 전이 시점만
11원시 데이터를 진단으로대역·가독성 저하요약만
12비밀 값 노출보안(13.4)마스킹
13개발 막바지에 추가구조 변경 필요처음부터 함께
14다중 로봇에서 진단 이름 충돌두 로봇이 같은 probe: 항목diagnostic_updater.use_fqn: true(6.9.12.2)
15생성자 인자로 주기 고정파라미터가 우선한다setPeriod() 호출(6.9.12.1)
16집계기 켠 뒤 전체가 Stale분석기 매칭 실패 — 빈 분석기가 Stale이름은 <노드>: <태스크>(6.9.14.3)
17개별 항목만 읽어 건강 판정종료돼도 마지막 값이 남는다그룹 수준에서 읽어라(6.9.14.2)
18관리형 노드가 자기 상태를 진단에 안 넣음finalized가 안 보인다라이프사이클을 태스크로(6.9.14.1)

6.9.11 체크포인트

  • 진단 레벨 네 가지와 각각의 의미를 말할 수 있는가. STALE이 특별한 이유는?
  • message와 values의 역할 분담을 설명할 수 있는가.
  • diagnostic_updater의 기본 구성 요소 다섯 가지를 말할 수 있는가. 태스크 콜백에서 하드웨어를 직접 읽으면 안 되는 이유는?
  • 주파수 감시와 타임스탬프 감시가 각각 무엇을 조기에 검출하는가.
  • DiagnosedPublisher를 센서 드라이버에 기본으로 쓰라는 조언의 근거는?
  • 집계기의 기능 네 가지를 말할 수 있는가. num_items가 검출하는 것은?
  • 실시간 경로와 진단 태스크를 잇는 패턴을 그릴 수 있는가. 왜 이 구조여야 하는가.
  • exchange(0) 구간 통계가 누적 카운터보다 나은 이유는?
  • WARN 임계를 정하는 방법과, 잘못 정했을 때의 두 가지 실패 양상은?
  • 진단으로 보고할 항목 여섯 범주와, 보고하지 말아야 할 것 세 가지를 말할 수 있는가.
  • 요약 메시지에 조치를 넣으라는 조언이 1.11.8의 어떤 원칙과 같은가.
  • 진단을 bag에 포함시켜야 하는 이유는?
  • 알림 폭주를 막는 방법은?
  • Updater의 갱신 주기를 정하는 네 경로의 우선순위는? 다중 로봇에서 진단 이름이 충돌하는 이유와 대처는(6.9.12).
  • 노드가 종료됐을 때 개별 항목과 그룹 항목은 각각 어떻게 되는가(6.9.14.2).
  • finalized 노드를 진단으로 검출하려면 무엇이 필요한가(6.9.14.1).
  • 집계기를 켰더니 전체가 Stale이면 무엇을 먼저 의심하는가(6.9.14.3).

여기까지가 제6부 하드웨어 인터페이스다. ros2_control 아키텍처(6.1), 하드웨어 컴포넌트(6.2), 관리 층(6.3), 컨트롤러 작성(6.4)과 표준 카탈로그(6.5), 실시간 제약(6.6), 버스와 드라이버(6.7), MCU 통합(6.8), 그리고 진단(6.9).

제6부가 반복한 것은 세 가지다.

  1. 경계를 긋는다. 하드웨어 통신과 제어 알고리즘을 분리하고(6.1.2), 실시간 경로와 ROS 통신을 분리하고(6.1.6), 빠른 것은 MCU에 느린 것은 ROS에 둔다(6.8.8).
  2. 활성화 순간이 위험하다. 하드웨어 컴포넌트도(6.2.6) 컨트롤러도(6.4.7) 활성화 시 현재 상태로 초기화하지 않으면 로봇이 급격히 움직인다. 시뮬에서는 안 드러나고 실기에서 물리적 사고가 된다.
  3. 조용한 실패를 드러낸다. 실시간 경로에서는 로그를 못 쓰므로 카운터를 두고(6.6.3), 그것을 진단으로 내보내(6.9) 시스템이 조용히 악화되는 것을 감지한다.

이제 로봇은 움직인다. 다음은 본다. 카메라·라이다·IMU가 만드는 데이터를 어떤 규약으로 표현하고, 어떻게 처리하며, 서로 다른 시각의 센서를 어떻게 정렬하는가 — 제7부 센서·인지 파이프라인(7.1~7.8) 의 주제다. 센서 메시지 규약(REP)부터 시작한다.

6.9.13 제6부를 마치며 — 하드웨어 인터페이스

이 장은 제6부(6.1~6.9)의 마지막이므로, 부 전체를 하나의 관점으로 묶는다.

제6부가 반복해 말한 세 가지:

  1. 제어 루프는 예산이 있다. 주기 안에 끝나야 하고, 못 끝내면 조용히 성능이 떨어진다(6.2·6.6). 여유가 아니라 최악을 재라.
  2. 하드웨어 경계는 상태기계다. 활성화·비활성화·오류 복구를 설계하지 않으면 재시작 말고는 방법이 없는 시스템이 된다(6.3·6.4).
  3. 타입과 권한이 조용히 어긋난다. TwistStamped인지 Twist인지(6.5.12), RT 권한이 실제로 붙었는지(12.2.12.4) — 로그 한 줄이 성패를 가른다.

다음 부에서 무엇이 달라지나: 제6부는 명령을 내보내는 쪽이었다. 제7부는 들어오는 쪽 — 센서 데이터다. 대역·지연·동기화가 지배 요인이 되고, "어떻게 다 처리할 것인가"가 아니라 "무엇을 버릴 것인가"가 설계 문제가 된다.

제7부는 센서·인지 파이프라인(7.1~7.8)다.