Post

[ROS2 실전] Day 4: CI/CD - GitHub Actions와 시뮬레이션 자동 테스트

[ROS2 실전] Day 4: CI/CD - GitHub Actions와 시뮬레이션 자동 테스트

서론: 머지 전에 시뮬레이션에서 검증한다

로봇 코드는 하드웨어에서 직접 테스트하기 전에 시뮬레이션에서 먼저 검증해야 한다. GitHub Actions에서 Gazebo를 실행하고 내비게이션 성공 여부를 자동으로 확인하는 파이프라인을 만들면, PR마다 시뮬레이션 테스트가 돌아간다.

1. 전체 파이프라인 구조

1
2
3
4
5
6
7
8
9
10
11
12
PR 생성
  ↓
[lint] 코드 스타일 검사 (병렬)
[unit-test] 단위 테스트 (병렬)
  ↓
[build-image] Docker 이미지 빌드
  ↓
[sim-test] Gazebo 시뮬레이션 테스트
  ↓
[push] 이미지 레지스트리 푸시 (main 머지 시)
  ↓
[deploy] 현장 로봇 OTA 업데이트 (태그 생성 시)

2. 기본 CI 워크플로

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# .github/workflows/ci.yml
name: ROS2 CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  ROS_DISTRO: jazzy

jobs:
  lint:
    runs-on: ubuntu-24.04
    container:
      image: ros:jazzy
    steps:
      - uses: actions/checkout@v4

      - name: ament_lint
        run: |
          apt-get update -q
          apt-get install -y python3-ament-flake8 \
            python3-ament-pep257 ament-cmake-clang-format
          source /opt/ros/jazzy/setup.bash
          ament_flake8 --config setup.cfg
          ament_pep257

  unit-test:
    runs-on: ubuntu-24.04
    container:
      image: ros:jazzy
    steps:
      - uses: actions/checkout@v4
        with:
          path: ros2_ws/src/robot

      - name: 의존성 설치
        working-directory: ros2_ws
        run: |
          apt-get update -q
          rosdep update -q
          rosdep install --from-paths src --ignore-src -r -y

      - name: 빌드 (테스트 포함)
        working-directory: ros2_ws
        run: |
          source /opt/ros/jazzy/setup.bash
          colcon build --cmake-args -DBUILD_TESTING=ON

      - name: 테스트 실행
        working-directory: ros2_ws
        run: |
          source /opt/ros/jazzy/setup.bash
          source install/setup.bash
          colcon test --return-code-on-test-failure

      - name: 테스트 결과 업로드
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: ros2_ws/log/latest_test/

3. Docker 이미지 빌드 및 푸시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
  build-image:
    needs: [lint, unit-test]
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Docker Buildx 설정
        uses: docker/setup-buildx-action@v3

      - name: GitHub Container Registry 로그인
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: $
          password: $

      - name: 이미지 메타데이터
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/$/robot
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern=
            type=sha,prefix=sha-

      - name: 빌드 및 푸시
        uses: docker/build-push-action@v5
        with:
          context: .
          push: $
          tags: $
          cache-from: type=gha
          cache-to: type=gha,mode=max

4. Gazebo 시뮬레이션 테스트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
  sim-test:
    needs: [build-image]
    runs-on: ubuntu-24.04
    container:
      image: ghcr.io/$/robot:sha-$
      options: --privileged

    steps:
      - uses: actions/checkout@v4

      - name: 가상 디스플레이 설정 (Xvfb)
        run: |
          apt-get install -y xvfb
          Xvfb :99 -screen 0 1280x720x24 &
          export DISPLAY=:99

      - name: 시뮬레이션 테스트 실행
        timeout-minutes: 10
        run: |
          source /opt/ros/jazzy/setup.bash
          source /ros2_ws/install/setup.bash
          export DISPLAY=:99

          # Gazebo + Nav2 실행
          ros2 launch robot_bringup sim_test.launch.py &
          SIM_PID=$!

          # 시뮬레이터 준비 대기
          sleep 15

          # 내비게이션 테스트 실행
          python3 test/sim/test_navigation.py
          TEST_RESULT=$?

          kill $SIM_PID
          exit $TEST_RESULT

      - name: 테스트 영상 업로드 (실패 시)
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: sim-failure-recording
          path: /tmp/sim_recording.mp4

