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?

ファイル転送アプリ「Ftenso」のアーキテクチャと採用技術(第2回)

Posted at

この記事は前回の続編です: 第1回はこちら


この記事でわかること(要約)

  • 全体アーキテクチャ(サーバーレス基盤、ストレージ、DB、メール)
  • S3互換ストレージ+Signed URL で“アプリを経由しない”安全な大容量アップロード/ダウンロードの考え方
  • Cloud Run(サーバーレス)× 外部VPS(MySQL on Indigo) の接続パターン
  • 運用・セキュリティ設計の勘所と、コスト最適化のヒント

全体アーキテクチャ概要

構成要素

  • アプリケーション:Python Flask(コンテナ化)
  • 実行基盤:Google Cloud Cloud Run(完全マネージド / オートスケール)
  • オブジェクトストレージ:WebARENA Wasabi(S3互換)
  • データベースMySQL(Indigo上のVPSに Docker/Podman で構築)
  • メール送信Mailgun(PaaS)
  • ファイル転送方式S3 Signed URL(PUT/GET 直対)

全体構成図(論理)

ポイント:アップロード/ダウンロード自体は Signed URL でクライアント→S3直結。アプリは「URLの発行」「メタ情報の管理」「通知/認可」を担うため、スループットとコストが最適化されます。


リクエスト〜アップロードの流れ(シーケンス)


採用技術と設計の意図

Cloud Run(サーバーレス)

  • ゼロ→自動スケール:ピーク/オフを気にせず運用。ユーザが接続しないアイドル時はほぼコストゼロ。
  • コンテナ標準:gcloudコマンド※一発で開発環境PCのPython+Flaskのソースコードをコンテナ化し、Cloud Runへデプロイ可能。 ※gcloudコマンドを開発環境PCにインストールする必要あります。(Mac対応)

Wasabi(S3互換 / WebARENA)

  • S3 API互換でツール/SDKが豊富。
  • Signed URLでクライアントから直接ファイルアップロードを実現。大容量転送でもアプリに負荷集中しない。
  • バケットポリシー/CORSでセキュアに制御。
  • 安い 1TBが1,085円(税込み) https://web.arena.ne.jp/wasabi/

MySQL(Indigo上)

  • アプリ外部化:アプリのライフサイクルとデータベースを独立。
  • pymysqlで軽量に接続。
  • 可用性:運用要件に応じてレプリカやバックアップスナップショットを定期取得※。
     ※別途、記事を掲載予定

Python Flask+Bootstrap

  • シンプルなGUIを短時間で実装。
  • Cloud Run の コンテナ(gunicorn)で軽量化。

Mailgun(メール送信)

  • ドメイン認証(SPF/DKIM/DMARC)で高到達率。
  • APIベースのメール配信システムで通知メール、ダウンロードリンク配信を自動化。
  • 以前は有名なRackspace社が買収、現在別サービス。 https://www.mailgun.com/

Signed URL(クライアントブラウザからwasabi(S3)へ直接接続)

  • **PUT(アップロード)/GET(ダウンロード)**を時限署名で安全に委譲。
  • Content-Type / Content-MD5 を固定すると改ざん耐性が向上。

参考実装(抜粋サンプル)

※あくまで雛形。例外処理・バリデーション・認可は要件に合わせて強化してください。

1) Flask: PUT用Signed URLの発行

# app/presign.py
import os
import time
import uuid
import pymysql
import boto3
from boto3.session import Session
from botocore.config import Config
from flask import Blueprint, request, jsonify

bp = Blueprint("presign", __name__)

S3_ENDPOINT = os.environ["S3_ENDPOINT"]           # 例: https://s3.<region>.wasabisys.com
S3_REGION   = os.environ.get("S3_REGION", "auto")
S3_ACCESS   = os.environ["S3_ACCESS_KEY"]
S3_SECRET   = os.environ["S3_SECRET_KEY"]
S3_BUCKET   = os.environ["S3_BUCKET"]

DB_HOST     = os.environ["DB_HOST"]
DB_PORT     = int(os.environ.get("DB_PORT", 3306))
DB_USER     = os.environ["DB_USER"]
DB_PASS     = os.environ["DB_PASS"]
DB_NAME     = os.environ["DB_NAME"]

session = Session()
s3 = session.client(
    "s3",
    region_name=S3_REGION,
    endpoint_url=S3_ENDPOINT,
    aws_access_key_id=S3_ACCESS,
    aws_secret_access_key=S3_SECRET,
    config=Config(signature_version="s3v4"),
)

def mysql_conn():
    return pymysql.connect(
        host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS,
        db=DB_NAME, charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor,
        autocommit=True,
    )

