はじめに
「AWSにはAI系のサービスがいろいろあるらしい。でも、機械学習のモデルを自分で作るところから始めるのは大変そう」
そんなところから、まずはAWSのAI画像解析を実際に使ってみることにしました。
今回作ったのは、iPhoneで写真を選ぶか、その場で撮影すると、写真に写っている物や場面を解析してラベルと信頼度を表示するアプリです。
たとえば猫の写真なら、次のような結果が返ります。
Cat 99.4%
Animal 98.6%
Pet 96.8%
画像解析にはAmazon RekognitionのDetectLabelsを使いました。iOSアプリはSwiftUI、解析履歴の保存にはSwiftData、AWS側の構築にはAWS SAMを使っています。
この記事では、完成したコードだけでなく、次の点を初学者向けに順番に説明します。
- Amazon Rekognitionで何ができるのか
- iPhoneからAWSを安全に使うにはどうすればよいか
- なぜ画像を直接Lambdaへ送らず、S3へアップロードするのか
- Presigned URLとは何か
- AWS SAMでどんなリソースを作ったか
- LambdaとSwiftUIをどう接続したか
- 実装中にどこでつまずいたか
- 個人開発でも気をつけたいセキュリティと費用
完成したコードはこちらで公開しています。
本記事の構成は学習・本人利用向けのMVPです。APIには利用者認証がないため、このまま不特定多数向けに公開することは想定していません。
作ったもの
アプリ名は「AI写真解析アプリ」です。
![]() |
| ホーム画面 |
主な機能は次のとおりです。
- 写真ライブラリから画像を1枚選ぶ
- 実機のカメラで写真を撮る
- 画像を端末上で縮小・圧縮する
- 非公開のS3へ画像をアップロードする
- Amazon Rekognitionで画像を解析する
- 最大10件、信頼度70%以上のラベルを表示する
- 解析結果と画像をSwiftDataへ保存する
- 履歴の一覧・詳細表示・削除を行う
- 通信中や失敗時の状態を画面に表示する
アプリの表示は日本語ですが、Rekognitionが返すラベルは英語のまま表示しています。
当初は端末内辞書で日本語化も試しました。しかし、辞書にない語が英語で残り、かえって表示の一貫性が悪くなったため、MVPでは原文を尊重することにしました。
Amazon Rekognitionとは
Amazon Rekognitionは、画像や動画を分析できるAWSのAIサービスです。
今回利用したDetectLabelsは、画像から次のような情報を検出します。
- 物体:
Cat、Car、Table - 場面:
Beach、City、Forest - 概念:
Nature、Evening - イベント:
Wedding、Birthday Party
JPEGまたはPNGを、画像バイト列またはS3オブジェクトとして渡せます。レスポンスにはラベル名と信頼度が含まれます。
{
"Labels": [
{
"Name": "Cat",
"Confidence": 99.4
}
]
}
MaxLabelsで返してほしいラベル数、MinConfidenceで最低信頼度を指定できます。今回は結果が多すぎないよう、次の値にしました。
MaxLabels = 10
MinConfidence = 70
信頼度70%未満の結果は返さないため、画面が曖昧なラベルで埋まりにくくなります。
公式ドキュメント:
なお、Rekognitionと画像を置くS3バケットは同じリージョンにする必要があります。今回は両方とも東京リージョンap-northeast-1を使いました。
最初に考えた構成
最初に思いつくのは、iOSアプリから直接Rekognitionを呼び出す構成です。
しかし、この構成ではiOSアプリがAWSへアクセスするための認証情報が必要になります。
アクセスキーとシークレットキーをアプリ内へ埋め込むと、アプリの解析によって取り出される可能性があります。もし漏れれば、第三者にAWS APIを呼ばれたり、想定外の料金が発生したりするかもしれません。
そのため、今回はiOSアプリからRekognitionを直接呼ばない構成にしました。
採用したアーキテクチャ
最終的な構成は次のとおりです。
登場するAWSサービスの役割を整理すると、次のようになります。
| サービス | 役割 |
|---|---|
| API Gateway | iOSアプリが呼び出すHTTP API |
| Lambda | URL発行と画像解析の処理 |
| S3 | 解析対象画像の一時保存 |
| Rekognition | 画像ラベルの検出 |
| CloudWatch Logs | APIとLambdaのログ |
| AWS Budgets | 費用の通知 |
なぜ画像をLambdaへ直接送らないのか
画像をBase64へ変換し、API GatewayからLambdaへ送ることもできます。
ただし、画像データはJSONより大きく、Base64化するとさらにサイズが増えます。API GatewayとLambdaを画像本体の中継に使うと、通信量、実行時間、ペイロード上限を意識する必要が出てきます。
そこで画像本体はiPhoneからS3へ直接送り、APIには小さなJSONだけを送ることにしました。
画像本体: iPhone → S3
制御情報: iPhone → API Gateway → Lambda
この役割分担を可能にするのがPresigned URLです。
Presigned URLとは
Presigned URLは、特定のAWS操作を一時的に許可する署名付きURLです。
今回発行するURLには、次の制約があります。
- 操作は
PutObjectだけ - 対象は生成した1つのS3キーだけ
- 有効期限は300秒
- 署名時に指定した
Content-Typeを使う
iOSアプリはAWSのアクセスキーを持っていません。それでも、URLが有効な間だけ指定されたオブジェクトへPUTできます。
一方で、Presigned URLを知っている人は期限内なら利用できます。つまりURL自体が短時間の認証情報のようなものです。ログ、スクリーンショット、Issueへ貼らないようにします。
AWS公式ドキュメントでも、Presigned URLはBearer Tokenとして適切に保護するよう案内されています。
使用した技術
iOS
- SwiftUI
- SwiftData
- PhotosUI
- UIKitの
UIImagePickerController URLSession- iOS 17以降
AWS
- Amazon API Gateway HTTP API
- AWS Lambda / Node.js 24
- Amazon S3
- Amazon Rekognition
- CloudWatch Logs
- AWS X-Ray
- AWS Budgets
- AWS SAM
AWS SAMを選んだ理由
Infrastructure as Codeには、AWS CDK、Terraform、CloudFormationなど複数の選択肢があります。
今回はLambdaとAPI Gatewayが中心の小規模なサーバーレス構成です。SAMはCloudFormationをベースにしつつ、LambdaやAPI Gatewayを短い記述で定義できます。
Transform: AWS::Serverless-2016-10-31
初学者としても「このテンプレートをデプロイすると何が作られるのか」を追いやすく、削除もCloudFormationスタック単位で行えるため、SAMを選びました。
バックエンドを作る
ここから実際のコードを見ていきます。
バックエンドの構成は次のとおりです。
backend/
├── template.yaml
├── package.json
├── shared/
│ └── http.js
├── functions/
│ ├── create-upload-url/
│ │ └── handler.js
│ └── analyze-image/
│ └── handler.js
└── tests/
S3バケットをSAMで定義する
画像を置くS3バケットは公開しません。
ImageBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Delete
UpdateReplacePolicy: Delete
Properties:
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerEnforced
PublicAccessBlockConfiguration:
BlockPublicAcls: true
IgnorePublicAcls: true
BlockPublicPolicy: true
RestrictPublicBuckets: true
LifecycleConfiguration:
Rules:
- Id: ExpireTemporaryUploads
Status: Enabled
Prefix: uploads/
ExpirationInDays: 1
重要なのはPublicAccessBlockConfigurationです。4項目をすべて有効にしています。
また、uploads/配下のオブジェクトは1日後に期限切れになるよう設定しました。
ただし、「1日後」は厳密な24時間後ではありません。S3 Lifecycleは期限をUTCの日境界へ切り上げ、削除も非同期で行います。そのため、実ファイルが残る時間には余裕があります。
よりプライバシーを重視する場合は、解析成功直後にLambdaから削除し、Lifecycleを削除漏れの安全網として残す方法がよさそうです。
アップロードURLを発行するLambda
POST /upload-urlへ次のJSONを送ります。
{
"contentType": "image/jpeg",
"fileExtension": "jpg"
}
Lambdaでは、Content-Typeと拡張子の組み合わせを検証します。
const allowed = new Map([
["image/jpeg", new Set(["jpg", "jpeg"])],
["image/png", new Set(["png"])]
]);
export function validateUploadRequest(body) {
const contentType =
typeof body.contentType === "string"
? body.contentType.toLowerCase()
: "";
const fileExtension =
typeof body.fileExtension === "string"
? body.fileExtension.toLowerCase().replace(/^\./, "")
: "";
if (!allowed.get(contentType)?.has(fileExtension)) {
throw new ClientError(
400,
"INVALID_IMAGE_TYPE",
"Only matching JPEG or PNG files are supported."
);
}
return { contentType, fileExtension };
}
クライアントから送られた拡張子を、そのまま信用しないことがポイントです。
検証後、UUIDを使ってS3キーを作り、Presigned URLを発行します。
const objectKey = `${prefix}${randomUUID()}.${fileExtension}`;
const command = new PutObjectCommand({
Bucket: bucket,
Key: objectKey,
ContentType: contentType
});
const uploadUrl = await getSignedUrl(s3, command, {
expiresIn: 300
});
レスポンスは次の形です。
{
"uploadUrl": "<presigned-url>",
"objectKey": "uploads/550e8400-e29b-41d4-a716-446655440000.jpg",
"expiresIn": 300
}
元のファイル名はS3キーに使いません。ユーザー入力をパスへ混ぜず、毎回UUIDを生成することで、名前の衝突や予期しないキーを避けています。
画像を解析するLambda
アップロード後、iOSアプリはPOST /analyzeへS3キーだけを送ります。
{
"objectKey": "uploads/550e8400-e29b-41d4-a716-446655440000.jpg"
}
サーバー側でもキー形式を検証します。
const keyPattern =
/^uploads\/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.(?:jpg|jpeg|png)$/i;
export function validateObjectKey(value) {
if (typeof value !== "string" || !keyPattern.test(value)) {
throw new ClientError(
400,
"INVALID_OBJECT_KEY",
"Object key is invalid."
);
}
return value;
}
これにより、たとえば次のような値は拒否されます。
other/image.jpg
uploads/../secret.jpg
uploads/not-a-uuid.jpg
Rekognitionの前にS3メタデータを確認する
キーが正しくても、中身が期待する画像とは限りません。HeadObjectを使って次を確認します。
- オブジェクトが存在するか
-
Content-TypeがJPEGまたはPNGか - 空ファイルではないか
- 5 MiB以下か
const metadata = await s3.send(
new HeadObjectCommand({
Bucket: bucket,
Key: objectKey
})
);
if (!["image/jpeg", "image/png"].includes(metadata.ContentType)) {
throw new ClientError(
400,
"INVALID_IMAGE_TYPE",
"Uploaded object is not a supported image."
);
}
if (metadata.ContentLength > maxBytes) {
throw new ClientError(
413,
"IMAGE_TOO_LARGE",
"Image exceeds the 5 MiB limit."
);
}
iOS側で画像を縮小していても、サーバー側の検証は省略しません。クライアントは改変できるものとして扱います。
DetectLabelsを呼び出す
検証できたら、S3上の画像をRekognitionへ渡します。
const result = await rekognition.send(
new DetectLabelsCommand({
Image: {
S3Object: {
Bucket: bucket,
Name: objectKey
}
},
MaxLabels: 10,
MinConfidence: 70
})
);
AWS SDKのレスポンス全体をそのままiOSへ返さず、画面で必要な項目だけにします。
const labels = (result.Labels || [])
.filter(
label =>
typeof label.Name === "string" &&
Number.isFinite(label.Confidence)
)
.map(label => ({
name: label.Name,
confidence: label.Confidence
}));
成功レスポンスはシンプルです。
{
"labels": [
{
"name": "Cat",
"confidence": 99.4
}
]
}
エラーレスポンスを統一する
内部エラーやAWS SDKのレスポンスをそのまま返すと、バケット名、実装詳細、スタックトレースなどが漏れる可能性があります。
そこでエラー形式を統一しました。
{
"error": {
"code": "INVALID_REQUEST",
"message": "Request body is invalid.",
"requestId": "<redacted>"
}
}
ステータスコードは次のように使い分けています。
| Status | 意味 |
|---|---|
| 400 | JSON、Content-Type、S3キーなどが不正 |
| 404 | S3オブジェクトが存在しない |
| 413 | 画像が5 MiBを超えている |
| 429 | API Gatewayでスロットリングされた |
| 500 | Lambda内部の予期しないエラー |
| 502 | Rekognition呼び出しに失敗した |
ログにもPresigned URLや画像本体は出しません。
console.error(JSON.stringify({
level: "ERROR",
requestId,
dependency: "Rekognition",
errorName: error?.name
}));
IAMをLambdaごとに分ける
2つのLambdaは別々の実行ロールを持ちます。
URL発行Lambdaに必要なのはPutObjectだけです。
Policies:
- Statement:
- Effect: Allow
Action: s3:PutObject
Resource: !Sub ${ImageBucket.Arn}/uploads/*
解析Lambdaには、画像の読み取りとラベル検出だけを許可します。
Policies:
- Statement:
- Effect: Allow
Action: s3:GetObject
Resource: !Sub ${ImageBucket.Arn}/uploads/*
- Effect: Allow
Action: rekognition:DetectLabels
Resource: "*"
rekognition:DetectLabelsはリソースレベルで対象を絞れないためResource: "*"になります。一方、S3は生成されたバケットのuploads/*に限定できます。
「とりあえず管理者権限」をLambdaへ与えないことが重要です。
API Gatewayにスロットリングを設定する
本人だけが試すMVPですが、API URLはインターネットから到達できます。誤操作や連打による想定外の呼び出しを抑えるため、低めの制限を設定しました。
DefaultRouteSettings:
ThrottlingBurstLimit: 2
ThrottlingRateLimit: 1
1秒あたり1リクエスト、短時間のバーストは2を目安にしています。
ただし、API Gatewayのスロットリングはベストエフォートで、厳密な呼び出し上限ではありません。そして何より、認証ではありません。
アプリを第三者へ配布する場合は、Cognito、IAM認証、独自バックエンド認証などを追加し、利用者ごとの認可とクォータを設計する必要があります。
SwiftUIアプリを作る
iOS側は次の構成に分けました。
AIPhotoDictionary/
├── App/
├── Features/
│ ├── Analysis/
│ └── History/
├── Models/
├── Networking/
├── Services/
├── Resources/
└── SupportingFiles/
画面、通信、画像処理、永続化モデルを分けています。
写真を選択する
写真ライブラリにはSwiftUIのPhotosPickerを使いました。
PhotosPicker(selection: $selectedItem, matching: .images) {
Label(
hasImage ? "写真を変更" : "写真を選ぶ",
systemImage: "photo"
)
}
選択結果はPhotosPickerItemからDataとして読み込みます。
func load(item: PhotosPickerItem?) async {
guard let item else { return }
guard let data = try await item.loadTransferable(type: Data.self) else {
throw ImageProcessingError.unreadable
}
await load(imageData: data)
}
カメラで撮影する
SwiftUIからカメラを起動するため、UIImagePickerControllerをUIViewControllerRepresentableでラップしました。
struct CameraPicker: UIViewControllerRepresentable {
let onCapture: (UIImage) -> Void
func makeUIViewController(
context: Context
) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.cameraCaptureMode = .photo
picker.delegate = context.coordinator
return picker
}
}
撮影にはNSCameraUsageDescriptionが必要です。
<key>NSCameraUsageDescription</key>
<string>撮影した写真の内容を解析するためにカメラを使用します。</string>
Simulatorにはカメラがないため、カメラ機能の確認は実機で行います。
アップロード前に画像を縮小する
iPhoneの写真は、そのままでは数MBから十数MBになることがあります。
大きな画像を送ると、アップロードに時間がかかり、S3保存量も増えます。そこで端末上で長辺2048px以下に縮小し、JPEG品質0.8から圧縮します。
func process(_ sourceData: Data) async throws -> ProcessedImage {
try await Task.detached(priority: .userInitiated) {
guard let source = UIImage(data: sourceData) else {
throw ImageProcessingError.unreadable
}
let image = source.normalizedAndResized(
maxDimension: 2048
)
var quality: CGFloat = 0.8
while quality >= 0.4 {
if let data = image.jpegData(
compressionQuality: quality
), data.count <= 5 * 1024 * 1024 {
return ProcessedImage(
data: data,
preview: image,
contentType: "image/jpeg",
fileExtension: "jpg"
)
}
quality -= 0.1
}
throw ImageProcessingError.tooLarge
}.value
}
UIGraphicsImageRendererへ描き直すことで、画像の向きを見た目どおりに正規化しつつリサイズしています。
URLSessionでS3へPUTする
Presigned URLへのアップロードは通常のHTTP PUTです。
func upload(
_ data: Data,
to url: URL,
contentType: String
) async throws {
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue(
contentType,
forHTTPHeaderField: "Content-Type"
)
request.timeoutInterval = 60
let (_, response) = try await session.upload(
for: request,
from: data
)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode)
else {
throw APIError.invalidResponse
}
}
ここで重要なのがContent-Typeです。URL発行時にimage/jpegとして署名したなら、PUT時にも同じ値を指定します。
一致しない場合、S3からSignatureDoesNotMatchが返ることがあります。
解析フローを状態として表現する
画像処理、URL取得、アップロード、解析には時間がかかります。現在どこまで進んでいるか分からないと、利用者はボタンをもう一度押したくなります。
そこで処理状態をenumで表しました。
enum AnalysisState: Equatable {
case idle
case preparingImage
case ready
case requestingUploadURL
case uploading
case analyzing
case success
case failure(message: String, retryable: Bool)
}
解析処理は順番に状態を更新します。
state = .requestingUploadURL
let upload = try await apiClient.requestUploadURL(
contentType: image.contentType,
fileExtension: image.fileExtension
)
state = .uploading
try await apiClient.upload(
image.data,
to: upload.uploadUrl,
contentType: image.contentType
)
state = .analyzing
let detected = try await apiClient.analyze(
objectKey: upload.objectKey
)
state.isBusyがtrueの間はボタンを無効化します。これにより同じ画像を連打で解析し、重複課金や履歴の重複保存が起きるのを防ぎます。
さらに操作ごとにUUIDを作り、完了時に同じ操作か確認しています。
let currentID = UUID()
operationID = currentID
// 通信処理
guard operationID == currentID else {
return
}
SwiftDataへ履歴を保存する
Rekognitionのレスポンス型と永続化モデルは分けました。
struct DetectedLabel: Codable, Hashable, Identifiable {
var id: String { name }
let name: String
let confidence: Double
}
MVPではラベル単位の検索を行わないため、ラベル配列をJSONのDataへ変換してSwiftDataモデルへ保存しています。
@Model
final class AnalysisHistory {
@Attribute(.unique) var id: UUID
@Attribute(.externalStorage) var imageData: Data
var analyzedAt: Date
var labelsData: Data
init(
id: UUID = UUID(),
imageData: Data,
analyzedAt: Date = .now,
labels: [DetectedLabel]
) throws {
self.id = id
self.imageData = imageData
self.analyzedAt = analyzedAt
self.labelsData = try JSONEncoder().encode(labels)
}
}
画像には@Attribute(.externalStorage)を付けています。大きなバイナリデータを永続化ストア内へ直接抱え込みにくくするためです。
将来、ラベル検索や「Catを含む履歴だけ表示」といった機能を作るなら、ラベルを別モデルとして正規化する方が扱いやすくなります。
APIの接続先をxcconfigで管理する
API GatewayのURLは秘密情報ではありません。ただし、開発環境と本番環境を切り替えやすくし、実URLをGitHubへ載せないため、xcconfigへ分離しました。
Config/
├── Base.xcconfig
├── Debug.example.xcconfig
├── Debug.xcconfig # Git管理外
├── Release.example.xcconfig
└── Release.xcconfig # Git管理外
xcconfigでは//がコメントとして解釈されるため、URLは次のように書きます。
API_BASE_URL = https:/$()/example.execute-api.ap-northeast-1.amazonaws.com/dev
Info.plistからビルド設定を参照します。
<key>API_BASE_URL</key>
<string>$(API_BASE_URL)</string>
Swift側では、HTTPSで設定されていることも確認します。
guard
let raw = Bundle.main.object(
forInfoDictionaryKey: "API_BASE_URL"
) as? String,
!raw.isEmpty,
let url = URL(string: raw),
url.scheme == "https"
else {
return nil
}
AWSへデプロイする
必要なコマンドは次のとおりです。
cd backend
npm ci
npm test
sam validate --lint
sam build
sam deploy --guided \
--profile default \
--region ap-northeast-1
sam deploy --guidedでは、スタック名、リージョン、Budgetの通知先などを入力します。
Stack Name: ai-photo-dictionary-dev
AWS Region: ap-northeast-1
Parameter StageName: dev
Parameter BudgetEmail: 自分のメールアドレス
Confirm changes before deploy: Y
Allow SAM CLI IAM role creation: Y
Disable rollback: N
デプロイ完了後、CloudFormation OutputsにApiBaseUrlが表示されます。
https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev
このURLをローカルのDebug.xcconfigへ設定します。
デプロイにはCloudFormation、S3、Lambda、IAM、API Gateway、CloudWatch Logs、Budgetsなどを作成する権限が必要です。個人ユーザーへ強い権限を一時付与した場合は、デプロイ後に外し、可能ならCloudFormationサービスロールへ移行してください。
自動テスト
バックエンドは依存オブジェクトを差し替えられる形にしました。
export function createHandler({
s3 = new S3Client({}),
rekognition = new RekognitionClient({}),
env = process.env
} = {}) {
return async function handler(event, context) {
// ...
};
}
テストではAWSへ接続せず、S3とRekognitionを偽物へ置き換えます。
const s3 = {
send: async () => ({
ContentType: "image/jpeg",
ContentLength: 1234
})
};
const rekognition = {
send: async () => ({
Labels: [
{
Name: "Cat",
Confidence: 99.4
}
]
})
};
確認している主な項目は次のとおりです。
- JPEG/PNGの正しい組み合わせを許可する
- Content-Typeと拡張子の不一致を拒否する
-
uploads/外のキーを拒否する - Rekognitionレスポンスを期待するJSONへ変換する
- 5 MiB超過を解析前に拒否する
- S3未存在を安全な404へ変換する
- Rekognition障害を内部情報のない502へ変換する
現時点ではバックエンド8件のテストが通っています。
実装中につまずいたところ
1. SAMデプロイでCloudFormationのAccessDenied
最初のデプロイでは、次のエラーになりました。
not authorized to perform:
cloudformation:CreateChangeSet
原因は、AWS CLIで使っていたIAMユーザーにSAMデプロイ用の権限がなかったことです。
SAMは内部でCloudFormationを使います。さらに初回は、デプロイパッケージを置く管理用S3バケットも準備します。そのため、Rekognitionの実行権限だけではデプロイできません。
個人検証では管理者権限で初回構築する方法もありますが、常用は避けるべきです。本来はCloudFormation用サービスロールを用意し、利用者にはそのロールだけをiam:PassRoleできるようにするのが安全です。
2. AWS BudgetsでJPYを指定するとロールバック
月500円を目安にBudgetを作ろうとして、次の設定にしました。
BudgetLimit:
Amount: 500
Unit: JPY
しかしデプロイ時に次のエラーが返りました。
JPY is not in the supported unit set: [USD]
Budget作成失敗によってCloudFormationスタック全体がロールバックしました。
そこでUSD 3を、月500円前後を意識した保守的な目安として設定しました。
BudgetLimit:
Amount: 3
Unit: USD
為替は変わるので、固定的に「USD 3 = 500円」とは扱わず、公開時や運用時に見直す必要があります。
3. API_BASE_URLを設定したのにアプリから見えない
Build SettingsではURLが確認できるのに、アプリでは次のエラーが出ました。
APIの接続先が未設定です。
API_BASE_URLを確認してください。
原因は、自動生成Info.plistへ任意のAPI_BASE_URLキーが入っていなかったことです。
明示的なInfo.plistを作り、次を追加して解決しました。
<key>API_BASE_URL</key>
<string>$(API_BASE_URL)</string>
「Build Settingsに値があること」と「BundleのInfo.plistから読めること」は別でした。ビルド後のInfo.plistをplutilで確認すると、切り分けしやすくなります。
4. Presigned URLへのPUTで署名エラーになり得る
Presigned URLの署名時とPUT時でContent-Typeが違うと、SignatureDoesNotMatchになる可能性があります。
署名時: image/jpeg
PUT時: image/png
この違いを起こさないため、画像処理結果にcontentTypeを持たせ、URL取得とPUTで同じ値を使うようにしました。
セキュリティで意識したこと
個人開発でも、最低限次を守るようにしました。
iOSアプリ
- AWSアクセスキーやシークレットを含めない
- 実API URLはローカルxcconfigへ置く
- 解析中はボタンを無効化する
- 通信エラーを利用者向けメッセージへ変換する
APIとLambda
- JSON、Content-Type、拡張子、S3キーを検証する
- 画像の存在、種類、サイズをサーバー側でも確認する
- AWS SDKレスポンスをそのまま返さない
- バケット名やスタックトレースをエラーへ含めない
- Presigned URLや画像をログへ出さない
S3
- Block Public Accessをすべて有効にする
- Bucket owner enforcedを使う
- サーバー側暗号化を有効にする
-
uploads/をLifecycle削除の対象にする
IAM
- Lambdaごとにロールを分ける
- URL発行Lambdaは
PutObjectだけ - 解析Lambdaは
GetObjectとDetectLabelsだけ - S3権限は
uploads/*へ限定する
運用
- API Gatewayに低いスロットリングを設定する
- CloudWatch Logsを7日で削除する
- USD 3のBudget通知を設定する
- 不要になったスタックを削除する
ただし、このAPIには認証がありません。
「推測されにくいURLだから安全」「アプリにURLを埋め込んだから本人しか知らない」とは考えない方がよいです。API URLはアプリから取り出せます。
本人利用を超える場合は、認証と利用者ごとの認可が必須です。
費用について
AWSの料金はリージョン、無料利用枠、リクエスト数、画像数、保存量、ログ量によって変わります。また料金表も変わるため、この記事では未確認の概算を実測値として書かないことにしました。
今回、費用に影響する主な項目は次のとおりです。
- Rekognitionで解析した画像数
- API Gatewayのリクエスト数
- Lambdaの呼び出し回数と実行時間
- S3のPUT、GET、保存量
- CloudWatch Logsの保存量
公式料金ページ:
- Amazon Rekognition Pricing
- Amazon S3 Pricing
- AWS Lambda Pricing
- Amazon API Gateway Pricing
- Amazon CloudWatch Pricing
本稿執筆時点では請求データの確定前です。公開後もリポジトリのdocs/cost-log.mdへ、解析回数、S3使用量、実際の請求額を追記する予定です。
後片付け
検証が終わったら、不要なAWSリソースを削除します。
STACK_NAME=ai-photo-dictionary-dev \
AWS_REGION=ap-northeast-1 \
./scripts/teardown.sh
S3バケットにオブジェクトが残っているとCloudFormationから削除できない場合があるため、対象バケットを確認してから空にします。
この操作でAWS側のスタックは削除されますが、iPhone内のSwiftData履歴は別です。履歴画面から削除するか、アプリをアンインストールします。
今回学んだこと
今回の目的は「AWSのAI画像解析を使ってみたい」というシンプルなものでした。
実際に作ってみると、DetectLabelsを呼ぶコードそのものは短く書けました。
new DetectLabelsCommand({
Image: {
S3Object: {
Bucket: bucket,
Name: objectKey
}
},
MaxLabels: 10,
MinConfidence: 70
});
むしろ多くの時間を使ったのは、その前後でした。
- 画像をどの経路でAWSへ送るか
- 認証情報をどこへ置かないか
- 入力をどこまで検証するか
- 一時画像をいつ削除するか
- IAMをどこまで絞るか
- エラーをどう利用者へ見せるか
- 料金の暴走をどう防ぐか
AIサービスを使うアプリでも、AI APIの呼び出しだけで完成するわけではありません。データの入口と出口、権限、失敗時の挙動まで含めて設計する必要があると分かりました。
一方で、独自モデルを学習しなくても、写真をS3へ置いてAPIを1回呼ぶだけでラベル検出を体験できたのは面白かったです。
AWSのAIサービスを初めて触る題材として、結果が目で確認しやすい画像解析はよい入口だと思います。
今後改善したいこと
1. Cognitoなどの認証
最優先です。一般配布する前に利用者を識別し、利用者ごとの認可とクォータを追加します。
2. 解析直後のS3削除
成功時にオブジェクトを削除し、Lifecycleは異常終了時の安全網として残します。
3. ラベル検索
現在はラベル配列をJSONのDataとして保存しています。検索機能を作るならSwiftDataモデルを分けます。
4. バウンディングボックス
Rekognitionのインスタンス情報を使い、写真上のどこに物体があるか表示したいです。
5. 日本語化
端末内の小さな辞書では訳されない語が多く、表示品質が不安定でした。対応するなら、網羅性、追加費用、キャッシュ、翻訳結果の一貫性を含めて仕組みを設計します。
6. 実測の追加
次の情報を計測して記事へ追記したいです。
- 圧縮前後の画像サイズ
- 端末上の画像処理時間
- S3アップロード時間
- Rekognition応答時間
- 解析した画像数
- 実際のAWS請求額
おわりに
SwiftUIアプリからAmazon Rekognitionを使い、写真を解析するところまで実装できました。
今回の構成では、iPhoneへAWS認証情報を置かず、Presigned URLを使って画像を非公開S3へ直接アップロードしています。Lambdaは入力を検証してRekognitionを呼び、必要なラベルだけをiOSへ返します。
小さな個人開発でも、S3の非公開設定、IAMの最小権限、画像削除、ログ保持期間、Budget通知まで含めることで、AWSを使う際の基本的な考え方を一通り体験できました。
同じように「AWSのAIサービスを何か一つ使ってみたい」と考えている方の最初の題材として、少しでも参考になればうれしいです。
ソースコード:
