NFLabs. エンジニアブログ

セキュリティやソフトウェア開発に関する情報を発信する技術者向けのブログです。

AWSでのCTFd構築職人マニュアル【完全版】~IaCから詳細設定まで~

はじめに

今年もMWS Cupが無事終了しました。今年のplatform担当はNFLabs.の市岡、北村、渡邉、貝塚の計4名です。去年のスコアサーバの課題を洗い出し、改良を加えました。インフラ構築手順や構築時に使用できるテンプレートを紹介いたしますので、CTFの運営をされる方はぜひ読んでいってください。MWS Cupの紹介は前回のブログ記事で行いましたので割愛させていただきます。

スコアサーバの要件と課題

今年もCTFdを使ってMWS Cupのスコアサーバを構築することにしました。スコアサーバの構築にあたって、要件と昨年までの構成の問題点を洗い出しました。

スコアサーバに求められる要件

  1. 競技時間中、CTFdを使用した競技継続が不能とならないこと(高信頼性)
  2. 障害が起こった場合、できる限りダウンタイムを短くし、稼働率を高めること(高可用性)
  3. 障害が起こった場合でも、データ損失の可能性が低い構成であること
  4. MWS Cup 2023参加者以外に問題が漏洩しないこと

昨年までの構成の問題点

  1. 信頼性・可用性が低い

    • 昨年までの構成では、AWS上に構築した1つのEC2インスタンスでCTFdを実行していました。インスタンスに障害が発生した場合、競技の中断という事態に直結します。また、リクエスト数の増大を緩和する措置が存在しないため、単一のインスタンスの処理量が過多となると、応答速度の低下は免れません。
    • LoadBalancerを導入し、実際の処理を行うインスタンスは冗長構成とする必要があります。
  2. データ損失のリスクが高い

    • データの保存領域(RDS)は冗長化されていないため、単一障害点となります。
    • EC2インスタンスと同様に冗長化する必要があります。
  3. 競技中の問題修正が不便
    • 昨年度までの構成では問題データ(問題文以外の添付ファイルなど)はEBSに書き込まれるため、事前にバックアップを取る場合はEBSのスナップショットを取る必要があります。競技中かつ問題修正後に障害が発生した場合、事前に取ったバックアップから復旧し、その後に再度、問題の修正を適用する必要があるためダウンタイムが長くなります。(これは可用性の面でも解決する必要がある問題です。)
    • EFSをマウントする構成を導入し、問題データ(問題文以外の添付ファイルなど)の書き込みをEFSに行うことで、インスタンス側の障害と問題データの保存箇所を分離する必要があります。この変更により、インスタンスの復旧のみで最新の問題データは保持されたまま参照できるのでダウンタイムが短くなります。

競技インフラの構築

上記の要件や問題点を踏まえ、インフラ構成の改良を行いました。 本年度のMWS Cup競技で使用した最終的なインフラの構成は以下の通りです。

当日のスコアサーバのインフラ構成

構築手順

1. CloudFormationによるインフラ構築

上記のリソースは全てAWS上に構築しています。 CloudFormationのtemplateにしました。

CloudFormation-template.yaml

AWSTemplateFormatVersion: 2010-09-09
Parameters:
  ProjectName: 
    Description: unique in this aws account 
    Type: String
    Default: "cf-platform-2023"
  resourceNamePrefix:
    Description: unique in this aws account
    Type: String
    Default: "cf-platform"
  DeployRegion:
    Description: region to deploy
    Type: String
    Default: "ap-northeast-1"
  FQDN:
    Description: fqdn of ALB.
    Type: String
    Default: "score.example.com"
  hostedZoneId:
    Description: route53 zoneid of example.com
    Type: AWS::Route53::HostedZone::Id
  keyPair:
    Description: keypair for ec2 instance
    Type: AWS::EC2::KeyPair::KeyName
  AMI: 
    Description: AMI id to use ec2 instance
    Type: AWS::EC2::Image::Id
    Default: "ami-09a81b370b76de6a2"
  EngineVersion:
    Type: String
    AllowedValues:
      - '8.0.28'
    Default: '8.0.28'

  DBUsername:
    Type: String
    # 文字数制限
    MinLength: '1'
    MaxLength: '16'

  DBPassword:
    NoEcho: 'true'
    Type: String
    MinLength: '8'
    MaxLength: '41'

  DeletionProtection:
    Type: String
    AllowedValues:
      - 'true'
      - 'false'
    Default: 'false'
  