@bp.route("/api/presign_upload", methods=["POST"])
def presign_upload():
    payload = request.get_json(force=True)
    filename = payload["filename"]
    content_type = payload["content_type"]
    size = int(payload.get("size", 0))
    ttl_sec = int(payload.get("ttl_sec", 15 * 60))  # 15分

    token = uuid.uuid4().hex
    object_key = f"uploads/{time.strftime('%Y/%m/%d')}/{token}/{filename}"

    # メタ保存
    with mysql_conn() as conn, conn.cursor() as cur:
        cur.execute(
            """
            INSERT INTO upload_logs
              (token, object_key, filename, content_type, size, status, expires_at)
            VALUES
              (%s, %s, %s, %s, %s, %s, FROM_UNIXTIME(UNIX_TIMESTAMP() + %s))
            """,
            (token, object_key, filename, content_type, size, "reserved", ttl_sec),
        )

    # 署名URL
    url = s3.generate_presigned_url(
        ClientMethod="put_object",
        Params={
            "Bucket": S3_BUCKET,
            "Key": object_key,
            "ContentType": content_type,
        },
        ExpiresIn=ttl_sec,
        HttpMethod="PUT",
    )

    return jsonify({
        "token": token,
        "bucket": S3_BUCKET,
        "key": object_key,
        "upload_url": url,
        "expires_in": ttl_sec,
    })

2) アップロード完了コールバック

# app/callbacks.py
from flask import Blueprint, request, jsonify
import pymysql, os

bp = Blueprint("callbacks", __name__)

DB_HOST     = os.environ["DB_HOST"]
DB_PORT     = int(os.environ.get("DB_PORT", 3306))
DB_USER     = os.environ["DB_USER"]
DB_PASS     = os.environ["DB_PASS"]
DB_NAME     = os.environ["DB_NAME"]

def mysql_conn():
    return pymysql.connect(
        host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS,
        db=DB_NAME, charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor,
        autocommit=True,
    )

@bp.route("/api/callback/uploaded", methods=["POST"])
def uploaded():
    payload = request.get_json(force=True)
    token = payload["token"]
    with mysql_conn() as conn, conn.cursor() as cur:
        cur.execute(
            "UPDATE upload_logs SET status='uploaded', uploaded_at=NOW() WHERE token=%s",
            (token,),
        )
    return jsonify({"ok": True})

3) ダウンロード用Signed URLの発行(ワンタイム/回数制限例)

# app/download.py
import os, pymysql, boto3
from boto3.session import Session
from botocore.config import Config
from flask import Blueprint, request, jsonify, abort

bp = Blueprint("download", __name__)

S3_ENDPOINT = os.environ["S3_ENDPOINT"]
S3_REGION   = os.environ.get("S3_REGION", "auto")
S3_ACCESS   = os.environ["S3_ACCESS_KEY"]
S3_SECRET   = os.environ["S3_SECRET_KEY"]
S3_BUCKET   = os.environ["S3_BUCKET"]

session = Session()
s3 = session.client(
    "s3", region_name=S3_REGION, endpoint_url=S3_ENDPOINT,
    aws_access_key_id=S3_ACCESS, aws_secret_access_key=S3_SECRET,
    config=Config(signature_version="s3v4"),
)

def mysql_conn():
    return pymysql.connect(host=os.environ["DB_HOST"], port=int(os.environ.get("DB_PORT", 3306)),
                           user=os.environ["DB_USER"], password=os.environ["DB_PASS"],
                           db=os.environ["DB_NAME"], charset="utf8mb4",
                           cursorclass=pymysql.cursors.DictCursor, autocommit=True)

@bp.route("/api/presign_download", methods=["POST"])
def presign_download():
    token = request.json["token"]
    ttl = int(request.json.get("ttl_sec", 5 * 60))

    with mysql_conn() as conn, conn.cursor() as cur:
        cur.execute("SELECT object_key, max_downloads, current_downloads FROM upload_logs WHERE token=%s", (token,))
        row = cur.fetchone()
        if not row:
            abort(404)
        if row["max_downloads"] is not None and row["current_downloads"] >= row["max_downloads"]:
            abort(403)
        key = row["object_key"]

        url = s3.generate_presigned_url(
            ClientMethod="get_object",
            Params={"Bucket": S3_BUCKET, "Key": key},
            ExpiresIn=ttl,
        )

        cur.execute("UPDATE upload_logs SET current_downloads = current_downloads + 1 WHERE token=%s", (token,))

    return jsonify({"download_url": url, "expires_in": ttl})

4) Mailgunで通知メールを送る(シンプル版)

# app/mailer.py
import os, requests

MAILGUN_DOMAIN = os.environ["MAILGUN_DOMAIN"]
MAILGUN_APIKEY = os.environ["MAILGUN_APIKEY"]
FROM = f"Ftenso <noreply@{MAILGUN_DOMAIN}>"

