はじめに
近年、WebAssembly (Wasm) を使ったサーバーレス技術が注目されています。開発者にとっては、「軽くて速くて安全な、ポータブル関数実行環境」が手に入るという意味で革命的です。今回はAkamaiとFermyonのパートナーシップによって実現した「Fermyon Wasm Functions on Akamai」を実際に試してみました。
「Fermyon Wasm Functions on Akamai」について
Fermyonは、WebAssembly(Wasm)を活用した次世代のサーバーレスプラットフォームを提供しています。Fermyon Wasm Functions on AkamaiはFermyonとAkamaiとのパートナーシップにより、Akamaiの分散ネットワークを組み合わせることで、高速なWasmファンクションの実行を実現し、開発者にとって最適な環境を提供しています。
FermyonのCEOマット・ブッチャー氏はこの記事で次のように言っています。
「私達がコンピューティング側の課題を、Akamaiがデリバリー側の課題を解決した。合わせて、地球上で最も早いサーバーレスプラットフォームを構築しました」
「Fermyon Wasm Functions on Akamai」のメリット
- サーバーを自分で用意する必要がない
- Akamaiのグローバルネットワーク上で動作するため、世界中どこからでも高速アクセス
- Spinコンポーネントを数秒でデプロイ
「軽いマイクロサービスを書いて、Akamai上に即デプロイ」できるのが最大の魅力です。
今回試した内容:
Rustで書いたAVIF変換APIを、Fermyonにデプロイしてみました。
Fermyonの"Quickstart"の内容を参考に環境準備、Deploy方法をツールのインストールとFermyonにDeployまで試してみました。
まずはFermyonが開発したフレームワークであるSpinを使います。

今回はmacOSにSpinをインストールしてみました。開発言語はJavascript/TypeScript、Rust、Go、Pythonなどが使えるのですが、Rustを使ってAVIF変換APIを作ってみました。
※FermyonにLogin、Deployするためには"Quickstart"又はここに記載されているフォームから申請が必要です。(利用者のGitHubハンドル名が必要)

ステップ1:環境準備
Spin CLIのインストール(今回はmacOSを選択しました)
curl -fsSL https://wasm-functions.fermyon.app/downloads/install.sh | bash
sudo mv ./spin /usr/local/bin/spin
「Fermyon Wasm Functions on Akamaiと連携(Login、Deployなど)するためのAkamai Spin pluginをインストール
spin plugin install aka
今回はRustを使うので、Rustをインストール
https://rust-lang.org/tools/install/

