Post

[System Design] FOTA 업데이트 시스템 구현하기 - Manifest 기반 자동 업데이트

Semantic Versioning, SHA256 검증, Rollback 메커니즘을 갖춘 FOTA 시스템 설계와 구현

FOTA란? (Firmware Over-The-Air)

FOTA(Firmware Over-The-Air)는 네트워크를 통해 원격으로 소프트웨어를 업데이트하는 시스템입니다. 원래는 IoT 디바이스의 펌웨어 업데이트를 위해 만들어졌지만, 일반 소프트웨어 업데이트에도 적용할 수 있습니다.

필요성

1
2
3
4
5
6
7
8
9
10
전통적인 업데이트:
1. 새 버전 릴리스
2. 사용자가 수동으로 다운로드
3. 설치 스크립트 실행
4. 서비스 재시작

문제점:
- 대규모 Agent를 일일이 업데이트? 불가능
- 업데이트 누락 가능성 높음
- 버전 파편화 발생

FOTA 방식

1
2
3
4
5
6
7
8
9
10
11
12
FOTA 업데이트:
1. Backend에 새 버전 등록
2. Agent가 자동으로 체크 (매일 밤 12-1시)
3. 새 버전 발견 시 자동 다운로드
4. SHA256 검증 후 자동 설치
5. 서비스 자동 재시작

장점:
- 중앙 집중식 배포
- 일관된 버전 관리
- 무결성 보장
- Rollback 가능

시스템 아키텍처

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
┌──────────────────────────────────────────────────────────┐
│                   Backend Server                         │
│                                                          │
│  1. 새 버전 등록 (관리자)                                │
│     POST /api/v1/fota/register                          │
│     {                                                    │
│       "target": "linux-agent",                          │
│       "semver": "1.2.0",                                │
│       "package_url": "https://s3.../agent-1.2.0.tar.gz",│
│       "sha256": "abc123...",                            │
│       "release_notes": "Bug fixes"                      │
│     }                                                    │
│                                                          │
│  2. Manifest 생성                                        │
│     GET /api/v1/fota/manifest?agent=linux-agent         │
│     → 최신 버전 정보 반환                                │
└──────────────────────────────────────────────────────────┘
                        ↑
                        │ HTTPS
                        │
┌──────────────────────────────────────────────────────────┐
│                   Agent (대규모)                         │
│                                                          │
│  매일 00:00-01:00 KST (랜덤 오프셋)                     │
│  1. Manifest 확인                                        │
│  2. 새 버전 발견                                         │
│  3. tar.gz 다운로드                                      │
│  4. SHA256 검증                                          │
│  5. 압축 해제                                            │
│  6. install.sh 실행                                      │
│  7. 서비스 재시작                                        │
└──────────────────────────────────────────────────────────┘

Manifest 기반 버전 관리

Manifest API 응답

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
  "success": true,
  "data": {
    "packages": [
      {
        "target": "linux-agent",
        "semver": "1.2.0",
        "download_url": "https://s3.amazonaws.com/bucket/linux-agent-1.2.0.tar.gz",
        "sha256": "a3f2e8c1b4d5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6",
        "release_notes": "- Fixed memory leak\n- Added retry logic",
        "release_date": "2025-12-01T10:00:00Z",
        "min_version": "1.0.0",
        "rollback_available": true
      }
    ],
    "latest_version": "1.2.0",
    "update_available": true
  },
  "timestamp": "2025-12-02T00:30:15.123Z"
}

Agent 구조체

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
type Updater struct {
    ManifestURL    string
    CurrentVersion string
    APIKey         string
    client         *http.Client
}

type ManifestResponse struct {
    Success bool   `json:"success"`
    Data    struct {
        Packages       []Package `json:"packages"`
        LatestVersion  string   `json:"latest_version"`
        UpdateAvailable bool    `json:"update_available"`
    } `json:"data"`
    Timestamp string `json:"timestamp"`
}

type Package struct {
    Target          string `json:"target"`
    Semver          string `json:"semver"`
    DownloadURL     string `json:"download_url"`
    SHA256          string `json:"sha256"`
    ReleaseNotes    string `json:"release_notes"`
    ReleaseDate     string `json:"release_date"`
    MinVersion      string `json:"min_version"`
    RollbackAvailable bool `json:"rollback_available"`
}

업데이트 프로세스

