code-server に Kiro CLI と Kiro Crew を同居させ Amazon Cognito で MFA 認証をさせる – EC2 インスタンス実装編

こんにちは、広野です。

以前、code-server を nginx で HTTPS 化して Amazon Cognito で MFA 認証をさせる構成を紹介しました。本記事では、同じ Amazon EC2 インスタンス上で AI エージェントの Kiro CLI と Kiro Crew を立ち上げる構成を紹介します。Amazon EC2 インスタンス実装編です。

実装は 3つの AWS CloudFormation テンプレートに分けてデプロイします。本記事はその 2つ目で、Amazon EC2 インスタンスと、それに付随するリソースを作るテンプレートを紹介します。

  • Cognito 実装編
  • EC2 インスタンス実装編 (本記事)
  • SSM ドキュメント編

作成物

このテンプレートをデプロイすると、次のリソースができます。

  • Amazon EC2 インスタンス (Amazon Linux 2023 x86_64)
  • Elastic IP アドレス
  • セキュリティグループ。許可した送信元 IP アドレスからの HTTPS (443) だけを受け付けます。
  • IAM ロールとインスタンスプロファイル
  • Amazon Route 53 の A レコード 2つ。code-server 用と Kiro Crew 用で、どちらも同じ Elastic IP アドレスを指します。
  • Amazon Cognito のアプリケーションクライアントと、マネージドログインのデザイン。Cognito 編のユーザープールに、このインスタンス用のクライアントを追加します。
  • Amazon CloudWatch Logs のロググループ 4つ

このテンプレートは、EC2 インスタンス 1台ごとにデプロイします。YourId を変えれば、同じ AWS アカウントに何台でも作れます。

なお、このテンプレートが実施することは、インスタンスを起動して、後続の作業に必要な情報を渡すところまでです。code-server や Kiro Crew などのソフトウェアは、まだ入りません。ソフトウェアの導入は、続編の SSM ドキュメント編で行います。

前提事項

事前に用意するもの

以下は構築済みであることを前提にします。構築方法は、この記事では扱いません。これらリソースの情報を AWS CloudFormation テンプレートに入力することになります。

事前に用意するもの 用途 テンプレートのパラメータ
Cognito 編でデプロイしたスタック ユーザープールの情報を取り込む。同じ SystemName と SubName を指定する SystemName、SubName
Amazon Route 53 のパブリックホストゾーン (example.com) インスタンスの DNS レコードを登録する。Let’s Encrypt の証明書発行にも使う DomainName、Route53HostZoneId
VPC と、インターネットに出られるパブリックサブネット インスタンスを配置する VpcId、InstanceSubnet

このテンプレートは、Cognito 編のスタックが出力した値を、スタック間参照 (エクスポート値のインポート) で取り込みます。取り込む値は、ユーザープールの ID、ARN、プロバイダー URL の 3つです。エクスポート名に `SystemName` と `SubName` が入っているため、Cognito 編と同じ値を指定しないと、デプロイに失敗します。

デプロイ時に決める値

パラメータ 内容
YourId インスタンスの識別子。小文字の英数字とハイフンが使えます。URL (FQDN) やリソース名に入ります。例: client01
YourEmail このインスタンスにログインできる人のメールアドレス。Cognito に登録するアドレスと同じものにします。Let’s Encrypt の登録にも使います。既定値の xxx@xxx.xxx のままにはできません
AllowedSubnet 接続を許可する送信元の IPv4 アドレス (CIDR 形式)。例: 203.0.113.10/32
InstanceType インスタンスタイプ。t3.xlarge か t3.2xlarge から選びます。Kiro Crew は、10GB 以上のメモリが必要です (推奨は 16GB)
InstanceVolumeSize ルートボリュームのサイズ (GB)。20、30、50 から選びます
TimeZone OS のタイムゾーン。既定値は Asia/Tokyo です
LogRetentionInDays CloudWatch Logs のログの保持期間 (日)

設計のポイント

セキュリティグループ

セキュリティグループは、インバウンドで開けるポートを `AllowedSubnet` からの 443 だけにしています。アウトバウンドは、すべて許可しています。パッケージの取得や、GitHub、Let’s Encrypt、AWS の API への通信に必要です。

起動テンプレートと EC2 インスタンス

インスタンスの設定は、起動テンプレートで定義します。主な設定は次のとおりです。

  • IMDSv2 を必須にしています。(`HttpTokens: required`) インスタンスメタデータの取得には、セッショントークンが必要になります。
  • ルートボリュームは gp3 で、暗号化しています。
  • Elastic IP アドレスを関連付けます。

