왼쪽에는 눈금선까지 가득 찬 용량 게이지가 '먼저 닿음', 오른쪽에는 모래가 남은 모래시계가 '아직 멂'으로 표시되고, 가운데 칼날이 녹화 막대를 둘로 자르는 개념 일러스트

ros2 bag record 파일이 너무 커질 때: -b와 -d를 함께 주면 어느 쪽이 먼저 자르는지 토픽 데이터율로 계산하기


Read in English

로봇 카메라와 IMU 토픽을 몇 시간씩 녹화하면 bag 파일 하나가 수십 GB까지 커진다. ros2 bag record에 크기 기준 -b와 시간 기준 -d를 함께 주면 둘 중 먼저 닿는 쪽에서 파일이 잘리는데, 어느 쪽이 먼저 닿는지는 옵션 값만 봐서는 알 수 없다. 같은 -b 900000000 -d 60이라도 초당 30 MB를 쓰는 카메라 녹화는 30초마다 크기 기준으로, 초당 200 kB인 IMU·tf 녹화는 60초마다 시간 기준으로 잘린다(둘 다 설명용 가정 값). 판정에 필요한 값은 -b를 토픽 데이터율로 나눈 시간 하나다.

이 계산이 실제 녹화와 맞는지 보려고 HuntLab 실험 하네스가 ROS 2 Jazzy 컨테이너에서 규모를 -b 8000000 -d 6으로 줄이고 합성 토픽 두 개를 녹화하는 스크립트를 5회 실행했다. 분할은 계산이 가리킨 기준과 간격대로 일어났고, 어긋남은 메시지 한 개 주기 안이었다. 첫 파일만 조금 길었는데, 녹화를 시작하자마자 들어온 /tf_static이 첫 파일의 시계를 먼저 출발시켰기 때문이다. 그 /tf_static은 다섯 번 모두 첫 파일에만 있었고, Jazzy에는 이 메시지를 새 파일마다 다시 넣는 옵션이 없다. 실물 로봇이나 실제 센서 데이터는 쓰지 않았다. ROS 2의 토픽과 메시지를 알고 바이트를 초로 나눌 수 있으면 끝까지 따라올 수 있다.

-b를 데이터율로 나누면 어느 쪽이 먼저인지 보인다

먼저 용어 몇 개를 맞춘다. bag(백) 파일은 토픽 메시지를 시간순으로 저장한 녹화본이다. rosbag2의 bag는 사실 디렉터리이고, 그 안에 실제 저장 파일(.mcap 또는 .db3)과 metadata.yaml이 들어 있다. 분할(split)은 녹화 도중 지금 쓰던 저장 파일을 닫고 새 파일을 여는 일이다. -b(--max-bag-size)는 바이트 정수, -d(--max-bag-duration)는 초 정수이며 둘 다 기본값 0은 “그 기준을 쓰지 않음”이다.

두 기준을 함께 켰을 때의 규칙은 rosbag2 README에 한 문장으로 적혀 있다. “If both splitting by size and duration are enabled, the bag will split at whichever threshold is reached first.”(README, rolling 커밋 고정) Jazzy README와 ros2 bag record --help 문구도 같고, writer 코드의 should_split_bagfile()은 크기 조건과 시간 조건을 OR로 묶는다(rolling, jazzy). 둘 다 만족해야 자르는 AND가 아니다.

그러면 “먼저”를 계산으로 바꿀 수 있다. 데이터율(data rate)은 녹화하는 토픽들이 초당 만들어 내는 직렬화 바이트 수(B/s)다. -b는 저장 파일 전체 크기를 보므로 R은 녹화하는 모든 토픽의 합으로 잡는다.

R      = 녹화하는 모든 토픽의 데이터율 합 (B/s)
B      = -b 값 (바이트),  D = -d 값 (초)
T_size = B / R            # 크기 기준이 차는 데 걸리는 시간 (초)

T_size < D  →  크기 기준이 먼저 자른다. 분할 간격 ≈ T_size
T_size > D  →  시간 기준이 먼저 자른다. 분할 간격 ≈ D
녹화 시간당 파일 수 ≈ 녹화 시간 / 분할 간격

