Tigris分散オブジェクトストレージ入門 - グローバルにキャッシュされる高速ストレージ


Tigris分散オブジェクトストレージ入門

Tigrisは、グローバルに分散されたオブジェクトストレージサービスです。S3互換のAPIを提供しながら、世界中の複数リージョンにデータを自動的に複製し、ユーザーに最も近い場所からデータを配信することで、低レイテンシーなアクセスを実現します。

Tigrisとは

Tigrisは、Fly.ioのインフラストラクチャ上に構築された分散オブジェクトストレージサービスで、以下の特徴を持ちます。

主な特徴

  • グローバル分散: 世界中の複数リージョンに自動複製
  • 低レイテンシー: ユーザーに最も近い場所から配信
  • S3互換: 既存のS3ツールやライブラリが使える
  • 自動キャッシング: エッジでの高速アクセス
  • シンプルな価格設定: データ転送費用なし

従来のオブジェクトストレージとの違い

AWS S3:
- 単一リージョンにデータを保存
- グローバル配信にはCloudFrontが必要
- リージョン間転送に課金

Tigris:
- 複数リージョンに自動複製
- グローバルキャッシュが標準搭載
- データ転送費用なし

ユースケース

  • 静的サイトホスティング: 画像、CSS、JavaScriptファイル
  • メディア配信: 動画、音声、画像の高速配信
  • バックアップ: アプリケーションデータのバックアップ
  • ファイル共有: ユーザーアップロードファイルの保管
  • ログストレージ: アプリケーションログの長期保存

セットアップ

Tigrisアカウントの作成

# Fly.io CLIをインストール
curl -L https://fly.io/install.sh | sh

# ログイン
fly auth login

# Tigrisプロジェクトを作成
fly storage create my-storage

# 認証情報を取得
fly storage credentials my-storage

認証情報は以下の形式で表示されます。

AWS_ACCESS_KEY_ID=tid_xxxxxxxxxxxx
AWS_SECRET_ACCESS_KEY=tsec_xxxxxxxxxxxx
AWS_ENDPOINT_URL_S3=https://fly.storage.tigris.dev

環境変数の設定

# .envファイル
AWS_ACCESS_KEY_ID=tid_xxxxxxxxxxxx
AWS_SECRET_ACCESS_KEY=tsec_xxxxxxxxxxxx
AWS_ENDPOINT_URL_S3=https://fly.storage.tigris.dev
AWS_REGION=auto

AWS SDK for JavaScriptでの使用

インストール

npm install @aws-sdk/client-s3
npm install dotenv

基本的な操作

import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { config } from 'dotenv';

config();

// Tigrisクライアントの作成
const s3Client = new S3Client({
  region: 'auto',
  endpoint: process.env.AWS_ENDPOINT_URL_S3,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
});

const BUCKET_NAME = 'my-storage';

// ファイルをアップロード
async function uploadFile(key, content) {
  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
    Body: content,
    ContentType: 'text/plain',
  });

  const response = await s3Client.send(command);
  console.log('Upload successful:', response);
  return response;
}

// ファイルをダウンロード
async function downloadFile(key) {
  const command = new GetObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
  });

  const response = await s3Client.send(command);
  const content = await response.Body.transformToString();
  return content;
}

// ファイル一覧を取得
async function listFiles(prefix = '') {
  const command = new ListObjectsV2Command({
    Bucket: BUCKET_NAME,
    Prefix: prefix,
  });

  const response = await s3Client.send(command);
  return response.Contents || [];
}

// 使用例
async function main() {
  // アップロード
  await uploadFile('test.txt', 'Hello, Tigris!');

  // ダウンロード
  const content = await downloadFile('test.txt');
  console.log('Downloaded:', content);

  // 一覧取得
  const files = await listFiles();
  console.log('Files:', files.map(f => f.Key));
}

main();

画像のアップロードと配信

import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import fs from 'fs/promises';

// 画像をアップロード
async function uploadImage(filePath, key) {
  const fileContent = await fs.readFile(filePath);

  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
    Body: fileContent,
    ContentType: 'image/jpeg',
    CacheControl: 'public, max-age=31536000', // 1年間キャッシュ
  });

  await s3Client.send(command);

  // 公開URLを生成
  const url = `${process.env.AWS_ENDPOINT_URL_S3}/${BUCKET_NAME}/${key}`;
  return url;
}

// 署名付きURLを生成(一時的なアクセス用)
async function getPresignedUrl(key, expiresIn = 3600) {
  const command = new GetObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
  });

  const url = await getSignedUrl(s3Client, command, { expiresIn });
  return url;
}

// 使用例
async function main() {
  // 画像をアップロード
  const imageUrl = await uploadImage('./photo.jpg', 'images/photo.jpg');
  console.log('Image URL:', imageUrl);

  // 1時間有効な署名付きURLを生成
  const presignedUrl = await getPresignedUrl('images/photo.jpg', 3600);
  console.log('Presigned URL:', presignedUrl);
}

main();

Next.jsとの統合

ファイルアップロードAPI