IAM ロール

インスタンスに付ける IAM ロールの権限は、用途ごとに絞っています。

権限 範囲 用途
AmazonSSMManagedInstanceCore (管理ポリシー) – SSM の Run Command や Session Manager を使う
AWSCodeCommitPowerUser (管理ポリシー) – CodeCommit のリポジトリを git で使う。SSM ドキュメントが git の認証ヘルパーを設定する
route53:ListHostedZones、route53:GetChange すべて Let’s Encrypt の DNS-01 チャレンジ
route53:ChangeResourceRecordSets 指定したホストゾーンだけ Let’s Encrypt の DNS-01 チャレンジ (TXT レコードの登録)
cognito-idp:DescribeUserPoolClient Cognito 編のユーザープールだけ アプリケーションクライアントのシークレットを取得する
logs:DescribeLogGroups すべて CloudWatch エージェントがロググループを確認する
logs:CreateLogStream、logs:DescribeLogStreams、logs:PutLogEvents このテンプレートで作る 4つのロググループだけ CloudWatch エージェントがログを送る

ロググループは CloudFormation でデプロイするため、logs:CreateLogGroup と logs:PutRetentionPolicy は付けていません。

クライアントシークレットは、テンプレートにも次に説明する環境情報にも入れていません。SSM ドキュメントが、実行のたびに cognito-idp:DescribeUserPoolClient で取得します。

Cognito アプリケーションクライアント

アーキテクチャ編のとおり、アプリケーションクライアントは EC2 インスタンス 1台につき 1つ作ります。主な設定は次のとおりです。

  • クライアントシークレットを生成します。
  • 認可コードフロー (code) を使います。スコープは openid、email、profile です。
  • コールバック URL とログアウト URL は、code-server 用と Kiro Crew 用の 2つずつです。
  • トークンの有効期間は、更新トークンが 3日、アクセストークンと ID トークンが 6時間です。更新トークンの 3日は、oauth2-proxy の Cookie の有効期間 (72時間) と同じ長さです。
  • ユーザーが存在しない場合のエラー内容を隠します。(PreventUserExistenceErrors: ENABLED)

UserData が行うこと

EC2 の UserData で行うのは、次の作業だけです。ソフトウェアの導入は SSM ドキュメントに任せます。

  1. OS のタイムゾーン設定
  2. ログ用のフォルダ作成
  3. CloudWatch エージェント導入
  4. git 導入
  5. AWS CLI v2 更新
  6. 環境情報 JSON (environment.json) 作成

UserData のスクリプトは、途中で失敗すると [ERROR] の行を出力して終了します。この場合、environment.json は作成されません。

環境情報 JSON (environment.json)

UserData の最後に、/etc/ide-setup-config/environment.json を生成します。後続のセットアッププロセスとしてソフトウェアインストールのために SSM ドキュメントを実行させますが、そのときにこのファイルの値を読み取ります。インスタンスごとに異なる環境変数 (名前、FQDN、Cognito の情報など) をここに集約させており、SSM ドキュメントにはインスタンス固有の値が入りません。SSM ドキュメントを共用できるようにするための設計です。

中のデータ構造は、以下のようになります。(値はダミー)

{
  "schemaVersion": 1,
  "systemName": "example",
  "subName": "dev",
  "yourId": "client01",
  "yourEmail": "user@example.com",
  "region": "ap-northeast-1",
  "domainName": "example.com",
  "timeZone": "Asia/Tokyo",
  "codeserverFqdn": "codeserver-example-dev-client01.example.com",
  "kirocrewFqdn": "kirocrew-example-dev-client01.example.com",
  "userPoolId": "ap-northeast-1_AbCdEfGhI",
  "userPoolClientId": "1example23456789abcdefghij",
  "userPoolProviderUrl": "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_AbCdEfGhI",
  "cognitoDomain": "example-dev.auth.ap-northeast-1.amazoncognito.com",
  "workDir": "/home/ec2-user/workplace"
}

