0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Fermyon × Akamai で WebAssembly アプリを動かしてみた

Last updated at Posted at 2025-12-01

はじめに

近年、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上に即デプロイ」できるのが最大の魅力です。

image.png

今回試した内容:

Rustで書いたAVIF変換APIを、Fermyonにデプロイしてみました。

Fermyonの"Quickstart"の内容を参考に環境準備、Deploy方法をツールのインストールとFermyonにDeployまで試してみました。

まずはFermyonが開発したフレームワークであるSpinを使います。
image.png

今回はmacOSにSpinをインストールしてみました。開発言語はJavascript/TypeScript、Rust、Go、Pythonなどが使えるのですが、Rustを使ってAVIF変換APIを作ってみました。

※FermyonにLogin、Deployするためには"Quickstart"又はここに記載されているフォームから申請が必要です。(利用者のGitHubハンドル名が必要)
image.png

ステップ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/
image.png

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に変換した画像が返ってきました。

image.png

まとめ

今回は軽量なAVIF変換APIをAkamai Cloud上で驚くほど短時間で公開することができました。
最大のポイントは、やはり自前サーバーが一切不要になったことです。
高速で安全なAPIを、サーバーレスという最も効率的な形で、すぐに利用開始できます。
さらに、インフラがAkamaiのクラウド上で動作しているため、世界中に展開されたAkamaiの強力なネットワークを活かし、グローバルなスケーラビリティと圧倒的な低レイテンシを両立。世界中のエンドユーザーに、最速かつ安定した配信を提供できます。

##関連記事
アカマイ・テクノロジーズ合同会社の Qiita では、 Akamai Cloud 関連などの開発者向けの記事を掲載しております。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?