5. 시뮬레이션 테스트 스크립트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# test/sim/test_navigation.py
import rclpy
import sys
import time
from geometry_msgs.msg import PoseStamped
from nav2_msgs.action import NavigateToPose
from rclpy.action import ActionClient
from action_msgs.msg import GoalStatus

def test_navigation_to_goal():
    rclpy.init()
    node = rclpy.create_node('nav_test')
    client = ActionClient(node, NavigateToPose, 'navigate_to_pose')

    # 액션 서버 대기 (최대 30초)
    if not client.wait_for_server(timeout_sec=30.0):
        print("FAIL: navigate_to_pose 서버 응답 없음")
        return False

    # 목표 위치 전송
    goal = NavigateToPose.Goal()
    goal.pose.header.frame_id = 'map'
    goal.pose.header.stamp = node.get_clock().now().to_msg()
    goal.pose.pose.position.x = 3.0
    goal.pose.pose.position.y = 2.0
    goal.pose.pose.orientation.w = 1.0

    future = client.send_goal_async(goal)
    rclpy.spin_until_future_complete(node, future, timeout_sec=5.0)
    goal_handle = future.result()

    if not goal_handle.accepted:
        print("FAIL: 목표 거부됨")
        return False

    # 완료 대기 (최대 120초)
    result_future = goal_handle.get_result_async()
    rclpy.spin_until_future_complete(
        node, result_future, timeout_sec=120.0)

    status = result_future.result().status
    if status == GoalStatus.STATUS_SUCCEEDED:
        print("PASS: 목표 도달 성공")
        return True
    else:
        print(f"FAIL: 내비게이션 실패 (status={status})")
        return False

if __name__ == '__main__':
    success = test_navigation_to_goal()
    sys.exit(0 if success else 1)

6. 자동 배포 (태그 생성 시)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
  deploy:
    needs: [sim-test]
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-24.04

    steps:
      - name: 현장 로봇 OTA 업데이트
        uses: appleboy/ssh-action@v1
        with:
          host: $
          username: robot
          key: $
          script: |
            cd /opt/robot
            ./update.sh $

      - name: Slack 배포 알림
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "로봇 $ 배포 완료",
              "attachments": [{
                "color": "good",
                "text": "현장: warehouse_seoul_01"
              }]
            }
        env:
          SLACK_WEBHOOK_URL: $

7. 빌드 캐시 전략

1
2
3
4
5
6
7
8
9
10
11
12
      # colcon 빌드 캐시 (GitHub Actions Cache)
      - name: colcon 캐시 복원
        uses: actions/cache@v4
        with:
          path: |
            ros2_ws/build
            ros2_ws/install
          key: colcon-$-$
          restore-keys: |
            colcon-$-

      # Docker layer 캐시는 build-push-action의 cache-from/to 활용

8. Day 4 체크리스트

  1. lint/unit-test를 병렬로 실행하고 빌드·시뮬레이션은 순차로 구성했다.
  2. Docker 레이어 캐시(cache-from: type=gha)로 빌드 시간을 단축했다.
  3. Xvfb로 헤드리스 Gazebo를 CI 환경에서 실행했다.
  4. 내비게이션 성공 여부를 액션 클라이언트로 자동 검증하는 스크립트를 작성했다.
  5. 태그 생성 시 SSH로 현장 로봇에 OTA 업데이트를 트리거했다.

다음 글 예고

Day 5에서는 AMR 시스템 아키텍처 설계를 다룬다. 물류 창고 자율이동로봇 시스템 전체를 ROS2로 설계할 때 고려해야 할 패키지 구조, 통신 패턴, 안전 설계를 정리한다.

This post is licensed under CC BY 4.0 by the author.