CTFdを(必要以上にいろいろ使いながら)AWSで立てる
モチベーション
仕事でCTFdをAWSに建てたのをきっかけに、もっとお金と時間をかけたらどういう構成にするだろうか?というのをブログにしました。業務でもCTFdをECSで使ったりしているのは変わっていないので、メイン部はそんなに差分はなかったりします。
環境変数周りとかはdocker composeで立てている人も参考になる部分は多少はあるかもしれません。
図表少なめ、文字多めになってます。(もう少し簡略化したもので何か出せればいいかなぁぐらいの気持ちです)
同じくAWSにCTFdを立てている以下が参考になります。
よく触っていたのはCTFdのバージョンが3.7.0(2024年2月頃)のときなので、少し変わっているかも。
概要図
IAMなど省略しているサービスはあります。
だいたいユーザから近いほうから概要を羅列します。
- CloudFront
- Challengeの添付ファイルなどが格納されるので、それがキャッシュできる
- 添付ファイルが格納されているS3をオリジンに追加している
- WAF
- 長期運用してるわけでもないしとりあえずお気持ちで。IP制限などもここでかけている
- ECS
- (docker composeではなく)CTFdのコンテナをFargateで動かしている
- 環境変数でDBなどのアクセス情報を渡している
- アウトバウンド通信もだいたい制限
- Aurora Serverless v2
- できるだけ考えることを少なくしたいのでAurora Serverless v2でオートスケーリングしている
実際にはインスタンス数の管理などは必要
- できるだけ考えることを少なくしたいのでAurora Serverless v2でオートスケーリングしている
- ElastiCache for Redis
- スケーリングとかはしてない
- Serverlessは採用しなかった
- S3
- 問題の添付ファイルやCTFdに設定したロゴなどはS3に保存される
- SES
- アカウントリセット用のメールなどをSESから送信している
- CDK
- ここでは特に取り上げないけどもIaC(IfC)としてAWS CDKを使っている
-
https://github.com/raihalea/ExcessiveCloudTFd
- ここで書いている以外のこともいろいろやっているおもちゃ
細かい話
Route53
ドメイン
SESなどのメールを送る機能が不要であれば、CloudFrontやALBに付与されたDNS名など利用するなどもできるが、ドメインだけは事前に用意することを前提としている。
以降、ここで設定しているホストゾーンをexample.com
と表記する。
CloudFront
キャッシュ
正直そんなに詳しいわけではないので、雰囲気で設定している。あんまりCDNを活かせてる感じはない。
以下のような考えで作った。
- 前提としてそもそもキャッシュを欲張らない。
- キャッシュしても問題なさそうなパスを探す
-
themes/*
とfiles/*
-
- 怪我しそうな部分は明示的にキャッシュしないかつ、ほぼすべてのオリジンへのリクエストを通す
-
api/*
、admin/*
などが対象
-
- 上記でひっかからなかった場合にはデフォルトでキャッシュしない。
CTFdは自分で追加のページを作成できるので、それをキャッシュ対象に設定してもいいかもしれない。
CTFdのバージョン3.7.1で以下のような変更が追加されているので、挙動が変わってるかも
Static assets (theme files, static files) will now return a Cache-Control header with a max-age of 3600
オリジン
CloudFrontから見るオリジンは基本はALBとなりますが、S3のパスだけはオリジンにS3を設定しています。
S3に関連する設定なのでS3の項目で説明します。
レコード
CloudFrontのディストリビューションをエイリアスのターゲットにして、ctf.example.com
にAレコードとAAAAレコードと設定している。
basic認証
CloudFront functionsで認証をかけるようなものを作ってみたが、CTFdでAuthorization
ヘッダーをすでに利用しているらしく避けたほうが無難。
https://github.com/CTFd/CTFd/blob/master/CTFd/utils/initialization/init.py#L284
WAF
IaC化で一番めんどくさかった部分。そもそもあんまり向いてない気がする。
ルール設定などが効果に対して時間がかかりがちなので、必要かどうかは開催形態に依存しそう。
ルール
基本はAWSが提供するマネージドルールを利用しているが、IPマッチなどの一部ルールを個別に作っている。
以下のようなルールを設定している。AWSManagedRules
から始まるルールがAWSが提供するルール
ルール名が結構雑。この後書き直したのでGitHub上は違うルール名になっている。
だいたいこんな感じ
個々のルールについて(長いのでトグルした)
TrustedIp
唯一のAllowルール
基本は利用しない想定で、特定ユーザがアクセスできないなどの緊急時に特定IPアドレスからの通信をすべて許可するルール(マッチすると以降のルールを評価しない)
LimitRequests
Rateベースのルール
同一送信元IPから閾値以上のリクエストがあった場合にはBlockする
組織内で開催とかにすると同じIPでマッチすることになるので、カウント対象をXFFにするか、そもそも無効にするとかにしたほうがいい。
ルール
{
"Name": "LimitRequests",
"Priority": 0,
"Statement": {
"RateBasedStatement": {
"Limit": 1000,
"EvaluationWindowSec": 300,
"AggregateKeyType": "IP"
}
},
"Action": {
"Block": {}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "LimitRequests"
}
}
SizeRestriction & AdminIp
後段のAWSManagedRulesCommonRuleSet
のSizeRestrictions_BODY
は8KB以上でブロックのルールになるが、CloudFrontでのWAFの評価ではデフォルトで16KBまでが評価対象になるため、このルールで定義している。
なお、例外として以下の2点の両方にマッチする場合にはルールの評価を行わない。また特定IPアドレスが空の場合には例外は特定パスだけで判断される。
- ロゴイメージや問題の添付ファイルのアップロードが想定されるパス(
/api/
、/setup
)では評価しない - 設定ファイルに記載した特定IPアドレスにマッチする場合には評価しない
ルール
{
"Name": "SizeRestriction",
"Priority": 1,
"Statement": {
"AndStatement": {
"Statements": [
{
"SizeConstraintStatement": {
"FieldToMatch": {
"Body": {}
},
"ComparisonOperator": "GT",
"Size": 16384,
"TextTransformations": [
{
"Priority": 0,
"Type": "NONE"
}
]
}
},
{
"NotStatement": {
"Statement": {
"AndStatement": {
"Statements": [
{
"OrStatement": {
"Statements": [
{
"ByteMatchStatement": {
"SearchString": "/api/",
"FieldToMatch": {
"UriPath": {}
},
"TextTransformations": [
{
"Priority": 0,
"Type": "NONE"
}
],
"PositionalConstraint": "STARTS_WITH"
}
},
{
"ByteMatchStatement": {
"SearchString": "/setup",
"FieldToMatch": {
"UriPath": {}
},
"TextTransformations": [
{
"Priority": 0,
"Type": "NONE"
}
],
"PositionalConstraint": "EXACTLY"
}
}
]
}
},
{
"IPSetReferenceStatement": {
"ARN": "arn:aws:wafv2:us-east-1:123456789012:global/ipset/AdminIpv6Set/f170302a-1aad-4593-b1d7-14d4o0sd6443"
}
}
]
}
}
}
}
]
}
},
"Action": {
"Block": {}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "SizeRestriction"
}
}
AllowedIp
組織内でのCTFの開催などで、特定のIPアドレスからの利用がわかっている場合に設定する。
これが設定される場合には、特定IPアドレス外からのアクセスはブロックされる。
TrustedIPルールと異なり、特定のIPからの通信であっても以降のWAFルールに評価される。
ルール
{
"Name": "AllowedIp",
"Priority": 2,
"Statement": {
"NotStatement": {
"Statement": {
"IPSetReferenceStatement": {
"ARN": "arn:aws:wafv2:us-east-1:123456789012:global/ipset/SpecificIpv6Set/f170302a-1aad-4593-b1d7-14d4o0sd6443"
}
}
}
},
"Action": {
"Block": {}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "AllowedIp"
}
}
GeoMatch
名前の通り、GeoIP情報をもとに特定の国だけを通すようにする。
あんまり必要そうな場面は思いつかなけど一応
ルール
{
"Name": "GeoMatch",
"Priority": 3,
"Statement": {
"NotStatement": {
"Statement": {
"GeoMatchStatement": {
"CountryCodes": [
"JP"
]
}
}
}
},
"Action": {
"Block": {}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "GeoMatch"
}
}
AWSManagedRulesCommonRuleSet
AWSが提供するマネージドルール
SizeRestrictions_BODY
は前述のSizeRestrictionルールで16KB制限をかけているのでここではカウントにしている。
CrossSiteScripting_BODY
は実際に使ってみて、このルールでブロックされたのでカウントにしている。
AWSManagedRulesAmazonIpReputationList
AWSが提供するマネージドルール
マネージドルールグループをそのまま適用している
このルールグループ使うとき、AWSManagedIPDDoSList
がデフォルトでカウントになってていつも気になっている。
AWSManagedRulesKnownBadInputsRuleSet
AWSが提供するマネージドルール
マネージドルールグループをそのまま適用している
AWSManagedRulesAnonymousIpList
AWSが提供するマネージドルール
マネージドルールグループをそのまま適用している
AWSManagedRulesLinuxRuleSet
AWSが提供するマネージドルール
マネージドルールグループをそのまま適用している
AWSManagedRulesSQLiRuleSet
AWSが提供するマネージドルール
マネージドルールグループをそのまま適用している
XssLabelMatch
AWSManagedRulesCommonRuleSet
でカウントにしているCrossSiteScripting_BODY
をカバーするルール
AWS WAFが検出した際に付与されるラベルを用いて、該当ラベルを持つかつ特定のパス(/api/
や/setup)でないときにブロックする。
SizeRestriction
の時のように合わせてできないは、マネージドルールの場合、複雑なルールを書くことができないためです。(ネストしたルールが書けない)
そのため、このようなラベルを使ったマッチやスコープダウンステートメントを合わせることで回避します。
ルール
{
"Name": "XssLabelMatch",
"Priority": 10,
"Statement": {
"AndStatement": {
"Statements": [
{
"LabelMatchStatement": {
"Scope": "LABEL",
"Key": "awswaf:managed:aws:core-rule-set:CrossSiteScripting_Body"
}
},
{
"NotStatement": {
"Statement": {
"AndStatement": {
"Statements": [
{
"OrStatement": {
"Statements": [
{
"ByteMatchStatement": {
"SearchString": "/api/",
"FieldToMatch": {
"UriPath": {}
},
"TextTransformations": [
{
"Priority": 0,
"Type": "NONE"
}
],
"PositionalConstraint": "STARTS_WITH"
}
},
{
"ByteMatchStatement": {
"SearchString": "/setup",
"FieldToMatch": {
"UriPath": {}
},
"TextTransformations": [
{
"Priority": 0,
"Type": "NONE"
}
],
"PositionalConstraint": "EXACTLY"
}
}
]
}
},
{
"IPSetReferenceStatement": {
"ARN": "arn:aws:wafv2:us-east-1:0123456789012:global/ipset/AdminIpv6Set/f170302a-1aad-4593-b1d7-14d4o0sd6443"
}
}
]
}
}
}
}
]
}
},
"Action": {
"Block": {}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "XssLabelMatch"
}
}
ALB
ECSのタスク(CTFd)をインターネットに公開している部分。
セキュリティグループでマネージドプレフィックスリストを使ってCloudFrontからのアクセスに制限している。
リスナー
HTTPS:443
でのみ受け付けて、CTFdに転送しています。このような理由のためにALBにもctf-alb.example.com
のようなドメインを割り当てます。
またHTTP:80
へのリダイレクトなどはCloudFrontで行うようにしています。
ターゲット
ECSのタスク(CTFd)をターゲットにしている。この管理はECSが行っているため、CTFdコンテナが増減してもECSがターゲットの登録や削除を行う。
ヘルスチェック
ALBからCTFdに対して定期的にヘルスチェックを行っている。
CTFdには/healthcheck
が用意されているので、そこに対してステータスコード200
が返ってくることを確認している。200
以外の場合、ALBは該当コンテナをターゲットグループから削除し、ECSは新しいコンテナをターゲットグループに登録する。
ECS
ECSを知らない人向けに簡単に説明すると、コンテナのオーケストレーションを行うサービスです。よくKubernetes(AWSでのマネジメントサービスではEKS)と比較されるものと理解すればイメージしやすいと思います。
タスク
ECSにおけるタスクとは、1つ以上のコンテナをどう動かすかを定義するものです。
例えば、container imageはどこからpullするのか、CPUやメモリはどの程度割り当てるのか等です。2つ以上コンテナを1つのタスクとして定義することもできますが、今回は単純にCTFdのコンテナだけを動かすタスクを作成しています。
Fargate
ECSを使う上で、コンテナを動かす場所としてEC2とFargate(あとECS Anywhereも)から選ぶことになりますが、今回はFargateを選択しています。考えるのを楽にしたいからです。
安さを目指すのであれば、EC2のほうが余地はありますが、その場合ホストのスペックとコンテナのスペックを考える必要があるので、私は基本的にFargateを選択してしまいます。
Fargateを選択すると、ネットワークモードをawsvpcを選ぶことになります。これはコンテナのポートマッピングに影響する設定で、awsvpcの場合は単純にコンテナで使いたいポートを設定すればよいです。
CTFdの場合はEXPOSE 8000
なので、8000番のポートを指定しています。
コンテナ
CTFdのイメージは手元にgithubをcloneして、buildしたものをリポジトリ(ECR)にpushして利用しています。
Docker Hub上のイメージをECRにpull through cacheするのもいいかもしれません。
CTFdは環境変数で設定することができます。(環境変数でしか指定できない設定や逆に環境変数では触れないものもあります。)
ECSでは直接値を与えてる方法(value)とSecrets ManagerやParameter Storeなどに格納した値を取得する方法(valueFrom)があります。
私はパスワードなどの機微情報はSecrets Managerで渡して、他の情報は直接渡すようにしています。
Secrets Managerを使っているのはCDKを使っている都合が大きいので、手動で使う場合にはParameter Storeを使ったと思います。
環境変数の細かい説明はCTFdの項目でやります。
CPU/メモリー
ある程度高性能のものを一つ用意するよりは、小さいのをたくさん動かすほうがECSを使う上で向いていると思っているので、スペックは小さめです。とはいえもう少し大きくしてもいいかもしれません。(とりあえず1.0vCPUから始めちゃう気がします。)
実際にアクセスがあると要調整の部分かもしれませんが、ある程度はオートスケールでなんとかなるんじゃないかと思っています。
概要 | 設定値 |
---|---|
CPU | 0.5vCPU |
memory | 1GB |
Arch | ARM |
ストレージ
永続ストレージは、S3やRDSがあるので他のサービスは利用していません。
CTFdはRedisを利用していることもあって、できるだけコンテナ自体はステートレスにしておきたいためです。
サービス
サービスはタスクをどう動かすかを定義しています。
例えば、タスクの必要数を3と決めれば、ECSはタスク数3を維持するように動きます。仮に一つのタスクが異常停止してしまっても、タスク数が3になるように新たにタスクを立ち上げます。
また、必要なタスク数をオートスケーリングさせることもできます。
オートスケーリング
以下のように設定しています。ちゃんと検証してるわけではないため不備はあるかもしれません。
異常時に1分程度の断を許容するならタスク数1でもいいと思います。
またある程度アクセス数があるなら最大タスク数は大きめにしておいたほうがいいです。
概要 | 設定値 |
---|---|
必要なタスク数 | 2 |
最小タスク数 | 1 |
最大タスク数 | 5 |
ポリシー1 | 平均CPU使用率が3分間75%を超過する場合、平均CPU使用率が75%になるまでタスク数を増減させる。スケールインクールダウンは10分。 |
ポリシー2 | 平均メモリー使用率が3分間75%を超過する場合、平均メモリー使用率が75%になるまでタスク数を増減させる。スケールインクールダウンは10分。 |
ポリシー3 | CTFdのWorker数*1000の70%になるようにタスク数を増減 |
オートスケーリングにはターゲット追跡するポリシーを利用しています。特定のメトリクスを目標値にして、サービスが実行するタスク数を増減させます。
ポリシー3はworker_connections
のデフォルト値が1000になっているため、環境変数で設定されたWorker数*1000が1つのターゲットが同時に処理できるリクエスト数になります。雑に70%を目標値に設定していますが、突発的なトラフィックの変化にはついていけないのでお気持ち程度かもしれません。
もし指定したいならこの辺りに追加すれば設定できると思います。
RDS
RDS Aurora Serverless v2を利用しています。
RDSにはざっくりエンジンの種類とインスタンスの種類が選択でき、今回は(MySQL互換の)AuroraエンジンのServerless v2インスタンスを使っています。
認証はクライアントであるCTFdがIAMを使ったアクセスはできないので、ユーザ・パスワードでの接続です。
Aurora MySQL
利用するエンジンはCTFdのドキュメントやdocker-compose.ymlを参考にしています。
ドキュメントではMySQL、MariaDB、SQLiteでテストされ、MySQLが推奨とされています。
docker-sompose.ymlではimage: mariadb:10.4.12
を利用しています。
Serverless v2
Serverlessといいつつ、インスタンスの数やACUをユーザ側で決める必要があります。
0.5ACUから32ACUの間でスケールするようにしていますが、まぁ大丈夫じゃないですかね。。
読み取り専用のReaderインスタンスが作成可能です。とはいえCTFdでは読み取りだけにアクセスするような機能は使えませんが、Writerインスタンスが故障した場合にReaderインスタンスが昇格することができ、可用性の向上に期待できます。
環境変数
CTFdに設定するRDSに関する環境変数
環境変数名 | 設定内容 |
---|---|
DATABASE_USER | RDSにアクセスするためのユーザ名 |
DATABASE_PASSWORD | RDSにアクセスするためのパスワード |
DATABASE_HOST | RDS(writerインスタンス)のホスト名 |
DATABASE_PORT | ポート番号 |
ElastiCache for Redis
2023年のre:inventで出たServerlessではなく、通常のサーバーがあるタイプのものを利用しています。Serverlessはどうも高そうなのであんまり向いてないかもしれないと思っています。
ユーザ・パスワードでの認証など、だいたいRDSでやっていることと同じようなことをしています。
docker-compose.ymlでは4系を使っていますが、私は7系のRedisを使っています。ドキュメント見ててもRedisが推奨以上の情報が読み取れなかったので利用できる高いのを使っています。
またARM系のインスタンスを使おうとすると5.0.6
以上になるので、あんまりバージョンにはこだわらずに動けばいいや、で決めています。
cache.t4g.microでprimaryとreplicaの2ノードで構成しており、クラスターモードは無効にしています。
全体の中でここが一番貧弱ですが、それでも冗長構成だし、負荷もt4g.microで十分かと思っています。実際のトラフィックを見つつ、適宜調整で諦めです。
Redisにも一応パスワードでアクセスするようにした。CTFdのGithubにあるdocker-compose.ymlにはなかったのでつけてない人は多い気はします。
パスワードは特殊文字なしでSecrets Managerの自動生成し、ElastiCacheに設定しています。
CTFdには環境変数で渡しています。
なお、TLSを必須にする上で、REDIS_PROTOCOL
でrediss
を明示的に指定する必要があります。(これに気づかずに時間をつぶした)
環境変数
CTFdに設定するElastiCache for Redisに関する環境変数
環境変数名 | 設定内容 |
---|---|
REDIS_PASSWORD | ElastiCache for Redisにアクセスするためのパスワード |
REDIS_PROTOCOL | ElastiCache for Redisにアクセスするプロトコル(デフォルトだとredis になるため、rediss にする) |
REDIS_HOST | ElastiCache for Redis(primary)のホスト名 |
S3
添付ファイルやロゴの画像ファイルなどはS3に保存されるよう設定しています。
アップロードはCTFdを通して行うので、WAFの特定パス(/api
)においてSizeRestrictionルールの緩和が必要です。
有効にするためには、環境変数でS3バケット名やIAMアクセスキーをCTFdに渡す必要があります。S3バケットとIAMユーザを作成し、IAMユーザにS3にアクセスさせる権限を付与します。
なお、ECSのタスクロールに権限を渡す方法は対応していないので使えないようです。参考1 参考2
S3を利用している場合にファイルダウンロードが発生すると、CTFdはS3の署名付きURLを発行し、ユーザにリダイレクトさせることでファイルをダウンロードさせます。
環境変数
CTFdに設定するS3に関する環境変数
環境変数名 | 設定内容 |
---|---|
UPLOAD_PROVIDER | ファイルアップロードの設定(s3 を設定する) |
AWS_S3_BUCKET | S3バケット名 |
AWS_S3_REGION | S3バケットがあるリージョン(ap-northeast-1 など) |
AWS_S3_CUSTOM_DOMAIN | ctf.example.com |
AWS_S3_CUSTOM_PREFIX | S3オブジェクトのプレフィックス |
AWS_ACCESS_KEY_ID | IAMユーザのアクセスキー |
AWS_SECRET_ACCESS_KEY | IAMユーザのシークレットアクセスキー |
AWS_S3_CUSTOM_DOMAIN
はAWS以外のS3互換サービスを利用する際に使えるように設定できるようになっている。
CloudFrontのオリジンとしてのS3
CloudFrontの項目でも触れていますが、オリジンをS3にするにはCTFdも併せて設定する必要があります。
オリジンにS3を使わない場合も含めて3種類のパターンを紹介します。オリジンに関連するAWS_S3_CUSTOM_DOMAIN
とAWS_S3_CUSTOM_PREFIX
のみ説明しますが、どのパターンに限らず他のS3の環境変数も必要になります。
そもそも設定するとめちゃくちゃいいことがあるかというと大量アクセスでもない限りそんなこともないので、好みでいいと思います。
オリジンにS3を設定しない(CTFdにS3を設定する場合の一般的な構成)
S3が発行する署名付きURLでアクセスされます。ただCloudFrontがあるだけでS3オブジェクトはCloudFrontを経由しません。
環境変数名 | 設定内容 |
---|---|
設定しない | |
設定しない |
オリジンにS3を設定する(プラグインで対応)
CloudFrontにS3をオリジンに設定する場合、パスパターンに合致した場合にS3にアクセスがいくようにする必要があります。また、そのパスパターンは既存のCTFdのパス(/adminや/challengesなど)と重複しないようにしてください。
CTFdが応答するS3オブジェクトの署名付きURLはS3のものではなくCloudFrontのものにする必要があります。この部分はCTFの挙動を変える必要があるので、プラグインで対応します。またCloudFrontとCTFdに信頼されたキーグループ
を設定します。
これでS3へのアクセスがCloudFrontを経由するので、キャッシュが可能になります。
特にAWS_S3_CUSTOM_PREFIX
で指定したパスをパスパターンに設定する必要があります。files/
を設定すると署名付きURLへのリダイレクトがキャッシュされるので注意。
環境変数名 | 設定内容 |
---|---|
AWS_S3_CUSTOM_DOMAIN | ctfd.example.com |
AWS_S3_CUSTOM_PREFIX | S3オブジェクトのプレフィックス(図の場合はs3/ ) |
オリジンにS3を設定する(トリッキー版)
オリジンに設定するパスにfiles/
を設定すると、(そのリクエストがCTFdに届かずに)S3が応答します。
S3へのアクセスがCloudFrontを経由するので、キャッシュが可能になります。
CTFdの署名付きURLが無効になるので、ログインしなくてもパスがわかればオブジェクトにアクセス可能になってしまうので注意。
もちろんCTFd的には想定していない挙動なので不具合や将来的に使えなくなったりするかもしれない。
環境変数名 | 設定内容 |
---|---|
AWS_S3_CUSTOM_DOMAIN | ctfd.example.com |
AWS_S3_CUSTOM_PREFIX | files/ |
SES
ユーザの初期登録やパスワードリセット用のメールをSMTPサーバを指定することで送信することができます。
ドメインの検証
まず送信元となる自身のドメインを検証します。
Route53のホストゾーンに登録したexample.com
と同じドメインであれば、簡単にドメインの検証までが終わります。
設定セット
SESでメールを送る際に設定セット(ConfigurationSet)を指定することができます。
TLSの必須や仕様するIPアドレス帯などを設定セットに従ってメールを送ることができます。
これは必須ではなく、メールのバウンス率などをCloudWatchで取得しやすくするために使用しています。
SMTPエンドポイント
一般的にAWSのAPIを利用する際にはHTTPSで行います。SESも同様にAPI経由でメールを送る機能も持っていますが、CTFdがAPIをたたけるわけではないのでSMTPプロトコルでメールを送るようにしています。
SMTPの認証情報はこのような手順で生成可能です。
私はIaCで発行する都合上、いったんIAMユーザとそのアクセスキーを取得してから変換して認証情報を作成しています。S3アクセス用のIAMロールとも別にIAMユーザを作成しました。
認証情報やSMTPのエンドポイントも環境変数で渡しています。
環境変数
CTFdに設定するSESに関する環境変数
環境変数名 | 設定内容 |
---|---|
MAIL_SERVER | email-smtp.{AWSリージョン}.amazonaws.com |
MAIL_PORT | 587 |
MAIL_USEAUTH | true |
MAIL_TLS | true |
MAIL_USERNAME | IAMユーザのアクセスキー |
MAIL_PASSWORD | IAMユーザのシークレットアクセスキーを基に生成したSMTP用認証情報 |
MAILFROM_ADDR | ctfd@example.com |
VPC
アウトバウンド通信の制限
セキュリティグループをマネジメントコンソールから作ると、インバウンドは何も許可されていないかつ、アウトバウンドは全許可になっているものが作成される。
インターネット向け(VPC外向け)通信が必要ならともかく、そうでないならアウトバウンドの全許可をほとんど消すようにしている。
IaCで作るならセキュリティグループのアウトバウンドの制御もできるだろ、ぐらいの考え。
今回の場合、AWSのエンドポイント以外にアウトバウンドの通信が必要ではない。ECSやコンテナを使うメリットはこういう資材を別環境で作れるところにあると思う。
ちゃんとやるならDenyでの制限もしたほうがいいと思うけど、めんどくさくなりそうだったのでやっていない。
VPCエンドポイント
VPCエンドポイントによってVPC内にAWSのエンドポイントを置くことで、インターネットに出なくてもS3やSESなんかと通信ができるようになる。
コストカットを狙うなら真っ先に削ると思う。
今回はコストは過剰でなければかけていいぐらい気持ちなので、VPCエンドポイントたくさん使ってアウトバウンドの制御を優先した。
VPCエンドポイントを使わないのであれば、NAT Gatewayを使うか、CTFdのコンテナにパブリックIPを持たせる。
利用しているVPCエンドポイント一覧
エンドポイント | 利用目的 |
---|---|
S3 | CTFdからS3にアクセス。ゲートウェイ型を利用 |
ECS | ECSがコンテナを配置するために必要 |
ECR | ECSがコンテナを配置するために必要 |
CloudWatch Logs | コンテナからのログ用 |
SSM | コンテナにECS Execするとき用 |
SSM Messages | コンテナにECS Execするとき用 |
SecretsManager | 環境変数の取得 |
SMTP | メール用。このエンドポイントのみポート番号がHTTPSでない |
CloudWatch
コンテナのCPUメトリクスやメールのバウンス率などを確認するダッシュボードや、閾値超過時にアラート通知などを行っている。
ダッシュボード
なんとなくそれっぽいメトリクスを並べています。
実運用ではここを見るより、適切にアラームにするのがいいかと思います。
また、ECSやALBなどはCloudWatchが自動作成するダッシュボードなどもあるので、そちらを使ってもいいかもしれないです。
メトリクス以外にもログ中にあるINFO
やCRITICAL
を拾って通知しています。Pythonのloggingででるだろうと思って設定していますが、ちゃんと確認していないです。
アラーム
閾値を超える場合に、SNS経由で通知しています。
SNSのTopicを4種類(Cretical,Warn,Notice,Info)を用意しておいて、アラームのアクションで宛先を選んで通知しています。
TopicのサブスクリプションはSlackやメールを宛先にする想定です。
ECSのタスク数が最大値になった場合などに通知が飛ぶようにしてます。
CTFd
上記で説明しているとおり、設定のほとんどを環境変数で与えています。
ドキュメントは以下ですがあまりメンテナンスされていないので、github上のconfig.iniやconfig.pyも併せて確認するといいと思います。
例えば、データベースに関連する環境変数は、ドキュメント上だとDATABASE_URL
しか書いてないですが、config.iniではDATABASE_HOST
やDATABASE_PASSWORD
などが用意されていることがわかります。
環境変数
AWSに関連しない、CTFd自体の設定を行う環境変数です。
ほとんどがgunicornの設定です。
環境変数名 | 設定内容 |
---|---|
SECRET_KEY | セッション作成などに用いられます。WORKERSやECSタスクを複数に持つときには必須で固定値を持つ必要があります。 |
WORKERS | gunicornのworker数です。何も指定しないと1になります。 |
ACCESS_LOG |
- を指定すると標準出力にログを出力します。参考
|
ERROR_LOG |
- を指定すると標準出力にログを出力します。参考
|
REVERSE_PROXY |
true 以外にも, 区切りの文字列も受け取る |
SECRET_KEY
SECRET_KEY
は何も設定しない場合でもローカルで生成してくれるようです。もちろんほかのコンテナと共有することはないので、なんでもいいので設定しておいたほうがいいと思います。
WORKERS
workersの適正値は僕もわからないのですが、参考リンクを置いておきます。
(2 x $num_cores) + 1
と記載されています。
CPU使用率見て決めちゃっていい気がします。
REVERSE_PROXY
ALBもあるので(安易に)true
でいいか。。。と思っていたら、REVERSE_PROXY
には,
区切りで複数の値でも受け取るらしい。
このあたりでREVERSE_PROXY
に対する扱いを確認できる。
もしALBの背後にNginxがあり、それぞれX-Forwarded-For
を付与する場合には、x-for
が2になるようにする必要がある。(と理解した)
具体的な値は以下がわかりやすい。
他の構成
いくつかパターンはあると思うけど、動作確認していないので実は動かないなどの罠はあるかもしれない。
簡易な構成
簡易にするならこんな感じ
- CloudFrontとWAFは設定がややこしくなるので削る
- インターフェス型のVPCエンドポイントをやめてNAT Gatewayにする。もし通信料が高いものがあればそれは別途VPCエンドポイントを考える。
- S3用のゲートウェイ型VPCエンドポイントは課金されないのでそのまま利用する。
(そこそこ)安い構成
安いといっても最安ではなく、それなりにリソースを使っている。
EC2かLightsailで1台だけ立ててdocker composeするほうがさらに安い
- ECSおよびタスクにパブリックIPを付与して、NAT Gatewayも削除する
- FargateをやめてEC2の上にコンテナを動かす
- ElastCacheもやめてRedisをタスクで動かす(落ちてもセッションが落ちるだけと割り切る。永続ボリュームも持たない)
- RDSも適当に安いインスタンス2台ぐらいで立てたほうが見積もりはしやすい
- SpotFleetでspot instanceの割合を増やすなど検討する
App Runnerを使う構成
利用状況次第では現実的に一番安くなるかもしれないと思っている。
起動ぐらいしかしたことないので実態の構成図とは異なったり、そもそも何か不都合があるかもしれない。
(なんで使ってないかというとECSのほうが設定が慣れてるから。)
Route53が図から消えてるけどドメインの設定もできる。
- 課金体型がEC2などと異なる部分があるため、より使った分だけ課金されるイメージに近い
- ECSやALBが担っていた部分はApp Runnerが行う
Lambdaを使う構成(実験的)
Lambda Web Adapterを使ってCTFdをLambdaで動かす構成。
App Runnerと違って動くかどうか不安だったけど、動作確認までできたので紹介。
- ログイン、問題作成、フラグ解答までは実行することを確認した。
- リロードすれば治るけども、たまに描画が崩れたりする。
- Lambdaの関数URLを使っているので、CloudFrontは必須ではない。CloudFrontを使うなら関数URLに直接アクセスさせないようにする必要がありそうだけども調べていない。
- めんどうだったのでNAT Gatewayを使っている
おわりに
AWSでCTFdを立ててみました。割といろいろなAWSサービスを使った気がします。たくさん使ったほうがいいというわけではないですが。
いまいち誰向けなのかよくわからない(AWSを知っている人にはそりゃそうだろ、という内容だし、CTFを開催したい人にはたいていはdocker composeで用が足りると思う)記事になってしまった気がしますが、自己満足なので良しとします。
ここまで大げさな構成にする必要はあるのか?みたいなことを思った人もいるかもしれないけども、個々のパーツで見ればそんなに大変なことはしてないのと、個人的にはIaCのファイルが大きくなることは良くて、それ以外の手順を省きたいという感じ。
(デプロイコマンド一発で済んでいるうちはまぁいいかなぐらいの感覚。どうせ立てては捨てるを繰り返すようなものだし。)
正直、CTFに特段強い思い入れがあるわけではないのだけど、僕でも解答できるCTFがたくさん開催されると嬉しい。
Discussion