各項目の意味と、SSM ドキュメントでの用途は以下の通りです。

  • schemaVersion: このファイルの形式のバージョンです。SSM ドキュメントは、自分が対応するバージョンと一致するかを最初に確認します。一致しなければ、何も変更せずに失敗します。項目を増やしたり変えたりするときは、この値を上げて、テンプレートとドキュメントのずれを検知できるようにします。
  • systemName、subName: パラメータ SystemName と SubName の値です。SSM ドキュメントのログ出力と、エラーメッセージに使います。
  • yourId: パラメータ YourId の値です。git の user.name の初期値に使います。
  • yourEmail: パラメータ YourEmail の値です。git の user.email の初期値、Let’s Encrypt の登録、oauth2-proxy が許可するメールアドレスに使います。
  • region: インスタンスがあるリージョンです。Cognito からクライアントシークレットを取得するときの、AWS CLI のリージョンに使います。
  • domainName: パラメータ DomainName の値です。現在の SSM ドキュメントは、この値を確認するだけで、他には使っていません。
  • timeZone: パラメータ TimeZone の値です。Kiro Crew のタイムゾーン設定に使います。
  • codeserverFqdn: code-server にアクセスする FQDN です。codeserver-<SystemName>-<SubName>-<YourId>.<DomainName> の形式です。証明書の発行、nginx の server_name、oauth2-proxy のリダイレクト URL、サインアウト用リンクに使います。
  • kirocrewFqdn: Kiro Crew にアクセスする FQDN です。kirocrew-<SystemName>-<SubName>-<YourId>.<DomainName> の形式です。証明書の発行、nginx の server_name、Kiro Crew 用 oauth2-proxy のリダイレクト URL、Kiro Crew の dashboard.url に使います。
  • userPoolId: Cognito ユーザープールの ID です。クライアントシークレットの取得に使います。
  • userPoolClientId: このインスタンス用のアプリケーションクライアントの ID です。oauth2-proxy の --client-id、クライアントシークレットの取得、サインアウト用リンクに使います。
  • userPoolProviderUrl: ユーザープールの OIDC プロバイダー URL です。oauth2-proxy の --oidc-issuer-url に使います。
  • cognitoDomain: Cognito マネージドログインのドメインです。<SystemName>-<SubName>.auth.<リージョン>.amazoncognito.com の形式で、Cognito 編で作ったユーザープールのドメインです。oauth2-proxy のログイン、トークン取得、ユーザー情報の URL と、サインアウト用リンクに使います。
  • workDir: 作業用のフォルダです。code-server の初期フォルダ、サインアウト用リンクのファイルの置き場所、Kiro Crew の既定のワークスペースに使います。

ファイルは一時ファイルに書いてから mv で置き換えます。それにより中途半端な内容のファイルを SSM ドキュメントが読み込むことを防止しています。また、UserData の最後に作るので、このファイルが存在することを UserData が最後まで成功したこととして位置づけています。

CloudWatch Logs

CloudWatch エージェントが、次の 4つのロググループにログを送ります。ロググループの名前は、/<SystemName>-<SubName>-<YourId>/ で始まります。

ロググループ (末尾の部分) 内容
cloud-init-output UserData の出力 (/var/log/cloud-init-output.log)
ide-setup SSM ドキュメントの実行ログ (/var/log/ide-setup/)。ドキュメントごとにログストリームが分かれます
letsencrypt 証明書発行のログ
nginx-error nginx のエラーログ

保持期間は、`LogRetentionInDays` で指定します。

AWS CloudFormation テンプレート

