PHPアプリケーションを想定したECS FargateのEFSマウントやRDS接続を検証してみた
このブログを見つけていただきありがとうございます!
コンサルティング部のみゃんです。
皆さんは耳で聞いた情報でも、実際に自分の目で見るまでは「本当?」と疑いたくなること、ありませんか?
私は自分で試してみるまで、たとえ公式ドキュメントに書いてあることでも100%信用できません。
ちょうど業務でAmazon ECS on AWS Fargate(以下、ECS on Fargate)を構築する機会がありました。
ECS on Fargateにおいて、EFSのマウントやAWS Secrets Manager(以下、Secrets Manager)からDBシークレットを取得して、RDS(MySQL)へ接続するなど、机上では簡単にできそうなことでも実際に作って試してみないと分かりません。
というわけで今回は、ECS on FargateをPHPアプリケーション用に構築した際に、インフラ観点でいくつかの動作検証を実際に試してみることにしました。
最初に断っておきますが、今回はあくまで私の個人的な好奇心を満たすために一部本番環境では許されない設計(特に権限周りやセキュリティ)になっています。
絶対に真似しないでください!!!
まずは結論から
忙しい人のために、まずは結論をまとめます。
詳細は、後述の「やってみた」章をご覧ください!
今回主に試したのは以下の2点です。
①ECS on FargateにEFSをマウントして、ファイルの読み書きをさせる
②ECS on FargateからRDS(MySQL)へ接続して、SQLを実行する
①CloudFormationとDockerfileの設定で、手をあまり動かすことなく、EFSのマウントができました!
実際にコンテナに接続して、EFSをマウントしたディレクトリを複数タスク間で共通で読み取り、書き込みが行えていることを確認しています。
②も同様にIaCで定義しておけば、簡単にSecrets Manager経由でDBの接続情報をECS on Fargateに取得させて、環境変数として持っておき、PHPアプリケーションへ変数を渡して、DB接続することを確認できました。
また、実際にコンテナへ接続して、DBへの書き込みや読み取りが行えることを確認しています。
やってみた
前提
- CloudFormationで構築
- 今回は構築部分はカットし、検証部分にフォーカスしてお届け
- アプリケーション : PHP
- Webサーバー : nginx
- コンテナOS : Alpine Linux
今回の検証を行った構成概要図はこちらです。