1. 버전 확인

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
func (u *Updater) CheckAndUpdate() bool {
    logger.Info(fmt.Sprintf("Checking for updates (Current: %s)", u.CurrentVersion))

    // Manifest 가져오기
    manifestResp, err := u.fetchManifest()
    if err != nil {
        logger.Error(fmt.Sprintf("Failed to fetch manifest: %v", err))
        return false
    }

    // 업데이트 필요 여부 확인
    if !manifestResp.Data.UpdateAvailable {
        logger.Info(fmt.Sprintf("No update available. Current version: %s, Latest: %s",
            u.CurrentVersion, manifestResp.Data.LatestVersion))
        return false
    }

    // Linux Agent용 패키지 찾기
    var latestPkg *Package
    for i := range manifestResp.Data.Packages {
        pkg := &manifestResp.Data.Packages[i]
        if pkg.Target == "linux-agent" {
            if latestPkg == nil || pkg.Semver > latestPkg.Semver {
                latestPkg = pkg
            }
        }
    }

    if latestPkg == nil {
        logger.Warn("No linux-agent package found in manifest")
        return false
    }

    logger.Info(fmt.Sprintf("New version available: %s (Current: %s)",
        latestPkg.Semver, u.CurrentVersion))
    if latestPkg.ReleaseNotes != "" {
        logger.Info(fmt.Sprintf("Release notes: %s", latestPkg.ReleaseNotes))
    }

    // 다운로드 및 설치
    success := u.downloadAndInstall(*latestPkg)
    if success {
        u.reportUpdate(*latestPkg, "success", "")
    } else {
        u.reportUpdate(*latestPkg, "failed", "Installation failed")
    }
    return success
}

2. 다운로드

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
func (u *Updater) downloadAndInstall(pkg Package) bool {
    // 임시 디렉토리 생성
    tempDir, err := os.MkdirTemp("", "agent-update-")
    if err != nil {
        logger.Error(fmt.Sprintf("Failed to create temp directory: %v", err))
        return false
    }
    defer os.RemoveAll(tempDir)  // 완료 후 자동 정리

    tempFile := filepath.Join(tempDir, "update.tar.gz")

    logger.Info(fmt.Sprintf("Downloading update from: %s", pkg.DownloadURL))

    // HTTP GET으로 tar.gz 다운로드
    resp, err := http.Get(pkg.DownloadURL)
    if err != nil {
        logger.Error(fmt.Sprintf("Failed to download update: %v", err))
        return false
    }
    defer resp.Body.Close()

    // 파일로 저장
    out, err := os.Create(tempFile)
    if err != nil {
        logger.Error(fmt.Sprintf("Failed to create temp file: %v", err))
        return false
    }

    _, err = io.Copy(out, resp.Body)
    out.Close()
    if err != nil {
        logger.Error(fmt.Sprintf("Failed to save update: %v", err))
        return false
    }

    // 다음 단계로...
}

3. SHA256 검증 (핵심!)

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
func (u *Updater) calculateSHA256(filePath string) (string, error) {
    f, err := os.Open(filePath)
    if err != nil {
        return "", err
    }
    defer f.Close()

    h := sha256.New()
    if _, err := io.Copy(h, f); err != nil {
        return "", err
    }

    return fmt.Sprintf("%x", h.Sum(nil)), nil
}

// downloadAndInstall 계속...
func (u *Updater) downloadAndInstall(pkg Package) bool {
    // ... 다운로드 코드 ...

    // SHA256 검증
    logger.Info("Verifying checksum...")
    fileHash, err := u.calculateSHA256(tempFile)
    if err != nil {
        logger.Error(fmt.Sprintf("Failed to calculate checksum: %v", err))
        return false
    }

    if fileHash != pkg.SHA256 {
        logger.Error(fmt.Sprintf("Checksum mismatch! Expected: %s, Got: %s",
            pkg.SHA256, fileHash))
        return false  // 불일치 시 설치 중단!
    }

    logger.Info("Checksum verified successfully")

    // 다음 단계로...
}

