こんにちは!イーゴリです。
背景
近年、機械学習を活用した画像分類のニーズは急速に高まっています。 例えば、以下のようなユースケースがあります。
- ECサイトでの商品画像の自動分類
- 不正画像や不適切コンテンツの検知
- 製造業における外観検査の自動化
- ペット・動物画像の自動タグ付け
しかし従来、画像分類モデルの構築には以下のような課題がありました。
- Pythonや機械学習の専門知識が必要
- モデル選定やハイパーパラメータ調整が難しい
- 学習環境の構築に時間がかかる
- 推論用APIの実装・運用が複雑
このような課題を解決するサービスがAmazon SageMaker Canvasです。
SageMaker Canvasを利用することで、
- コードを書かずにモデルを作成できる
- AutoMLにより最適なモデルが自動選択される
- 数クリックで推論(Prediction)が可能
- そのままエンドポイントとしてデプロイできる
といったメリットがあります。
本記事では、シンプルな例として「犬と猫の画像分類」を題材に、 以下の流れを実践します。

- 画像データをS3にアップロード
- SageMaker Canvasでデータセット作成
- 手動ラベル付与
- 画像分類モデルの構築
- 推論(Single / Batch)
- エンドポイント作成
- AWS CLI / Webアプリからの利用
機械学習の専門知識がなくても、 実際に「動く画像分類API」を構築できることをゴールとします。
構成図

- 背景
- 構成図
- 画像を収集し、Amazon S3にアップロードする
- Amazon SageMaker Canvas 初期設定(ドメイン作成)
- Amazon SageMaker Canvas でデータセットを作成する
- 画像にラベルを付与
- トレーニングデータセットを用いて画像分類モデルを構築する
- 検証データセットを用いてモデルの評価を行う
- SageMaker Canvasからモデルをデプロイし、エンドポイントを作成する
- モデル登録
- 作成したエンドポイントをAWS CLI・Python(Webページ)から利用する
画像を収集し、Amazon S3にアップロードする
まず、S3バケットを作成します。
[バケットを作成]をクリックします。
バケット名:任意の名前(例:sagemaker-dataset-433)

その他の項目はすべてデフォルトのままにして、[バケットを作成] をクリックします。

動物が好きなので、サンプルとして猫と犬の画像をS3にアップロードします。

- 検証用データセット
犬:149枚
猫:149枚



- 検証用データセット
犬:15枚
猫:15枚


アップロード結果は以下の通りです。


Amazon SageMaker Canvas 初期設定(ドメイン作成)
まず、[Amazon SageMaker AI]に移動します。

画面に移動後、初期設定を行います。
続いて、ドメインを作成します。