Resources:
  # VPC subnetの設定(public subnet2つとprivate subnet2つ)
  VPC:
    Type: AWS::EC2::VPC
    Properties: 
      CidrBlock: "10.0.0.0/16"
      EnableDnsHostnames: true
      EnableDnsSupport: true
      InstanceTenancy: "default"
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-vpc"
        - Key: "Project"
          Value: !Ref ProjectName

  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties: 
      CidrBlock: "10.0.128.0/20"
      AvailabilityZone: !Sub "${DeployRegion}a"
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-private1"
        - Key: "Project"
          Value: !Ref ProjectName
      VpcId: !Ref VPC

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties: 
      CidrBlock: "10.0.144.0/20"
      AvailabilityZone: !Sub "${DeployRegion}c"
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-private2"
        - Key: "Project"
          Value: !Ref ProjectName
      VpcId: !Ref VPC

  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties: 
      CidrBlock: "10.0.0.0/20"
      AvailabilityZone: !Sub "${DeployRegion}a"
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-public1"
        - Key: "Project"
          Value: !Ref ProjectName
      VpcId: !Ref VPC

  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties: 
      CidrBlock: "10.0.16.0/20"
      AvailabilityZone: !Sub "${DeployRegion}c"
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-public2"
        - Key: "Project"
          Value: !Ref ProjectName
      VpcId: !Ref VPC

  internetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-igw"
        - Key: "Project"
          Value: !Ref ProjectName

  internetGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties: 
      InternetGatewayId: !GetAtt internetGateway.InternetGatewayId
      VpcId: !Ref VPC

  EIPForNat:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-eip"
        - Key: "Project"
          Value: !Ref ProjectName

  NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt EIPForNat.AllocationId
      SubnetId: !Ref PublicSubnet1
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-nat"
        - Key: "Project"
          Value: !Ref ProjectName

  routeTablePublic:
    Type: AWS::EC2::RouteTable
    Properties: 
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-rt-public"
        - Key: "Project"
          Value: !Ref ProjectName
      VpcId: !Ref VPC

  routeTablePrivate:
    Type: AWS::EC2::RouteTable
    Properties: 
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-rt-private"
        - Key: "Project"
          Value: !Ref ProjectName
      VpcId: !Ref VPC

  routepublic:
    Type: AWS::EC2::Route
    DependsOn: internetGateway
    Properties:
       RouteTableId: !Ref routeTablePublic
       DestinationCidrBlock: 0.0.0.0/0
       GatewayId: !GetAtt internetGateway.InternetGatewayId

  routeTableAssociationPublic1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: 
      RouteTableId: !Ref routeTablePublic
      SubnetId: !Ref PublicSubnet1

  routeTableAssociationPublic2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: 
      RouteTableId: !Ref routeTablePublic
      SubnetId: !Ref PublicSubnet2
  
  RouteNATGateway:
   DependsOn: NatGateway
   Type: AWS::EC2::Route
   Properties:
      RouteTableId: !Ref routeTablePrivate
      DestinationCidrBlock: '0.0.0.0/0'
      NatGatewayId: !Ref NatGateway

  RouteTableAssociationPrivate1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: 
      RouteTableId: !Ref routeTablePrivate
      SubnetId: !Ref PrivateSubnet1

  RouteTableAssociationPrivate2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: 
      RouteTableId: !Ref routeTablePrivate
      SubnetId: !Ref PrivateSubnet2

  # データストア
  EFS:
    Type: AWS::EFS::FileSystem
    Properties: 
      BackupPolicy: 
        Status: "DISABLED"
      Encrypted: true
      FileSystemTags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-efs"
        - Key: "Project"
          Value: !Ref ProjectName
      LifecyclePolicies: 
        - TransitionToIA: AFTER_30_DAYS
        - TransitionToPrimaryStorageClass: AFTER_1_ACCESS
      PerformanceMode: "generalPurpose"
      ThroughputMode: "bursting"
      ReplicationConfiguration:
        Destinations:
          - AvailabilityZoneName: !Sub "${DeployRegion}c"
            Region: !Ref DeployRegion

  EFSMountTarget1:
    Type: AWS::EFS::MountTarget
    Properties:
      FileSystemId: !Ref EFS
      SubnetId: !Ref PrivateSubnet1
      SecurityGroups:
      - !GetAtt SGForEFS.GroupId

  EFSMountTarget2:
    Type: AWS::EFS::MountTarget
    Properties:
      FileSystemId: !Ref EFS
      SubnetId: !Ref PrivateSubnet2
      SecurityGroups:
      - !GetAtt SGForEFS.GroupId
  
  SGForEFS:
    Type: AWS::EC2::SecurityGroup
    Properties: 
      GroupDescription: 'for efs'
      GroupName: !Sub "${resourceNamePrefix}-sg-efs"
      SecurityGroupIngress: 
        - IpProtocol: -1
          FromPort: -1
          ToPort: -1
          SourceSecurityGroupId: !GetAtt SGForEC2.GroupId
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-sg-efs"
        - Key: "Project"
          Value: !Ref ProjectName
      VpcId: !Ref VPC

  RDS:
    Type: 'AWS::RDS::DBInstance'
    DeletionPolicy: Delete
    Properties:
      DBInstanceIdentifier: !Sub "${resourceNamePrefix}-rds"
      DBInstanceClass: "db.t3.small"
      AllocatedStorage: 20
      Engine: "mysql"
      EngineVersion: !Ref EngineVersion
      MasterUsername: !Ref DBUsername
      MasterUserPassword: !Ref DBPassword
      PubliclyAccessible: false
      StorageType: "gp3"
      BackupRetentionPeriod: 0
      MultiAZ: true
      StorageEncrypted: true
      DeletionProtection: !Ref DeletionProtection
      CopyTagsToSnapshot: true
      DBSubnetGroupName: !Ref RDSSubnetGroup
      VPCSecurityGroups:
        - !GetAtt SGForRDS.GroupId
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-rds"
        - Key: "Project"
          Value: !Ref ProjectName

  RDSSubnetGroup:
    Type: "AWS::RDS::DBSubnetGroup"
    Properties:
      DBSubnetGroupDescription: !Sub "for ${resourceNamePrefix}-rds"
      DBSubnetGroupName: !Sub "${resourceNamePrefix}-rds-subnet-group"
      SubnetIds: 
      - !Ref PrivateSubnet1
      - !Ref PrivateSubnet2
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-rds-subnet-group"
        - Key: "Project"
          Value: !Ref ProjectName

  SGForRDS:
    Type: AWS::EC2::SecurityGroup
    Properties: 
      GroupDescription: 'for rds'
      GroupName: !Sub "${resourceNamePrefix}-sg-rds"
      SecurityGroupIngress: 
        - IpProtocol: 'tcp'
          FromPort: 3306
          ToPort: 3306
          SourceSecurityGroupId: !GetAtt SGForEC2.GroupId
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-sg-rds"
        - Key: "Project"
          Value: !Ref ProjectName
      VpcId: !Ref VPC

  RedisSubnetGroup:
    Type: AWS::ElastiCache::SubnetGroup
    Properties:
      CacheSubnetGroupName: !Sub "${resourceNamePrefix}-redis-subnetgroup"
      Description: !Sub "${resourceNamePrefix}-redis-SubnetGroup"
      SubnetIds:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2

  Redis: 
    Type: AWS::ElastiCache::ReplicationGroup
    Properties:
      AutoMinorVersionUpgrade: true
      CacheNodeType: "cache.t4g.micro"
      CacheParameterGroupName: "default.redis7"
      CacheSubnetGroupName: !Ref RedisSubnetGroup
      Engine: "redis"
      EngineVersion: 7.0
      MultiAZEnabled: true
      NodeGroupConfiguration:
        - ReplicaCount: 1
      ReplicationGroupDescription: !Sub "${resourceNamePrefix}-redis"
      SecurityGroupIds:
        - !Ref SGForRedis

  SGForRedis:
    Type: AWS::EC2::SecurityGroup
    Properties: 
      GroupDescription: 'for redis'
      GroupName: !Sub "${resourceNamePrefix}-sg-redis"
      SecurityGroupIngress: 
        - IpProtocol: "tcp"
          FromPort: 6379
          ToPort: 6379
          SourceSecurityGroupId: !GetAtt SGForEC2.GroupId
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-sg-redis"
        - Key: "Project"
          Value: !Ref ProjectName
      VpcId: !Ref VPC

  SGForEC2:
    Type: AWS::EC2::SecurityGroup
    Properties: 
      GroupDescription: 'for ec2'
      GroupName: !Sub "${resourceNamePrefix}-sg-ec2"
      SecurityGroupEgress: 
        - IpProtocol: -1
          FromPort: -1
          ToPort: -1
          CidrIp: 0.0.0.0/0
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          SourceSecurityGroupId: !GetAtt SGForALB.GroupId
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-sg-ec2"
        - Key: "Project"
          Value: !Ref ProjectName
      VpcId: !Ref VPC

  EC2Role:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      RoleName: !Sub ${resourceNamePrefix}-EC2Role
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - sts:AssumeRole
      MaxSessionDuration: 3600
      ManagedPolicyArns: 
        - "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"

  InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: /
      Roles:
        - !Ref EC2Role

  # EC2インスタンス2つ立てる
  EC2one:
    Type: AWS::EC2::Instance
    Properties: 
      BlockDeviceMappings: 
        - DeviceName: /dev/sda1
          Ebs:
            VolumeSize: 8
      CreditSpecification: 
        CPUCredits: "standard"
      IamInstanceProfile: !Ref InstanceProfile
      ImageId: !Ref AMI
      InstanceType: "t3.micro"
      KeyName: !Ref keyPair
      Monitoring: false
      PropagateTagsToVolumeOnCreation: true
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-ec2-1"
        - Key: "Project"
          Value: !Ref ProjectName
      NetworkInterfaces: 
      - AssociatePublicIpAddress: "false"
        DeviceIndex: "0"
        GroupSet: 
          - !GetAtt SGForEC2.GroupId
        SubnetId: !Ref PrivateSubnet1
  
  EC2two:
    Type: AWS::EC2::Instance
    Properties: 
      BlockDeviceMappings: 
        - DeviceName: /dev/sda1
          Ebs:
            VolumeSize: 8
      CreditSpecification: 
        CPUCredits: "standard"
      IamInstanceProfile: !Ref InstanceProfile
      ImageId: !Ref AMI
      InstanceType: "t3.micro"
      Monitoring: false
      PropagateTagsToVolumeOnCreation: true
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-ec2-2"
        - Key: "Project"
          Value: !Ref ProjectName
      NetworkInterfaces: 
      - AssociatePublicIpAddress: "false"
        DeviceIndex: "0"
        GroupSet: 
          - !GetAtt SGForEC2.GroupId
        SubnetId: !Ref PrivateSubnet2

  ALBTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub "${resourceNamePrefix}-tg"
      Tags:
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-tg"
        - Key: "Project"
          Value: !Ref ProjectName
      Port: 80
      Protocol: HTTP
      Matcher:
        HttpCode: '200'
      VpcId: !Ref VPC
      TargetType: instance
      Targets:
        - Id: !Ref EC2one
        - Id: !Ref EC2two

  ALB:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Type: "application"
      Scheme: "internet-facing"
      Name: !Sub ${resourceNamePrefix}-alb
      Tags:
        - Key: Name
          Value: !Sub ${resourceNamePrefix}-alb
        - Key: "Project"
          Value: !Ref ProjectName
      Subnets: 
        - !Ref PublicSubnet1
        - !Ref PublicSubnet2
      SecurityGroups: 
        - !Ref SGForALB
  
  SGForALB:
    Type: AWS::EC2::SecurityGroup
    Properties: 
      GroupDescription: 'for alb'
      GroupName: !Sub "${resourceNamePrefix}-sg-alb"
      SecurityGroupIngress: 
        - IpProtocol: 'tcp'
          FromPort: 443
          ToPort: 443
          CidrIp: 0.0.0.0/0
        - IpProtocol: 'tcp'
          FromPort: 80
          ToPort: 80
          CidrIp: 0.0.0.0/0
      Tags: 
        - Key: "Name"
          Value: !Sub "${resourceNamePrefix}-sg-alb"
        - Key: "Project"
          Value: !Ref ProjectName
      VpcId: !Ref VPC

  ListenerHTTP:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - Type: redirect
          RedirectConfig:
            Protocol: HTTPS
            Port: 443
            Host: '#{host}'
            Path: '/#{path}'  
            Query: '#{query}'
            StatusCode: 'HTTP_301'
      LoadBalancerArn: !Ref ALB
      Port: 80
      Protocol: HTTP

  
  ListenerHTTPS:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties: 
      Certificates: 
        - CertificateArn: !Ref Cert
      DefaultActions:
        - Type: authenticate-cognito
          Order: 1
          AuthenticateCognitoConfig:
            UserPoolArn: !GetAtt UserPool.Arn
            UserPoolClientId: !Ref UserPoolClient
            UserPoolDomain: !Ref UserPoolDomain
        - Type: forward
          TargetGroupArn: !Ref ALBTargetGroup
          Order: 2
      LoadBalancerArn: !Ref ALB
      Port: 443
      Protocol: HTTPS

  UserPool: 
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: !Sub "${resourceNamePrefix}-userpool"
      UsernameConfiguration:
        CaseSensitive: true
      Schema:
        - Name: name
          AttributeDataType: String
          Required: true
      Policies: 
        PasswordPolicy:
          MinimumLength: 9
          RequireLowercase: false
          RequireNumbers: false
          RequireSymbols: false
          RequireUppercase: false
          TemporaryPasswordValidityDays: 90
      AdminCreateUserConfig: 
        AllowAdminCreateUserOnly: true
      AccountRecoverySetting:
        RecoveryMechanisms:
          - Name: admin_only
            Priority: 1
  
  UserPoolClient:
    Type: "AWS::Cognito::UserPoolClient"
    Properties: 
      ClientName: !Sub "${resourceNamePrefix}-client"
      GenerateSecret: true
      UserPoolId: !Ref UserPool
      CallbackURLs: 
        - !Sub "https://${FQDN}/oauth2/idpresponse"
      AllowedOAuthFlows:
        - "code"
      AllowedOAuthScopes: 
        - "email"
        - "openid"
        - "phone"
      AllowedOAuthFlowsUserPoolClient: true
      ExplicitAuthFlows:
        - "ALLOW_USER_SRP_AUTH"
        - "ALLOW_REFRESH_TOKEN_AUTH"
        - "ALLOW_USER_PASSWORD_AUTH"
      SupportedIdentityProviders:
        - "COGNITO"

  UserPoolDomain:
    Type: "AWS::Cognito::UserPoolDomain"
    Properties:
      Domain: !Sub "${resourceNamePrefix}-domain"
      UserPoolId: !Ref UserPool

  Cert: 
    Type: AWS::CertificateManager::Certificate
    Properties: 
      DomainName: !Ref FQDN
      Tags: 
        - Key: Name
          Value: !Sub ${resourceNamePrefix}-cert
        - Key: "Project"
          Value: !Ref ProjectName
      ValidationMethod: DNS
      DomainValidationOptions:
        - DomainName: !Ref FQDN
          HostedZoneId: !Ref hostedZoneId
  
  ALBARecord:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneId: !Ref hostedZoneId
      Name: !Ref FQDN
      Type: A
      AliasTarget:
        DNSName: !GetAtt ALB.DNSName
        HostedZoneId: !GetAtt ALB.CanonicalHostedZoneID
        EvaluateTargetHealth: false