도입의 두 녹화를 이 식에 넣어 보자. 메시지 크기와 주기가 일정하고 압축·저장 형식 오버헤드·캐시가 없다는 설명용 가정이다.

녹화 (설명용 가정) R T_size = 900,000,000 / R 먼저 자르는 기준 1시간 파일 수
카메라: 1,000,000 B × 30 Hz 30,000,000 B/s 30 s 크기 (30 s < 60 s) 3600 / 30 = 120
IMU + tf 200,000 B/s 4,500 s 시간 (4,500 s > 60 s) 3600 / 60 = 60

카메라 쪽에서 -d 60은 한 번도 일하지 않는다. 파일을 60초 단위로 끊고 싶다면 -d를 아무리 바꿔도 소용없고, -b를 R × 60 = 1,800,000,000 바이트보다 크게 잡아야 시간 기준이 이긴다. 이 계산과 가정, 기대값은 Python만으로 다시 돌려 볼 수 있는 산술 예제 example.py와 사례별 검산 기록 verification.json에 있다. 이쪽은 산술 검산이지 녹화 실행이 아니다. 검산 기록의 8번 사례 이름은 sqlite3 하한 86016을 두고 “이 값 자체는 허용되지 않으며 -b는 이보다 커야 한다”고 적었는데, 이 설명은 틀렸다. 뒤의 하한 절에서 보듯 writer는 하한 미만만 거부하므로 86016 자체는 받아들여진다. 그 사례의 계산식과 값은 그대로 맞다.

자기 토픽의 R은 ros2 topic bw <topic>으로 구한다. 이 명령은 수신한 메시지의 직렬화 길이를 더해 창(window, 기본 100개 메시지) 시간으로 나눈다. 출력의 KB는 1,000바이트, MB는 1,000,000바이트인 10진 단위다(ros2cli bw.py 단위 표기). -b에 “2 GB”를 주려고 2147483648을 쓰면 실제로는 2³¹ B, 즉 2 GiB다. 1 GiB는 1,073,741,824 B이고 1 GB는 1,000,000,000 B이므로 두 단위를 섞으면 T_size 계산이 그만큼 어긋난다. bw 값은 직렬화된 메시지만 세며 저장 형식의 인덱스·메타데이터는 포함하지 않는다.

8 MB·6초로 줄인 Jazzy 녹화에서도 계산한 기준이 잘랐다

식은 단순하지만 실제 writer는 메시지 단위로 움직이고 타임스탬프를 따로 다룬다. 그래서 같은 판정식을 실제 ros2 bag record에 걸어 확인했다. 시나리오는 두 개다. high는 큰 메시지를 자주 보내 크기 기준이 먼저 닿도록, low는 작은 메시지를 보내 시간 기준이 먼저 닿도록 골랐다. 재현 스크립트와 실행별 원본 출력은 실험 디렉터리에 있고, 파일 단위로는 experiment.py, plan.md(예측과 시행착오 기록), results.json(5회 원본 출력)이다. plan.md의 예측은 시험 실행 두 번을 거친 뒤, 최종 5회를 돌리기 전에 적은 것이다.

측정 환경은 다음과 같다. 이미지 ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca(ros:jazzy-ros-base), ROS 2 Jazzy, Python 3.12.3, rclpy 7.1.12-1noble.20260902.053513이다. RMW(ROS 2가 통신 계층 구현을 갈아 끼우는 미들웨어 인터페이스, ROS Middleware)는 기본값인 rmw_fastrtps_cpp를 썼다. 이름의 Fast DDS는 노드끼리 메시지를 주고받는 통신 표준 DDS(Data Distribution Service)의 구현 하나다. 컨테이너는 네트워크 없이 1 CPU·1 GiB로 제한했고 /tmp는 메모리 기반 tmpfs였다. 저장 형식은 mcap, 쓰기 캐시는 기본값, 압축은 끈 상태로 같은 스크립트를 2026-10-14 KST에 5회 실행했고 5회 모두 종료 코드 0이었다.

