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 시스템으로 자동화하면 빠르고 안전한 배포가 가능합니다.
도움이 되셨길 바랍니다! 😀