Outputs:
  EFSFileSystemId:
    Description: efs
    Value: !Ref EFS

このテンプレートを実行することによって、今回の環境で使用するインフラについては全て構築されます。

テンプレートに必要なパラメータは以下の通りです。

パラメータ名 設定内容 設定例
AMI 最新のubuntuのAMI ami-09a81b370b76de6a2
DBPassword 新たに設定するDBのパスワード LYimv6sXnj72jGRH
DBUsername 新たに設定するDBのユーザ名 dbuser
DeletionProtection DBの削除保護を有効化するか否か false
DeployRegion デプロイ先のリージョン ap-northeast-1
EngineVersion デプロイするMySQLのバージョン 8.0.28
FQDN CTFdにアクセスするときのFQDN test.example.com
hostedZoneId 上記のFQDNを名前解決するためのRoute 53 ホストゾーン Z00000000D00M0FZRZ0BI
keyPair EC2インスタンス用のキーペアの名前 MWS-Score-Server-Keypair
ProjectName Projectタグの値 作成するすべてのリソースに可能な限りProjectタグを付けます。 cf-platform-2023
resourceNamePrefix リソースの名前の先頭につける名前 cf-platform-test1

2. EC2インスタンスでの作業

EC2インスタンスへのアクセスにはAWS Systems Manager Session Manager(以下、SSM)を使用します。SSMを使用することで、プライベートサブネットに存在するインスタンスにBastionなしでアクセスすることができ、セキュリティグループでSSH接続を許可する必要もありません。(メリット多いのでぜひ使いましょう) さて、本題。アクセス後、EC2インスタンス内で必要な作業を以下に示します。