스크립트는 한 rclpy 노드에서 tf2_ros.StaticTransformBroadcaster로 /tf_static(base_link→camera_link)을 한 번 발행한 뒤, 시나리오마다 실제 ros2 bag record -o /tmp/bag_high -s mcap -b 8000000 -d 6 /high/data /tf_static 같은 명령을 하위 프로세스로 띄운다. 녹화기가 data 토픽을 구독한 것을 확인하고 0.5초 기다린 다음, std_msgs/UInt8MultiArray 메시지를 단조 시계(monotonic clock, 시스템 시각 조정에 영향받지 않고 앞으로만 가는 시계) 기준 일정 주기로 보낸다. 끝나면 SIGINT(Ctrl+C와 같은 신호)로 녹화를 멈추고, metadata.yaml과 분할 파일 하나하나를 다시 열어 센다.

  • high: 페이로드 200000 B(직렬화 200016 B) × 20 Hz, 9초 발행
  • low: 페이로드 1000 B(직렬화 1016 B) × 50 Hz, 14초 발행
항목 high low
plan.md 예측 R 약 4,000,320 B/s 약 50,800 B/s
실측 R (5회 범위) 4022306~4022336 B/s 50871~50872 B/s
T_size = 8000000 / R 1.989 s 157.259 s
예측한 분할 기준 크기 (1.989 s < 6 s) 시간 (157.259 s > 6 s)
닫힌 파일의 실제 분할 기준 (5회 모두) 크기 4개 / 4개 시간 2개 / 2개
분할 간격 (첫 파일 제외) 2.0 s 6.0~6.02 s
첫 파일의 분할 간격 2.487~2.495 s 6.002~6.018 s
전체 파일 수 (마지막 파일은 Ctrl+C로 닫힘) 5 3
크기로 닫힌 파일 크기 8017304~8018939 B 해당 없음 (305290~333418 B)
/tf_static이 든 파일 0번 파일만 0번 파일만
발행 수 / 녹화된 data 메시지 수 181 / 181 701 / 701

아래는 1회차 원본 출력에서 판단에 쓴 줄을 그대로 옮긴 것이다.

{"scenario": "high", "event": "file", "index": 0, "closed_by_split": true, "size_bytes": 8018939, "size_minus_B": 18939, "data_payload_bytes": 8000640, "data_msgs": 40, "tf_static_msgs": 1, "start_offset_s": 0.0, "first_data_after_file_start_s": 0.491, "meta_duration_s": 2.441, "split_interval_s": 2.491, "size_reached_B": true, "interval_exceeded_D": false}
{"scenario": "high", "event": "file", "index": 1, "closed_by_split": true, "size_bytes": 8017304, "size_minus_B": 17304, "data_payload_bytes": 8000640, "data_msgs": 40, "tf_static_msgs": 0, "start_offset_s": 2.491, "first_data_after_file_start_s": 0.0, "meta_duration_s": 1.95, "split_interval_s": 2.0, "size_reached_B": true, "interval_exceeded_D": false}
{"scenario": "high", "event": "summary", "files": 5, "closed_files": 4, "mean_split_interval_s": 2.123, "min_split_interval_s": 2.0, "max_split_interval_s": 2.491, "closed_split_by_size": 4, "closed_split_by_duration": 0, "observed_first": "size", "predicted_first": "size", "max_size_minus_B": 18939, "recorded_data_msgs": 181, "published": 181, "files_with_tf_static": [0]}
{"scenario": "low", "event": "file", "index": 1, "closed_by_split": true, "size_bytes": 332355, "size_minus_B": -7667645, "data_payload_bytes": 304800, "data_msgs": 300, "tf_static_msgs": 0, "start_offset_s": 6.013, "first_data_after_file_start_s": 0.0, "meta_duration_s": 5.98, "split_interval_s": 6.0, "size_reached_B": false, "interval_exceeded_D": true}
{"scenario": "low", "event": "summary", "files": 3, "closed_files": 2, "mean_split_interval_s": 6.006, "min_split_interval_s": 6.0, "max_split_interval_s": 6.013, "closed_split_by_size": 0, "closed_split_by_duration": 2, "observed_first": "duration", "predicted_first": "duration", "max_size_minus_B": -7667645, "recorded_data_msgs": 701, "published": 701, "files_with_tf_static": [0]}