// app/api/upload/route.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { NextRequest, NextResponse } from 'next/server';
import { randomUUID } from 'crypto';

const s3Client = new S3Client({
  region: 'auto',
  endpoint: process.env.AWS_ENDPOINT_URL_S3,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

const BUCKET_NAME = process.env.BUCKET_NAME!;

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData();
    const file = formData.get('file') as File;

    if (!file) {
      return NextResponse.json(
        { error: 'No file provided' },
        { status: 400 }
      );
    }

    // ファイルを読み込み
    const buffer = Buffer.from(await file.arrayBuffer());

    // ユニークなキーを生成
    const key = `uploads/${randomUUID()}-${file.name}`;

    // Tigrisにアップロード
    const command = new PutObjectCommand({
      Bucket: BUCKET_NAME,
      Key: key,
      Body: buffer,
      ContentType: file.type,
      CacheControl: 'public, max-age=31536000',
    });

    await s3Client.send(command);

    const url = `${process.env.AWS_ENDPOINT_URL_S3}/${BUCKET_NAME}/${key}`;

    return NextResponse.json({
      success: true,
      url,
      key,
    });
  } catch (error) {
    console.error('Upload error:', error);
    return NextResponse.json(
      { error: 'Upload failed' },
      { status: 500 }
    );
  }
}

アップロードコンポーネント

// components/FileUploader.tsx
'use client';

import { useState } from 'react';

export default function FileUploader() {
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [uploadedUrl, setUploadedUrl] = useState<string>('');

  const handleUpload = async () => {
    if (!file) return;

    setUploading(true);

    try {
      const formData = new FormData();
      formData.append('file', file);

      const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
      });

      const data = await response.json();

      if (data.success) {
        setUploadedUrl(data.url);
        alert('Upload successful!');
      } else {
        alert('Upload failed');
      }
    } catch (error) {
      console.error('Upload error:', error);
      alert('Upload failed');
    } finally {
      setUploading(false);
    }
  };

  return (
    <div className="p-4">
      <input
        type="file"
        onChange={(e) => setFile(e.target.files?.[0] || null)}
        className="mb-4"
      />

      <button
        onClick={handleUpload}
        disabled={!file || uploading}
        className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
      >
        {uploading ? 'Uploading...' : 'Upload'}
      </button>

      {uploadedUrl && (
        <div className="mt-4">
          <p>Uploaded successfully!</p>
          <img src={uploadedUrl} alt="Uploaded" className="max-w-md mt-2" />
          <p className="text-sm text-gray-500 mt-2">{uploadedUrl}</p>
        </div>
      )}
    </div>
  );
}

マルチパートアップロード

大きなファイルは分割してアップロードすることで、信頼性とパフォーマンスを向上させます。

import {
  CreateMultipartUploadCommand,
  UploadPartCommand,
  CompleteMultipartUploadCommand,
  AbortMultipartUploadCommand,
} from '@aws-sdk/client-s3';
import fs from 'fs';

async function uploadLargeFile(filePath, key) {
  const fileSize = (await fs.promises.stat(filePath)).size;
  const chunkSize = 5 * 1024 * 1024; // 5MB
  const numParts = Math.ceil(fileSize / chunkSize);

  try {
    // マルチパートアップロードを開始
    const createResponse = await s3Client.send(
      new CreateMultipartUploadCommand({
        Bucket: BUCKET_NAME,
        Key: key,
      })
    );

    const uploadId = createResponse.UploadId;
    const uploadedParts = [];

    // 各パートをアップロード
    for (let i = 0; i < numParts; i++) {
      const start = i * chunkSize;
      const end = Math.min(start + chunkSize, fileSize);

      const buffer = Buffer.alloc(end - start);
      const fd = await fs.promises.open(filePath, 'r');
      await fd.read(buffer, 0, end - start, start);
      await fd.close();

      const uploadPartResponse = await s3Client.send(
        new UploadPartCommand({
          Bucket: BUCKET_NAME,
          Key: key,
          UploadId: uploadId,
          PartNumber: i + 1,
          Body: buffer,
        })
      );

      uploadedParts.push({
        ETag: uploadPartResponse.ETag,
        PartNumber: i + 1,
      });

      console.log(`Uploaded part ${i + 1}/${numParts}`);
    }

    // マルチパートアップロードを完了
    const completeResponse = await s3Client.send(
      new CompleteMultipartUploadCommand({
        Bucket: BUCKET_NAME,
        Key: key,
        UploadId: uploadId,
        MultipartUpload: {
          Parts: uploadedParts,
        },
      })
    );

    console.log('Upload completed:', completeResponse.Location);
    return completeResponse.Location;
  } catch (error) {
    // エラー時はアップロードを中止
    await s3Client.send(
      new AbortMultipartUploadCommand({
        Bucket: BUCKET_NAME,
        Key: key,
        UploadId: uploadId,
      })
    );
    throw error;
  }
}

Cloudflare Workersとの統合

// worker.ts
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';