PHPアプリケーションのファイルはClaude Codeに生成させました。簡易的なPHPファイルを実行して、EFSとRDSへの接続性を検証できる構成になっています。
Dockerビルドでコンテナの中に以下のテストファイルを仕込んでおき、コンテナへCloudShellから接続して、テストファイルを実行し、EFS・RDSへの接続性を検証しました。
実際に使用したテスト用PHPファイル「verify-infra.php」
<?php
declare(strict_types=1);
/**
* 必須環境変数を取得する。
*/
function requiredEnv(string $name): string
{
$value = getenv($name);
if ($value === false || trim($value) === '') {
throw new RuntimeException(
sprintf(
'Required environment variable is missing: %s',
$name,
),
);
}
return $value;
}
/**
* Boolean形式の環境変数を取得する。
*/
function booleanEnv(string $name, bool $default): bool
{
$value = getenv($name);
if ($value === false || trim($value) === '') {
return $default;
}
$parsed = filter_var(
$value,
FILTER_VALIDATE_BOOLEAN,
FILTER_NULL_ON_FAILURE,
);
if ($parsed === null) {
throw new RuntimeException(
sprintf(
'Environment variable %s must be a boolean value: %s',
$name,
$value,
),
);
}
return $parsed;
}
/**
* EFSの読み書きを確認する。
*/
function verifyEfs(): void
{
$mountPath = getenv('EFS_MOUNT_PATH') ?: '/mnt/efs';
$mountPath = rtrim($mountPath, '/');
if ($mountPath === '') {
throw new RuntimeException(
'EFS_MOUNT_PATH must not be empty',
);
}
if (!is_dir($mountPath)) {
throw new RuntimeException(
sprintf(
'EFS mount path does not exist: %s',
$mountPath,
),
);
}
if (!is_readable($mountPath)) {
throw new RuntimeException(
sprintf(
'EFS mount path is not readable: %s',
$mountPath,
),
);
}
if (!is_writable($mountPath)) {
throw new RuntimeException(
sprintf(
'EFS mount path is not writable: %s',
$mountPath,
),
);
}
$verificationFile = $mountPath
. '/ECS on Fargate-fargate-efs-verification.log';
$marker = bin2hex(random_bytes(16));
$record = sprintf(
"%s\thost=%s\tmarker=%s\n",
gmdate(DATE_ATOM),
gethostname() ?: 'unknown',
$marker,
);
$handle = fopen($verificationFile, 'c+b');
if ($handle === false) {
throw new RuntimeException(
sprintf(
'Failed to open EFS verification file: %s',
$verificationFile,
),
);
}
$locked = false;
try {
$locked = flock($handle, LOCK_EX);
if (!$locked) {
throw new RuntimeException(
sprintf(
'Failed to lock EFS verification file: %s',
$verificationFile,
),
);
}
if (fseek($handle, 0, SEEK_END) !== 0) {
throw new RuntimeException(
sprintf(
'Failed to seek EFS verification file: %s',
$verificationFile,
),
);
}
$writtenBytes = fwrite($handle, $record);
if (
$writtenBytes === false
|| $writtenBytes !== strlen($record)
) {
throw new RuntimeException(
sprintf(
'Failed to write EFS verification file: %s',
$verificationFile,
),
);
}
if (!fflush($handle)) {
throw new RuntimeException(
sprintf(
'Failed to flush EFS verification file: %s',
$verificationFile,
),
);
}
if (fseek($handle, 0, SEEK_SET) !== 0) {
throw new RuntimeException(
sprintf(
'Failed to rewind EFS verification file: %s',
$verificationFile,
),
);
}
$contents = stream_get_contents($handle);
if (
$contents === false
|| !str_contains($contents, $marker)
) {
throw new RuntimeException(
sprintf(
'Failed to read the written marker from EFS: %s',
$verificationFile,
),
);
}
$recordCount = count(
array_filter(
explode("\n", trim($contents)),
static fn (string $line): bool => $line !== '',
),
);
} finally {
if ($locked) {
flock($handle, LOCK_UN);
}
fclose($handle);
}
printf(
"[OK] EFS read/write succeeded: path=%s records=%d marker=%s\n",
$verificationFile,
$recordCount,
$marker,
);
}
/**
* RDS MySQL / Aurora MySQLへのTLS接続を確認する。
*/
function verifyRds(): void
{
$driver = getenv('DB_CONNECTION') ?: 'mysql';
if ($driver !== 'mysql') {
throw new RuntimeException(
sprintf(
'This verification script supports mysql only: %s',
$driver,
),
);
}
if (!extension_loaded('pdo_mysql')) {
throw new RuntimeException(
'The pdo_mysql PHP extension is not loaded',
);
}
$host = requiredEnv('DB_HOST');
$port = getenv('DB_PORT') ?: '3306';
$database = requiredEnv('DB_DATABASE');
$username = requiredEnv('DB_USERNAME');
$password = requiredEnv('DB_PASSWORD');
if (!ctype_digit($port)) {
throw new RuntimeException(
sprintf(
'DB_PORT must be numeric: %s',
$port,
),
);
}
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$host,
$port,
$database,
);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 5,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$sslCa = requiredEnv('MYSQL_ATTR_SSL_CA');
if (!is_file($sslCa) || !is_readable($sslCa)) {
throw new RuntimeException(
sprintf(
'RDS CA bundle is not readable: %s',
$sslCa,
),
);
}
$options[PDO::MYSQL_ATTR_SSL_CA] = $sslCa;
$verifyServerCertificate = booleanEnv(
'DB_SSL_VERIFY_SERVER_CERT',
true,
);
if (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')) {
$option = constant(
'PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT',
);
$options[$option] = $verifyServerCertificate;
} elseif ($verifyServerCertificate) {
throw new RuntimeException(
'PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT is not available',
);
}
$pdo = new PDO(
$dsn,
$username,
$password,
$options,
);
$connectionResult = $pdo
->query(
<<<'SQL'
SELECT
1 AS connection_test,
DATABASE() AS database_name,
CURRENT_USER() AS current_user
SQL,
)
->fetch();
if ((int) ($connectionResult['connection_test'] ?? 0) !== 1) {
throw new RuntimeException(
'Unexpected RDS SELECT 1 result',
);
}
$sslStatus = $pdo
->query("SHOW SESSION STATUS LIKE 'Ssl_cipher'")
->fetch();
$sslCipher = '';
if (is_array($sslStatus)) {
$sslCipher = (string) (
$sslStatus['Value']
?? $sslStatus['VALUE']
?? ''
);
}
if ($sslCipher === '') {
throw new RuntimeException(
'RDS connection succeeded, but TLS is not enabled',
);
}
printf(
"[OK] RDS connection succeeded: host=%s database=%s user=%s server=%s tlsCipher=%s\n",
$host,
(string) ($connectionResult['database_name'] ?? ''),
(string) ($connectionResult['current_user'] ?? ''),
(string) $pdo->getAttribute(PDO::ATTR_SERVER_VERSION),
$sslCipher,
);
}
try {
verifyEfs();
verifyRds();
fwrite(
STDOUT,
"[OK] All infrastructure checks succeeded.\n",
);
exit(0);
} catch (Throwable $exception) {
fwrite(
STDERR,
sprintf(
"[NG] Infrastructure verification failed: %s: %s\n",
$exception::class,
$exception->getMessage(),
),
);
exit(1);
}
使用したDockerfile
ARG PHP_IMAGE=php:8.5-fpm-alpine
ARG NGINX_IMAGE=nginx:alpine
## PHP-FPM
FROM ${PHP_IMAGE} AS php-fpm
RUN set -eux; \
apk add --no-cache \
ca-certificates \
curl \
su-exec; \
\
docker-php-ext-install -j"$(nproc)" pdo_mysql; \
\
mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"; \
\
# RDS TLS接続検証用のAWS公式CA証明書を取得する。
curl \
--fail \
--silent \
--show-error \
--location \
--retry 5 \
--retry-delay 2 \
--proto '=https' \
--tlsv1.2 \
--output /etc/ssl/certs/rds-global-bundle.pem \
https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem; \
\
test -s /etc/ssl/certs/rds-global-bundle.pem; \
\
# 必要なPHP拡張をビルド時に確認する。
php -r ' \
$requiredExtensions = ["PDO", "pdo_mysql", "Zend OPcache"]; \
foreach ($requiredExtensions as $extension) { \
if (!extension_loaded($extension)) { \
fwrite(STDERR, "Missing PHP extension: {$extension}\n"); \
exit(1); \
} \
} \
'
COPY docker/php/zz-app.ini \
"$PHP_INI_DIR/conf.d/zz-app.ini"
COPY docker/php/zz-fpm.conf \
/usr/local/etc/php-fpm.d/zz-fpm.conf
WORKDIR /var/www/html
COPY docker/public /var/www/html/public
COPY --chmod=0644 docker/php/verify-infra.php \
/usr/local/libexec/verify-infra.php
COPY --chmod=0755 docker/php/verify-infra \
/usr/local/bin/verify-infra
RUN set -eux; \
\
# ECS on Fargateタスク定義のEfsMountContainerPathと一致させる。
# ECS on Fargate起動時、このディレクトリへEFSがマウントされる。
# この処理はマウントポイントを事前作成するためのものです。EFSマウント後の権限は、EFSアクセスポイントに設定したPOSIXユーザーおよびルートディレクトリの所有者・パーミッションに依存します。
mkdir -p /mnt/efs; \
chown www-data:www-data /mnt/efs; \
chmod 0750 /mnt/efs; \
\
# PHPアプリケーション自体はroot所有・読み取り専用にする。
chown -R root:root /var/www/html; \
find /var/www/html -type d -exec chmod 0755 {} +; \
find /var/www/html -type f -exec chmod 0644 {} +; \
\
# PHP構文チェック。
php -l /var/www/html/public/index.php; \
php -l /usr/local/libexec/verify-infra.php
# 環境固有値および認証情報はECS on Fargateタスク定義から注入する。
ENV APP_ENV=verification \
APP_DEBUG=false \
DB_CONNECTION=mysql \
DB_PORT=3306 \
MYSQL_ATTR_SSL_CA=/etc/ssl/certs/rds-global-bundle.pem \
DB_SSL_VERIFY_SERVER_CERT=true \
EFS_MOUNT_PATH=/mnt/efs
EXPOSE 9000
CMD ["php-fpm", "-F"]
## Nginx
FROM ${NGINX_IMAGE} AS nginx
# ECS on Fargateタスク定義のコンテナHealthCheckでcurlを使用する。
RUN apk add --no-cache curl
COPY docker/nginx/default.conf.template \
/etc/nginx/templates/default.conf.template
COPY docker/public /var/www/html/public
ENV PHP_FPM_HOST=127.0.0.1 \
PHP_FPM_PORT=9000 \
NGINX_ENVSUBST_FILTER="^(PHP_FPM_HOST|PHP_FPM_PORT)$"
EXPOSE 80
今回ご紹介するのは、ECS on Fargateの疎通確認項目を網羅するものではなく、個人的に気になった箇所をピックアップして実施しています。
実際にテスト項目を検討する際は、ユースケースやインフラ・アプリの構成に合わせて、何をテストするべきかを十分にご検討ください。
今回検証する項目は以下の通りです。
- ECS on FargateにEFSをマウントして、ファイル書き込み、読み取りができるか
- ECS on FargateからSecrets Manager経由でDB認証情報を取得して、RDS(MySQL)へ接続し、SQLを実行できるか
検証1: ECS on Fargate FargateからEFSをマウントして、ファイル書き込み、読み取りする
まずは、EFSのマウント部分を簡単に説明します。
手動でのマウント手順は以下のブログがわかりやすいのでこちらをご覧ください。
前述のDockerfile内でマウント用のディレクトリを作成しています。
抜粋
mkdir -p /mnt/efs; \
chown www-data:www-data /mnt/efs; \
chmod 0750 /mnt/efs; \
\
CloudFormation側ではECS on Fargateのタスク定義とコンテナ設定において、EFSのマウント設定を記述しています。
EFSへの接続情報等はCloudFormationのパラメータ化してあります。
例えば、EFSのAccessPointのArnやID、FileSystemのIDといった情報が設定には必要です。