4. tar.gz 압축 해제

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
func (u *Updater) extractTarGz(tarGzPath, destDir string) error {
    f, err := os.Open(tarGzPath)
    if err != nil {
        return err
    }
    defer f.Close()

    // Gzip 압축 해제
    gzr, err := gzip.NewReader(f)
    if err != nil {
        return err
    }
    defer gzr.Close()

    // Tar 아카이브 읽기
    tr := tar.NewReader(gzr)

    for {
        header, err := tr.Next()
        if err == io.EOF {
            break  // 끝
        }
        if err != nil {
            return err
        }

        target := filepath.Join(destDir, header.Name)

        switch header.Typeflag {
        case tar.TypeDir:
            // 디렉토리 생성
            if err := os.MkdirAll(target, 0755); err != nil {
                return err
            }

        case tar.TypeReg:
            // 파일 생성
            f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
            if err != nil {
                return err
            }
            if _, err := io.Copy(f, tr); err != nil {
                f.Close()
                return err
            }
            f.Close()
        }
    }

    return nil
}

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
// downloadAndInstall 계속...
func (u *Updater) downloadAndInstall(pkg Package) bool {
    // ... 다운로드, 검증, 압축 해제 ...

    // 압축 해제
    extractDir := filepath.Join(tempDir, "extract")
    if err := u.extractTarGz(tempFile, extractDir); err != nil {
        logger.Error(fmt.Sprintf("Failed to extract archive: %v", err))
        return false
    }

    // install.sh 찾기
    installScript := filepath.Join(extractDir, "scripts", "install.sh")
    if _, err := os.Stat(installScript); os.IsNotExist(err) {
        // 대체 경로 시도
        installScript = filepath.Join(extractDir, "install.sh")
        if _, err := os.Stat(installScript); os.IsNotExist(err) {
            logger.Error("Installation script not found")
            return false
        }
    }

    logger.Info("Running installation script...")

    // Bash 스크립트 실행
    cmd := exec.Command("/bin/bash", installScript)
    cmd.Dir = extractDir
    output, err := cmd.CombinedOutput()
    if err != nil {
        logger.Error(fmt.Sprintf("Installation failed: %v - %s", err, string(output)))
        return false
    }

    logger.Info(fmt.Sprintf("Installation output: %s", string(output)))

    // 설정 파일 업데이트
    u.updateConfigVersion(pkg.Semver)

    logger.Info(fmt.Sprintf("Update completed successfully to version %s", pkg.Semver))
    return true
}

install.sh 스크립트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash
set -e

echo "=== System Agent Installation ==="

# 바이너리 복사
echo "Installing binaries..."
sudo cp ./build/agent /usr/local/bin/agent
sudo cp ./build/agent-collect-data /usr/local/bin/
sudo cp ./build/agent-collect-performance /usr/local/bin/
sudo cp ./build/agent-security-scan /usr/local/bin/
sudo cp ./build/agent-fota /usr/local/bin/

sudo chmod +x /usr/local/bin/agent*

# Systemd 서비스 재시작
echo "Restarting service..."
sudo systemctl restart agent

echo "Installation completed successfully"

랜덤 오프셋으로 부하 분산

대규모 Agent가 정확히 00:00:00에 업데이트를 체크하면 서버가 폭발합니다. 랜덤 오프셋을 적용합니다.

Systemd Timer 설정

1
2
3
4
5
6
7
8
9
10
11
12
# /etc/systemd/system/agent-fota.timer
[Unit]
Description=System Agent FOTA Update Check Timer

[Timer]
# 매일 00:00에 시작
OnCalendar=daily
# 랜덤 지연: 0-3600초 (1시간)
RandomizedDelaySec=3600

[Install]
WantedBy=timers.target

효과

1
2
3
4
5
Without RandomizedDelaySec:
00:00:00 → 대량 requests (SPIKE!)

With RandomizedDelaySec=3600:
00:00:00-01:00:00 → 균등하게 분산 (SMOOTH)

Semantic Versioning (SemVer)

FOTA 시스템은 Semantic Versioning을 따릅니다.

버전 형식: MAJOR.MINOR.PATCH

1
2
3
4
5
1.2.3
│ │ │
│ │ └─ PATCH: 버그 수정 (Backward Compatible)
│ └─── MINOR: 기능 추가 (Backward Compatible)
└───── MAJOR: 호환성 깨지는 변경

예시

1
2
3
4
5
1.0.0 → 초기 릴리스
1.0.1 → 버그 수정 (메모리 누수)
1.1.0 → 기능 추가 (보안 스캔 추가)
1.1.1 → 버그 수정 (스캔 오탐 해결)
2.0.0 → 호환성 깨지는 변경 (API 구조 변경)

버전 비교

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 단순 문자열 비교로 충분 (SemVer 형식일 때)
if pkg.Semver > u.CurrentVersion {
    // 업데이트 필요
}

// 더 정교한 비교가 필요하면 라이브러리 사용
import "github.com/Masterminds/semver/v3"

v1, _ := semver.NewVersion("1.2.3")
v2, _ := semver.NewVersion("1.10.0")

if v2.GreaterThan(v1) {
    // 업데이트 필요
}

Rollback 메커니즘