これらの作業は構築したインスタンスすべて(テンプレート通りなら2つ)に対して行います。全てのインスタンスに対して作業を行わない場合、一つのインスタンスで作業を行った後に、当該インスタンスをもとにAMIを作成して、作成したAMIをもとに他インスタンスを起動します。(テンプレートでAMI指定部分だけ変更した後に、CloudFormationの変更セット機能を使用すると楽です。)

2.1 必要なサービスのインストール

以下ではSSMでシェルにアクセスし、cdコマンドで/home/ssm-userに移動後に作業することをお勧めします。

  1. Dockerやdocker composeをインストール
  2. CTFdをダウンロード
sudo apt install unzip
wget https://github.com/CTFd/CTFd/archive/refs/tags/3.6.0.zip
unzip 3.6.0.zip

2.2. EFSのマウント

EFSをマウントします。NFSは起動のたびにマウントする必要があるため、systemdを使います。

NFSに必要なパッケージをインストールします。

sudo apt install nfs-common

次に、/etc/systemd/system/auto-mount.serviceという名前でファイルを生成し、マウントコマンドを書き込みます。

Description = Auto EFS Mount.
After=local-fs.target
ConditionPathExists=/home/ssm-user/CTFd-3.6.0/.data

[Service]
ExecStart=/home/ssm-user/AutoMount.sh
Restart=no
Type=simple

