# ADR-004:模型儲存採用 S3-Compatible 介面 ## 狀態 Accepted — 2026-04-21 ## 背景 (Context) visionA Cloud 需要儲存: 1. **使用者上傳的模型檔案**(`.nef`、`.onnx` 等,幾 MB ~ 幾百 MB) 2. **模型 metadata**(JSON,幾 KB) 3. **(未來)轉檔產物**:kneron_model_converter 產生的 `.nef` 4. **(未來)推論紀錄、snapshots、截圖** POC(edge-ai-platform)用本地檔案系統 + in-memory registry。local-tool 也一樣。這在單機環境 OK,雲端不行: | 問題 | 本地檔案系統 | S3-compatible | |------|------------|--------------| | 多節點共用 | ❌ 各節點看不到彼此 | ✅ 共享 bucket | | 持久性 | 單機磁碟壞 = 資料沒 | ✅ 11 個 9 的設計 | | 容量彈性 | 單機磁碟有上限 | ✅ 幾乎無上限 | | 成本效益 | 計算節點帶容量 = 貴 | 冷熱分層、便宜 | | 直接給前端下載 | 要透過應用伺服器 | ✅ Presigned URL 直給 | | Multipart upload | 自己實作 | ✅ 標準 | 同時,雛形階段不想立刻接真實 S3 / MinIO(需架設、需 credentials、增加 setup 成本)。 ## 決策 (Decision) 在 `internal/storage` 定義 **`Store` interface**,以 S3 的語義為基礎: ```go package storage import ( "context" "io" "time" ) // Store 是模型 / 檔案儲存的抽象層。語義對齊 S3。 type Store interface { // Put 上傳一個物件。size <0 表示未知大小(streaming)。 Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error // Get 下載一個物件,回傳 reader(caller 要負責 Close)。 Get(ctx context.Context, key string) (io.ReadCloser, *ObjectInfo, error) // Stat 取得物件資訊但不下載 body。 Stat(ctx context.Context, key string) (*ObjectInfo, error) // Delete 刪除一個物件。 Delete(ctx context.Context, key string) error // List 列出 prefix 下的物件。 List(ctx context.Context, prefix string) ([]*ObjectInfo, error) // PresignedGetURL 產生讓使用者直接下載的簽名 URL。 PresignedGetURL(ctx context.Context, key string, ttl time.Duration) (string, error) // PresignedPutURL 產生讓使用者直接上傳的簽名 URL。 PresignedPutURL(ctx context.Context, key string, ttl time.Duration) (string, error) } type ObjectInfo struct { Key string Size int64 ContentType string LastModified time.Time ETag string Metadata map[string]string } ``` ### 雛形提供兩個實作 1. **`LocalFSStore`**:把物件存在本地 `data/storage/` 下 - Key 對應檔案路徑(`models/user-123/yolov5.nef` → `data/storage/models/user-123/yolov5.nef`) - Metadata 存在同名 `.meta.json` - `PresignedGetURL` / `PresignedPutURL` 回 `http://localhost:PORT/storage/...` 這種由 api-server 代理的 URL(雛形的「假簽名」) - 啟動最快,開發者零成本 2. **`S3Store`**(可選,雛形後期加):用 `aws-sdk-go-v2` - 連真實 AWS S3、MinIO、Cloudflare R2、Backblaze B2 等 - `PresignedGetURL` / `PresignedPutURL` 是真的 S3 presigned URL - 需要 `S3_ENDPOINT`、`S3_BUCKET`、`S3_REGION`、`S3_ACCESS_KEY`、`S3_SECRET_KEY` env ### 選擇由 config 決定 ```yaml storage: backend: localfs # or "s3" localfs: root: ./data/storage base_url: http://localhost:3001/storage # api-server 代理路徑 s3: endpoint: "" # 空字串用 AWS 原生;填 MinIO / R2 等用自訂 bucket: visiona-models region: us-east-1 access_key: ${S3_ACCESS_KEY} secret_key: ${S3_SECRET_KEY} ``` ## 考慮過的替代方案 | 方案 | 優點 | 缺點 | 排除原因 | |------|------|------|---------| | **直接用 AWS SDK 呼叫 S3** | 簡單 | 綁定 AWS;MinIO / R2 要 workaround | 綁定單一雲 | | **用 MinIO SDK** | 輕量 | MinIO SDK 寫的 code 去接 AWS S3 要微調;綁定特定 SDK | 換雲成本 | | **自己做儲存服務** | 完全掌控 | 重新發明輪子;費時;達不到 S3 等級的持久性 | 不值得 | | **資料庫存 blob** | 交易一致性 | DB 不適合儲存大檔;昂貴 | 技術錯配 | | **只用 LocalFS 不抽象** | 簡單 | 未來替換 = 重寫所有 model handler | 違反抽象原則 | ## 後果 (Consequences) ### 正面影響 - **雛形零 setup**:`docker-compose up` 直接跑,不需 S3 / MinIO - **切雲端零改動**:換 config 的 `backend: s3` 即可 - **支援多家雲**:endpoint 可指向 AWS / Cloudflare R2 / Backblaze B2 / MinIO / 自建 Ceph 等 - **介面 S3-native**:未來加 multipart upload、server-side encryption 等 S3 進階特性無需變介面 ### 負面影響(接受的取捨) - **`PresignedGetURL` 在 LocalFS 是假的**:需 api-server 提供 `/storage/...` 代理端點。這段代理程式碼要寫,但不複雜 - **LocalFS 不支援多節點**:雛形階段只有單節點,可接受;Phase 1 遷到 S3 後自動解決 - **Interface 比純 SDK 包裝更抽象**:略多一層呼叫,效能無感(本地 disk IO 遠大於 interface method dispatch) ### 風險 - **LocalFS 的 metadata 靠 sidecar `.meta.json`**:不是原子的;雛形忽略;Phase 1 改 S3 自然原子 - **PresignedPutURL 在 LocalFS 實作要處理 CSRF / auth**:雛形可暫時接受裸上傳;Phase 1 上 S3 後由 S3 presigned signature 保護 - **大檔上傳 OOM**:`Put` 使用 `io.Reader` 已是 streaming,LocalFS 實作用 `io.Copy` 避免 buffer 整個 body;需要 code review 把關 ## 合規性 - [x] Architect 確認 - [ ] 雛形測試:確保 `LocalFSStore` 通過 interface conformance tests - [ ] Phase 1:確保 `S3Store` 通過相同 tests - [ ] 加入 `.gitignore`:`data/storage/` 不進 Git ## 相關文件 - Design Doc §3.3(外部依賴) - TDD §8(儲存層介面) - 相關 ADR:ADR-005(雛形不接 DB / Auth)