背景
IoTプラットフォームのデータ分析基盤運用にて、「ファイル書き込みが異常に遅いです」、
「ファイル読み取りのタイムアウトエラーが大量発生してます」というファイルシステム障害が発生し、
データ分析サービスがほぼ停止状態になったことがありました。
サービスログよりデバイス数が急増し、同じEFSディレクトリに10万ファイル以上が蓄積した状態で、
ファイル存在チェック(os.path.exists())が1秒未満から30秒超に悪化し、
書き込みも毎分8000-9000個から2000-3000個までおちてしまいましたことが判明しました。
本記事の目的
この記事では、上記のようなファイルシステムのパフォーマンス問題解決策を解説します。
特に以下に焦点を中心とします。
- ファイルシステム問題の根本原因の特定
- 採用したアーキテクチャ
- 実装中に遭遇した具体的な問題と解決方法
対象読者
- AWS サーバレスアーキテクチャの運用知識が持つ方が対象と想定しています。
- クラウドサービスにてファイルストレージのスケーラビリティ設計に関心がある方と想定しています。
想定する前提知識:
- AWS EFSの基本的な知識
- Fargate/ECSの基礎知識
- Pythonの基本文法
システム構成と技術スタック
アーキテクチャ概要
本ソリューションアーキテクチャは、複数EFSマウントポイントを活用し、ファイルアクセスを分散することによりパフォーマンスを改善します。
基本的にSSM Parameter Storeで利用可能なEFSマウントポイントを保管し、データ分析基盤のタスク環境変数としてEFSにアクセスします。
単一EFSマウントポイント配下のファイル数が閾値を超えた場合、イベント駆動で新たなEFSマウントポイントを作成し、SSMに追加します。
上記の準備作業が完了したら、データ分析基盤のタスクを更新し、新規追加されたマウントポイントに新しいファイルを作成します。
全体構成
┌─ 自動化レイヤー ─────────────────┐
│ │
│ EventBridge (5分おき) │
│ ↓ │
│ Lambda (file_monitor) │
│ - ファイル数監視 │
│ - 閾値チェック(5万) │
│ - Mount Target自動作成 │
│ - SSM更新 + ECS再デプロイ │
│ │
└──────────────────────────────────┘
↓
┌─ 設定管理 ───────────────────────┐
│ │
│ SSM Parameter Store │
│ { │
│ "mount_targets": [ │
│ {"id": "fsmt-xxx", │
│ "ip": "10.0.1.x"}, │
│ {"id": "fsmt-yyy", │
│ "ip": "10.0.2.x"} │
│ ] │
│ } │
│ │
└──────────────────────────────────┘
↓
┌─ アプリケーションレイヤー ───────┐
│ │
│ Fargate Tasks │
│ 起動時: │
│ - SSMから設定読み込み │
│ - 全Mount TargetをNFSマウント │
│ │
│ ファイルアクセス時: │
│ - パスをSHA256ハッシュ化 │
│ - hash % mount_count = index │
│ - 該当Mount Point経由でアクセス│
│ │
└──────────────────────────────────┘
↓
┌─ ストレージレイヤー ─────────────┐
│ │
│ EFS File System │
│ Mount Target 1 (ENI 10.0.1.x) │
│ Mount Target 2 (ENI 10.0.2.x) │
│ Mount Target 3 (ENI 10.0.3.x) │
│ │
└──────────────────────────────────┘
技術スタック
下記のAWSサービスで該当アーキテクチャを実現しました。
- ストレージ: AWS EFS (General Purpose, Bursting)
- コンピュート: AWS Fargate (50タスク並列実行)
- オーケストレーション: ECS + EventBridge
- 設定管理: SSM Parameter Store
- イベント駆動: Lambda
- 言語: Python 3.11
試行①:問題の特定と初期アプローチ
現状把握:何が起きているのか
パフォーマンス問題が発生してから、まず負荷テストでファイルシステムのアクセス状況を定量化しましょう。
データ分析基盤で利用しているEFSフォルダについて、すべてのデバイスが同じディレクトリに書き込む設計になります。
/data/analysis/device-{id}-{timestamp}.json
例: device-12345-20240115-120000.json
下記の条件で負荷テストを実施すると、EFSサービス自体のデータI/Oには余裕があり、
メタデータ部分がボトルネックになっていることがわかりました。
テスト環境
- Fargateタスク: 50並列
- 単一ディレクトリのファイル数: 15万
- 各タスク: 100回のファイル操作(存在チェック + 書き込み)
結果
| 項目 | 値 |
|---|---|
os.path.exists() |
30.2秒 |
| ファイル読み取り | 5-10秒 |
| 書き込み全体 | 35-40秒 |
| CPU使用率 | 15%(ほぼI/O待ち) |
| 毎分書き込み数 | 2,000-3,000個 |
CloudWatch メトリクス
MetadataIOPS: 32,000(上限張り付き)
PercentIOLimit: 95%(ボトルネック)
DataReadIOBytes: 0.5 GB/s(余裕あり)
BurstCreditBalance: 0(消費済み)
根本原因の特定
メタデータ部分がボトルネックになる原因を深掘りすると、
ファイル存在の線形探索で処理時間の85%がここに費やされていいました。
NFSのファイル存在チェックは、実際にはこういう処理をしていることが判明しました。
# os.path.exists() の内部動作
def exists(path):
# ディレクトリ全体のエントリリストを読む
dir_entries = nfs_readdir("/data/analysis/") # 15万エントリ
# 線形探索でファイル名を検索
for entry in dir_entries:
if entry.name == "device-12345.json":
return True
return False
解決案検討
ファイル存在チェックの処理時間をチューリングするため、下記のいくつ解決案を検討してみました。
アプローチ1:Redisキャッシュ
ファイル存在情報がRedisにキャッシュし、該当チェック処理を省略することが可能かどうかを検討しました。
コストの問題、およびファイルシステムの整合性担保問題により、該当案が未採用になりました。
以下のポイントが不採用理由になります。
- 書き込み時のキャッシュ更新ロジックが複雑
- キャッシュとファイルシステムの整合性保証が困難
- タイムアウト処理の実装コストが高い
- 障害時のフォールバック設計が複雑
- Redisクラスターのコスト追加(月$100程度)
アプローチ2:サブディレクトリ分割
単一フォルダにファイル数を減らし、時系列でディレクトリを分ける方法を検討してみましたが、
既存データ分析基盤に対して、改修量が多く、影響範囲が広くため、該当解決案が未採用でした。
不採用理由が主に以下の点になります。
- アプリケーション全体のコード改修が必要
- 既存15万ファイルとの互換性がなくなる
- 時系列検索時に複数ディレクトリをスキャン必要
- リリースまでに時間がかかりすぎる
/data/analysis/2024/01/15/device-*.json
/data/analysis/2024/01/16/device-*.json
アプローチ3:マルチMount Target + ハッシュ分散
何か既存アプリに改修量が抑えて、ファイルシステムのパフォーマンスを向上できる案がないでしょうかと思いつつ、
最後、EFSの公式ドキュメントを読み直して、複数マウントポイントによりファイルを分散する案を検討しました。
基本的にMount Targetが別のネットワークインターフェースになるため、物理から各ファイルへのアクセスが負荷できます。
負荷分散の仕組み
実装案のイメージについて、以下になります。
- Mount Targetを3個作成
- ファイルパスをハッシュして振り分け
- アプリケーションは
/data/analysis/にアクセス(透過的) - 裏側で自動的に負荷分散
従来(1個):
/data/analysis/ 15万ファイル
↓ すべてのアクセス
Mount Target 1 (ENI)
↓
MetadataIOPS: 32,000(上限)
改善後(3個):
ファイルパスをハッシュで振り分け
hash(device-12345-*.json) % 3 = 0
→ /mnt/efs-0/data/analysis/ (5万)
hash(device-12346-*.json) % 3 = 1
→ /mnt/efs-1/data/analysis/ (5万)
hash(device-12347-*.json) % 3 = 2
→ /mnt/efs-2/data/analysis/ (5万)
↓ ↓ ↓
Target 1 Target 2 Target 3
(ENI 1) (ENI 2) (ENI 3)
↓ ↓ ↓
MetadataIOPS: 各10,000-11,000(分散)
実装詳細
1. ハッシュルーティングクラス
import hashlib
import os
class EFSLoadBalancer:
def __init__(self):
self.mount_targets = []
self.mount_points = []
def initialize(self):
"""SSMから設定を読み込み、NFSマウント"""
param_name = os.environ['SSM_PARAMETER_NAME']
response = ssm.get_parameter(Name=param_name)
config = json.loads(response['Parameter']['Value'])
self.mount_targets = config['mount_targets']
for idx, mt in enumerate(self.mount_targets):
mount_point = f"/mnt/efs-{idx}"
self._mount_nfs(mt, mount_point)
self.mount_points.append(mount_point)
def get_file_path(self, original_path):
"""ハッシュでMount Pointを決定"""
# ファイルパス全体をハッシュ
hash_value = hashlib.sha256(
original_path.encode('utf-8')
).hexdigest()
hash_int = int(hash_value, 16)
# モジュロ演算でインデックス取得
index = hash_int % len(self.mount_points)
mount_point = self.mount_points[index]
return os.path.join(mount_point, original_path)
def file_exists(self, path):
"""ファイル存在チェック(分散版)"""
actual_path = self.get_file_path(path)
return os.path.exists(actual_path)
def write_file(self, path, content):
"""ファイル書き込み(分散版)"""
actual_path = self.get_file_path(path)
os.makedirs(os.path.dirname(actual_path), exist_ok=True)
with open(actual_path, 'w', encoding='utf-8') as f:
f.write(content)
なぜか時限列ではなく、ハッシュの方式でファイル分散することでしょうか。
理由としてはハッシュルーティングでファイル分散するが以下のメリットがあります。
- 一貫性: 同じファイルパスは常に同じMount Pointにルーティング
- 均等分散: SHA256の特性により均等に分散(後述の検証で実証)
- アプリ透過性: アプリケーションは論理パスのみ意識
2. 既存ファイルへのフォールバック
なお、新しいマウントポイントが追加された場合、既存ファイル(Mount Target 1のみに存在)にアクセスできない問題が発生する可能性があるので、
マイグレーションスクリプトで全ファイル移動するではなく、ファイルアクセス時にフォールバック機能を実装しました。
def read_file_with_fallback(self, path):
"""ハッシュ位置になければ全Mount Pointを検索"""
# 1. 通常のハッシュルーティング
actual_path = self.get_file_path(path)
if os.path.exists(actual_path):
return open(actual_path, 'r').read()
# 2. フォールバック: 全Mount Pointを検索
for mount_point in self.mount_points:
fallback_path = os.path.join(mount_point, path)
if os.path.exists(fallback_path):
# 見つかったら正しい位置に移動
os.makedirs(
os.path.dirname(actual_path),
exist_ok=True
)
shutil.move(fallback_path, actual_path)
return open(actual_path, 'r').read()
raise FileNotFoundError(f"File not found: {path}")
これにより、ダウンタイムなしで既存ファイルは最初のアクセス時に自動的に正しい位置に移行できました。
3. マウント自動スケール
import boto3
efs = boto3.client('efs')
ssm = boto3.client('ssm')
ecs = boto3.client('ecs')
FILE_SYSTEM_ID = os.environ['EFS_FILE_SYSTEM_ID']
THRESHOLD = 50000
def lambda_handler(event, context):
# ファイル数カウント
file_count = count_files_in_efs('/mnt/efs/data/analysis')
if file_count <= THRESHOLD:
return {'statusCode': 200}
# 既存Mount Target取得
existing_mts = get_mount_targets(FILE_SYSTEM_ID)
# 空きサブネット検索
available_subnet = find_available_subnet(existing_mts)
if not available_subnet:
return {'statusCode': 200}
# 新Mount Target作成
new_mt = create_mount_target(
FILE_SYSTEM_ID,
available_subnet['SubnetId']
)
# SSM更新 + ECS再デプロイ
all_mts = existing_mts + [new_mt]
update_ssm_parameter(
'/efs-mount-autoscaling/mount-targets',
all_mts
)
trigger_ecs_deployment(
os.environ['ECS_CLUSTER_NAME'],
os.environ['ECS_SERVICE_NAME']
)
return {
'statusCode': 200,
'new_mount_target': new_mt,
'total': len(all_mts)
}
結果
パフォーマンス改善
上記のマルチマウントポイント導入により、ファイルシステムのアクセス性能が3倍以上に改善されました。
改善前(Mount Target 1個)
| 項目 | 値 |
|---|---|
os.path.exists() |
30秒以上 |
| ファイル読み取り | 5-10秒 |
| 書き込み全体 | 35-40秒 |
| 毎分書き込み数 | 3,000個前後 |
| CPU使用率 | 15% |
CloudWatch
MetadataIOPS: 32,000(上限)
PercentIOLimit: 95%
DataReadIOBytes: 0.5 GB/s
BurstCreditBalance: 0
改善後(Mount Target 3個)
| 項目 | 値 | 改善率 |
|---|---|---|
os.path.exists() |
1秒未満 | 30倍 |
| ファイル読み取り | 5秒未満 | 2倍 |
| 書き込み全体 | 15秒前後 | 2倍 |
| 毎分書き込み数 | 11,000個前後 | 4倍 |
| CPU使用率 | 45% | 3倍 |
CloudWatch
MetadataIOPS(分散):
Mount Target 1: 10,500
Mount Target 2: 10,800
Mount Target 3: 10,200
合計: 31,500(均等分散)
PercentIOLimit: 各30-35%(余裕あり)
DataReadIOBytes: 合計1.4 GB/s
BurstCreditBalance: 安定
ファイル分散の検証
各Mount Targetで管理されているファイル数をカウントしてみると、SHA256ハッシュによる均等分散されていることが確認できます。
/mnt/efs-0/data/analysis/: 49,500ファイル前後 (33.2%)
/mnt/efs-1/data/analysis/: 50,000ファイル前後 (33.6%)
/mnt/efs-2/data/analysis/: 49,800ファイル前後 (33.2%)
実装中に遭遇した問題と解決策
問題1:Mount Target作成後の非同期待機
現象
mount.nfs: Connection timed out
Mount Target作成直後EFSマウントポイントへのアクセスについて、
タイムアウト問題が発生したことがあります。
原因
Mount Target作成は非同期処理で、available状態まで1-2分待ちすることが必要になります。
解決策
アクセス可能までWaiterで待機すること解決できます。
waiter = efs.get_waiter('mount_target_available')
waiter.wait(
MountTargetId=mt_id,
WaiterConfig={'Delay': 10, 'MaxAttempts': 30}
)
問題2:既存ファイルアクセス不可
現象
既存ファイル(Mount Target 1のみに存在)が読み取れないことがあります。
原因
新しいハッシュルーティングが別のMount Targetを参照していることによりアクセス不可になってしまいました。
解決策
フォールバック機能を実装することにより、最初のアクセスマウントポイントを利用することで、
ダウンタイムなしで段階的にファイルを新しいマウントポイントに移行できます。
まとめ
本実装による利点
データ分析基盤のファイルシステムについて、該当アーキテクチャにより下記のパフォーマンス向上および運用手間軽減が実現できました。
- 既存ファイルへのアクセスパフォーマンス向上
- 既存アプリケーションへの影響が最小限に抑え
- ファイルシステムの拡張が完全自動化
適用シナリオ
向いているケース
-
単一ディレクトリの大量ファイル
- ファイル数: 5万以上
- 頻繁な存在チェック(
os.path.exists()、os.listdir()) - メタデータIOPSがボトルネック(
MetadataIOPSが高い)
-
アプリケーション改修が困難
- サブディレクトリ分割のコードが複雑
- 既存パス構造を維持する必要がある
- リリースまでの時間が限られている
-
コストを抑えたい
- キャッシュレイヤーの追加コストを避けたい
- 既存EFS構成を大きく変えたくない
向いていないケース
-
ファイルが既に適切に分散
- サブディレクトリ構造が整理されている
- 各ディレクトリのファイル数が1万未満
-
データI/Oがボトルネック
-
DataReadIOBytes、DataWriteIOBytesが上限 - メタデータは問題ない
-
-
ファイル数が少ない
- 単一ディレクトリで1万ファイル未満
- パフォーマンス問題が顕在化していない
今後試したいこと
1. 自動スケールダウン
現在は自動でMount Targetを追加する機能のみが実現していますが、
ファイル数が減少したときに、EFSマウントポイントの自動削減機能を実装した方がいいと思います。
検討事項
- 削除前のファイル移行ロジック
- 削減閾値の設定(追加閾値の60%など)
- 安全なクールダウン期間の設定
2. 動的リバランス
現在Mount Target追加した後で、既存ファイルはアクセス時にしか移動されないため、バックグラウンドで徐々に再分散させる仕組みを考慮する必要になります。
アイデア
- 低負荷時間帯(深夜2-4時)に実行
- 1日あたり最大1万ファイル移動
- CloudWatch Eventsでスケジュール実行