※EFSのARNはIAMロールの権限を絞るために使用しているので、マウント設定に使用しているわけではありません。
CloudFormationのECS on Fargateのテンプレートの該当箇所を抜粋しました。
〜略〜
TaskDefinition:
Type: AWS::ECS on Fargate::TaskDefinition
Properties:
〜略〜
Volumes:
- Name: efs-data
EFSVolumeConfiguration:
FilesystemId: !Ref EfsFileSystemId
RootDirectory: "/"
TransitEncryption: ENABLED
AuthorizationConfig:
AccessPointId: !Ref EfsAccessPointId
IAM: ENABLED
ContainerDefinitions:
- Name: php-fpm
Image: !Ref PhpContainerImage
Essential: true
PortMappings:
- ContainerPort: !Ref PhpFpmPort
Protocol: tcp
Environment:
〜略〜
- Name: EFS_MOUNT_PATH
Value: !Ref EfsMountContainerPath
Secrets:
- Name: APP_ENV
ValueFrom: !Sub "${AppConfigSecretArn}:app_environment::"
- Name: LOG_LEVEL
ValueFrom: !Sub "${AppConfigSecretArn}:log_level::"
- Name: API_TIMEOUT_SEC
ValueFrom: !Sub "${AppConfigSecretArn}:api_timeout_sec::"
- Name: DB_CONNECTION
ValueFrom: !Sub "${AppConfigSecretArn}:db_engine::"
- Name: DB_HOST
ValueFrom: !Sub "${AppConfigSecretArn}:db_endpoint::"
- Name: DB_PORT
ValueFrom: !Sub "${AppConfigSecretArn}:db_port::"
- Name: DB_DATABASE
ValueFrom: !Sub "${AppConfigSecretArn}:db_name::"
- Name: DB_USERNAME
ValueFrom: !Sub "${DbSecretArn}:username::"
- Name: DB_PASSWORD
ValueFrom: !Sub "${DbSecretArn}:password::"
MountPoints:
- SourceVolume: efs-data
ContainerPath: !Ref EfsMountContainerPath
ReadOnly: false
〜略〜
主なタスク定義のEFSマウント用のパラメータの解説
※パラメータ名から自明なものは割愛します
| パラメータ名 | 説明 | 今回の設定値 |
|---|---|---|
| TransitEncryption | ECS on FargateとEFS間でのデータ転送中のEFSデータの暗号化の有効化、今回IAM認証を使用してEFSへアクセスさせているので、有効化に設定 | ENABLED |
| RootDirectory | ECS on Fargate内にEFSをマウントするときに、ルートディレクトリとしてマウントするEFSファイルシステム内のディレクトリを指定、今回アクセスポイントを使用するため、省略するか/に指定する必要あり(省略すると/に設定される) |
"/" |
| AccessPointId | 使用するアクセスポイントのID/アクセスポイントを使用する際は、前述のデータ転送の暗号化の有効化とRootDirectoryを省略または/に設定する必要がある |
XXX |
| IAM | タスク定義で指定したECS on FargateタスクのIAMロールをEFSのマウント時に使用するかを指定/使用するには前述のデータ転送の暗号化を有効にする必要あり | ENABLED |
それではお待ちかねの検証をやっていきましょう!
CloudShellから以下のコマンドを実行して、構築した検証用ECS on Fargateへ接続します。
aws ECS on Fargate execute-command \
--cluster <ECS on Fargateクラスター名> \
--task <タスクID> \
--container <コンテナ名> \
--interactive \
--command "/bin/sh"
接続できました。