업데이트가 실패하거나 문제가 발생하면 이전 버전으로 되돌려야 합니다.

백업 전략

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (u *Updater) backupCurrentVersion() error {
    backupDir := "/opt/agent/backup"
    os.MkdirAll(backupDir, 0755)

    timestamp := time.Now().Format("20060102-150405")
    backupPath := filepath.Join(backupDir, fmt.Sprintf("agent-backup-%s.tar.gz", timestamp))

    // 현재 설치된 바이너리들을 tar.gz로 압축
    files := []string{
        "/usr/local/bin/agent",
        "/usr/local/bin/agent-collect-data",
        "/usr/local/bin/agent-collect-performance",
        "/etc/agent/agent-config.json",
    }

    // tar.gz 생성 로직 (생략)

    logger.Info(fmt.Sprintf("Backup created: %s", backupPath))
    return nil
}

Rollback API

1
2
3
4
5
6
7
8
// Backend API
POST /api/v1/fota/rollback
{
    "target": "linux-agent",
    "rollback_to": "1.1.0"
}

// Agent는 다음 체크 시 이전 버전으로 다운그레이드

업데이트 상태 리포팅

Backend에 업데이트 결과를 보고합니다.

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
func (u *Updater) reportUpdate(pkg Package, status, errorMsg string) {
    agentID := os.Getenv("AGENT_ID")
    if agentID == "" {
        hostname, _ := os.Hostname()
        agentID = hostname
    }

    report := map[string]interface{}{
        "agent_id":      agentID,
        "target":        pkg.Target,
        "from_version":  u.CurrentVersion,
        "to_version":    pkg.Semver,
        "status":        status,  // "success" 또는 "failed"
        "error_message": errorMsg,
        "timestamp":     time.Now().UTC().Format(time.RFC3339),
    }

    jsonData, _ := json.Marshal(report)

    // Backend에 POST
    url := u.ManifestURL + "/report"
    req, _ := http.NewRequest("POST", url, bytes.NewReader(jsonData))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("X-API-Key", u.APIKey)

    u.client.Do(req)
}

보안 고려사항

1. HTTPS 필수

1
2
3
4
5
// HTTP는 거부
if !strings.HasPrefix(pkg.DownloadURL, "https://") {
    logger.Error("Download URL must use HTTPS")
    return false
}

2. API Key 인증

1
req.Header.Set("X-API-Key", u.APIKey)

3. SHA256 검증 (Man-in-the-Middle 방어)

1
2
3
4
if fileHash != pkg.SHA256 {
    logger.Error("Checksum mismatch! Possible tampering detected.")
    return false
}

트러블슈팅

문제 1: Checksum 불일치

1
[ERROR] Checksum mismatch! Expected: abc123..., Got: def456...

원인:

  • 다운로드 중 네트워크 에러
  • S3 파일이 손상됨
  • Man-in-the-Middle 공격

해결:

1
2
3
4
5
6
7
8
9
// 재시도 로직 추가
maxRetries := 3
for i := 0; i < maxRetries; i++ {
    if u.downloadAndInstall(pkg) {
        return true
    }
    logger.Warn(fmt.Sprintf("Download attempt %d failed, retrying...", i+1))
    time.Sleep(time.Second * 10)
}

문제 2: 설치 스크립트 실패

1
[ERROR] Installation failed: exit status 1

원인:

  • 권한 부족 (sudo 필요)
  • 바이너리 경로 불일치
  • Systemd 서비스 재시작 실패

해결:

1
2
3
4
5
6
7
8
9
10
11
12
# install.sh에 에러 처리 추가
#!/bin/bash
set -e  # 에러 발생 시 즉시 중단

if [ "$EUID" -ne 0 ]; then
    echo "Please run as root"
    exit 1
fi

# 각 단계마다 확인
sudo cp ./build/agent /usr/local/bin/agent || exit 1
sudo systemctl restart agent || exit 1

마치며

대규모 Agent 시스템에서 FOTA는 필수 인프라입니다.

Manifest 기반의 중앙 집중식 버전 관리로 모든 Agent를 일관되게 유지할 수 있고, SHA256 검증으로 무결성을 보장합니다. 랜덤 오프셋을 통한 부하 분산으로 서버 과부하를 방지하고, Rollback 메커니즘으로 문제 발생 시 빠르게 복구할 수 있습니다.

수동 업데이트는 규모가 커질수록 불가능해지지만, FOTA 시스템으로 자동화하면 빠르고 안전한 배포가 가능합니다.

도움이 되셨길 바랍니다! 😀

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