こんにちは、広野です。
以前、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 ドキュメントに任せます。
- OS のタイムゾーン設定
- ログ用のフォルダ作成
- CloudWatch エージェント導入
- git 導入
- AWS CLI v2 更新
- 環境情報 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"
デプロイ手順
- Cognito 実装編のスタックが作成済みであることを確認します。
- このテンプレートでスタックを作成します。パラメータを入力します。
SystemNameとSubNameは、Cognito 実装編と同じ値にします。 - 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 でスタックの作成と削除を繰り返したりすると、上限に達して、証明書を発行できなくなります。

試行錯誤するときは、YourId を変えると FQDN が変わるので、この上限の影響を避けられます。
続編記事
続編記事ができ次第、こちらに追記します。
まとめ
いかがでしたでしょうか。
EC2 インスタンスと、後続の作業に必要な環境変数情報 (environment.json) 作成を、AWS CloudFormation テンプレートにしました。インスタンスは起動していますが、まだ code-server や Kiro Crew は機能しない状態です。続編記事の SSM ドキュメント編でソフトウェアを導入します。
本記事が皆様のお役に立てれば幸いです。