まずは仕込んでおいたテストファイルを使って、EFSへの書き込みとRDSへの接続が上手くいっているかを検証します。
(www-dataユーザー権限でPHPスクリプトverify-infra.phpを実行)
su-exec www-data php /usr/local/libexec/verify-infra.php

ブログ執筆前に何度か実行していたので、EFSのレコード数は5になっています。
無事EFSへの書き込みとRDSへの接続を検証できたようです。
それでは試しにタスクをもう1つ増やしてみて、マウント先のディレクトリを確認してみます。
※タスク数の増やし方は割愛しますが、ECS on Fargateのサービスの更新から簡単に増やすことができました。
ls /mnt/efs
cat /mnt/efs/<書き込んだファイル>
追加したタスクに接続して、先ほどのテストファイルで作成、書き込みされたファイルを確認しました!
中身を見ると検証時の日時で書き込みされていることも確認できました。

まだ物足りないので、直接マウントしたディレクトリにファイルを作成してみます。

一度今接続しているコンテナからはexitで抜けて、元々起動していた方に再接続します。
再度ファイルを確認し、無事こちらのコンテナでも見れることを確認しました!

検証2: ECS on Fargate FargateからSecrets Manager経由でDB認証情報を取得して、RDS(MySQL)へ接続し、SQLを実行する
先ほど検証1で以下をコンテナで実行した際に、PHPでRDSへの接続は検証しました。
su-exec www-data php /usr/local/libexec/verify-infra.php
Secrets Manager側にDBの接続情報を登録しておき、タスク起動時に、タスク実行ロールを使用してSecrets Managerから値を取得し、コンテナの環境変数へ注入します。
Secrets Managerの例