[Install]
WantedBy=multi-user.target

/home/ssm-user/AutoMount.shに以下を書き込みます。

#!/usr/bin/bash

mkdir -p "/home/ssm-user/CTFd-3.6.0/.data"
mount -t nfs4 -o nfsvers=4.1,rsize=1048576,wsize=1048576,hard,timeo=600,retrans=2,noresvport <ファイルシステムID>.efs.<リージョン名>.amazonaws.com:/ "/home/ssm-user/CTFd-3.6.0/.data"

<ファイルシステムID>はCloudFormationの出力EFSFileSystemIdの値に置き換えてください。また<リージョン名>はCloudFormationのパラメータで指定した値に置き換えてください。

一般的にホームディレクトリにrootが実行するコマンドを置くことはよくないかもしれませんが、ユーザubuntuはパスワードなしでsudoを実行できる設定のため、新たな脅威にはならないと判断しました。気になる方はsudo chown root:root /home/ssm-user/AutoMount.shchmod 755 /home/ssm-user/AutoMount.shを実行してください。

2.3. docker-compose.ymlの修正

データの保持はAWSのマネージドサービス(RDS、ElastiCache、EFS)に移動させたため、CTFd公式のdocker-compose.ymlを以下のように修正しました。

version: '2'

services:
  ctfd:
    build: .
    user: root
    restart: always
    ports:
      - "8000:8000"
    environment:
      - UPLOAD_FOLDER=/var/uploads
      - DATABASE_URL=mysql+pymysql://<データベースのユーザ名>:<データベースのパスワード>@<データベースのエンドポイントのURL>/ctfd
      - REDIS_URL=redis://<ElastiCache for Redisの設定エンドポイントのURL>:6379
      - WORKERS=2
      - SECRET_KEY=aaaaaa
      - LOG_FOLDER=/var/log/CTFd
      - ACCESS_LOG=-
      - ERROR_LOG=-
      - REVERSE_PROXY=true
    volumes:
      - .data/CTFd/logs1:/var/log/CTFd
      - .data/CTFd/uploads:/var/uploads
      - .:/opt/CTFd:ro
    networks:
        default:
        internal:

  nginx:
    image: nginx:stable
    restart: always
    volumes:
      - ./conf/nginx/http.conf:/etc/nginx/nginx.conf
    ports:
      - 80:80
    depends_on:
      - ctfd

