쿠버네티스 클러스터도 있겠다, 
혼자 해볼 수 있는 수준의 웹서비스와 마이크로서비스는 웬만큼 해본것 같았기에 뭐 더 할게 없나 찾아보던 중,
라즈베리파이 하나를 방화벽으로 만들면 재밌겠다는 생각이 들었다. 
 
방화벽이 뭔지도 모르고있었지만, 서버랙에 방화벽이랍시고 서버랑 똑같은 크기의 무언가가 들어가는걸 보고 뭔지 궁금해졌고 
그래서 직접 만들어보려고 한다. 
 
일단 k3s 클러스터만 담당하는 방화벽으로 할 생각이라, 
공유기(라우터) -> 방화벽 -> 클러스터 의 순서로 구성할 예정이다. 
 
파이의 내장 랜 포트는 WAN으로,
LAN은 USB 포트에 유선랜카드를 연결할 생각이다. 
 
세팅 과정

1. 부팅 이미지 굽기

 
https://firmware-selector.openwrt.org/

OpenWrt Firmware Selector

العربية (Arabic) Asturianu (Asturian) български (Bulgarian) বাংলা (Bengali) Català (Catalan) Čeština (Czech) Dansk (Danish) Deutsch (German) Ελληνικά (Greek) English Español (Spanish) فارسی (Persian) Suomalainen (

firmware-selector.openwrt.org

위 링크에서 OpenWrt의 펌웨어 이미지 (Factory Ext4) 를 다운받는다. 
 
그리고 Raspberry Pi Imager 프로그램에서 
OS 선택 -> Use Custom (사용자 지정) -> 다운로드한 ext4 이미지 선택하고, 이미지를 디스크에 굽는다. 
 

2. 부팅 확인

 
파이와 노트북을 랜선으로 연결한다.
이후 파이에 전원을 넣고 약 1분간 기다리고
노트북 브라우저에서 192.168.1.1 로 접속한다. 

그럼 위와같은 화면이 뜰텐데, 기본 비밀번호는 없으니 그냥 로그인버튼을 누르면 된다. 
 

3. 유선랜카드 드라이버 설치

파이와 연결은 확인했으니 유선랜카드 드라이버를 다운로드 받아야하는데, 
지금 파이에는 인터넷이 없는 상태다. 
근데 지금 이걸 공유기에 연결하게된다면, 공유기에서 할당해주는 50번 대역의 ip와, OpenWrt 자체가 갖고있는 1번 대역의 ip가 충돌할것으로 예상되어 그냥 내 노트북 와이파이 인터넷을 파이쪽에 공유해주려고 한다. 
 

3-1. 인터넷 설정

제어판 > 네트워크 및 인터넷 > 네트워크 및 공유 센터 > 어댑터 설정 변경 으로 들어간다. 

여기서 Wi-Fi를 우클릭해서 속성으로 들어간다. 

그리고 위처럼 공유 탭에서 허용을 눌러주고, 홈 네트워킹 연결은 이더넷으로 설정해준다. 

그리고 이번에는 이더넷을 우클릭해 속성을 들어간 뒤에, 
네트워킹 탭에서 인터넷 프로토콜 버전 4를 눌러 속성으로 들어간다.
 

사진처럼 노트북의 ip를 192.168.1.2 로 지정해주면 된다.  
 

3-2. 파이에서 게이트웨이 지정

192.168.1.1 로 접속하고 로그인하면 위와같은 화면이 뜰 것이다. 
Network > Interfaces 로 들어간 후, 아래 화면에서 lan 옆의 Edit을 누른다. 

 


위 화면에서 IPv4 gateway 란에 192.168.1.2 를 입력하고,
 Advanced Settings 탭으로 들어가서 Custom DNS Server 를 8.8.8.8 로 입력하고 저장한다. 
반드시 save 누른 후 나오는 화면에서 save & apply도 눌러야 한다. 그래야 적용되기 때문
 

3-3. 윈도우 방화벽 해제

 
윈도우 검색창에 '방화벽 상태 확인' 검색, 
좌측의 'Windows Defender 방화벽 설정 또는 해제' 클릭해서 방화벽을 해제해준다. 
드라이버 설치 이후 다시 활성화하면 된다. 
 
 
 
 

3-4. 드라이버 설치

인터넷에 연결시킨 후, 192.168.1.1 로 접속하고 로그인하면 위와같은 화면이 뜰 것이다. 
여기서 상단의 System > Software 로 들어가서, 아래의 update lists 버튼을 누른다. 

Error가 뜨지 않았다면, 아래와 같이 Filter에 kmod-usb-net-rtl8152 를 입력하고 install 버튼을 눌러 설치한다. 

 
설치하면 아래처럼 Network > Interfaces > Devices 에서 드디어 새로운 어댑터인 eth1 이 잡힌걸 확인할 수 있다!

 
유선랜카드와 랜선이 연결되어있지 않고 유선랜카드만 연결해도 떠야한다. 
 
 

4. WAN 인터페이스 만들기

Network > Interfaces > Add new interface 로 들어간 후, 위처럼 wan (소문자), DHCP client, eth1를 지정한 후 create interface를 누른다. 
 

create interface를 누르면 위와같은 창이 뜰텐데, Firewall Settings 탭으로 들어가 wan을 지정해준 후 save해준다. 
이후 save & apply도 눌러야 적용된다. 


 
 

'프로젝트 > 셀프 호스팅' 카테고리의 다른 글

갑자기 k3s 클러스터가 죽었다.  (0) 2026.01.27

 

1월 26일 밤 12시 30분경 부터 갑자기 서버에 접속이 안되기 시작했다. 

그냥 잘 돌고있나 확인차 ssh로 들어갔는데 응답이 없었고,

ping을 날렸는데도 지연시간이 3000ms정도로 뜨다가 

그러다가 결국 아예 연결이 끊겼는지 tailscale의 last seen 시간이 12시 39분으로 찍혀있는 상태로 완전 멈춰버렸다. 

 

주말동안 본가에 있던 나로서는 서버를 만질 수가 없었기에 평일에 서버가 있는 자취방으로 돌아와서 확인해본 결과, 

 

일단 인터넷 모뎀에 문제가 있었는지 집 와이파이가 인터넷이 안되는 상태였고, 

왜인지 마스터노드인 n100 미니pc는 랜선이 꽂혀있었음에도 공유기에 연결이 안되어있었다. 

 

아예 모니터를 연결하고 재부팅을 해주니 이젠 다시 정상적으로 네트워크도 연결이 되었고, 서비스들도 다시 돌아가기 시작했다. 

 

 

일단 인터넷만의 문제는 아니었던게, 서버 접속이 끊긴 12시 30분부터 정확하게 prometheus의 기록이 끊겨있다. 

또 보면 죽기 직전까지 급격하게 디스크 사용량이 계단식으로 증가하는걸 볼 수 있는데, 

막상 집에 와서 접속해보니 디스크 상태는 너무 널널한 상태였다. 

 

일단 crash라는 로그가 보이긴 하고, 

디스크 사용량이 계단식으로 늘어났던게 좀 불안하긴 하지만, 원인을 찾을 수 없으니 할 수 있는 조치만 해보려고 한다.

 

조치

1. 커널 사망 -> 재부팅 설정

# 커널 패닉 발생 시 10초 후 자동 재부팅
echo "kernel.panic = 10" | sudo tee -a /etc/sysctl.conf
echo "kernel.panic_on_oops = 1" | sudo tee -a /etc/sysctl.conf
# 설정 즉시 적용
sudo sysctl -p

 

2. 로그 보존 설정

sudo mkdir -p /var/log/journal
sudo systemctl restart systemd-journald

기존에는 로그가 남아있지 않았기 때문에 위 명령으로 메모리가 아닌 디스크에 영구 저장되도록 바꿔주었다. 

 

 

계획

일단 라즈베리파이 2대를 마스터로 돌려서 taint를 걸어둔 상태로 마스터노드가 3개인 HA모드로 구축해보려고 한다. 

그리고 그만큼 worker가 줄어들었으니 n100 미니pc의 taint를 해제해서 연산용으로 사용하면서, 마스터노드의 역할도 할 수 있도록 해줄 생각이다.

어차피 etcd 관리는 taint 걸린 파이 2대가 잘 해주지 않을까 싶다.

라즈베리파이 를 활용한 프레임워크의 구조를 배포 가능하게 바꾸고 서버에 배포하니 문제가 발생했다. 

기존에는 로컬에서 한번에 실행하고 화면 뜨는걸 확인하고 바로 껐기 때문에 문제를 못느꼈지만,

서버에서 24시간 돌아가게 해놓으니 메모리 누수 문제를 확인할 수 있었다. 

 

발견 계기

집에서 잘 돌아가고 있나 궁금해서 웹서버가 떠있는 주소로 접속했더니, 연결이 안됐다.

클러스터에 설치해둔 grafana를 통해 확인해보니 k3s 노드 하나가 아래와 같은 상태였다. 

 

메모리 사용량이 특정 시점부터 쭉 증가하다가 죽었고, 다시 켜지자마자 또 메모리 사용량이 쭉 증가하다가 죽기를 반복하고 있었다. 

 

문제를 파악하기 위해 prometheus 쿼리를 통해 메모리 사용량 상위 다섯개 파드를 확인해보니 바로 범인을 찾을 수 있었다. 

 

범인

일단 gateway 노드가 문제였다. 

gateway node가 inteface들에 데이터를 쫙 뿌릴 때, 

web inteface에는 내부적으로 영상 끊김 방지를 위해 내장되어있는 Time Jitter Buffer(우선순위 큐)를 거친다.

 

그런데 그 buffer 내부적으로 최대 크기가 지정되어있지 않았고, gateway에서는 web_interface의 buffer에 프레임을 계속해서 push한다.

하지만 유일하게 buffer에서 pop하는 로직이 들어있는 stream_generator는 웹에 접속자가 있을때만 작동했기 때문에

내가 접속을 끊은 이후에는 buffer에서 pop이 이루어지지 않는것이었다.

 

buffer_delay값이 존재하긴 했지만, pop할때만 delay 안쪽에 있는 프레임들을 pop되지 않게만 해주는 역할일 뿐, 

buffer가 넘치지 않게 막아주는 역할은 해주지 못했다. 

 

 

해결

따라서 push 메소드에

버퍼 최대 크기를 넘었다면 최대 크기 이하일때까지 pop을 진행하고, 그 이후에 push하도록 로직을 추가해주었다.

 

이렇게 메모리가 폭발하지 않게 되었다. 

 

Gemini의 도움

 

사실 이렇게 원인을 해결하는데는 gemini의 도움이 컸다. 

1. camera node는 30fps로 송출

2. 터질때의 메모리 사용량이 6GB(swap 포함인듯?)

3. 터지는데까지 50분 소요

 

3가지 단서를 주니까 즉시 메모리 누수 문제라는 것을 추론해냈다.

진짜 이 답변 보고서는 이게 특이점인가 싶을 정도로 소름이 돋았다.

 

정말로 640*480 해상도의 jpeg 이미지를 송출하고 있었기 때문이다. 

 

나는 파드가 문제인것까지만 확인했지 어떻게 메모리가 터지는지는 몰랐는데, 

Gemini 덕분에 모든 이미지들이 램에 저장되고 있다는걸 깨달을 수 있었다. 

 

 

ROS2를 로봇에서 실제로 활용해보기 전에, 시뮬레이션으로 먼저 제어에 익숙해진 다음에 실물 로봇으로 넘어가는게 나을 것 같다는 생각에 윈도우 노트북 위에 WSL2를 통해 ROS2를 설치하였다. 

 

그렇게 간단한 URDF를 만들고 걷는 흉내를 내보는 도중, RVIZ에서 로봇 모양이 제대로 안뜨는 문제가 발생했다. 

 

내가 돌리던 코드는 다음과 같다.

 

#biped_view.launch.py

import os
from launch import LaunchDescription
from launch_ros.actions import Node

def generate_launch_description():
    # 1. URDF 파일의 절대 경로 설정 (본인의 환경에 맞게 수정됨)
    urdf_path = os.path.expanduser('~/humanoid_ws/src/simple_biped.urdf')

    # 2. URDF 파일 내용 읽기
    with open(urdf_path, 'r') as infp:
        robot_desc = infp.read()

    return LaunchDescription([
        # 1. Robot State Publisher: 파라미터로 URDF 내용을 전달
        Node(
            package='robot_state_publisher',
            executable='robot_state_publisher',
            name='robot_state_publisher',
            output='screen',
            parameters=[{'robot_description': robot_desc}]
        ),
       
        # 2. RViz2: 설정 없이 깨끗하게 실행
        Node(
            package='rviz2',
            executable='rviz2',
            name='rviz2',
            output='screen'
        )
    ])

 

#leg_ik_controller.py

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
import math

class LegIKController(Node):
    def __init__(self):
        super().__init__('leg_ik_controller')
        self.publisher_ = self.create_publisher(JointState, 'joint_states', 10)
        self.timer = self.create_timer(0.05, self.timer_callback)
        self.counter = 0.0
       
        # 다리 길이 (URDF 설정값과 일치해야 함)
        self.L1 = 0.4  # 허벅지
        self.L2 = 0.4  # 종아리

    def solve_ik(self, x, z):
        # 코사인 법칙을 이용한 역기구학 계산
        dist_sq = x**2 + z**2
        cos_theta2 = (dist_sq - self.L1**2 - self.L2**2) / (2 * self.L1 * self.L2)
        cos_theta2 = max(-1.0, min(1.0, cos_theta2)) # 수치 에러 방지
       
        theta2 = math.acos(cos_theta2)
        theta1 = math.atan2(x, -z) - math.atan2(self.L2 * math.sin(theta2), self.L1 + self.L2 * math.cos(theta2))
       
        return theta1, theta2

    def timer_callback(self):
        msg = JointState()
        msg.header.stamp = self.get_clock().now().to_msg()
        msg.name = ['r_hip_joint', 'r_knee_joint', 'l_hip_joint', 'l_knee_joint']
       
        # 발바닥이 그릴 궤적 설계 (예: 타원형 궤적)
        # x: 앞뒤 이동, z: 위아래 이동
        target_x = 0.1 * math.sin(self.counter)
        target_z = -0.6 + 0.05 * math.cos(self.counter) # 기본 -0.6m 높이에서 까딱거림
       
        r_theta1, r_theta2 = self.solve_ik(target_x, target_z)
        # 왼쪽 다리는 반대 위상으로 (걷는 동작 모사)
        l_theta1, l_theta2 = self.solve_ik(-target_x, target_z)
       
        msg.position = [r_theta1, r_theta2, l_theta1, l_theta2]
        self.publisher_.publish(msg)
        self.counter += 0.1

def main(args=None):
    rclpy.init(args=args)
    node = LegIKController()
    rclpy.spin(node)
    node.destroy_node()
    rclpy.shutdown()

if __name__ == '__main__':
    main()

 

<!-- simple_biped.urdf -->
<?xml version="1.0"?>
<robot name="simple_biped">
  <material name="blue"><color rgba="0 0 0.8 1"/></material>
  <material name="white"><color rgba="1 1 1 1"/></material>

  <!-- Base -->
  <link name="base_link">
    <visual>
      <geometry><box size="0.2 0.3 0.1"/></geometry>
      <material name="blue"/>
    </visual>
  </link>

  <!-- Right leg -->
  <link name="r_thigh">
    <visual>
      <origin xyz="0 0 -0.2" rpy="0 0 0"/>
      <geometry><cylinder length="0.4" radius="0.04"/></geometry>
      <material name="white"/>
    </visual>
  </link>

  <joint name="r_hip_joint" type="revolute">
    <parent link="base_link"/>
    <child link="r_thigh"/>
    <origin xyz="0 -0.1 0" rpy="0 0 0"/>
    <axis xyz="0 1 0"/>
    <limit effort="30" lower="-1.57" upper="1.57" velocity="1.0"/>
  </joint>

  <link name="r_shin">
    <visual>
      <origin xyz="0 0 -0.2" rpy="0 0 0"/>
      <geometry><cylinder length="0.4" radius="0.04"/></geometry>
      <material name="blue"/>
    </visual>
  </link>

  <joint name="r_knee_joint" type="revolute">
    <parent link="r_thigh"/>
    <child link="r_shin"/>
    <origin xyz="0 0 -0.4" rpy="0 0 0"/>
    <axis xyz="0 1 0"/>
    <limit effort="30" lower="0" upper="2.6" velocity="1.0"/>
  </joint>

  <!-- Left leg -->
  <link name="l_thigh">
    <visual>
      <origin xyz="0 0 -0.2" rpy="0 0 0"/>
      <geometry><cylinder length="0.4" radius="0.04"/></geometry>
      <material name="white"/>
    </visual>
  </link>

  <joint name="l_hip_joint" type="revolute">
    <parent link="base_link"/>
    <child link="l_thigh"/>
    <origin xyz="0 0.1 0" rpy="0 0 0"/>
    <axis xyz="0 1 0"/>
    <limit effort="30" lower="-1.57" upper="1.57" velocity="1.0"/>
  </joint>

  <link name="l_shin">
    <visual>
      <origin xyz="0 0 -0.2" rpy="0 0 0"/>
      <geometry><cylinder length="0.4" radius="0.04"/></geometry>
      <material name="blue"/>
    </visual>
  </link>

  <joint name="l_knee_joint" type="revolute">
    <parent link="l_thigh"/>
    <child link="l_shin"/>
    <origin xyz="0 0 -0.4" rpy="0 0 0"/>
    <axis xyz="0 1 0"/>
    <limit effort="30" lower="0" upper="2.6" velocity="1.0"/>
  </joint>
</robot>

 

간단히 허벅지와 종아리로 이루어진 로봇이다. 

~/humanoid_ws/src$ ros2 launch biped_view.launch.py

이걸 위 명령으로 실행시키고,

~/humanoid_ws/src$ python3 leg_ik_controller.py

다른 터미널에서 위 명령으로 publisher를 생성해주면 rviz 창이 뜰텐데, 몇가지 설정만 해주면 된다. 

 

이 화면에서, Global Options > Fixed Frame 부분 우측에 map이라고 쓰인 부분을 클릭해서

사진처럼 base_link로 설정해줘야한다. 

 

그리고 왼쪽 하단의 Add를 누르고 뜨는 화면에서 RobotModel을 추가해주고, 

다시 Add를 눌러서 TF도 추가해주면 아래처럼 로봇같이 생긴 무언가가 움직이는걸 볼 수 있다. 

 

난 이게 문제 없는 화면인줄 알았는데, 자세히 보니 종아리가 없다는 것에서 뭔가 잘못된걸 느꼈다. 

분명 여기 Status도 Ok로 떠있는데, 이 상태는 urdf가 제대로 불러와진 상태가 아니고, 

Description Topic을 지정해줘야 하는 상태였다. 

이렇게 Description Topic 옆의 빈칸을 눌러주니, Topic을 지정해줄 수 있었다. 

 

Topic 하나를 지정해주니 문제없이 허벅지와 종아리가 뜨는걸 확인할 수 있었다. 

 

 

내가 보던 허벅지인줄 알던 노란색 막대기는, 

부모 좌표계에서 자식 좌표계로 가는 변위를 그려주는 Arrow 기능이었던 거였다.

1. 현상

https://emanual.robotis.com/docs/en/platform/turtlebot3/manipulation/

 

ROBOTIS e-Manual

 

emanual.robotis.com

위 매뉴얼의 7.7 부분을 수행하던 중, 터틀봇3에서 아래 명령을 실행하면 

  $ ros2 launch turtlebot3_manipulation_bringup hardware.launch.py

 

아래와 같은 문구를 뱉으며 프로세스가 죽어버리는 현상이 발생했다. 

 

주요 로그: 

[ros2_control_node-1] [ERROR] [DynamixelSDKWrapper] [TxRxResult] There is no status packet!
[ros2_control_node-1] *** stack smashing detected ***: terminated
[ros2_control_node-1] #7 Object "libturtlebot3_manipulation_hardware.so", at ..., in DynamixelSDKWrapper::write(...)

 

2. 원인

처음에는 OpenCR의 펌웨어와, ros2 humble간의 패킷 사이즈가 버전 차이 이슈로 맞지 않나 싶어서 아두이노IDE로 OpenCR의 펌웨어를 다시 올려보기도 했고, ros2 패키지를 처음부터 다시 빌드하기도 했었으나, 

 

https://github.com/ROBOTIS-GIT/turtlebot3_manipulation/issues/63

 

Open Manipulator Fails BringUP · Issue #63 · ROBOTIS-GIT/turtlebot3_manipulation

Hi everyone, I'm having this errors (exception) when trying to bringup the turtlebot3 with the open manipulator: $ ros2 launch turtlebot3_manipulation_bringup hardware.launch.py [INFO] [launch]: Al...

github.com

 

해당 깃허브 이슈를 통해 진짜 원인은 다이나믹셀 내부 설정인 Return Delay Time이 기본값(500)으로 설정되어있었기 때문이라는걸 알 수 있었다. 

 

ros2 humble의 ros2_control의 데이터 i/o 속도보다 모터가 응답을 늦게 주는 바람에 패킷들이 서로 뒤섞이는 문제가 발생하게 된다. 

따라서 정해진 메모리 범위를 넘어서 기록하게 되어 강제종료되었기 때문에 stack smashing detected 같은 메시지가 뜬 것이었다.

 

3. 해결 과정

Step 1: OpenCR 펌웨어 변경

OpenCR을 U2D2와 같은 통신 통로로 쓰기 위해, 펌웨어를 변경해야한다. 

 

1. 라즈베리파이와 연결되어있는 케이블을 OpenCR에서 제거하고, 컴퓨터와 연결된 USB 케이블을 OpenCR과 연결한다. 

 

2. 아두이노 IDE에서 파일 > 예제 > OpenCR > 10.Etc > usb_to_dxl 파일을 불러와 OpenCR에 업로드한다. 

 

Step 2: 다이나믹셀 위자드 설정 변경

1. Dynamixel Wizard 2.0 프로그램에서 검색 버튼을 통해 연결된 모든 모터들을 연결한다. 

 

2. 연결된 모든 모터를 클릭해 9 번 부근의 Return Delay Time 옵션을 0으로 변경한다. 

(본인은 250으로 되어있길래 0으로 변경)

(변경하고 아래쪽 저장버튼 눌러야함)

 

Step 3: OpenCR 펌웨어 복구

1. 아두이노 IDE에서 OpenCR의 펌웨어를 기존의 펌웨어로 다시 업로드한다. 

 

 

해결

  $ ros2 launch turtlebot3_manipulation_bringup hardware.launch.py

 

이후 다시 위 명령을 실행하니 아주 깔끔히 실행이 잘 되는걸 확인할 수 있었다.

 

https://emanual.robotis.com/docs/en/platform/turtlebot3/sbc_setup/#sbc-setup

 

ROBOTIS e-Manual

 

emanual.robotis.com

터틀봇3의 e-manual을 보고 라즈베리파이를 세팅하고 있었는데, 

 

$ ros2 launch turtlebot3_bringup camera.launch.py

 

라이브러리를 다 설치하고 노드를 실행하는 이 부분에서부터 문제가 생겼다. 

 

$ rqt_image_view

원격 pc (WSL2)에서 카메라 영상을 보여주는 위 명령을 실행하면 

 

아래와 같은 빈 화면만 뜨고, 좌측 상단의 topic도 아무것도 뜨지 않았다.

~$ ros2 topic list
/parameter_events
/rosout


topic들을 조회하면 /parameter/events, /rosout만 뜨는 상황이었고 domain_id나 이외 설정들은 문제가 없는 상태였기에 네트워크에 문제가 있다는걸 알 수 있었다. 

 

원인

네이티브 리눅스와는 다르게, WSL2에서는 네트워크가 윈도우 내부의 가상 네트워크 뒤에 숨어있는 상태이기 때문에 터틀봇이 보내는 멀티캐스트 데이터가 WSL2 안으로 제대로 들어오지 못하는 것이 원인이었다.

 

해결방법

1. WSL2 미러링모드 설정

윈도우 11에서 지원하는 미러링 모드를 쓰면, WSL2가 윈도우의 IP를 그대로 공유해서 쓴다. 

즉, 터틀봇과 같은 와이파이 대역에 바로 접속할 수 있게 된다. 

미러링 모드 설정 없이 WSL2 내부에서 ip를 확인해본다면 172.~으로 시작하는 ip가 나오게 되는데, 

미러링모드를 설정한 후에는 WSL2가 돌아가고 있는 PC와 동일한 IP주소를 가지게 된다. 

 

설정하기 위해서는 C:\Users\사용자명 폴더에 파일을 하나 만들어 아래 내용을 그대로 붙여넣어주면 된다. 

[wsl2]
networkingMode=mirrored
firewall=true

.wslconfig 파일

 

파일을 생성했다면 wsl --shutdown 명령으로 wsl을 재부팅하면, WSL2 에서의 IP가 호스트 PC의 주소와 같게 바뀐 것을 확인할 수 있다.

이제 양쪽 기기를 한번씩 재부팅 한 후에 노드를 다시 실행시키고

rqt 창 왼쪽 상단에서 /camera/image_raw/compressed 를 선택하면

 

터틀봇 터미널 로그
원격 pc 터미널 로그

 

터미널에서 Unrecognized image encoding [nv21] 과 같은 에러를 뱉는걸 볼 수가 있다. 

카메라는 기본적으로 NV21이나 압축된 포맷으로 데이터를 쏘려고 시도한다.

하지만 현재 ROS 2 버전이나 이미지 처리 노드(OpenCV 등)에서는 RGB888 같은 표준 포맷을 기대하기 때문에 충돌이 일어나는 것이다. (e-manual 따라하는데 왜 이런 일이 발생하는지는 모르겠다)

 

 

그렇기에 아래 명령으로 launch하지 않고 노드를 직접 실행하면서 포맷 파라미터를 지정해버리면

ros2 run camera_ros camera_node --ros-args -p format:="RGB888"

이렇게 영상이 잘 나오는걸 확인할 수 있다. 

 

 

프로젝트 개요

라즈베리파이 클러스터에서 실시간 영상 데이터를 노드별로 분산 처리하면 처리량을 향상시킬 수 있는지를 검증하기 위해 진행한 프로젝트다. 수평확장성을 위해 마이크로 서비스 아키텍처로 설계했고, 각 노드는 독립적으로 프레임을 소비/처리하는 구조를 적용했다. 
 

기술 스택

FastAPI, Redis, YOLO, K3S
 

최종 결과물

 

웹에 배포된 화면
배포된 파드들

 
초기에는 라즈베리파이만으로 구성했지만, 마스터노드를 N100기반 미니pc로 교체하면서 파드 3개에서 안정적으로 consumer를 실행할 수 있었다. 이를 통해 전체 처리 성능이 약 16fps까지 증가했고, 노드 수 증가에 따라 5 -> 10 -> 16fps형태의 선형적 처리량 향상을 확인했다. 
 

카메라가 장착된 라즈베리파이 클러스터



마이크로 서비스 구조

최종적으로 완성된 아키텍처

이 구조는 '최신 프레임만 소비하고, 오래된 프레임은 즉시 폐기하는 방식'을 통해 지연 누적 문제를 완전히 제거하고, 노드 수당 성능이 거의 선형적으로 증가하는 형태를 만들었다. 
 

소회

프로젝트 초기에는 메시지큐 기반으로 프레임을 배포할 계획이었다. 심지어 프레임을 NFS에 저장한 뒤 메타데이터만 메시지큐로 전달하는 방식, 메시지큐와 Redis를 혼용하는 방식 등 다양한 구조를 시도했다. 요소들을 이해하고 있음에도, 메시지큐를 써야한다는 전제에서 출발하다 보니 구조가 계속 복잡해지고 스스로도 방향성을 잃게 됐다. 
 
문제를 해결하기 위해 기술을 선택하는 것이 아니라, 이미 선택한 기술을 사용하기 위해 문제를 맞추려 했던 것이 가장 큰 원인이었다. 분산처리를 실험해보겠다는 초기 목적은 사라지고, 어느 시점부터는 메시지큐를 사용하는 것 자체가 목표로 변해 있었다. 
 
하지만 필요성을 하나씩 다시 질문하다보니, 오히려 메시지큐가 필요 없다는 결론에 도달했다. 
결국 가장 중요한 것은 실시간 처리, 그리고 프레임 누적 방지 였다. 최신 프레임만을 가져가고, 지나간 프레임은 버려도 무방한 구조라는 점을 다시 정의했을 때 Redis 하나로도 전체 파이프라인이 훨씬 단순하고 효과적으로 동작했다. 
 
 
 

향후계획

이번 프로젝트를 통해 저성능 엣지 디바이스에서도 마이크로서비스를 이용한 수평 확장이 충분히 가능하다는 점을 검증했다. 하지만 현재 구조에서는 여전히 한 프레임을 처리하는 데 약 0.2초가 소요되며, 이는 어떤 방식의 수평 확장으로도 줄일 수 없는 지연이다. 이 지연을 개선하려면 모델 자체를 최적화하거나 경량화하는 방향이 필요하다. 
 
따라서 다음 목표는 아래와 같다. 
 
1. 모델 경량화 및 성능 개선 실험
- YOLO 계열 모델을 기준으로 모델 경량화를 경험해볼 예정이다. 
2. 프레임 단위 분산처리 파이프라인을 경량 프레임워크로 발전
- 쿠버네티스 기반 엣지 분산 영상처리 파이프라인을 단순화해 쉽게 재사용 가능한 소형 프레임워크 형태로 배포해보고 싶다. 
3. ROS2 + 쿠버네티스 실험
- ROS2의 컴포넌트 간 통신 구조가 마이크로 서비스와 매우 유사하다는 점에서, 로봇 시스템과 쿠버네티스 클러스터를 접목시키는 새로운 프로젝트를 시도해볼 계획이다. 
 

+ Recent posts