コンテナの環境変数設定の例(ECS on Fargate>クラスター>タスク>設定)

例えば、DBのエンドポイント情報は先ほどのSecrets Managerのシークレットキー「db_endpoint」に登録された値を取ってくる設定になっています。

先ほどのテストファイル実行結果を見ると、RDSへの接続先ホスト名や、データベース名、ユーザー名等が確認できます。

では実際にDBへ読み取り、書き込みできるかを試していきます。
事前に踏み台経由でRDSへはblog_verification_testというテーブルを作成して、1件テストレコードを登録しておきました。
以下でテーブルの全レコードを取得してみます。
query("SELECT * FROM blog_verification_test"); ここがいわゆる実行するSQLにあたる箇所です。
php -r '
$pdo = new PDO(
sprintf("mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4", getenv("DB_HOST"), getenv("DB_PORT"), getenv("DB_DATABASE")),
getenv("DB_USERNAME"),
getenv("DB_PASSWORD"),
);
$stmt = $pdo->query("SELECT * FROM blog_verification_test");
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
print_r($row);
}
'
確認できました!

ではテストレコードを5件ほど追加してみましょう。
php -r '
$pdo = new PDO(
sprintf("mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4", getenv("DB_HOST"), getenv("DB_PORT"), getenv("DB_DATABASE")),
getenv("DB_USERNAME"),
getenv("DB_PASSWORD"),
);
$stmt = $pdo->prepare("INSERT INTO blog_verification_test (message) VALUES (?)");
for ($i = 1; $i <= 5; $i++) {
$stmt->execute([sprintf("test record #%d from app container", $i)]);
}
echo "Inserted 5 rows." . PHP_EOL;
'