측정된 사실만 추리면 이렇다. 같은 -b 8000000 -d 6에서 high의 닫힌 파일은 모두 크기 조건(size_reached_B: true)으로, low의 닫힌 파일은 모두 시간 조건(interval_exceeded_D: true)으로 닫혔다. plan.md에 실행 전 적어 둔 예측(약 2초마다 크기 기준으로 분할해 닫힌 파일 4개, 시간 기준으로 닫힌 파일 2개, /tf_static은 첫 파일에만)과 다섯 번 모두 같았다. 발행한 메시지는 하나도 빠지지 않고 녹화됐다.

해석을 붙이면, 판정식은 “어느 기준이 자르는가”와 “몇 초마다 자르는가”를 이 조건에서 둘 다 맞혔다. 다만 high의 간격이 1.989초가 아니라 2.0초로 나온 데서 보이듯, 실제 간격은 메시지 주기 단위로 반올림된다. 이 차이와 첫 파일이 긴 이유는 다음 절에서 따로 본다.

가로축을 녹화 시작 후 경과 시간(초)으로 둔 두 줄짜리 타임라인 개념도. 위 줄은 high 시나리오로 0, 2.491, 4.491, 6.491, 8.491초에 분할선이 있고 각 구간은 크기 기준으로 닫히며 마지막 구간만 Ctrl+C로 닫힌다. 아래 줄은 low 시나리오로 0, 6.013, 12.013초에 분할선이 있고 시간 기준으로 닫힌다. 두 줄 모두 0번 구간에만 /tf_static 표시가 있다.

아래 명령으로 같은 이미지와 같은 스크립트를 독자 환경에서 다시 돌릴 수 있다. Docker와 이 이미지를 받을 네트워크만 있으면 되고, ROS 2를 따로 설치할 필요는 없다. 하네스는 여기에 --tmpfs /tmp, 읽기 전용 마운트 같은 옵션 몇 개를 더해 실행했다. 출력은 위와 같은 형식의 JSON 줄이다.

mkdir rosbag2-split-check && cd rosbag2-split-check
curl -fsSLO https://raw.githubusercontent.com/sungpyo9053/blog/46c95c79cc434b24bcd240d1283bc8e362f7633d/experiments/ros2-bag-record-split-size-duration-whichever-first/experiment.py
sha256sum experiment.py   # abacd2ff211426a51c72c6e4008f3cd2fa475840dd0d43a0b5fa2e07bbbf5f93 이어야 한다
docker run --rm --network none --cpus 1 --memory 1g -v "$PWD":/work ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca bash -c "source /opt/ros/jazzy/setup.bash && python3 /work/experiment.py"

자기 결과를 위 표와 숫자 하나하나로 맞춰 볼 필요는 없다. 첫 파일의 분할 간격(split_interval_s)과 첫 data 지연(first_data_after_file_start_s)은 녹화기가 뜨고 구독을 확인하는 시점에 따라 정해지므로, 다른 머신에서는 위 5회 범위를 수십 ms 벗어날 수 있다. 정상인지는 두 시나리오의 summary 줄에서 판정한다. observed_first가 predicted_first와 같고(high는 size, low는 duration), files_with_tf_static이 [0]이고, recorded_data_msgs가 published와 같으면 이 글의 결론이 독자 환경에서도 재현된 것이다. observed_first가 예측과 다르거나 녹화된 메시지가 발행 수보다 적다면 CPU 제한이나 이미지가 하네스 조건과 같은지부터 확인한다. sha256sum 값이 다르면 다른 스크립트를 받은 것이므로 그 결과는 이 글과 비교할 수 없다.

계산과 실측 사이의 작은 차이는 세 군데서 생긴다

“영상 녹화를 용량이나 시간으로 끊는다”는 비유는 OR 판정까지는 잘 맞는다. 하지만 rosbag2는 메시지 중간을 쪼개지 않고, 메시지가 도착했을 때만 판정한다. writer의 write()는 메시지를 받을 때마다 should_split_bagfile()을 부르고, 참이면 지금 파일을 닫은 뒤 그 메시지를 새 파일에 쓴다(write() 분할 호출). 비유가 놓치는 이 두 성질이 아래 세 차이를 만든다.

크기: 닫힌 파일은 항상 -b 이상이다