def send_mail(to, subject, text, html=None):
    data = {"from": FROM, "to": to, "subject": subject, "text": text}
    if html:
        data["html"] = html
    resp = requests.post(
        f"https://api.mailgun.net/v3/{MAILGUN_DOMAIN}/messages",
        auth=("api", MAILGUN_APIKEY),
        data=data,
        timeout=10,
    )
    resp.raise_for_status()

バケットのCORS例(フロントから直接PUT/GETを行う場合)

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT", "GET"],
    "AllowedOrigins": ["https://<your-app-domain>", "http://localhost:5173"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 300
  }
]

セキュリティ設計の勘所(チェックリスト)

  • 認可:Signed URL発行前に、発行主体の認可チェック(ユーザー/テナント/レート制御)。
  • 署名パラメータの固定Content-Type/Content-Length/Content-MD5 の固定を検討。
  • キー設計object_key は推測困難(UUID+日付+サブディレクトリ)。
  • 時限性:短いTTL(例:数分)。
  • HTTP Method固定:PUT/GETを明示。
  • 固定送信IP:Cloud Run→MySQL/Wasabi は VPC Connector + NAT で送信元を固定し、Indigo側はIP許可
  • 秘匿情報:Secret ManagerでAPIキー/DBパスワードを管理、環境変数で注入
  • ログ:アクセスログ(アプリ/ストレージ)+監査ログをBigQuery等へ集約。
  • メール:SPF/DKIM/DMARCの整備、受信拒否対策、リンクのTTL/ワンタイム化。

データモデル(例)

CREATE TABLE upload_logs (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  token VARCHAR(64) NOT NULL UNIQUE,
  object_key VARCHAR(512) NOT NULL,
  filename VARCHAR(255) NOT NULL,
  content_type VARCHAR(127) NOT NULL,
  size BIGINT DEFAULT 0,
  status ENUM('reserved','uploaded','deleted') DEFAULT 'reserved',
  max_downloads INT NULL,
  current_downloads INT NOT NULL DEFAULT 0,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  uploaded_at TIMESTAMP NULL,
  expires_at DATETIME NULL,
  INDEX idx_object_key (object_key),
  INDEX idx_status (status)
);

デプロイと設定(抜粋)

  • Cloud Run

    • コンテナ:gcr.io/.../ftenso-api:TAG
    • 最小インスタンス=0、同時実行数は要件に合わせて調整
    • 環境変数:S3_*, DB_*, MAILGUN_*
    • VPC コネクタ + Cloud NAT(固定送信IP)
  • Indigo(MySQL)

    • Podman/Dockerで mysql:8(永続ボリューム確保、バックアップ)
    • Cloud Runの送信IPのみ許可、またはWireGuard/VPNで閉域
  • Wasabi

    • バケット作成、CORS/ポリシー設定、Lifecycle(期限切れ自動削除など)
  • Mailgun

    • ドメイン認証(SPF/DKIM/DMARC)、サブドメイン運用推奨

運用・監視

  • メトリクス:リクエスト数/エラー率/レイテンシ(Cloud Run)
  • ログ:APIアクセス、署名発行、DB操作、ストレージイベント
  • ストレージ健全性:オブジェクト存在確認のジョブ(期限切れ/リンク切れの掃除)
  • バックアップ:MySQLダンプ、バケットのバージョニング/ライフサイクル

コストの考え方(方向性)

  • データ転送は可能な限り クライアント↔Wasabi(S3)直結(アプリ帯域を使わない)
  • 低トラフィック時はサーバー部分のコストは安い(Cloud Run) 
     Artifact Registryなどコンテナ保存先のコストは固定でかかります
  • 保管/転送費はバケットリージョン・転送先で差異。ライフサイクルで自動削除を徹底

トラブルシューティング(よくある)

  • CORSエラー:バケットCORS設定の許可Origin/ヘッダ/メソッドを確認
  • 403/SignatureDoesNotMatchContent-Typeの固定/実ファイル不一致、時計ズレ
  • DB接続不可:Cloud Runの送信IP固定化、VPC経路/Firewall見直し
  • メール未達:SPF/DKIM/DMARC、送信ドメイン評価、テンプレ内容(スパムワード)

サービス紹介:Ftenso(エフテンソ)

大容量ファイルを安全・手早く。 この記事で紹介したアーキテクチャを採用したファイル転送サービスです。現在(2025/9/8時点)では無料提供しています。是非お試しください。

  • 直感的UI、モバイル対応
  • 期限/回数付きリンク、ダウンロード通知
  • Wasabi(S3互換)オブジェクトストレージ基盤で安心運用

👉 公式サイト:https://www.ftenso.com/


※表記の各種名称は各社の商標または登録商標です。運用設計・料金は変更される場合があります。

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?