AWSTemplateFormatVersion: "2010-09-09"
Description: The CloudFormation template that creates an Amazon Linux 2023 x86_64 EC2 instance with code-server and Cognito authentication.
# ------------------------------------------------------------#
# General Information
#
# Last update: 2026-11-01
# Author: Hirono
# ------------------------------------------------------------#

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  SystemName:
    Type: String
    Description: System name. use lower case only. (e.g. example)
    Default: example
    MaxLength: 10
    MinLength: 1
    AllowedPattern: "^[a-z0-9]+$"

  SubName:
    Type: String
    Description: System sub name. use lower case only. (e.g. prod or dev)
    Default: dev
    MaxLength: 10
    MinLength: 1
    AllowedPattern: "^[a-z0-9]+$"

  DomainName:
    Type: String
    Description: Domain name for URL. xxxxx.xxx
    Default: example.com
    AllowedPattern: "^(?!-)(?:[a-zA-Z0-9-]{0,62}[a-zA-Z0-9])(?:\\.(?!-)(?:[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]))*$"

  VpcId:
    Type: AWS::EC2::VPC::Id
    Description: Choose a existing VPC ID you deploy the EC2 instance in.

  InstanceSubnet:
    Type: AWS::EC2::Subnet::Id
    Description: Choose an existing Public Subnet ID you deploy the EC2 instance in.

  InstanceType:
    Type: String
    Description: EC2 instance type
    Default: t3.xlarge
    AllowedValues:
      - t3.xlarge
      - t3.2xlarge

  InstanceVolumeSize:
    Type: Number
    Description: The volume size in GB
    Default: 30
    AllowedValues:
      - 20
      - 30
      - 50

  AllowedSubnet:
    Type: String
    Description: Allowed source IPv4 subnet and subnet mask. (e.g. xxx.xxx.xxx.xxx/32)
    Default: 203.0.113.10/32
    AllowedPattern: "^((25[0-5]|2[0-4][0-9]|1?[0-9]{1,2})\\.){3}(25[0-5]|2[0-4][0-9]|1?[0-9]{1,2})\\/([0-9]|[1-2][0-9]|3[0-2])$"

  TimeZone:
    Type: String
    Description: The specified time zone.
    Default: Asia/Tokyo
    AllowedPattern: "^(Africa|America|Antarctica|Arctic|Asia|Atlantic|Australia|Europe|Indian|Pacific)/[A-Za-z0-9_/-]+$"

  Route53HostZoneId:
    Type: String
    Description: Route 53 public host zone ID for the SSL certificate.
    Default: Z0123456789EXAMPLE
    MaxLength: 30
    MinLength: 1

  YourId:
    Type: String
    Description: The individual instance id (or number) specified from the administrator. use lower case letters, numbers and hyphens only.
    Default: client01
    MaxLength: 20
    MinLength: 1
    AllowedPattern: "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"

  YourEmail:
    Type: String
    Description: Your email address for the access control.
    Default: xxx@xxx.xxx
    AllowedPattern: "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"

  LogRetentionInDays:
    Type: Number
    Description: Retention period in days for the CloudWatch Logs log groups of the instance.
    Default: 400
    AllowedValues:
      - 1
      - 3
      - 5
      - 7
      - 14
      - 30
      - 60
      - 90
      - 120
      - 150
      - 180
      - 365
      - 400
      - 545
      - 731
      - 1096
      - 1827
      - 2192
      - 2557
      - 2922
      - 3288
      - 3653

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "General Configuration"
        Parameters:
          - SystemName
          - SubName
          - YourId
          - YourEmail
      - Label:
          default: "Domain Configuration"
        Parameters:
          - DomainName
          - Route53HostZoneId
      - Label:
          default: "Network Configuration"
        Parameters:
          - VpcId
          - InstanceSubnet
          - AllowedSubnet
      - Label:
          default: "EC2 Configuration"
        Parameters:
          - InstanceType
          - InstanceVolumeSize
      - Label:
          default: "OS Configuration"
        Parameters:
          - TimeZone
      - Label:
          default: "Log Configuration"
        Parameters:
          - LogRetentionInDays
  cfn-lint:
    config:
      ignore_checks:
        - "W3005"

Resources:
# ------------------------------------------------------------#
# CloudWatch Logs (logs sent by the CloudWatch agent on the instance)
# ------------------------------------------------------------#
  LogGroupCloudInitOutput:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/${SystemName}-${SubName}-${YourId}/cloud-init-output"
      RetentionInDays: !Ref LogRetentionInDays
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}

  LogGroupIdeSetup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/${SystemName}-${SubName}-${YourId}/ide-setup"
      RetentionInDays: !Ref LogRetentionInDays
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}

  LogGroupLetsEncrypt:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/${SystemName}-${SubName}-${YourId}/letsencrypt"
      RetentionInDays: !Ref LogRetentionInDays
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}

  LogGroupNginxError:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/${SystemName}-${SubName}-${YourId}/nginx-error"
      RetentionInDays: !Ref LogRetentionInDays
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}

# ------------------------------------------------------------#
# EC2 Launch template
# ------------------------------------------------------------#
  EC2LaunchTemplate:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateName: !Sub codeserver-${SystemName}-${SubName}-${YourId}
      LaunchTemplateData:
        InstanceType: !Ref InstanceType
        # For ap-north-east-1 region as of 2026-10-30, DO NOT CHANGE ImageId. OR INITIALIZE YOUR EXISTANT EBS.
        ImageId: ami-0c051224e18c11f2e
        BlockDeviceMappings:
          - Ebs:
              VolumeSize: !Ref InstanceVolumeSize
              VolumeType: gp3
              DeleteOnTermination: true
              Encrypted: true
            DeviceName: /dev/xvda
        NetworkInterfaces:
          - SubnetId: !Ref InstanceSubnet
            Groups:
              - !Ref Ec2SecurityGroup
            DeviceIndex: 0
            AssociatePublicIpAddress: true
        MetadataOptions:
          HttpTokens: required
        TagSpecifications:
          - ResourceType: volume
            Tags:
              - Key: Cost
                Value: !Sub ${SystemName}-${SubName}
    DependsOn:
      - Ec2SecurityGroup