networks:
    default:
    internal:
        internal: true

<データベースのユーザ名><データベースのパスワード>はCloudFormationのパラメータで指定した値に置き換えてください。

<データベースのエンドポイントのURL><ElastiCache for Redisの設定エンドポイントのURL>はAWS Management Consoleで確認して置き換えてください。

パスワードやSECRET_KEYについて、パスワード生成器などを使って十分な強度を確保してください。

いよいよ、起動します。

sudo docker compose up -d

3. AWS Cognitoの設定

CloudFormationテンプレートで作成したCognito UserPoolに競技関係者用のユーザーとその資格情報を作成します。

CTFdではパスワードポリシを設定できないことから、MWS Cup参加者以外にログインされるリスクが高いと判断しました。このため、CTFdにアクセスする際に、参加者共通のパスワードを要求するためにAWS Cognitoを使用しています。

工夫した点について

  1. CTFdが保存する情報をすべてマネージドサービスに移動(信頼性・可用性バク上がり!!!)
    • CTFdが保存する情報をすべて以下のマネージドサービスに移動させることで、2つのアベイラビリティゾーンにまたがって保存されるようにしました。
      • RDB(データベース)
      • ElastiCache for Redis(キャッシュ)
      • EFS(ファイルシステム)
  2. ALBを導入することでEC2インスタンスの負荷分散
    • EC2インスタンスのサイズをt3.mediumにし、2台構成としました。昨年度はm5.xlarge1台で運用していました。結果として今年度の構成はインスタンスの数は増えていますが、より低コストで、冗長化された構成となりました。
  3. Basic認証からAWS Cognitoに変更
    • Dockerで動いていたNginxの設定を修正する必要が無くなりました。(ちょっとだけ手間が省けた!)