크기 조건은 추정 파일 크기 >= -b다. 판정은 이미 쓴 내용을 기준으로 하므로, 파일은 -b를 넘긴 다음 메시지가 들어올 때 닫힌다. high에서는 200016 B짜리 메시지 40개(직렬화 크기 합 8000640 B)가 들어가 -b를 넘었고, 그다음 메시지부터 새 파일로 갔다. 닫힌 파일 크기는 8017304~8018939 B였고, 파일 번호별로 보면 다섯 번 실행 모두 바이트 단위까지 같았다.

-b를 넘은 양(17304~18939 B)은 메시지 하나(200016 B)보다 작았다. plan.md는 초과분을 “메시지 하나 + 저장 형식 오버헤드 수준”으로 잡았는데, 실측은 그 범위 안이다. 파일 크기와 직렬화 크기 합의 차이에는 MCAP 형식의 기록 메타데이터·인덱스가 들어 있을 것으로 보이지만, 항목별로 나눠 재지는 않았다.

분할 간격이 1.989초가 아니라 2.0초로 나온 것도 같은 이유다. 20 Hz면 메시지가 50 ms마다 오므로 -b를 넘는 순간도 50 ms 단위로만 정해진다. 계산값은 메시지 주기 하나만큼의 오차를 안고 있다고 보면 된다.

시간: 벽시계가 아니라 메시지 수신 시각으로 잰다

시간 조건은 (지금 메시지의 수신 시각 − 지금 파일의 첫 메시지 시각) > -d다. 비교가 “초과”이고 메시지가 와야 판정하므로, 실제 분할은 6초가 지난 뒤 처음 도착한 메시지에서 일어난다. low는 50 Hz, 즉 20 ms마다 메시지가 오므로 분할 간격은 6.0~6.02 s였고 plan.md의 “6초 직후 첫 메시지(최대 20 ms 늦음)” 예측과 같았다. 6.0 s로 찍힌 간격도 소수 셋째 자리에서 반올림한 값이며, 판정은 다섯 번 모두 6초 초과였다. 파일 하나에 data 메시지가 300~301개씩 들어간 것도 이 경계에서 나온다.

첫 파일: 가장 먼저 들어온 메시지가 시계를 시작한다

첫 시험 실행은 실패 없이 끝났지만 두 시나리오 모두 첫 분할 간격만 나머지보다 길게 나왔다. 원인을 보려고 파일별로 “파일 시작 → 첫 data 메시지” 시간(first_data_after_file_start_s)을 출력에 추가했고, 두 번째 시험 실행에서 첫 파일의 시작 시각이 data보다 먼저 녹화된 /tf_static의 수신 시각이라는 것을 확인했다. 분할 판정 코드에는 손대지 않았다.

최종 5회에서 첫 data 메시지는 첫 파일 시작보다 0.488~0.496 s(high), 0.494~0.542 s(low) 늦게 들어왔다. 크기 기준인 high에서는 이 대기 시간만큼 첫 간격이 2.487~2.495 s로 늘어났을 뿐 담긴 데이터 양은 같았다. 시간 기준인 low는 반대다. 시계가 /tf_static에서 이미 출발했기 때문에 첫 파일은 6초를 채우고도 data 메시지가 273~276개로, 다른 파일의 300~301개보다 적었다.

실제 로봇에서 시간 기준으로 자를 때도 같은 일이 생길 수 있다. 녹화를 시작하자마자 latched 메시지나 다른 토픽이 먼저 들어오면 첫 파일의 센서 데이터 구간이 그만큼 짧아진다. 또 녹화하는 토픽이 모두 조용해지면 판정 자체가 일어나지 않으므로, -d 60이라도 메시지가 없는 동안에는 새 파일이 생기지 않는다(소스 기준, 실측하지 않음). --use-sim-time을 켜면 이 시각이 /clock 기준으로 바뀐다는 점도 README에 적혀 있는데, 이번 실험에서는 켜지 않았다. /clock을 받기 전의 시각 0이 계산을 어떻게 틀어지게 하는지는 use_sim_time 노드의 첫 dt 스파이크를 계산한 글에 따로 정리돼 있다.

두 번째 파일부터는 /tf_static이 없다

