펌웨어 CI/CD 파이프라인
Git 태그 기반 트리거로 ESP-IDF 펌웨어, Debian 패키지, Docker 이미지, 클라우드 인프라를 통합 자동화하는 6개 파이프라인 시스템
배경
- 시스템: Rovothome 제품군 (Ceily, Wally) - ESP32 펌웨어 + RPi5 ROS2 시스템 + 클라우드 모니터링
- 요구사항: Secure Boot 서명된 펌웨어, GPG 서명된 패키지, 멀티 아키텍처 Docker 이미지를 각각의 저장소에 배포
- 제약: 2인 팀, 보안 키를 CI에서 안전하게 처리, 5개 이상의 배포 채널 관리
핵심 문제
하나의 코드베이스에서 5가지 다른 아티팩트를 생성하고 배포해야 합니다:
firmware/
├── v1/ → ESP-IDF 펌웨어 → S3 (Secure Boot 서명)
├── v2/
│ ├── ros/ → Docker 이미지 → GHCR (amd64 + arm64)
│ └── deb/ → Debian 패키지 → APT 저장소 (GPG 서명)
├── cloud/ → Lambda, EC2 → AWS 인프라
└── monitor/ → Python 패키지 → PyPI
어려운 점: 각 타겟마다 빌드 도구, 서명 방식, 배포 프로세스가 완전히 다릅니다. ESP-IDF는 Docker 컨테이너에서 빌드하고 espsecure로 서명해야 하고, Debian 패키지는 dpkg-deb로 빌드 후 Aptly로 APT 저장소를 관리하며, Docker 이미지는 Buildx로 크로스 컴파일해야 합니다. 수동 처리 시 Secure Boot 키 유출 위험과 휴먼 에러가 발생합니다.
핵심 아이디어
Git 태그 패턴으로 릴리즈 의도를 명시적으로 표현합니다. 태그 형식이 어떤 파이프라인을 실행할지 결정합니다:
| 태그 패턴 | 트리거되는 파이프라인 | 배포 대상 |
|---|---|---|
v1-2.0.0 | v1-firmware-build | S3 (서명된 바이너리) |
v1-factory-1.0.0 | v1-factory-build | S3 (생산 라인용) |
rvt-system-v1.2.3 | rvt-deb-publish | APT 저장소 |
rvt-monitor-v0.1.0 | rvt-monitor-publish | PyPI |
클라우드 배포는 main 브랜치 푸시 + 경로 필터로 트리거됩니다.
접근 방식
1) Secure Boot 키 처리 아키텍처
Secure Boot 키가 레포에 저장되면 안 되지만, CI에서 펌웨어 서명이 필요합니다:
┌─────────────────┐ OIDC ┌─────────────────┐
│ GitHub Actions │───────────→│ AWS IAM Role │
└────────┬────────┘ └────────┬────────┘
│ │
│ assume role │ GetSecretValue
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Temp Credentials│ │ Secrets Manager │
└────────┬────────┘ │ (signing key) │
│ └────────┬────────┘
└──────────────┬───────────────┘
▼
┌─────────────────┐
│ 임시 파일 생성 │
│ (빌드 중에만) │
└────────┬────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
[빌드] [서명] [업로드]
│
▼
┌─────────────────┐
│ rm -rf keys/ │ ← always() 조건
└─────────────────┘
핵심 코드:
- name: Get signing keys from Secrets Manager
run: |
pip install boto3
python v1/keys/fetch_secure_boot_key.py
- name: Sign firmware
run: |
espsecure.py sign_data \
--keyfile ../keys/secure_boot_signing_key.pem \
--version 2 \
--output build/${{ matrix.device }}-signed.bin \
build/${{ matrix.device }}.bin
- name: Cleanup keys
if: always() # 빌드 실패해도 반드시 실행
run: rm -rf v1/keys/
if: always() 조건으로 빌드 성공/실패와 관계없이 키가 삭제됩니다.
2) ESP-IDF 멀티 디바이스 빌드
동일한 코드베이스에서 Ceily와 Wally 두 디바이스용 펌웨어를 빌드합니다:
strategy:
matrix:
device: [ceily, wally]
sdkconfig 병합 전략:
sdkconfig.defaults (공통 설정)
+
sdkconfig.${device} (디바이스별 핀맵, 기능)
+
sdkconfig.release (최적화, 디버그 비활성화)
=
sdkconfig (최종 빌드 설정)
이 구조로 공통 설정은 한 곳에서 관리하면서 디바이스별 차이만 분리합니다.
버전 디렉토리 구조:
s3://rvt-v1-firmware/dev/
├── 2.0.0-a1b2c3d/
│ ├── ceily.bin
│ └── wally.bin
├── 2.0.1-b2c3d4e/
│ ├── ceily.bin
│ └── wally.bin
└── latest.txt → "2.0.1-b2c3d4e"
버전 + git short hash 조합으로 같은 버전의 다른 빌드를 구분합니다.
3) Debian 패키지 배포 (Aptly + S3)
라즈베리파이에서 apt install rvt-system으로 설치할 수 있도록 자체 APT 저장소를 운영합니다:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ dpkg-deb │────→│ Aptly │────→│ S3 APT │
│ (빌드) │ │ (저장소관리) │ │ (호스팅) │
└─────────────┘ └──────┬──────┘ └─────────────┘
│
GPG 서명 추가
Aptly 워크플로우:
# 1. 패키지를 로컬 저장소에 추가
aptly repo add rvt-stable rvt-system_1.2.3_arm64.deb
# 2. 스냅샷 생성 (버전 추적)
aptly snapshot create rvt-system-20250204-153000 from repo rvt-stable
# 3. GPG 서명 후 S3에 발행
aptly publish switch \
-gpg-key="Rovothome" \
stable s3:rvt-apt: rvt-system-20250204-153000
라즈베리파이 클라이언트 설정:
# GPG 키 추가
curl -fsSL https://rvt-apt.s3.amazonaws.com/rvt-apt.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/rvt.gpg
# APT 소스 추가
echo "deb [signed-by=/etc/apt/keyrings/rvt.gpg] https://rvt-apt.s3.amazonaws.com stable main" | sudo tee /etc/apt/sources.list.d/rvt.list
# 설치/업데이트
sudo apt update && sudo apt install rvt-system
4) Docker 멀티 스테이지 + 멀티 아키텍처 빌드
개발(dev)과 프로덕션(prod) 이미지를 분리하면서 amd64(개발 PC)와 arm64(RPi5) 모두 지원합니다:
# Base: 공통 의존성 (ros2-control, can-utils 등)
FROM ros:jazzy-ros-base AS base
RUN apt-get install -y ros-${ROS_DISTRO}-ros2-control can-utils ...
# Dev: 소스 마운트, 빌드 없음
FROM base AS dev
# 런타임에 -v $(pwd):/ws/src 으로 소스 마운트
# Builder: 소스 복사 후 빌드
FROM base AS builder
COPY ros/src /ws/src
RUN colcon build --merge-install
# Prod: 빌드 결과물만 복사 (소스 없음)
FROM base AS prod
COPY --from=builder /ws/install /ws/install
왜 멀티 스테이지인가?
| 스테이지 | 용도 | 이미지 크기 | 소스 포함 |
|---|---|---|---|
| dev | 개발 (소스 마운트) | ~2.1GB | 런타임 마운트 |
| prod | 배포 (빌드 결과만) | ~1.8GB | 없음 |
프로덕션 이미지에 소스 코드가 포함되지 않아 이미지 크기가 줄고 지적 재산이 보호됩니다.
Buildx + QEMU로 크로스 컴파일:
- uses: docker/setup-qemu-action@v3 # arm64 에뮬레이션
- uses: docker/build-push-action@v5
with:
platforms: linux/amd64,linux/arm64 # 두 아키텍처 동시 빌드
push: true
tags: ghcr.io/rovothomeDev/rvt-ros2:prod
5) 클라우드 인프라 선택적 배포
변경된 컴포넌트만 배포합니다:
on:
push:
branches: [main]
paths:
- 'cloud/lambda/**'
- 'cloud/grafana/**'
- 'cloud/scripts/**'
jobs:
detect-changes:
steps:
- uses: dorny/paths-filter@v3
with:
filters: |
lambda:
- 'cloud/lambda/**'
grafana:
- 'cloud/grafana/**'
deploy-lambda:
needs: detect-changes
if: needs.detect-changes.outputs.lambda == 'true'
배포 후 Health Check:
- name: Health check
run: |
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://$EC2_HOST:3000/api/health)
if [ "$HTTP_CODE" != "200" ]; then
echo "Health check failed"
exit 1
fi
배포 직후 헬스체크로 문제를 즉시 감지합니다.
6) OIDC vs 장기 자격 증명
OIDC 인증 흐름:
GitHub Actions AWS
│ │
│ ──(1) JWT 토큰 요청──────────→ │
│ │ (2) IAM Role 확인
│ ←──(3) 임시 자격 증명 (15분)── │
│ │
│ ──(4) S3/Lambda/etc 호출─────→ │
│ │
장점:
- 시크릿에 액세스 키 저장 불필요
- 자격 증명이 15분 후 자동 만료
- 감사 로그에서 어떤 워크플로우가 접근했는지 추적 가능
permissions:
id-token: write # OIDC 토큰 요청 권한
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::xxx:role/github-actions
트레이드오프
| 결정 | 이유 | 대가 |
|---|---|---|
| GitHub Actions | GitHub 레포와 통합, OIDC 지원, 무료 2000분/월 | GitHub 종속성 |
| 태그 기반 트리거 | 릴리즈 의도 명시적, 버전 자동 파싱 | 수동 태깅 필요 |
| AWS Secrets Manager | 키 로테이션 용이, IAM 통합 | 호출당 $0.05, AWS 종속 |
| OIDC 인증 | 장기 자격 증명 제거, 15분 TTL | 초기 IAM 설정 복잡 |
| Aptly + S3 | 자체 APT 저장소, 버전 관리 | aptly 학습 비용, S3 비용 |
| Docker 멀티 스테이지 | dev/prod 분리, 이미지 크기 감소 | Dockerfile 복잡도 증가 |
| Buildx + QEMU | 단일 워크플로우로 2개 아키텍처 | arm64 빌드 느림 (~15분) |
| 경로 기반 배포 | 불필요한 재배포 방지 | paths-filter 액션 의존 |
결과
6개 파이프라인:
| 파이프라인 | 트리거 | 빌드 시간 | 배포 대상 |
|---|---|---|---|
| v1-firmware-build | v1-* 태그 | ~8분 | S3 |
| v1-factory-build | v1-factory-* 태그 | ~6분 | S3 |
| rvt-deb-publish | rvt-system-v* 태그 | ~3분 | APT (S3) |
| docker-build | 수동 | ~25분 | GHCR |
| cloud-deploy | main 푸시 + 경로 | ~2분 | Lambda/EC2 |
| rvt-monitor-publish | rvt-monitor-v* 태그 | ~1분 | PyPI |
보안:
- Secure Boot 키: Secrets Manager → 임시 파일 → 즉시 삭제
- APT 패키지: GPG 서명, 공개키 S3 호스팅
- AWS 인증: OIDC (장기 자격 증명 없음)
- SSH 키: base64 인코딩 시크릿 → 임시 파일 → 즉시 삭제
개발자 경험:
git tag v1-2.0.0 && git push --tags→ 자동 빌드/서명/업로드- GitHub Step Summary로 배포 결과 확인
- 빌드 실패 시 GitHub 알림
핵심 교훈
Git 태그 패턴 기반 트리거로 “어떤 아티팩트를 릴리즈할 것인가”를 명시적으로 표현할 수 있습니다. v1-*는 펌웨어, rvt-system-v*는 Debian 패키지—태그 형식만 보면 의도를 알 수 있습니다.
OIDC + Secrets Manager 조합으로 CI 환경에서 민감한 키를 안전하게 처리하면서 완전 자동화가 가능합니다. 키가 레포나 시크릿에 평문으로 저장되지 않고, 빌드 중에만 임시로 존재했다가 삭제됩니다.
소규모 팀에서 다중 배포 채널을 관리할 때, 채널별로 파이프라인을 분리하되 트리거 방식(태그 패턴)을 통일하는 것이 유지보수에 효과적입니다.