再度、テーブルの中身を覗いてみます。
ちゃんと5件追加されてますね!

つまずいた点
ECSからECRイメージを取得する際はS3への通信も必要
今回の検証構成で、当初は、NAT Gateway経由でAmazon ECRのパブリックサービスエンドポイントにアクセスしていました。
ただふと、本番の業務システムでそのような構成を取るか???と思い直し、あるべき姿で検証するために、ECS on Fargateからのアウトバウンド通信をSGレベルで制限し、ECRへはVPCエンドポイント(インターフェース型/ecr.api ecr.dkr)を使用する構成に変更しました。
変更後、再度ECS on Fargateをデプロイし直したのですが、その際にイメージのプルに失敗して焦りました。。
変更前は正常にイメージ取得して、コンテナが起動していたのになぜ。。。?
再デプロイの手順に間違いがあったようには思えませんでした。
タスクのログを見てみると、以下のような記述がありました。
CannotPullContainerError:
~略~
There is a connection issue between the task and the registry. Check your task network configuration. : failed to copy: httpReadSeeker: failed open: failed to do request:
~略~
ネットワークのエラーなのは一目瞭然ですね。
ここでVPCエンドポイント化やSG周りによる影響を疑いました。
調べてみると、ECRのイメージレイヤーはS3経由(今回の構成では、Gateway型VPCエンドポイント経由)で取得されますが、Gateway EndpointはSGを持たないため、DestinationSecurityGroupIdでは指定できません。代わりにECSタスクのSGでアウトバウンドを制限している場合は、S3のAWS管理プレフィックスリスト宛てにTCP/443を許可する必要があることがわかりました。
インターネット検索ですぐヒットしたので、知らなかった自分が恥ずかしいです。
以下のコマンドで、S3のプレフィックスリストIDを取得できます。
aws ec2 describe-managed-prefix-lists --filters "Name=owner-id,Values=AWS" "Name=prefix-list-name,Values=com.amazonaws.ap-northeast-1.s3" --query 'PrefixLists[0].PrefixListId' --output text --region ap-northeast-1
※リージョンは環境に合わせて変えてください!
ECS on FargateのSGのアウトバウンドルールに追加しました!

まとめ
今回は、ECS on Fargateのコンテナに実際に接続し、 EFSやRDSに対する動作検証を行いました。
知識として、「EFSをマウントして〜」や「DBの認証情報はSecrets Managerから取得して〜」といった感じで認識しているものでも、実際にその挙動を目にすると理解度が違うと実感しました!
やって良かったです。
Fargateタスクのローカルストレージは一時的なもので、タスクが終了すると保存データは失われます。一方、EFSはタスクのライフサイクルから独立した永続共有ストレージとして利用できます。
実際に触ってみて、EFSをマウントして共有ストレージとして利用することで、タスクの入れ替え時にもデータが保持されるだけでなく、タスクを増やしたときにも参照できるのが嬉しいポイントだなと改めて思いました。
これからも自分の目で見るまでは信用しないというマインドで積極的にいろいろなことを試してみようと思います!
以上、コンサルティング部のみゃんでした!
参考リンク