今回は[シングルユーザー向けの設定 (クイックセットアップ]を選択し、[設定]をクリックします。

セットアップが完了し、画面が切り替わるまで待機します。

次に [Canvas] に移動し、作成したドメインを選択した上で [Canvasを開く] をクリックします。

Amazon SageMaker Canvas でデータセットを作成する
[Datasets] に移動し、[Import Data] をクリックします。
続いて [Images] を選択します。

トレーニング用データセット
「Create an image dataset」の画面で任意のDataset Nameを入力し、[Create]をクリックします。

ローカル端末から直接アップロードすることも可能ですが、今回は画像をS3にアップロードしているため、S3を選択し、トレーニング用のデータセットを選択した上で、[Create Dataset]をクリックします。

Filter by data typeでImageを選択すると、対象のデータセットが見やすくなります。

検証用データセット
「Create an image dataset」の画面で任意のDataset Nameを入力し、[Create]をクリックします。

ローカル端末から直接アップロードすることも可能ですが、今回は画像をS3にアップロードしているため、S3を選択し、検証用のデータセットを選択した上で、[Create Dataset]をクリックします。


画像にラベルを付与
今回は猫と犬を区別するラベルが付与されていないため、手動でラベルを付与します。
「training-cats-vs-dogs」の右にある [...] をクリックし、[View Dataset] をクリックします。

通常、数時間後に画像が表示されるようになりますが、すぐにラベルを付与したい場合は、「Cats」および「Dogs」というフォルダ名が存在していれば、画像が表示されていなくても手動でラベルを付与できます。


ここでのハマりポイントとして、「Label」がなかなか表示されないことがありましたが、任意の画像を一度クリックすると表示されるようになります。



[Add Label] で [Cat] と [Dog] を追加します。

対象の画像を選択し、Unlabeledからcatまたはdogを選択します。

結果は以下の通りです。

次に別の動物の種類を選択し、同様の手順でラベルを付与します。
最終的にUnlabeledの画像がないことを確認して、次に進みます。

トレーニングデータセットを用いて画像分類モデルを構築する
[Models] に移動し、[Create new model] をクリックします。

任意のModel nameを入力し、[Image analysis] を選択した上で、[Create] をクリックします。

トレーニングデータセットを選択します。

次の画面で、[Quick Build](目安処理時間:15〜30分)または [Standard Build](目安処理時間:数時間)を選択します。
[Quick Build] の右にある [▲] をクリックすると、[Standard Build] が表示されます。
それぞれの違いは以下の通りです。
[Quick Build]:少ないイテレーション数で素早くモデルを構築します。データや設定の確認など、テスト目的に適しています。精度は [Standard Build] より低くなります。
[Standard Build]:AutoMLを使用して複数のアルゴリズムやハイパーパラメータを試し、最適なモデルを構築します。精度が高く、本番利用に適しています。
今回は動作確認を目的として [Quick Build] を選択しますが、より高い精度が必要な場合は [Standard Build] の利用をおすすめします。
Quick BuildとStandard Buildの比較表:
| 項目 | Quick Build | Standard Build |
|---|---|---|
| 処理時間(数値・カテゴリ) | 2〜20分 | 2〜4時間 |
| 処理時間(画像・テキスト) | 15〜30分 | 2〜5時間 |
| 精度 | 普通 | 高い |
| データのダウンサンプリング | 50,000行を超えると自動で50,000行に削減 | 削減なし(最大5GB) |
| 最小データ数 | 2カテゴリ分類:500行以上 | 250行以上(画像は50枚以上) |
| 用途 | 動作確認・テスト | 本番利用 |
ポイント:
- Quick Build は大きなデータセットだと 強制的にダウンサンプリングされるため精度が下がる
- Standard Build は 最低250行のデータが必要
- 画像分類の Standard Build は最低 50枚の画像が必要
詳細は下記になりますので、ご参照ください。
今回は動作確認を目的として [Quick Build] を選択しますが、より高い精度が必要な場合は [Standard Build] の利用をおすすめします。

[Analyze] の画面で進捗が100%になれば完了です。
検証データセットを用いてモデルの評価を行う
Single Prediction(1枚の場合)
試しに [Predict] をクリックし、猫と犬の分類を確認してみます。

画像を選択し、[Create Dataset] をクリックします。
![]()
選択した画像は猫だったため、正しく分類されています。

続いて、バッチ処理も確認します。
Batch Prediction(複数枚の場合)
[Batch Prediction] を選択し、[Manual] > [Select Dataset] をクリックします。

対象のデータセット(例:validation-cats-vs-dogs)を選択し、[Generate Predictions] をクリックします。

「Status」が「Generate Predictions」から「Ready」になるまで待機します。

[...] をクリックし、[View prediction results] をクリックします。

バッチ処理の結果が表示されます。
ただし、1件ずつ確認すると時間がかかるため、CSVでの確認をおすすめします。


CSVファイルをダウンロードします。[Download prediction] > [CSV] をクリックします。
「predicted_label」および「confidence_score」を確認できます。

CSVファイルの構造は以下の通りです。
| カラム名 | 説明 |
|---|---|
image_name |
画像ファイル名 |
image_url |
S3上のファイルパス |
predicted_label |
予測結果:dog または cat |
confidence_score |
モデルの信頼度(0〜1) |
結果からわかること:
- ファイル
6150〜6164→ すべて dog、信頼度ほぼ 100%(0.9997以上) - ファイル
6456〜6470→ すべて cat、信頼度 100% - FAILED — ファイルの処理に失敗(破損または形式が不正な可能性あり)
モデルの精度は非常に高く、ほぼすべての画像でconfidence_scoreが1.0に近い結果となりました。
SageMaker Canvasからモデルをデプロイし、エンドポイントを作成する
[Deploy] ボタンが無効になっているため、エンドポイントを作成できるように設定を行います。

Canvasで使用しているドメインおよびユーザープロファイルを開きます。



ML Ops設定を有効化し、[送信] をクリックします。


続いて、[Create Deployment] をクリックします。

適切なインスタンスタイプを選択し、[Deploy] をクリックします。

「Status」が「Creating」から「In service」に変わるまで待機します。


これでエンドポイントが利用可能になります。
モデル登録
任意の手順ですが、作成したモデルをModel Registryに登録することも可能です。
[My Models] > [Add to Model Registry] をクリックします。



作成したエンドポイントをAWS CLI・Python(Webページ)から利用する
AWS CLI
下記のコマンドで確認できます。
aws sagemaker-runtime invoke-endpoint \
--endpoint-name canvas-dogs-vs-cats \
--body fileb://12472.jpg \
--content-type image/jpeg \
--accept application/json \
output.txt && cat output.txt
{
"ContentType": "application/json",
"InvokedProductionVariant": "canvas-model-variant-2026-05-04-04-51-57-521450"
}
{"predicted_label": "cat", "probability": 1.0, "probabilities": [1.0, 6.9957616088967e-12], "labels": ["cat", "dog"]}%
Python(Webページ)
ローカル端末上のWebページ版
最も簡単な確認方法は、ローカル端末上でPythonを実行する方法です。
以下のファイルを作成します。
- server.py
#!/usr/bin/env python3
import boto3
import base64
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
ENDPOINT_NAME = "canvas-dogs-vs-cats"
PORT = 8080
class Handler(BaseHTTPRequestHandler):
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', '*')
self.end_headers()
def do_POST(self):
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length)
try:
client = boto3.client('sagemaker-runtime')
response = client.invoke_endpoint(
EndpointName=ENDPOINT_NAME,
ContentType='image/jpeg',
Accept='application/json',
Body=body
)
result = response['Body'].read()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(result)
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps({"error": str(e)}).encode())
def log_message(self, format, *args):
print(f"[{self.address_string()}] {format % args}")
print(f"Starting proxy on http://localhost:{PORT}")
print(f"Endpoint: {ENDPOINT_NAME}")
HTTPServer(('', PORT), Handler).serve_forever()
- index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SageMaker Image Classifier</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #1a1a1a; min-height: 100vh; display: flex; align-items: flex-start; justify-content: center; padding: 2rem 1rem; }
.container { width: 100%; max-width: 600px; }
h1 { font-size: 20px; font-weight: 500; margin-bottom: 0.25rem; }
.subtitle { font-size: 13px; color: #666; margin-bottom: 1.5rem; }
.card { background: #fff; border: 1px solid #e5e5e5; border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem; }
.card-title { font-size: 12px; font-weight: 500; color: #888; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 1rem; }
label { font-size: 13px; color: #555; display: block; margin-bottom: 5px; }
input[type="text"], input[type="password"], select {
width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 8px;
font-size: 14px; font-family: inherit; color: #1a1a1a; background: #fafafa;
outline: none; transition: border-color 0.15s;
}
input:focus, select:focus { border-color: #999; background: #fff; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.field { margin-bottom: 12px; }
.drop-zone {
border: 2px dashed #ddd; border-radius: 8px; padding: 2rem;
text-align: center; cursor: pointer; transition: all 0.15s; background: #fafafa;
}
.drop-zone:hover { border-color: #aaa; background: #f0f0f0; }
.drop-zone.dragover { border-color: #555; background: #efefef; }
.drop-zone p { font-size: 14px; color: #666; margin-bottom: 4px; }
.drop-zone small { font-size: 12px; color: #aaa; }
#preview { width: 100%; max-height: 240px; object-fit: contain; border-radius: 8px; display: none; margin-top: 12px; }
#fileName { font-size: 12px; color: #888; margin-top: 6px; display: none; }
.btn {
width: 100%; padding: 11px; background: #1a1a1a; color: #fff;
border: none; border-radius: 8px; font-size: 14px; font-family: inherit;
cursor: pointer; transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.spinner { display: none; margin: 1rem auto 0; width: 22px; height: 22px; border: 2px solid #ddd; border-top-color: #333; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.result { border-radius: 8px; padding: 1.25rem; margin-top: 1rem; display: none; }
.result.success { background: #f0faf4; border: 1px solid #b8e5c8; }
.result.error { background: #fdf0f0; border: 1px solid #f0b8b8; }
.result-label { font-size: 24px; font-weight: 500; color: #1a1a1a; }
.result-prob { font-size: 13px; color: #666; margin-top: 4px; }
.bar-wrap { margin-top: 14px; }
.bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; font-size: 13px; }
.bar-name { min-width: 60px; color: #555; }
.bar-track { flex: 1; background: #e8e8e8; border-radius: 4px; height: 8px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 4px; background: #1a1a1a; transition: width 0.4s ease; }
.bar-val { min-width: 40px; text-align: right; font-weight: 500; color: #1a1a1a; }
.toggle-btn { font-size: 12px; color: #888; cursor: pointer; background: none; border: none; font-family: inherit; text-decoration: underline; margin-top: 10px; padding: 0; }
pre { background: #f5f5f5; border-radius: 8px; padding: 1rem; font-size: 12px; font-family: monospace; white-space: pre-wrap; word-break: break-all; margin-top: 10px; display: none; max-height: 200px; overflow-y: auto; color: #444; }
</style>
</head>
<body>
<div class="container">
<h1>SageMaker Image Classifier</h1>
<p class="subtitle">SageMaker Canvas Endpoint を使った画像分類ツール</p>
<div class="card">
<div class="card-title">設定</div>
<div class="field">
<label>エンドポイント名</label>
<input type="text" id="endpoint" placeholder="canvas-dogs-vs-cats" value="canvas-dogs-vs-cats" />
</div>
</div>
<div class="card">
<div class="card-title">画像</div>
<div class="drop-zone" id="dropZone">
<p>画像をドラッグ&ドロップ、またはクリックして選択</p>
<small>.jpg, .jpeg, .png</small>
<input type="file" id="fileInput" accept="image/*" style="display:none;" />
</div>
<img id="preview" alt="preview" />
<div id="fileName"></div>
</div>
<button class="btn" id="runBtn" onclick="runInference()">分類を実行する</button>
<div class="spinner" id="spinner"></div>
<div class="result" id="result">
<div class="result-label" id="resultLabel"></div>
<div class="result-prob" id="resultProb"></div>
<div class="bar-wrap" id="barWrap"></div>
<button class="toggle-btn" onclick="toggleCode()">生のレスポンスを表示</button>
<pre id="rawCode"></pre>
</div>
</div>
<script>
const drop = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
let selectedFile = null;
drop.addEventListener('click', () => fileInput.click());
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('dragover'); });
drop.addEventListener('dragleave', () => drop.classList.remove('dragover'));
drop.addEventListener('drop', e => { e.preventDefault(); drop.classList.remove('dragover'); if (e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]); });
fileInput.addEventListener('change', () => { if (fileInput.files[0]) setFile(fileInput.files[0]); });
function setFile(f) {
selectedFile = f;
const preview = document.getElementById('preview');
preview.src = URL.createObjectURL(f);
preview.style.display = 'block';
const fn = document.getElementById('fileName');
fn.textContent = f.name + ' (' + (f.size / 1024).toFixed(1) + ' KB)';
fn.style.display = 'block';
document.getElementById('result').style.display = 'none';
}
function toggleCode() {
const c = document.getElementById('rawCode');
c.style.display = c.style.display === 'none' ? 'block' : 'none';
}
async function runInference() {
if (!selectedFile) { alert('画像を選択してください'); return; }
const btn = document.getElementById('runBtn');
const spinner = document.getElementById('spinner');
btn.disabled = true;
spinner.style.display = 'block';
document.getElementById('result').style.display = 'none';
try {
const arrayBuffer = await selectedFile.arrayBuffer();
const body = new Uint8Array(arrayBuffer);
const response = await fetch('http://localhost:8080', {
method: 'POST',
headers: { 'Content-Type': 'image/jpeg' },
body
});
if (!response.ok) { const t = await response.text(); throw new Error(`HTTP ${response.status}: ${t}`); }
const text = await response.text();
console.log('RAW RESPONSE:', text);
let data;
try {
data = JSON.parse(text);
} catch(e) {
const parts = text.trim().split(',');
data = {
predicted_label: parts[0] ? parts[0].trim() : '—',
probability: parts[1] ? parseFloat(parts[1].trim()) : null,
labels: parts[2] ? JSON.parse(parts[2].trim()) : [],
probabilities: parts[3] ? JSON.parse(parts[3].trim()) : [],
_raw: text
};
}
showResult(data);
} catch (err) {
const r = document.getElementById('result');
r.className = 'result error';
r.style.display = 'block';
document.getElementById('resultLabel').textContent = 'エラー';
document.getElementById('resultProb').textContent = err.message;
document.getElementById('barWrap').innerHTML = '';
document.getElementById('rawCode').textContent = err.stack || err.message;
document.getElementById('rawCode').style.display = 'block';
} finally {
btn.disabled = false;
spinner.style.display = 'none';
}
}
function showResult(data) {
const r = document.getElementById('result');
r.className = 'result success';
r.style.display = 'block';
document.getElementById('rawCode').textContent = JSON.stringify(data, null, 2);
document.getElementById('rawCode').style.display = 'none';
const label = data.predicted_label || data.label || (data.labels && data.labels[0]) || '—';
const prob = data.probability !== undefined ? (data.probability * 100).toFixed(1) : null;
document.getElementById('resultLabel').textContent = label;
document.getElementById('resultProb').textContent = prob ? `信頼度: ${prob}%` : '';
const barWrap = document.getElementById('barWrap');
barWrap.innerHTML = '';
const labels = data.labels || [];
const probs = data.probabilities || [];
if (labels.length && probs.length) {
labels.forEach((l, i) => {
const p = (probs[i] * 100).toFixed(1);
barWrap.innerHTML += `<div class="bar-row"><span class="bar-name">${l}</span><div class="bar-track"><div class="bar-fill" style="width:${p}%"></div></div><span class="bar-val">${p}%</span></div>`;
});
}
}
function signRequest({ keyId, keySecret, sessionToken, region, url, body, amzDate, dateStamp, contentType }) {
const parsedUrl = new URL(url);
const host = parsedUrl.host;
const path = parsedUrl.pathname;
const bodyHash = sha256Hex(body);
const hasToken = sessionToken && sessionToken.length > 0;
const canonicalHeaders = hasToken
? `content-type:${contentType}\nhost:${host}\nx-amz-date:${amzDate}\nx-amz-security-token:${sessionToken}\n`
: `content-type:${contentType}\nhost:${host}\nx-amz-date:${amzDate}\n`;
const signedHeaders = hasToken
? 'content-type;host;x-amz-date;x-amz-security-token'
: 'content-type;host;x-amz-date';
const canonicalRequest = `POST\n${path}\n\n${canonicalHeaders}\n${signedHeaders}\n${bodyHash}`;
const credentialScope = `${dateStamp}/${region}/sagemaker/aws4_request`;
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${sha256Hex(strToBytes(canonicalRequest))}`;
const signingKey = getSigningKey(keySecret, dateStamp, region, 'sagemaker');
const signature = hmacHex(signingKey, stringToSign);
const result = {
'Content-Type': contentType,
'X-Amz-Date': amzDate,
'Authorization': `AWS4-HMAC-SHA256 Credential=${keyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
};
if (hasToken) result['X-Amz-Security-Token'] = sessionToken;
return result;
}
function strToBytes(s) {
const arr = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i) & 0xff;
return arr;
}
function sha256Hex(data) {
const bytes = typeof data === 'string' ? strToBytes(data) : data;
return toHex(sha256(bytes));
}
function hmacHex(key, data) {
const k = typeof key === 'string' ? strToBytes(key) : key;
return toHex(hmacSha256(k, strToBytes(data)));
}
function hmacRaw(key, data) {
const k = typeof key === 'string' ? strToBytes(key) : key;
return hmacSha256(k, strToBytes(data));
}
function getSigningKey(secret, date, region, service) {
const kDate = hmacRaw('AWS4' + secret, date);
const kRegion = hmacRaw(kDate, region);
const kService = hmacRaw(kRegion, service);
return hmacRaw(kService, 'aws4_request');
}
function toHex(arr) {
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}
function hmacSha256(key, data) {
const blockSize = 64;
let k = key.length > blockSize ? sha256(key) : key;
const kPad = new Uint8Array(blockSize);
kPad.set(k);
const oKey = kPad.map(b => b ^ 0x5c);
const iKey = kPad.map(b => b ^ 0x36);
const inner = new Uint8Array(iKey.length + data.length);
inner.set(iKey); inner.set(data, iKey.length);
const innerHash = sha256(inner);
const outer = new Uint8Array(oKey.length + innerHash.length);
outer.set(oKey); outer.set(innerHash, oKey.length);
return sha256(outer);
}
function sha256(data) {
const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
let h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
const msg = new Uint8Array(data);
const bitLen = msg.length * 8;
const padLen = msg.length % 64 < 56 ? 56 - msg.length % 64 : 120 - msg.length % 64;
const padded = new Uint8Array(msg.length + padLen + 8);
padded.set(msg);
padded[msg.length] = 0x80;
const dv = new DataView(padded.buffer);
dv.setUint32(padded.length - 4, bitLen >>> 0, false);
dv.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000), false);
for (let i = 0; i < padded.length; i += 64) {
const w = new Array(64);
for (let j = 0; j < 16; j++) w[j] = dv.getUint32(i + j * 4, false);
for (let j = 16; j < 64; j++) {
const s0 = rr(w[j-15],7)^rr(w[j-15],18)^(w[j-15]>>>3);
const s1 = rr(w[j-2],17)^rr(w[j-2],19)^(w[j-2]>>>10);
w[j] = (w[j-16]+s0+w[j-7]+s1) >>> 0;
}
let [a,b,c,d,e,f,g,hh] = h;
for (let j = 0; j < 64; j++) {
const S1 = rr(e,6)^rr(e,11)^rr(e,25);
const ch = (e&f)^(~e&g);
const t1 = (hh+S1+ch+K[j]+w[j]) >>> 0;
const S0 = rr(a,2)^rr(a,13)^rr(a,22);
const maj = (a&b)^(a&c)^(b&c);
const t2 = (S0+maj) >>> 0;
hh=g; g=f; f=e; e=(d+t1)>>>0; d=c; c=b; b=a; a=(t1+t2)>>>0;
}
h = [h[0]+a,h[1]+b,h[2]+c,h[3]+d,h[4]+e,h[5]+f,h[6]+g,h[7]+hh].map(x=>x>>>0);
}
const out = new Uint8Array(32);
const odv = new DataView(out.buffer);
h.forEach((v,i) => odv.setUint32(i*4, v, false));
return out;
}
function rr(x, n) { return (x >>> n) | (x << (32 - n)); }
</script>
</body>
</html>
下記のコマンドを実行します。
python3 server.py
http://localhost:8080にアクセスします。
画像をアップロードし、[分類を実行する] をクリックします。cat または dog の判定結果が表示されます。


AWS上のWebページ版
以下の index.html をS3に配置し、ブラウザからアクセスします。
- index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SageMaker Image Classifier</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; color: #1a1a1a; min-height: 100vh; display: flex; align-items: flex-start; justify-content: center; padding: 2rem 1rem; }
.container { width: 100%; max-width: 600px; }
h1 { font-size: 20px; font-weight: 500; margin-bottom: 0.25rem; }
.subtitle { font-size: 13px; color: #666; margin-bottom: 1.5rem; }
.card { background: #fff; border: 1px solid #e5e5e5; border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem; }
.card-title { font-size: 12px; font-weight: 500; color: #888; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 1rem; }
label { font-size: 13px; color: #555; display: block; margin-bottom: 5px; }
input[type="text"], input[type="password"], select {
width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 8px;
font-size: 14px; font-family: inherit; color: #1a1a1a; background: #fafafa;
outline: none; transition: border-color 0.15s;
}
input:focus, select:focus { border-color: #999; background: #fff; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.field { margin-bottom: 12px; }
.drop-zone {
border: 2px dashed #ddd; border-radius: 8px; padding: 2rem;
text-align: center; cursor: pointer; transition: all 0.15s; background: #fafafa;
}
.drop-zone:hover { border-color: #aaa; background: #f0f0f0; }
.drop-zone.dragover { border-color: #555; background: #efefef; }
.drop-zone p { font-size: 14px; color: #666; margin-bottom: 4px; }
.drop-zone small { font-size: 12px; color: #aaa; }
#preview { width: 100%; max-height: 240px; object-fit: contain; border-radius: 8px; display: none; margin-top: 12px; }
#fileName { font-size: 12px; color: #888; margin-top: 6px; display: none; }
.btn {
width: 100%; padding: 11px; background: #1a1a1a; color: #fff;
border: none; border-radius: 8px; font-size: 14px; font-family: inherit;
cursor: pointer; transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.spinner { display: none; margin: 1rem auto 0; width: 22px; height: 22px; border: 2px solid #ddd; border-top-color: #333; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.result { border-radius: 8px; padding: 1.25rem; margin-top: 1rem; display: none; }
.result.success { background: #f0faf4; border: 1px solid #b8e5c8; }
.result.error { background: #fdf0f0; border: 1px solid #f0b8b8; }
.result-label { font-size: 24px; font-weight: 500; color: #1a1a1a; }
.result-prob { font-size: 13px; color: #666; margin-top: 4px; }
.bar-wrap { margin-top: 14px; }
.bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; font-size: 13px; }
.bar-name { min-width: 60px; color: #555; }
.bar-track { flex: 1; background: #e8e8e8; border-radius: 4px; height: 8px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 4px; background: #1a1a1a; transition: width 0.4s ease; }
.bar-val { min-width: 40px; text-align: right; font-weight: 500; color: #1a1a1a; }
.toggle-btn { font-size: 12px; color: #888; cursor: pointer; background: none; border: none; font-family: inherit; text-decoration: underline; margin-top: 10px; padding: 0; }
pre { background: #f5f5f5; border-radius: 8px; padding: 1rem; font-size: 12px; font-family: monospace; white-space: pre-wrap; word-break: break-all; margin-top: 10px; display: none; max-height: 200px; overflow-y: auto; color: #444; }
</style>
</head>
<body>
<div class="container">
<h1>SageMaker Image Classifier</h1>
<p class="subtitle">SageMaker Canvas Endpoint を使った画像分類ツール</p>
<div class="card">
<div class="card-title">AWS 設定</div>
<div class="row">
<div class="field">
<label>AWS Access Key ID</label>
<input type="password" id="keyId" placeholder="AKIA..." />
</div>
<div class="field">
<label>AWS Secret Access Key</label>
<input type="password" id="keySecret" placeholder="secret..." />
</div>
</div>
<div class="row">
<div class="field">
<label>Region</label>
<select id="region">
<option value="us-east-1">us-east-1</option>
<option value="us-west-2" selected>us-west-2</option>
<option value="eu-west-1">eu-west-1</option>
<option value="ap-northeast-1">ap-northeast-1</option>
<option value="ap-southeast-1">ap-southeast-1</option>
<option value="ap-southeast-2">ap-southeast-2</option>
</select>
</div>
<div class="field">
<label>Endpoint Name</label>
<input type="text" id="endpoint" placeholder="canvas-dog-cat" />
</div>
</div>
<div class="field" style="margin-top:0;">
<label>Session Token <span style="color:#aaa;font-size:12px;">(一時的な認証情報の場合のみ)</span></label>
<input type="password" id="sessionToken" placeholder="FwoGZXIvYXdz..." />
</div>
</div>
<div class="card">
<div class="card-title">画像</div>
<div class="drop-zone" id="dropZone">
<p>画像をドラッグ&ドロップ、またはクリックして選択</p>
<small>.jpg, .jpeg, .png</small>
<input type="file" id="fileInput" accept="image/*" style="display:none;" />
</div>
<img id="preview" alt="preview" />
<div id="fileName"></div>
</div>
<button class="btn" id="runBtn" onclick="runInference()">分類を実行する</button>
<div class="spinner" id="spinner"></div>
<div class="result" id="result">
<div class="result-label" id="resultLabel"></div>
<div class="result-prob" id="resultProb"></div>
<div class="bar-wrap" id="barWrap"></div>
<button class="toggle-btn" onclick="toggleCode()">生のレスポンスを表示</button>
<pre id="rawCode"></pre>
</div>
</div>
<script>
const drop = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
let selectedFile = null;
drop.addEventListener('click', () => fileInput.click());
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('dragover'); });
drop.addEventListener('dragleave', () => drop.classList.remove('dragover'));
drop.addEventListener('drop', e => { e.preventDefault(); drop.classList.remove('dragover'); if (e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]); });
fileInput.addEventListener('change', () => { if (fileInput.files[0]) setFile(fileInput.files[0]); });
function setFile(f) {
selectedFile = f;
const preview = document.getElementById('preview');
preview.src = URL.createObjectURL(f);
preview.style.display = 'block';
const fn = document.getElementById('fileName');
fn.textContent = f.name + ' (' + (f.size / 1024).toFixed(1) + ' KB)';
fn.style.display = 'block';
document.getElementById('result').style.display = 'none';
}
function toggleCode() {
const c = document.getElementById('rawCode');
c.style.display = c.style.display === 'none' ? 'block' : 'none';
}
async function runInference() {
const keyId = document.getElementById('keyId').value.trim();
const keySecret = document.getElementById('keySecret').value.trim();
const region = document.getElementById('region').value;
const endpointName = document.getElementById('endpoint').value.trim();
if (!keyId || !keySecret || !endpointName) { alert('AWSキーとエンドポイント名を入力してください'); return; }
if (!selectedFile) { alert('画像を選択してください'); return; }
const btn = document.getElementById('runBtn');
const spinner = document.getElementById('spinner');
btn.disabled = true;
spinner.style.display = 'block';
document.getElementById('result').style.display = 'none';
try {
const arrayBuffer = await selectedFile.arrayBuffer();
const body = new Uint8Array(arrayBuffer);
const url = `https://runtime.sagemaker.${region}.amazonaws.com/endpoints/${endpointName}/invocations`;
const now = new Date();
const amzDate = now.toISOString().replace(/[:\-]|\.\d{3}/g, '').slice(0,15) + 'Z';
const dateStamp = amzDate.slice(0, 8);
const sessionToken = document.getElementById('sessionToken').value.trim();
const headers = signRequest({ keyId, keySecret, sessionToken, region, url, body, amzDate, dateStamp, contentType: 'application/x-image' });
const response = await fetch(url, { method: 'POST', headers, body });
if (!response.ok) { const t = await response.text(); throw new Error(`HTTP ${response.status}: ${t}`); }
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch(e) {
const parts = text.trim().split(',');
data = {
predicted_label: parts[0] ? parts[0].trim() : '—',
probability: parts[1] ? parseFloat(parts[1].trim()) : null,
labels: parts[2] ? JSON.parse(parts[2].trim()) : [],
probabilities: parts[3] ? JSON.parse(parts[3].trim()) : [],
_raw: text
};
}
showResult(data);
} catch (err) {
const r = document.getElementById('result');
r.className = 'result error';
r.style.display = 'block';
document.getElementById('resultLabel').textContent = 'エラー';
document.getElementById('resultProb').textContent = err.message;
document.getElementById('barWrap').innerHTML = '';
document.getElementById('rawCode').textContent = err.stack || err.message;
document.getElementById('rawCode').style.display = 'block';
} finally {
btn.disabled = false;
spinner.style.display = 'none';
}
}
function showResult(data) {
const r = document.getElementById('result');
r.className = 'result success';
r.style.display = 'block';
document.getElementById('rawCode').textContent = JSON.stringify(data, null, 2);
document.getElementById('rawCode').style.display = 'none';
const label = data.predicted_label || data.label || (data.labels && data.labels[0]) || '—';
const prob = data.probability !== undefined ? (data.probability * 100).toFixed(1) : null;
document.getElementById('resultLabel').textContent = label;
document.getElementById('resultProb').textContent = prob ? `信頼度: ${prob}%` : '';
const barWrap = document.getElementById('barWrap');
barWrap.innerHTML = '';
const labels = data.labels || [];
const probs = data.probabilities || [];
if (labels.length && probs.length) {
labels.forEach((l, i) => {
const p = (probs[i] * 100).toFixed(1);
barWrap.innerHTML += `<div class="bar-row"><span class="bar-name">${l}</span><div class="bar-track"><div class="bar-fill" style="width:${p}%"></div></div><span class="bar-val">${p}%</span></div>`;
});
}
}
function signRequest({ keyId, keySecret, sessionToken, region, url, body, amzDate, dateStamp, contentType }) {
const parsedUrl = new URL(url);
const host = parsedUrl.host;
const path = parsedUrl.pathname;
const bodyHash = sha256Hex(body);
const hasToken = sessionToken && sessionToken.length > 0;
const canonicalHeaders = hasToken
? `content-type:${contentType}\nhost:${host}\nx-amz-date:${amzDate}\nx-amz-security-token:${sessionToken}\n`
: `content-type:${contentType}\nhost:${host}\nx-amz-date:${amzDate}\n`;
const signedHeaders = hasToken
? 'content-type;host;x-amz-date;x-amz-security-token'
: 'content-type;host;x-amz-date';
const canonicalRequest = `POST\n${path}\n\n${canonicalHeaders}\n${signedHeaders}\n${bodyHash}`;
const credentialScope = `${dateStamp}/${region}/sagemaker/aws4_request`;
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${sha256Hex(strToBytes(canonicalRequest))}`;
const signingKey = getSigningKey(keySecret, dateStamp, region, 'sagemaker');
const signature = hmacHex(signingKey, stringToSign);
const result = {
'Content-Type': contentType,
'X-Amz-Date': amzDate,
'Authorization': `AWS4-HMAC-SHA256 Credential=${keyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
};
if (hasToken) result['X-Amz-Security-Token'] = sessionToken;
return result;
}
function strToBytes(s) {
const arr = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i) & 0xff;
return arr;
}
function sha256Hex(data) {
const bytes = typeof data === 'string' ? strToBytes(data) : data;
return toHex(sha256(bytes));
}
function hmacHex(key, data) {
const k = typeof key === 'string' ? strToBytes(key) : key;
return toHex(hmacSha256(k, strToBytes(data)));
}
function hmacRaw(key, data) {
const k = typeof key === 'string' ? strToBytes(key) : key;
return hmacSha256(k, strToBytes(data));
}
function getSigningKey(secret, date, region, service) {
const kDate = hmacRaw('AWS4' + secret, date);
const kRegion = hmacRaw(kDate, region);
const kService = hmacRaw(kRegion, service);
return hmacRaw(kService, 'aws4_request');
}
function toHex(arr) {
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}
function hmacSha256(key, data) {
const blockSize = 64;
let k = key.length > blockSize ? sha256(key) : key;
const kPad = new Uint8Array(blockSize);
kPad.set(k);
const oKey = kPad.map(b => b ^ 0x5c);
const iKey = kPad.map(b => b ^ 0x36);
const inner = new Uint8Array(iKey.length + data.length);
inner.set(iKey); inner.set(data, iKey.length);
const innerHash = sha256(inner);
const outer = new Uint8Array(oKey.length + innerHash.length);
outer.set(oKey); outer.set(innerHash, oKey.length);
return sha256(outer);
}
function sha256(data) {
const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
let h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
const msg = new Uint8Array(data);
const bitLen = msg.length * 8;
const padLen = msg.length % 64 < 56 ? 56 - msg.length % 64 : 120 - msg.length % 64;
const padded = new Uint8Array(msg.length + padLen + 8);
padded.set(msg);
padded[msg.length] = 0x80;
const dv = new DataView(padded.buffer);
dv.setUint32(padded.length - 4, bitLen >>> 0, false);
dv.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000), false);
for (let i = 0; i < padded.length; i += 64) {
const w = new Array(64);
for (let j = 0; j < 16; j++) w[j] = dv.getUint32(i + j * 4, false);
for (let j = 16; j < 64; j++) {
const s0 = rr(w[j-15],7)^rr(w[j-15],18)^(w[j-15]>>>3);
const s1 = rr(w[j-2],17)^rr(w[j-2],19)^(w[j-2]>>>10);
w[j] = (w[j-16]+s0+w[j-7]+s1) >>> 0;
}
let [a,b,c,d,e,f,g,hh] = h;
for (let j = 0; j < 64; j++) {
const S1 = rr(e,6)^rr(e,11)^rr(e,25);
const ch = (e&f)^(~e&g);
const t1 = (hh+S1+ch+K[j]+w[j]) >>> 0;
const S0 = rr(a,2)^rr(a,13)^rr(a,22);
const maj = (a&b)^(a&c)^(b&c);
const t2 = (S0+maj) >>> 0;
hh=g; g=f; f=e; e=(d+t1)>>>0; d=c; c=b; b=a; a=(t1+t2)>>>0;
}
h = [h[0]+a,h[1]+b,h[2]+c,h[3]+d,h[4]+e,h[5]+f,h[6]+g,h[7]+hh].map(x=>x>>>0);
}
const out = new Uint8Array(32);
const odv = new DataView(out.buffer);
h.forEach((v,i) => odv.setUint32(i*4, v, false));
return out;
}
function rr(x, n) { return (x >>> n) | (x << (32 - n)); }
</script>
</body>
</html>
セキュリティの観点では、このような構成が適切だと考えています。
本記事では詳細な構築手順は割愛し、設計時に押さえておくべきポイントのみ解説します。

WAF Rate LimitとAPI Gateway Throttlingの違い
| WAF Rate Limit | API Gateway Throttling | |
|---|---|---|
| 場所 | 入口(サービスの手前) | AWS内部(WAFの後) |
| カウント対象 | 特定IPからのリクエスト数 | エンドポイント全体のリクエスト数 |
| ブロック対象 | 不審なIPアドレス | サービスの過負荷 |
| レスポンス | 403 Forbidden | 429 Too Many Requests |
| 目的 | 攻撃からの保護 | 過負荷からの保護 |
例:
- WAF:「このIPが10秒間に1000リクエスト送信 → ブロック」
- API Gateway:「異なるIPから同時に500リクエスト → 制限」
両者は補完関係にありますが、目的が異なりますので、両方とも設定することをおすすめします。WAFは誰がアクセスしているかを監視し、API Gatewayはどれだけアクセスされているかを監視します。
以上、御一読ありがとうございました。
本田 イーゴリ (記事一覧)
カスタマーサクセス部
・2024 Japan AWS Top Engineers (Security)
・AWS SAP, DOP, SCS, DBS, SAA, DVA, COA, CLF, ANS, AIF, MLS, MLA, DEA
・Azure AZ-900
・EC-Council CCSE
趣味:日本国内旅行(47都道府県制覇)・ドライブ・音楽