Rust用のターゲット追加(wasm32-wasip1:WasmコードをWebブラウザ以外のシステムで実行するため)
rustup target add wasm32-wasip1
ステップ2:ログイン
「Fermyon Wasm Functions on Akamai」にログイン
spin aka login
表示されるリンク先をクリックしGitHubアカウントを使って認証を実行します。
Go to https://login.infra.fermyon.tech/realms/neutrino/device?user_code=AAAA-BBBB and follow the prompts.
Don't worry, we'll wait here for you. You got this.
ステップ3:プロジェクトの作成
spin new -t http-rust --accept-defaults avif-converter
cd avif-converter
すると、次のような構成のプロジェクトが作られます
avif-converter/
├─ src/
│ └─ lib.rs ←アプリ用のRustコード
├─ Cargo.toml ←ビルドの構成
└─ spin.toml ← Spinアプリ設定ファイル
spin.tomlを以下のように編集します
spin_manifest_version = 2
[application]
name = "image-to-avif"
version = "0.1.0"
authors = ["Your Name <your.email@example.com>"]
description = "Image to AVIF converter"
[[trigger.http]]
route = "/convert"
component = "image-converter"
[component.image-converter]
source = "target/wasm32-wasip1/release/image_to_avif.wasm"
allowed_outbound_hosts = []
[component.image-converter.build]
command = "cargo build --target wasm32-wasip1 --release"
watch = ["src/**/*.rs", "Cargo.toml"]
Cargo.tomlの内容は以下のようになります:
name = "image-to-avif"
version = "0.1.0"
edition = "2021"
[dependencies]
spin-sdk = "3.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
image = "0.25"
ravif = "0.11"
rgb = "0.8"
anyhow = "1.0"
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
Rustで画像をAVIFに変換するエンドポイントを作成します。(src/lib.rs)
(Rustコード(lib.rs)はAI(Claude,ChatGPTなど)を使ってSampleコードを用意しました。)
(今回はFermyonを使った動作確認が目的なのでメモリの制約などは何も考慮せず小さい画像をアップロードしてavifに変換されるRustコードを作成しました)
use spin_sdk::http::{IntoResponse, Request, Response};
use spin_sdk::http_component;
use ravif::Img;
#[http_component]
fn handle_request(req: Request) -> anyhow::Result<impl IntoResponse> {
// OPTIONSリクエスト(プリフライト)の処理
if req.method() == &spin_sdk::http::Method::Options {
return Ok(Response::builder()
.status(200)
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type")
.build());
}
// POSTリクエストのみ受け付ける
if req.method() != &spin_sdk::http::Method::Post {
return Ok(Response::builder()
.status(405)
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type")
.body("Method Not Allowed")
.build());
}
// リクエストボディから画像データを取得
let image_data = req.body();
if image_data.is_empty() {
return Ok(Response::builder()
.status(400)
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type")
.body("No image data provided")
.build());
}
// 画像を読み込む
let img = match image::load_from_memory(image_data) {
Ok(img) => img,
Err(e) => {
return Ok(Response::builder()
.status(400)
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type")
.body(format!("Failed to load image: {}", e))
.build());
}
};
// RGBAに変換
let rgba = img.to_rgba8();
let width = rgba.width() as usize;
let height = rgba.height() as usize;
// ravif用のバッファを作成
let pixels: Vec<rgb::RGBA8> = rgba
.pixels()
.map(|p| rgb::RGBA8 {
r: p[0],
g: p[1],
b: p[2],
a: p[3],
})
.collect();
// AVIFにエンコード
let img_buf = Img::new(pixels.as_slice(), width, height);
let encoder = ravif::Encoder::new()
.with_quality(80.0)
.with_speed(4);
let avif_data = match encoder.encode_rgba(img_buf) {
Ok(data) => data,
Err(e) => {
return Ok(Response::builder()
.status(500)
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type")
.body(format!("Failed to encode AVIF: {}", e))
.build());
}
};
// AVIFデータを返す
Ok(Response::builder()
.status(200)
.header("Content-Type", "image/avif")
.header("Content-Disposition", "attachment; filename=\"converted.avif\"")
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type")
.body(avif_data.avif_file)
.build())
}
ステップ4:ビルドしてFermyonに展開
cd avif-converter
spin build
spin aka deploy
※Localでビルドを確認する場合は'spin up'を使います。
FermyonにDeploy後以下のようなAPI endpointが作成されます。
https://abee6ce5-a4b7-4483-a7de-2b021534d133.fwf.app/convert
作成されたAPI endpointを以下のHTMLに適用し画像のAVIF変換を試してみました。
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image to AVIF Converter</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: white;
border-radius: 20px;
padding: 40px;
max-width: 1200px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
h1 {
color: #333;
margin-bottom: 30px;
text-align: center;
font-size: 28px;
}
.upload-area {
border: 3px dashed #667eea;
border-radius: 15px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 20px;
}
.upload-area:hover {
background: #f8f9ff;
border-color: #764ba2;
}
.upload-area.dragover {
background: #e8ebff;
border-color: #764ba2;
}
#fileInput {
display: none;
}
.upload-icon {
font-size: 48px;
margin-bottom: 15px;
}
.upload-text {
color: #666;
font-size: 16px;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px 30px;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
width: 100%;
transition: transform 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#status {
margin-top: 20px;
padding: 15px;
border-radius: 10px;
text-align: center;
font-weight: 500;
}
.success {
background: #d4edda;
color: #155724;
}
.error {
background: #f8d7da;
color: #721c24;
}
.info {
background: #d1ecf1;
color: #0c5460;
}
.file-info {
margin-top: 15px;
padding: 10px;
background: #f8f9fa;
border-radius: 8px;
font-size: 14px;
color: #666;
}
.comparison-container {
display: none;
margin-top: 30px;
}
.comparison-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.image-box {
border: 2px solid #e0e0e0;
border-radius: 12px;
padding: 15px;
background: #f9f9f9;
}
.image-box h3 {
margin-bottom: 10px;
color: #333;
font-size: 18px;
text-align: center;
}
.image-wrapper {
position: relative;
background: #fff;
border-radius: 8px;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
}
.image-wrapper img {
max-width: 100%;
max-height: 500px;
display: block;
border-radius: 8px;
}
.image-info {
margin-top: 10px;
padding: 10px;
background: white;
border-radius: 8px;
font-size: 13px;
color: #666;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 5px 0;
border-bottom: 1px solid #f0f0f0;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-weight: 600;
color: #555;
}
.compression-stats {
margin-top: 20px;
padding: 20px;
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
border-radius: 12px;
text-align: center;
}
.compression-stats h3 {
color: #333;
margin-bottom: 15px;
font-size: 20px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.stat-box {
background: white;
padding: 15px;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #667eea;
margin-bottom: 5px;
}
.stat-label {
font-size: 14px;
color: #666;
}
@media (max-width: 768px) {
.comparison-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<h1>🖼️ Image to AVIF Converter</h1>
<div class="upload-area" id="uploadArea">
<div class="upload-icon">📁</div>
<div class="upload-text">
クリックまたはドラッグ&ドロップで画像を選択
</div>
<input type="file" id="fileInput" accept="image/*">
</div>
<div class="file-info" id="fileInfo" style="display: none;"></div>
<button id="convertBtn" disabled>AVIF に変換</button>
<div id="status"></div>
<div class="comparison-container" id="comparisonContainer">
<div class="comparison-grid">
<div class="image-box">
<h3>📷 元の画像</h3>
<div class="image-wrapper">
<img id="originalImage" alt="Original">
</div>
<div class="image-info" id="originalInfo"></div>
</div>
<div class="image-box">
<h3>✨ AVIF変換後</h3>
<div class="image-wrapper">
<img id="convertedImage" alt="Converted AVIF">
</div>
<div class="image-info" id="convertedInfo"></div>
</div>
</div>
<div class="compression-stats" id="compressionStats">
<h3>📊 変換結果</h3>
<div class="stats-grid">
<div class="stat-box">
<div class="stat-value" id="originalSize">-</div>
<div class="stat-label">元のサイズ</div>
</div>
<div class="stat-box">
<div class="stat-value" id="convertedSize">-</div>
<div class="stat-label">変換後サイズ</div>
</div>
<div class="stat-box">
<div class="stat-value" id="compressionRatio">-</div>
<div class="stat-label">圧縮率</div>
</div>
</div>
</div>
</div>
</div>
<script>
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const convertBtn = document.getElementById('convertBtn');
const status = document.getElementById('status');
const fileInfo = document.getElementById('fileInfo');
const comparisonContainer = document.getElementById('comparisonContainer');
const originalImage = document.getElementById('originalImage');
const convertedImage = document.getElementById('convertedImage');
const originalInfo = document.getElementById('originalInfo');
const convertedInfo = document.getElementById('convertedInfo');
let selectedFile = null;
// Spin のエンドポイント
const API_ENDPOINT = 'https://abee6ce5-a4b7-4483-a7de-2b021534d133.fwf.app/convert';
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
function handleFile(file) {
if (!file.type.startsWith('image/')) {
showStatus('画像ファイルを選択してください', 'error');
return;
}
selectedFile = file;
convertBtn.disabled = false;
const sizeMB = (file.size / 1024 / 1024).toFixed(2);
fileInfo.textContent = `📄 ${file.name} (${sizeMB} MB)`;
fileInfo.style.display = 'block';
// 元の画像をプレビュー
const reader = new FileReader();
reader.onload = (e) => {
originalImage.src = e.target.result;
const img = new Image();
img.onload = () => {
originalInfo.innerHTML = `
<div class="info-row">
<span class="info-label">形式:</span>
<span>${file.type}</span>
</div>
<div class="info-row">
<span class="info-label">サイズ:</span>
<span>${formatFileSize(file.size)}</span>
</div>
<div class="info-row">
<span class="info-label">解像度:</span>
<span>${img.width} × ${img.height}px</span>
</div>
`;
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
showStatus('', '');
comparisonContainer.style.display = 'none';
}
convertBtn.addEventListener('click', async () => {
if (!selectedFile) return;
convertBtn.disabled = true;
showStatus('変換中...', 'info');
try {
const response = await fetch(API_ENDPOINT, {
method: 'POST',
body: selectedFile,
headers: {
'Content-Type': selectedFile.type
}
});
if (!response.ok) {
throw new Error(`変換に失敗しました: ${response.statusText}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
// 変換された画像を表示
convertedImage.src = url;
// 変換後の画像情報を取得
const img = new Image();
img.onload = () => {
convertedInfo.innerHTML = `
<div class="info-row">
<span class="info-label">形式:</span>
<span>image/avif</span>
</div>
<div class="info-row">
<span class="info-label">サイズ:</span>
<span>${formatFileSize(blob.size)}</span>
</div>
<div class="info-row">
<span class="info-label">解像度:</span>
<span>${img.width} × ${img.height}px</span>
</div>
`;
// 圧縮率を計算
const ratio = ((1 - blob.size / selectedFile.size) * 100).toFixed(1);
document.getElementById('originalSize').textContent = formatFileSize(selectedFile.size);
document.getElementById('convertedSize').textContent = formatFileSize(blob.size);
document.getElementById('compressionRatio').textContent = ratio + '%削減';
};
img.src = url;
// 比較表示を表示
comparisonContainer.style.display = 'block';
// ダウンロードリンクを作成
const a = document.createElement('a');
a.href = url;
a.download = selectedFile.name.replace(/\.[^/.]+$/, '') + '.avif';
a.click();
showStatus('✅ 変換が完了しました!ダウンロードを開始しています。', 'success');
} catch (error) {
showStatus(`❌ エラー: ${error.message}`, 'error');
} finally {
convertBtn.disabled = false;
}
});
function showStatus(message, type) {
status.textContent = message;
status.className = type;
status.style.display = message ? 'block' : 'none';
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / 1024 / 1024).toFixed(2) + ' MB';
}
</script>
</body>
</html>
ブラウザからテストした結果、pngファイルをアップするとFermyon側でAVIFに変換した画像が返ってきました。
まとめ
今回は軽量なAVIF変換APIをAkamai Cloud上で驚くほど短時間で公開することができました。
最大のポイントは、やはり自前サーバーが一切不要になったことです。
高速で安全なAPIを、サーバーレスという最も効率的な形で、すぐに利用開始できます。
さらに、インフラがAkamaiのクラウド上で動作しているため、世界中に展開されたAkamaiの強力なネットワークを活かし、グローバルなスケーラビリティと圧倒的な低レイテンシを両立。世界中のエンドユーザーに、最速かつ安定した配信を提供できます。
##関連記事
アカマイ・テクノロジーズ合同会社の Qiita では、 Akamai Cloud 関連などの開発者向けの記事を掲載しております。