interface Env {
  AWS_ACCESS_KEY_ID: string;
  AWS_SECRET_ACCESS_KEY: string;
  AWS_ENDPOINT_URL_S3: string;
  BUCKET_NAME: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const key = url.pathname.slice(1); // 先頭の / を削除

    if (!key) {
      return new Response('Not Found', { status: 404 });
    }

    const s3Client = new S3Client({
      region: 'auto',
      endpoint: env.AWS_ENDPOINT_URL_S3,
      credentials: {
        accessKeyId: env.AWS_ACCESS_KEY_ID,
        secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
      },
    });

    try {
      const command = new GetObjectCommand({
        Bucket: env.BUCKET_NAME,
        Key: key,
      });

      const response = await s3Client.send(command);

      return new Response(response.Body as ReadableStream, {
        headers: {
          'Content-Type': response.ContentType || 'application/octet-stream',
          'Cache-Control': 'public, max-age=31536000',
        },
      });
    } catch (error) {
      return new Response('Not Found', { status: 404 });
    }
  },
};

画像最適化プロキシ

// image-proxy/route.ts
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp';

const s3Client = new S3Client({
  region: 'auto',
  endpoint: process.env.AWS_ENDPOINT_URL_S3,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const key = searchParams.get('key');
  const width = parseInt(searchParams.get('width') || '800');
  const quality = parseInt(searchParams.get('quality') || '80');

  if (!key) {
    return new Response('Missing key parameter', { status: 400 });
  }

  try {
    // Tigrisから画像を取得
    const command = new GetObjectCommand({
      Bucket: process.env.BUCKET_NAME!,
      Key: key,
    });

    const response = await s3Client.send(command);
    const imageBuffer = await response.Body?.transformToByteArray();

    if (!imageBuffer) {
      return new Response('Image not found', { status: 404 });
    }

    // sharpで画像を最適化
    const optimizedImage = await sharp(Buffer.from(imageBuffer))
      .resize(width, null, { withoutEnlargement: true })
      .webp({ quality })
      .toBuffer();

    return new NextResponse(optimizedImage, {
      headers: {
        'Content-Type': 'image/webp',
        'Cache-Control': 'public, max-age=31536000, immutable',
      },
    });
  } catch (error) {
    console.error('Image optimization error:', error);
    return new Response('Error processing image', { status: 500 });
  }
}

バックアップとリストア

定期バックアップスクリプト

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { createGzip } from 'zlib';
import { pipeline } from 'stream/promises';
import { createReadStream } from 'fs';
import { promisify } from 'util';
import { exec } from 'child_process';

const execAsync = promisify(exec);

async function backupDatabase() {
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
  const dumpFile = `/tmp/backup-${timestamp}.sql`;
  const gzipFile = `${dumpFile}.gz`;

  try {
    // データベースダンプを作成
    await execAsync(`pg_dump ${process.env.DATABASE_URL} > ${dumpFile}`);

    // gzip圧縮
    await pipeline(
      createReadStream(dumpFile),
      createGzip(),
      fs.createWriteStream(gzipFile)
    );

    // Tigrisにアップロード
    const fileContent = await fs.promises.readFile(gzipFile);

    const command = new PutObjectCommand({
      Bucket: BUCKET_NAME,
      Key: `backups/database-${timestamp}.sql.gz`,
      Body: fileContent,
      ContentType: 'application/gzip',
    });

    await s3Client.send(command);

    console.log('Backup completed:', `backups/database-${timestamp}.sql.gz`);

    // 一時ファイルを削除
    await fs.promises.unlink(dumpFile);
    await fs.promises.unlink(gzipFile);
  } catch (error) {
    console.error('Backup failed:', error);
    throw error;
  }
}

// 定期実行(cron)
backupDatabase();

セキュリティベストプラクティス

1. バケットポリシーの設定

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-storage/public/*"
    }
  ]
}

2. 署名付きURLの使用

import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PutObjectCommand } from '@aws-sdk/client-s3';

// アップロード用の署名付きURLを生成
async function generateUploadUrl(key, contentType) {
  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
    ContentType: contentType,
  });

  const url = await getSignedUrl(s3Client, command, {
    expiresIn: 3600, // 1時間有効
  });

  return url;
}

// フロントエンドからの使用
const uploadUrl = await fetch('/api/get-upload-url', {
  method: 'POST',
  body: JSON.stringify({ filename: 'image.jpg' }),
}).then(r => r.json());

await fetch(uploadUrl.url, {
  method: 'PUT',
  body: file,
  headers: { 'Content-Type': file.type },
});

まとめ

Tigris分散オブジェクトストレージは、グローバルに分散された高速なストレージソリューションです。

主な利点

  • グローバル配信: 世界中のユーザーに低レイテンシーで配信
  • S3互換: 既存のツールやライブラリがそのまま使える
  • シンプルな価格: データ転送費用なし
  • 自動レプリケーション: 複数リージョンに自動複製

適用シーン

  • 静的アセットの配信
  • ユーザーアップロードファイルの保存
  • メディアファイルの配信
  • バックアップとアーカイブ

次のプロジェクトで、Tigrisを使ってグローバルに高速なストレージを実現してみてください。