/tf_static은 transient local(트랜지언트 로컬) 내구성으로 발행된다. 발행자가 마지막 메시지를 보관해 두었다가 나중에 접속한 구독자에게도 건네주는 QoS 설정이고, README는 이런 메시지를 latched(래치드) 메시지라고도 부른다. 실험에서도 녹화기는 /tf_static이 발행된 뒤에 떴지만 이 성질 덕분에 첫 파일에 1개를 받았다.

문제는 그다음이다. bag에는 녹화기가 실제로 받은 메시지만 들어가고, /tf_static은 다시 발행되지 않았다. 그래서 5회 모두 0번 파일에만 있었고 1번 파일부터는 0개였다. 두 번째 파일만 따로 재생하거나 분석하면 base_link→camera_link 같은 정적 변환을 찾을 수 없다는 뜻이다. README도 이 상황을 “consumers of the new file may need certain transient-local (latched) messages — such as /map or /tf_static — to be present, even if they were not published again during the new file’s timespan”이라고 설명한다(README transient-local 절).

해결 옵션은 배포판에 따라 있고 없고가 갈린다. Lyrical과 Rolling에는 --repeat-transient-local <topic>[=<depth>](depth 생략 시 1)와 --repeat-all-transient-local [<depth>]가 있어, 새 파일을 열 때 보관해 둔 마지막 N개 메시지를 맨 앞에 다시 쓴다(lyrical record.py, PR #2386). 이번 Jazzy 컨테이너의 ros2 bag record --help 출력에는 repeat-transient-local 문자열이 없었고, Humble·Kilted 브랜치 소스에도 없다.

# Lyrical·Rolling 전용. 옵션 형태는 README와 CLI 정의로 확인했고, 이 글에서 실행하지는 않았다.
ros2 bag record -a -b 900000000 -d 60 --repeat-transient-local /tf_static

이 옵션을 쓸 때 알아 둘 점이 두 가지 있다. 다시 쓴 메시지의 타임스탬프는 원래 발행 시각이 아니라 분할 직전 메시지의 시각으로 바뀌고(write_transient_local_messages), 반복분도 새 파일 크기에 들어간다. 추가 용량은 파일 수 × Σ(토픽별 depth × 메시지 크기)로 미리 셀 수 있다. 앞의 카메라 예처럼 1시간에 120개 파일이 생기고 /tf_static 한 개가 5,000 B라고 가정하면 120 × 5,000 = 600,000 B로, 1시간 데이터 108,000,000,000 B의 약 5.56 × 10⁻⁶이다. /map 같은 점유 격자는 메시지가 훨씬 클 수 있으니 같은 식으로 다시 계산해야 한다. QoS override 파일을 함께 쓰면 override가 우선하므로 override에도 transient_local 내구성을 넣어야 한다는 점도 README에 있다.

Humble·Jazzy·Kilted에서 쓸 수 있는 대안 옵션은 이 글에서 확인하지 못했다. 확실히 말할 수 있는 것은 “Jazzy에는 분할 파일마다 latched 메시지를 자동으로 다시 넣는 CLI 옵션이 없고, 첫 파일에만 들어간다”까지다.

-b 하한과 캐시 초과는 배포판과 저장 형식을 따른다

README는 -b 하한을 “must be greater than 86016“이라고 저장 형식 구분 없이 적어 두어, 문구만 보면 모든 저장 형식에 같은 하한이 걸리는 것처럼 읽힌다. 실제로 이 숫자는 sqlite3 저장 플러그인의 최소 파일 크기다(sqlite_storage.cpp). Jazzy·Kilted·Lyrical·Rolling의 기본 저장 형식인 MCAP의 하한은 따로 있다(mcap_storage.cpp, jazzy). writer는 -b가 저장 플러그인의 하한보다 작으면 녹화 시작 때 예외를 던진다.

같은 컨테이너에서 rosbag2_py.SequentialWriter.open()에 값을 넣어 보면 mcap은 1023을 거부하고 1024를 받았으며, sqlite3는 86015를 거부하고 86016을 받았다. 5회 모두 같았다.

{"check": "min_split_size", "storage": "mcap", "max_bag_size": 1023, "accepted": false, "error": "Invalid bag splitting size given. Please provide a value greater than 1024. Specified value of 1023"}
{"check": "min_split_size", "storage": "mcap", "max_bag_size": 1024, "accepted": true, "error": ""}
{"check": "jazzy_cli_options", "has_max_bag_size": true, "has_max_bag_duration": true, "has_repeat_transient_local": false, "has_max_bag_files": false, "max_cache_size_default_in_help": true}

오류 문구는 “greater than 1024″라고 말하지만 실제 검사는 “하한 미만이면 거부”라서 1024 자체는 통과한다. 세 번째 줄은 앞 절의 --repeat-transient-local 부재와, 순환 녹화 옵션 --max-bag-files도 Jazzy CLI에 없다는 확인이다.

실제 녹화에서 하한에 걸릴 일은 거의 없다. 86,016 B는 30 MB/s 녹화가 0.0029초 만에 채우는 크기다. 하한 숫자를 외우기보다 자기 bag가 어떤 저장 형식인지부터 확인하는 편이 쓸모 있다.

더 신경 쓸 쪽은 쓰기 캐시(write cache)다. --max-cache-size는 디스크에 쓰기 전에 메시지를 모아 두는 메모리 버퍼 크기로, 기본값은 100 MiB(104,857,600 B)다. Jazzy·Lyrical의 writer는 분할을 판정할 때 이 캐시에 남은 메시지를 크기에 넣지 않고, 분할하는 순간 남은 캐시를 이전 파일에 마저 쓴다(jazzy 캐시 소비 코드). 저장소 이슈 #2310이 이 현상을 “significantly larger than --max-bag-size“로 보고했고, PR #2496은 “a file can overshoot by up to the cache size”라고 적은 뒤 캐시 크기를 판정에 넣도록 고쳤다. 이 수정은 2026-10-01 Rolling에만 병합됐고, 2026-10-14 확인한 Lyrical 브랜치에는 아직 없다.

이 상한을 기본값에 대입하면 Jazzy에서 -b 900000000인 파일은 최대 900,000,000 + 104,857,600 = 1,004,857,600 B(약 11.7% 초과)까지 커질 수 있다는 추정이 나온다. 관측값이 아니라 PR 문구로 계산한 위쪽 경계다. 앞의 실측은 디스크 쓰기가 매우 빠른 tmpfs에서 했기 때문에 캐시로 인한 초과가 거의 드러나지 않았고, 느린 디스크에서의 초과량은 재지 않았다. FAT32의 4 GiB 제한이나 업로드 크기 제한처럼 상한을 반드시 지켜야 한다면, Jazzy·Lyrical에서는 -b를 목표보다 캐시 크기 이상 작게 잡는 것이 문서 근거로 안전한 쪽이다. 캐시를 0으로 꺼서 직접 쓰게 하는 방법도 있지만, CLI 도움말이 약 1초 분량의 캐시를 권하는 데서 보듯 고속 녹화에서는 쓰기 지연 위험이 커질 수 있다. 이 부분은 측정하지 않았다.

배포판별 차이를 한곳에 모으면 다음과 같다. Jazzy 열의 하한·옵션 유무는 위 컨테이너에서 확인했고, 나머지는 2026-10-14 기준 각 브랜치 소스와 이슈·PR에서 확인했다. apt로 설치한 바이너리는 브랜치 최신 커밋과 다를 수 있으니 ros2 bag record --help로 자기 환경을 다시 보는 것이 맞다.

Humble Jazzy Kilted Lyrical Rolling
기본 저장 형식 sqlite3 mcap mcap mcap mcap
기본 형식의 -b 하한 86016 1024 1024 1024 1024
--repeat-transient-local 없음 없음 없음 있음 있음
크기 판정에 캐시 포함 확인하지 않음 미포함 미포함(이슈 #2310 영향 범위) 미포함 포함(2026-10-01 이후)

직접 판정해 볼 네 가지 경우

1. 토픽 합계 R = 5,000,000 B/s, -b 900000000 -d 60이면 어느 쪽이 먼저 자를까?
T_size = 900,000,000 / 5,000,000 = 180 s다. 180 s > 60 s이므로 시간 기준이 먼저 자르고, 1시간이면 파일은 약 60개다. 크기 기준은 이 녹화에서 일하지 않는다.

2. 30 MB/s 카메라 녹화를 60초 단위 파일로 남기고 싶다. -b 900000000 -d 60에서 무엇을 바꿔야 할까?
-b다. 지금은 30초마다 크기 기준이 먼저 자른다. 시간 기준이 이기려면 B > 30,000,000 × 60 = 1,800,000,000 B여야 하고, Jazzy·Lyrical에서 파일 크기 상한까지 지켜야 한다면 캐시 초과분도 감안해야 한다.

3. Jazzy에서 2시간 녹화한 bag의 세 번째 파일만 열었다. /tf_static이 들어 있을까?
녹화 도중 누군가 다시 발행하지 않았다면 없다. 실측에서도 녹화 전에 한 번 발행한 /tf_static은 0번 파일에만 있었다. Jazzy CLI에는 이를 분할 파일마다 다시 넣는 옵션이 없다.

4. -d 60으로 녹화하던 중 모든 토픽이 10분 동안 아무 메시지도 보내지 않았다. 그사이 파일은 몇 개 새로 생길까?
0개다. 분할 판정은 메시지가 도착할 때만 일어나기 때문이다. 다시 메시지가 들어오면 그 메시지의 수신 시각이 지금 파일의 시작보다 60초 넘게 뒤이므로 그때 한 번 분할된다(소스 기준 추론, 실측하지 않음).

녹화 전에 정할 순서와 이 실측의 한계

값은 다음 순서로 정하면 계산과 실제 동작이 크게 어긋나지 않는다.

  1. ros2 topic bw로 녹화할 토픽마다 데이터율을 재고 합한다. MB는 10⁶ B다.
  2. T_size = B / R을 D와 비교해, 이 녹화에서 어느 기준이 분할을 맡을지 정한다. 원하는 기준이 이기도록 다른 쪽 값을 넉넉히 둔다.
  3. Jazzy·Lyrical에서 파일 크기 상한이 엄격하면 -b를 캐시 크기 이상 낮춘다.
  4. 짧게 녹화한 뒤 ros2 bag info로 파일 수·크기를 계산과 비교한다.
  5. ros2 bag record --help로 --repeat-transient-local이 있는지 보고, 없으면 분할 파일을 따로 쓸 때 정적 tf가 빠진다는 전제로 분석 방법을 정한다.

이번 실측이 보장하는 범위는 좁다. 한 컨테이너 안에서 일정한 주기로 보낸 합성 토픽이었고, ROS 2는 Jazzy 하나, 저장 형식은 mcap, 압축은 없었으며 /tmp는 tmpfs였다. 실제 카메라처럼 데이터율이 흔들리면 T_size도 흔들리고, 압축을 켜면 같은 R에서도 파일이 늦게 찬다. MCAP writer가 chunk 단위로 버퍼링하면서 크기가 늦게 반영될 수 있다는 점은 코드 구조상 추론이며 검증하지 않았다. Lyrical·Rolling의 반복 옵션과 캐시 수정은 소스로만 확인했다.

녹화는 로봇을 움직이지 않는 작업이지만, 실물 로봇 컴퓨터에서 장시간 녹화하다 디스크가 가득 차면 녹화와 다른 프로세스가 함께 실패할 수 있다. 녹화는 해당 터미널에서 Ctrl+C로 멈춘다. 실험에서도 같은 신호(SIGINT)로 멈췄고 녹화기는 종료 코드 0으로 끝났다. Lyrical·Rolling의 --max-bag-files는 분할 크기나 시간이 설정돼 있을 때 가장 오래된 파일부터 지워 개수를 유지하는 순환 녹화 옵션이다. Jazzy CLI에는 없다.

분할 파일에서 빠지는 latched 메시지가 왜 처음 파일에만 들어가는지 더 알고 싶다면, transient local의 depth와 late joiner를 다룬 글에서 발행자가 무엇을 얼마나 보관하는지부터 확인할 수 있다.

참고 링크

소스와 이슈·PR은 2026-10-14 KST에 확인했다. 이 글에는 제휴·협찬이 없다.

비슷한 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다