# ------------------------------------------------------------#
# EC2 Security Group
# ------------------------------------------------------------#
  Ec2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: !Ref VpcId
      GroupDescription: Allow HTTPS access from specified subnets
      SecurityGroupIngress:
        - Description: Allow HTTPS from the specified client
          FromPort: 443
          IpProtocol: tcp
          ToPort: 443
          CidrIp: !Ref AllowedSubnet
      SecurityGroupEgress:
        - Description: Allow all outbound traffic
          IpProtocol: -1
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}
        - Key: Name
          Value: !Sub SG-codeserver-${SystemName}-${SubName}-${YourId}

# ------------------------------------------------------------#
# EC2 Role / Instance Profile (IAM)
# ------------------------------------------------------------#
  Ec2Role:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub Ec2Role-codeserver-${SystemName}-${SubName}-${YourId}
      Description: This role allows the EC2 instance to use SSM, CodeCommit, Route 53, Cognito, and CloudWatch Logs.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
        - arn:aws:iam::aws:policy/AWSCodeCommitPowerUser
        # for Admin, not recommended
        # - arn:aws:iam::aws:policy/AdministratorAccess
      Policies:
        - PolicyName: !Sub Ec2Policy-codeserver-${SystemName}-${SubName}-${YourId}
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              # for Let's Encrypt
              - Effect: Allow
                Action:
                  - route53:ListHostedZones
                  - route53:GetChange
                Resource:
                  - "*"
              # for Let's Encrypt
              - Effect: Allow
                Action:
                  - route53:ChangeResourceRecordSets
                Resource:
                  - !Sub arn:aws:route53:::hostedzone/${Route53HostZoneId}
              # for Cognito authentication
              - Effect: Allow
                Action:
                  - cognito-idp:DescribeUserPoolClient
                Resource:
                  - Fn::ImportValue:
                      !Sub CognitoUserPoolArn-${SystemName}-${SubName}
              # for CloudWatch Logs (the log groups are created in advance, so no CreateLogGroup or PutRetentionPolicy)
              - Effect: Allow
                Action:
                  - logs:DescribeLogGroups
                Resource: "*"
              # for CloudWatch Logs: the logs sent by the CloudWatch agent (the log groups are created in this template)
              - Effect: Allow
                Action:
                  - logs:CreateLogStream
                  - logs:DescribeLogStreams
                  - logs:PutLogEvents
                Resource:
                  - !GetAtt LogGroupCloudInitOutput.Arn
                  - !GetAtt LogGroupIdeSetup.Arn
                  - !GetAtt LogGroupLetsEncrypt.Arn
                  - !GetAtt LogGroupNginxError.Arn
    DependsOn:
      - LogGroupCloudInitOutput
      - LogGroupIdeSetup
      - LogGroupLetsEncrypt
      - LogGroupNginxError

  Ec2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      InstanceProfileName: !Ref Ec2Role
      Path: /
      Roles:
        - !Ref Ec2Role
    DependsOn:
      - Ec2Role

# ------------------------------------------------------------#
# Elastic IP address for EC2 instance
# ------------------------------------------------------------#
  EipEc2:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}
        - Key: Name
          Value: !Sub eip-codeserver-${SystemName}-${SubName}-${YourId}

# ------------------------------------------------------------#
# EIP Association
# ------------------------------------------------------------#
  EIPAssociation:
    Type: AWS::EC2::EIPAssociation
    Properties:
      AllocationId: !GetAtt EipEc2.AllocationId
      InstanceId: !Ref Ec2Instance
    DependsOn:
      - Ec2Instance
      - EipEc2