実際のアクセス状況

今年度のMWS Cupの競技者人数は88名、スタッフを含めた関係者を合わせると、この環境にアクセスする人数はおよそ120名程度となりました。 MWS Cupでは、開始時刻直後と、終了時刻直後にアクセス数のピークが来ます。 LoadBalancerのダッシュボード上で確認したところ、レスポンスタイムは開始直後で、最大1.8秒かかりました。

競技開始時刻前後のレスポンスタイム

リクエスト数はピーク時の開始直後に約2700リクエスト、終了直後に約2000リクエストほどが来ていました。

競技中のリクエスト数

CPUとメモリの仕様状況

競技後、競技者の何人かにスコアサーバの応答速度についての不満をインタビューしてみましたが、特に不満を感じない応答速度だったようです。(やったね)

昨年度はEC2インスタンスがそのまま外部からのアクセス先になっていたため、通信状況をAWSコンソール上で可視化することはできませんでした。(CTFdの機能としてローカルファイルに通信ログを保存はしていましたが、視認性が悪かったです。) 今回はALBを通すことでALBのメトリクスとして通信状況が可視化でき、簡単に確認が出来るようになりました!

おわりに

MWS Cupのスコアサーバで実際に使用したAWSの設定やCTFdの設定を紹介しました。 CTFdを使って参加人数が少し大きい大会を開く方々の参考になればうれしいです。