# ------------------------------------------------------------#
# EC2
# ------------------------------------------------------------#
  Ec2Instance:
    Type: AWS::EC2::Instance
    Properties:
      IamInstanceProfile: !Ref Ec2InstanceProfile
      LaunchTemplate:
        LaunchTemplateId: !Ref EC2LaunchTemplate
        Version: !GetAtt EC2LaunchTemplate.LatestVersionNumber
      UserData:
        Fn::Base64: !Sub
          - |
            #!/bin/bash
            set -euxo pipefail
            # ----- Result marker: a failure leaves an [ERROR] line (visible in CloudWatch Logs) -----
            on_exit() {
              local rc=$?
              set +x
              if [ "$rc" -ne 0 ]; then
                echo "[ERROR] UserData failed (exit code: ${!rc}). environment.json was NOT written. See the lines above."
              fi
            }
            trap on_exit EXIT
            # ----- OS settings -----
            timedatectl set-timezone ${TimeZone}
            # ----- Folders (created before they are needed) -----
            install -d -o root -g root -m 755 /etc/ide-setup-config /var/log/ide-setup
            # ----- CloudWatch agent: a failure here must not stop the rest -----
            setup_cloudwatch_agent() {
              dnf install -y amazon-cloudwatch-agent || return 1
              cat > /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json << 'CWAGENT' || return 1 { "logs": { "logs_collected": { "files": { "collect_list": [ { "file_path": "/var/log/cloud-init-output.log", "log_group_name": "/${SystemName}-${SubName}-${YourId}/cloud-init-output", "log_stream_name": "{instance_id}" }, { "file_path": "/var/log/ide-setup/Setup-CodeServer-*.log", "log_group_name": "/${SystemName}-${SubName}-${YourId}/ide-setup", "log_stream_name": "{instance_id}/Setup-CodeServer" }, { "file_path": "/var/log/ide-setup/Setup-NodePython-*.log", "log_group_name": "/${SystemName}-${SubName}-${YourId}/ide-setup", "log_stream_name": "{instance_id}/Setup-NodePython" }, { "file_path": "/var/log/ide-setup/Setup-PythonModules-*.log", "log_group_name": "/${SystemName}-${SubName}-${YourId}/ide-setup", "log_stream_name": "{instance_id}/Setup-PythonModules" }, { "file_path": "/var/log/ide-setup/Setup-KiroCli-*.log", "log_group_name": "/${SystemName}-${SubName}-${YourId}/ide-setup", "log_stream_name": "{instance_id}/Setup-KiroCli" }, { "file_path": "/var/log/ide-setup/Setup-KiroCrew-*.log", "log_group_name": "/${SystemName}-${SubName}-${YourId}/ide-setup", "log_stream_name": "{instance_id}/Setup-KiroCrew" }, { "file_path": "/var/log/ide-setup/Setup-LibreOffice-*.log", "log_group_name": "/${SystemName}-${SubName}-${YourId}/ide-setup", "log_stream_name": "{instance_id}/Setup-LibreOffice" }, { "file_path": "/var/log/letsencrypt/letsencrypt.log", "log_group_name": "/${SystemName}-${SubName}-${YourId}/letsencrypt", "log_stream_name": "{instance_id}" }, { "file_path": "/var/log/nginx/error.log", "log_group_name": "/${SystemName}-${SubName}-${YourId}/nginx-error", "log_stream_name": "{instance_id}" } ] } } } } CWAGENT /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -s \ -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json || return 1 } if setup_cloudwatch_agent; then echo "[INFO] CloudWatch agent started." else echo "[WARN] The CloudWatch agent could not be set up. Logs will NOT reach CloudWatch Logs." echo "[WARN] Read /var/log/cloud-init-output.log with SSM Session Manager instead." fi # ----- Packages (git is used by several SSM documents) ----- dnf install -y git # ----- AWS CLI v2 update: a failure only leaves a warning ----- update_aws_cli() { curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip || return 1 unzip -qo /tmp/awscliv2.zip -d /tmp || return 1 /tmp/aws/install --update || return 1 } if update_aws_cli; then echo "[INFO] AWS CLI updated: $(/usr/local/bin/aws --version)" else echo "[WARN] The AWS CLI v2 update failed. The preinstalled version keeps working." echo "[WARN] Please update it yourself later:" echo "[WARN] curl -fsSL https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip -o /tmp/awscliv2.zip && cd /tmp && unzip -o awscliv2.zip && sudo ./aws/install --update" fi rm -rf /tmp/aws /tmp/awscliv2.zip # ----- Environment info JSON (non-secret; written last, atomically) ----- jq -n \ --argjson schemaVersion 1 \ --arg systemName "${SystemName}" \ --arg subName "${SubName}" \ --arg yourId "${YourId}" \ --arg yourEmail "${YourEmail}" \ --arg region "${AWS::Region}" \ --arg domainName "${DomainName}" \ --arg timeZone "${TimeZone}" \ --arg codeserverFqdn "codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}" \ --arg kirocrewFqdn "kirocrew-${SystemName}-${SubName}-${YourId}.${DomainName}" \ --arg userPoolId "${UserPoolId}" \ --arg userPoolClientId "${UserPoolAppClient}" \ --arg userPoolProviderUrl "${UserPoolProviderUrl}" \ --arg cognitoDomain "${SystemName}-${SubName}.auth.${AWS::Region}.amazoncognito.com" \ --arg workDir "/home/ec2-user/workplace" \ '{schemaVersion: $schemaVersion, systemName: $systemName, subName: $subName, yourId: $yourId, yourEmail: $yourEmail, region: $region, domainName: $domainName, timeZone: $timeZone, codeserverFqdn: $codeserverFqdn, kirocrewFqdn: $kirocrewFqdn, userPoolId: $userPoolId, userPoolClientId: $userPoolClientId, userPoolProviderUrl: $userPoolProviderUrl, cognitoDomain: $cognitoDomain, workDir: $workDir}' \ > /etc/ide-setup-config/environment.json.tmp
            chown root:root /etc/ide-setup-config/environment.json.tmp
            chmod 644 /etc/ide-setup-config/environment.json.tmp
            mv -f /etc/ide-setup-config/environment.json.tmp /etc/ide-setup-config/environment.json
            set +x
            echo "[COMPLETE] UserData finished. environment.json has been generated."
          - UserPoolProviderUrl:
              Fn::ImportValue:
                !Sub CognitoUserPoolProviderUrl-${SystemName}-${SubName}
            UserPoolId:
              Fn::ImportValue:
                !Sub CognitoUserPoolId-${SystemName}-${SubName}
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}
        - Key: Name
          Value: !Sub codeserver-${SystemName}-${SubName}-${YourId}
    DependsOn:
      - Ec2InstanceProfile
      - Route53RecordA
      - Route53RecordKirocrew
      - UserPoolAppClient
      - LogGroupCloudInitOutput
      - LogGroupIdeSetup
      - LogGroupLetsEncrypt
      - LogGroupNginxError

# ------------------------------------------------------------#
# Route 53
# ------------------------------------------------------------#
  Route53RecordA:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: !Sub ${DomainName}.
      Name: !Sub codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}.
      Type: A
      TTL: 300
      ResourceRecords:
        - !GetAtt EipEc2.PublicIp
    DependsOn: EipEc2

  # for Kiro Crew dashboard (served by the same nginx on 443, separated by server_name)
  Route53RecordKirocrew:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: !Sub ${DomainName}.
      Name: !Sub kirocrew-${SystemName}-${SubName}-${YourId}.${DomainName}.
      Type: A
      TTL: 300
      ResourceRecords:
        - !GetAtt EipEc2.PublicIp
    DependsOn: EipEc2

# ------------------------------------------------------------#
# Cognito User Pool App Client
# ------------------------------------------------------------#
  UserPoolAppClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId:
        Fn::ImportValue:
          !Sub CognitoUserPoolId-${SystemName}-${SubName}
      ClientName: !Sub ${SystemName}-${SubName}-${YourId}
      GenerateSecret: true
      RefreshTokenValidity: 3
      AccessTokenValidity: 6
      IdTokenValidity: 6
      ExplicitAuthFlows:
        - ALLOW_USER_SRP_AUTH
        - ALLOW_REFRESH_TOKEN_AUTH
      PreventUserExistenceErrors: ENABLED
      SupportedIdentityProviders: 
        - COGNITO
      CallbackURLs:
        - !Sub https://codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}/oauth2/callback
        - !Sub https://kirocrew-${SystemName}-${SubName}-${YourId}.${DomainName}/oauth2/callback
      LogoutURLs:
        - !Sub https://codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}/oauth2/sign_out
        - !Sub https://kirocrew-${SystemName}-${SubName}-${YourId}.${DomainName}/oauth2/sign_out
      DefaultRedirectURI: !Sub https://codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}/oauth2/callback
      AllowedOAuthFlows:
        - code
      AllowedOAuthFlowsUserPoolClient: true
      AllowedOAuthScopes:
        - openid
        - email
        - profile

  ManagedLoginBranding:
    Type: AWS::Cognito::ManagedLoginBranding
    Properties:
      ClientId: !Ref UserPoolAppClient
      ReturnMergedResources: true
      UseCognitoProvidedValues: true
      UserPoolId:
        Fn::ImportValue:
          !Sub CognitoUserPoolId-${SystemName}-${SubName}

# ------------------------------------------------------------#
# Output Parameters
# ------------------------------------------------------------#
Outputs:
# EC2
  codeserverUrl:
    Value: !Sub "https://codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}/?folder=/home/ec2-user/workplace"

デプロイ手順

  1. Cognito 実装編のスタックが作成済みであることを確認します。
  2. このテンプレートでスタックを作成します。パラメータを入力します。SystemName と SubName は、Cognito 実装編と同じ値にします。
  3. UserData の完了を確認します。このテンプレートは、UserData の完了を待たずに、スタックの作成を完了します。スタックが完了した後も、数分は UserData の処理が続きます。CloudWatch Logs の cloud-init-output ロググループに、次の行が出れば完了です。
[COMPLETE] UserData finished. environment.json has been generated.

失敗したときは、次の行が出ます。

[ERROR] UserData failed (exit code: ...). environment.json was NOT written. See the lines above.

CloudWatch エージェントの導入に失敗した場合は、ログが CloudWatch Logs に届きません。その場合は、SSM Session Manager でインスタンスに接続して、/var/log/cloud-init-output.log を読んでください。

スタックの「出力」には、code-server の URL が表示されます。ただし、この時点では nginx も code-server も入っていないため、接続できません。

引き続き、SSM ドキュメント編の作業を実施し、構築を完了させます。

運用上の注意

起動テンプレートの内容を変えると、インスタンスが置換される

EC2 インスタンスは、起動テンプレートのバージョンを参照しています。起動テンプレートの中身を変えると、新しいバージョンができ、インスタンスの LaunchTemplate プロパティが変わります。AWS CloudFormation では、このプロパティの更新は「置換」です。インスタンスが新しく作り直されます。

起動テンプレートの中にあるのは、ImageId、InstanceType、InstanceVolumeSize です。つまり、AMI だけでなく、インスタンスタイプやボリュームサイズをパラメータで変えても、インスタンスは置換されます。ルートボリュームは、インスタンスと一緒に削除されます。

テンプレートの ImageId の上には、次のコメントを入れています。

# For ap-north-east-1 region as of 2026-10-29, DO NOT CHANGE ImageId. OR INITIALIZE YOUR EXISTANT EBS.

AMI は変更しない、または、変更するならボリュームを初期化する (作業データを消す) 前提で行う、という意味です。置換される前に、必要なデータは別の場所に退避しておいてください。

また、このテンプレートに書いた AMI ID (ami-0f802b60247f1be4d) は、リージョンと時期に依存します。最新の AMI を確認してから使用してください。

作り直しを繰り返すと、証明書が発行できなくなる

Let’s Encrypt には、同じ FQDN の組み合わせで発行できる証明書の枚数に上限があります。7日間に 5枚までです。インスタンスを置換したり、同じ YourId でスタックの作成と削除を繰り返したりすると、上限に達して、証明書を発行できなくなります。

Rate Limits
Let’s Encrypt provides rate limits to ensure fair usage by as many people as possible. We believe these rate limits are ...

試行錯誤するときは、YourId を変えると FQDN が変わるので、この上限の影響を避けられます。

続編記事

続編記事ができ次第、こちらに追記します。

まとめ

いかがでしたでしょうか。

EC2 インスタンスと、後続の作業に必要な環境変数情報 (environment.json) 作成を、AWS CloudFormation テンプレートにしました。インスタンスは起動していますが、まだ code-server や Kiro Crew は機能しない状態です。続編記事の SSM ドキュメント編でソフトウェアを導入します。

本記事が皆様のお役に立てれば幸いです。

著者について
広野 祐司

AWS サーバーレスアーキテクチャと React を使用して社内向け e-Learning アプリ開発とコンテンツ作成に勤しんでいます。React でアプリを書き始めたら、快適すぎて他の言語には戻れなくなりました。近年は社内外への AWS 技術支援にも従事しています。AWS サービスには AWS が考える IT 設計思想が詰め込まれているので、いつも AWS を通して勉強させて頂いてまます。
取得資格:AWS 認定は13資格、Claude Certified Architect - Foundations、IT サービスマネージャ、ITIL v3 Expert 等
2020 - 2026 Japan AWS Top Engineer 受賞
2022 - 2026 AWS Ambassador 受賞
2023 当社初代フルスタックエンジニア認定
好きなAWSサービス:AWS AppSync Events / AWS Step Functions / AWS CloudFormation

広野 祐司をフォローする

クラウドに強いによるエンジニアブログです。

SCSKクラウドサービス(AWS)は、企業価値の向上につながるAWS 導入を全面支援するオールインワンサービスです。AWS最上位パートナーとして、多種多様な業界のシステム構築実績を持つSCSKが、お客様のDX推進を強力にサポートします。

AWSAI・MLアプリケーション開発クラウドソリューション
シェアする
×
タイトルとURLをコピーしました