はじめに
お疲れ様です。矢儀 @yuki_ink です。
こちらのAWS公式ハンズオンをやってみました。
ECSとFargate/EC2を利用した環境構築から、CI/CDパイプラインを利用したデプロイまで、一通り体験できる素晴らしいハンズオンでした。
次のようなみなさんにおすすめです。
- ECSを知識として知ってはいるが、実際に触ったことがない
- コンテナの何が優れているのか、実感を持っては理解できない
- CI/CDパイプラインでコンテナをデプロイしてみたい
この記事は、ハンズオンで学んだ内容を備忘的に残すことを目的とさせていただいてます。
詳細な手順などはAWSのハンズオンページをご確認ください!
実際に手を動かすことによって得られる学びは非常に大きいので、是非ご自身でもやってみていただければと思います!
1. VS Code Serverの構築
このハンズオンでは、開発環境として Visual Studio Code Server (VS Code Server) を利用するとのことで、まず、CloudFormationでVS Code Serverを構築していきます。
ハンズオンページの Launch Stack
ボタンをクリックして、開いたCloudFormationの画面で スタックの作成
ボタンを押せば完了なんですが、、
せっかくなので、ちょっと深堀をしてみます。
1.1. 実行するCloudFormationテンプレートについて
なかなか壮大なテンプレートですが、、
主な構成要素としてはこんな感じです。
- SSMドキュメント
- CloudWatchエージェントやAWS CLI、Git、Dockerなどのインストール
- VS Code Serverのインストールと設定
- Nginxによるリバースプロキシ設定
- SSMドキュメントを実行するLambda関数
- cfn-response モジュールで、スタック作成時に自動実行される
- EC2
- SSMドキュメントの実行先
- CloudFront
- EC2をオリジンとするディストリビューションとキャッシュ設定
- Secrets Manager シークレット
- VS Codeのユーザー認証情報(パスワード)を保管
実行するCloudFormationテンプレート
Description: Create a VS code-server instance with an Amazon CloudFront distribution for use in Workshop Studio. Version 4.0.0
Parameters:
VSCodeUser:
Type: String
Description: UserName for VS code-server
Default: participant
InstanceName:
Type: String
Description: VS code-server EC2 instance name
Default: VSCodeServer
InstanceVolumeSize:
Type: Number
Description: VS code-server EC2 instance volume size in GB
Default: 40
InstanceType:
Description: VS code-server EC2 instance type
Type: String
Default: t3.medium
AllowedPattern: '^(t3|t4|c6|c7|m6|m7|m8)[g|i|a]?(d|n|dn|-flex)?\.(nano|micro|small|medium|large|[2|4|9|12|16|18|24|32|48]?xlarge)$'
ConstraintDescription: Must be a valid t, c or m series EC2 instance type
InstanceOperatingSystem:
Description: VS code-server EC2 operating system
Type: String
Default: AmazonLinux-2023
AllowedValues: ['AmazonLinux-2023', 'Ubuntu-22', 'Ubuntu-24']
HomeFolder:
Type: String
Description: Folder to open in VS Code server
Default: /workshop
DevServerBasePath:
Type: String
Description: Base path for the application to be added to Nginx sites-available list
Default: app
DevServerPort:
Type: Number
Description: Port for the DevServer
Default: 8081
RepoUrl:
Description: Remote repo URL to clone. To not clone a remote repo, leave blank
Type: String
Default: ''
AssetZipS3Path:
Description: S3 path holding the asset zip file to be copied into the home folder. To not include any assets, leave blank
Type: String
Default: ''
BranchZipS3Path:
Description: S3 path holding the branches zip file to be checked into the git repo, with each folder being a branch. The content of each folder will added as under a branch, with the folder name being used as the branch name. To leave the empty, leave blank
Type: String
Default: ''
FolderZipS3Path:
Description: S3 path holding the folder zip file, with each folder being a subfolder of the home directory. Each folder will have its own local git repo. To not include any folders, leave blank
Type: String
Default: ''
Conditions:
IsAL2023: !Equals [!Ref InstanceOperatingSystem, 'AmazonLinux-2023']
IsGraviton: !Or
- !Equals [!Select [0, !Split ['.', !Ref InstanceType]], 't4g']
- !Equals [!Select [0, !Split ['.', !Ref InstanceType]], 'c6g']
- !Equals [!Select [0, !Split ['.', !Ref InstanceType]], 'c7g']
- !Equals [!Select [0, !Split ['.', !Ref InstanceType]], 'c7gd']
- !Equals [!Select [0, !Split ['.', !Ref InstanceType]], 'c8g']
- !Equals [!Select [0, !Split ['.', !Ref InstanceType]], 'm6g']
- !Equals [!Select [0, !Split ['.', !Ref InstanceType]], 'm6gd']
- !Equals [!Select [0, !Split ['.', !Ref InstanceType]], 'm7g']
- !Equals [!Select [0, !Split ['.', !Ref InstanceType]], 'm7gd']
- !Equals [!Select [0, !Split ['.', !Ref InstanceType]], 'm8g']
Metadata:
AWS::CloudFormation::Interface:
ParameterGroups:
- Label:
default: Instance Configuration
Parameters:
- InstanceName
- InstanceVolumeSize
- InstanceType
- InstanceOperatingSystem
- Label:
default: Code Server Configuration
Parameters:
- VSCodeUser
- HomeFolder
- DevServerBasePath
- DevServerPort
- RepoUrl
- AssetZipS3Path
- BranchZipS3Path
- FolderZipS3Path
ParameterLabels:
VSCodeUser:
default: VS code-server user name
InstanceName:
default: Instance name
InstanceVolumeSize:
default: Instance volume size
InstanceType:
default: Instance type
InstanceOperatingSystem:
default: Instance operating system
HomeFolder:
default: VS code-server home folder
DevServerBasePath:
default: Application base path
DevServerPort:
default: Application port
RepoUrl:
default: Git repo URL
AssetZipS3Path:
default: Asset file S3 path
BranchZipS3Path:
default: Branch file S3 path
FolderZipS3Path:
default: Folder file S3 path
Mappings:
ArmImage:
# aws ssm get-parameters-by-path --path "/aws/service/canonical/ubuntu/" --recursive --query "Parameters[*].Name" > canonical-ami.txt
# aws ssm get-parameters-by-path --path "/aws/service/ami-amazon-linux-latest/" --recursive --query "Parameters[*].Name" > amazon-ami.txt
Ubuntu-22:
ImageId: '{{resolve:ssm:/aws/service/canonical/ubuntu/server/jammy/stable/current/arm64/hvm/ebs-gp2/ami-id}}'
Ubuntu-24:
ImageId: '{{resolve:ssm:/aws/service/canonical/ubuntu/server/noble/stable/current/arm64/hvm/ebs-gp3/ami-id}}'
AmazonLinux-2023:
ImageId: '{{resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64}}'
AmdImage:
Ubuntu-22:
ImageId: '{{resolve:ssm:/aws/service/canonical/ubuntu/server/jammy/stable/current/amd64/hvm/ebs-gp2/ami-id}}'
Ubuntu-24:
ImageId: '{{resolve:ssm:/aws/service/canonical/ubuntu/server/noble/stable/current/amd64/hvm/ebs-gp3/ami-id}}'
AmazonLinux-2023:
ImageId: '{{resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64}}'
AWSRegionsPrefixListID:
# aws ec2 describe-managed-prefix-lists --region <REGION> | jq -r '.PrefixLists[] | select (.PrefixListName == "com.amazonaws.global.cloudfront.origin-facing") | .PrefixListId'
ap-northeast-1:
PrefixList: pl-58a04531
ap-northeast-2:
PrefixList: pl-22a6434b
ap-south-1:
PrefixList: pl-9aa247f3
ap-southeast-1:
PrefixList: pl-31a34658
ap-southeast-2:
PrefixList: pl-b8a742d1
ca-central-1:
PrefixList: pl-38a64351
eu-central-1:
PrefixList: pl-a3a144ca
eu-north-1:
PrefixList: pl-fab65393
eu-west-1:
PrefixList: pl-4fa04526
eu-west-2:
PrefixList: pl-93a247fa
eu-west-3:
PrefixList: pl-75b1541c
sa-east-1:
PrefixList: pl-5da64334
us-east-1:
PrefixList: pl-3b927c52
us-east-2:
PrefixList: pl-b6a144df
us-west-1:
PrefixList: pl-4ea04527
us-west-2:
PrefixList: pl-82a045eb
Resources:
VSCodeSecret:
Metadata:
cfn_nag:
rules_to_suppress:
- id: W77
reason: The default KMS Key used by Secrets Manager is appropriate for this password which will be used to log into VSCodeServer, which has very limited permissions. In addition this secret will not be required to be shared across accounts
Type: AWS::SecretsManager::Secret
DeletionPolicy: Delete
UpdateReplacePolicy: Delete
Properties:
Name: !Ref InstanceName
Description: VS code-server user details
GenerateSecretString:
PasswordLength: 16
SecretStringTemplate: !Sub '{"username":"${VSCodeUser}"}'
GenerateStringKey: 'password'
ExcludePunctuation: true
SecretPlaintextLambdaRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service: !Sub lambda.${AWS::URLSuffix}
Action: sts:AssumeRole
ManagedPolicyArns:
- !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: AwsSecretsManager
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource: !Ref VSCodeSecret
SecretPlaintextLambda:
Type: AWS::Lambda::Function
Metadata:
cfn_nag:
rules_to_suppress:
- id: W58
reason: Warning incorrectly reported. The role associated with the Lambda function has the AWSLambdaBasicExecutionRole managed policy attached, which includes permission to write CloudWatch Logs. See https://docs.aws.amazon.com/aws-managed-policy/latest/reference/AWSLambdaBasicExecutionRole.html
- id: W89
reason: CloudFormation custom function does not need the scaffolding of a VPC, to do so would add unnecessary complexity
- id: W92
reason: CloudFormation custom function does not need reserved concurrent executions, to do so would add unnecessary complexity
Properties:
Description: Return the value of the secret
Handler: index.lambda_handler
Runtime: python3.12
MemorySize: 128
Timeout: 10
Architectures:
- arm64
Role: !GetAtt SecretPlaintextLambdaRole.Arn
Code:
ZipFile: |
import boto3
import json
import cfnresponse
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def is_valid_json(json_string):
logger.debug(f'Calling is_valid_jason:{json_string}')
try:
json.loads(json_string)
logger.info('Secret is in json format')
return True
except json.JSONDecodeError:
logger.info('Secret is in string format')
return False
def lambda_handler(event, context):
logger.debug(f'event: {event}')
logger.debug(f'context: {context}')
try:
if event['RequestType'] == 'Delete':
cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData={}, reason='No action to take')
else:
resource_properties = event['ResourceProperties']
secret_name = resource_properties['SecretArn']
secrets_mgr = boto3.client('secretsmanager')
logger.info('Getting secret from %s', secret_name)
secret = secrets_mgr.get_secret_value(SecretId = secret_name)
logger.debug(f'secret: {secret}')
secret_value = secret['SecretString']
responseData = {}
if is_valid_json(secret_value):
responseData = secret_value
else:
responseData = {'secret': secret_value}
logger.debug(f'responseData: {responseData}')
logger.debug(f'type(responseData): {type(responseData)}')
cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData=json.loads(responseData), reason='OK', noEcho=True)
except Exception as e:
logger.error(e)
cfnresponse.send(event, context, cfnresponse.FAILED, responseData={}, reason=str(e))
SecretPlaintext:
Type: Custom::SecretPlaintextLambda
Properties:
ServiceToken: !GetAtt SecretPlaintextLambda.Arn
ServiceTimeout: 15
SecretArn: !Ref VSCodeSecret
VSCodeSSMDoc:
Type: AWS::SSM::Document
Properties:
DocumentType: Command
Content:
schemaVersion: '2.2'
description: Bootstrap VS code-server instance
parameters:
LinuxFlavor:
type: String
default: 'al2023'
VSCodePassword:
type: String
default: !Ref AWS::StackId
# all mainSteps scripts are in in /var/lib/amazon/ssm/<instanceid>/document/orchestration/<uuid>/<StepName>/_script.sh
mainSteps:
- name: InstallCloudWatchAgent
action: aws:configurePackage
inputs:
name: AmazonCloudWatchAgent
action: Install
- name: ConfigureCloudWatchAgent
action: aws:runDocument
inputs:
documentType: SSMDocument
documentPath: AmazonCloudWatch-ManageAgent
documentParameters:
action: configure
mode: ec2
optionalConfigurationSource: default
optionalRestart: 'yes'
- name: InstallAptPackagesApt
action: aws:runShellScript
precondition:
StringEquals:
- '{{ LinuxFlavor }}'
- ubuntu
inputs:
timeoutSeconds: 300
runCommand:
- '#!/bin/bash'
- dpkg --configure -a
- apt-get -q update && DEBIAN_FRONTEND=noninteractive apt-get install -y -q apt-utils
- apt-get -q update && DEBIAN_FRONTEND=noninteractive apt-get install -y -q needrestart unattended-upgrades
- sed -i 's/#$nrconf{kernelhints} = -1;/$nrconf{kernelhints} = 0;/' /etc/needrestart/needrestart.conf
- sed -i 's/#$nrconf{verbosity} = 2;/$nrconf{verbosity} = 0;/' /etc/needrestart/needrestart.conf
- sed -i "s/#\$nrconf{restart} = 'i';/\$nrconf{restart} = 'a';/" /etc/needrestart/needrestart.conf
- echo "Apt helper packages added. Checking configuration"
- cat /etc/needrestart/needrestart.conf
- name: InstallBasePackagesDnf
action: aws:runShellScript
precondition:
StringEquals:
- '{{ LinuxFlavor }}'
- al2023
inputs:
timeoutSeconds: 300
runCommand:
- '#!/bin/bash'
- dnf install -y --allowerasing curl gnupg whois argon2 unzip nginx openssl
- name: InstallBasePackagesApt
action: aws:runShellScript
precondition:
StringEquals:
- '{{ LinuxFlavor }}'
- ubuntu
inputs:
timeoutSeconds: 300
runCommand:
- '#!/bin/bash'
- dpkg --configure -a
- apt-get -q update && DEBIAN_FRONTEND=noninteractive apt-get install -y -q curl gnupg whois argon2 unzip nginx openssl locales locales-all apt-transport-https ca-certificates software-properties-common
- name: AddUserDnf
action: aws:runShellScript
precondition:
StringEquals:
- '{{ LinuxFlavor }}'
- al2023
inputs:
timeoutSeconds: 300
runCommand:
- '#!/bin/bash'
- !Sub |
echo 'Adding user: ${VSCodeUser}'
adduser -c '' ${VSCodeUser}
passwd -l ${VSCodeUser}
echo "${VSCodeUser}:{{ VSCodePassword }}" | chpasswd
usermod -aG wheel ${VSCodeUser}
- echo "User added. Checking configuration"
- !Sub getent passwd ${VSCodeUser}
- name: AddUserApt
action: aws:runShellScript
precondition:
StringEquals:
- '{{ LinuxFlavor }}'
- ubuntu
inputs:
timeoutSeconds: 300
runCommand:
- '#!/bin/bash'
- dpkg --configure -a
- !Sub |
if [[ "${VSCodeUser}" == "ubuntu" ]]
then
echo 'Using existing user: ${VSCodeUser}'
else
echo 'Adding user: ${VSCodeUser}'
adduser --disabled-password --gecos '' ${VSCodeUser}
echo "${VSCodeUser}:{{ VSCodePassword }}" | chpasswd
usermod -aG sudo ${VSCodeUser}
fi
- !Sub |
tee /etc/sudoers.d/91-vscode-user <<EOF
${VSCodeUser} ALL=(ALL) NOPASSWD:ALL
EOF
- !Sub mkdir -p /home/${VSCodeUser} && chown -R ${VSCodeUser}:${VSCodeUser} /home/${VSCodeUser}
- !Sub mkdir -p /home/${VSCodeUser}/.local/bin && chown -R ${VSCodeUser}:${VSCodeUser} /home/${VSCodeUser}
- echo "User added. Checking configuration"
- !Sub getent passwd ${VSCodeUser}
- name: UpdateProfile
action: aws:runShellScript
inputs:
timeoutSeconds: 300
runCommand:
- '#!/bin/bash'
- echo LANG=en_US.utf-8 >> /etc/environment
- echo LC_ALL=en_US.UTF-8 >> /etc/environment
- !Sub echo 'PATH=$PATH:/home/${VSCodeUser}/.local/bin' >> /home/${VSCodeUser}/.bashrc
- !Sub echo 'export PATH' >> /home/${VSCodeUser}/.bashrc
- !Sub echo 'export AWS_REGION=${AWS::Region}' >> /home/${VSCodeUser}/.bashrc
- !Sub echo 'export AWS_ACCOUNTID=${AWS::AccountId}' >> /home/${VSCodeUser}/.bashrc
- !Sub echo 'export NEXT_TELEMETRY_DISABLED=1' >> /home/${VSCodeUser}/.bashrc
- !Sub echo "export PS1='\[\033[01;32m\]\u:\[\033[01;34m\]\w\[\033[00m\]\$ '" >> /home/${VSCodeUser}/.bashrc
- !Sub chown -R ${VSCodeUser}:${VSCodeUser} /home/${VSCodeUser}
- name: InstallAWSCLI
action: aws:runShellScript
inputs:
timeoutSeconds: 300
runCommand:
- '#!/bin/bash'
- mkdir -p /tmp
- curl -fsSL https://awscli.amazonaws.com/awscli-exe-linux-$(uname -m).zip -o /tmp/aws-cli.zip
- !Sub chown -R ${VSCodeUser}:${VSCodeUser} /tmp/aws-cli.zip
- unzip -q -d /tmp /tmp/aws-cli.zip
- sudo /tmp/aws/install
- rm -rf /tmp/aws
- echo "AWS CLI installed. Checking configuration"
- aws --version
- name: InstallGitDnf
action: aws:runShellScript
precondition:
StringEquals:
- '{{ LinuxFlavor }}'
- al2023
inputs:
timeoutSeconds: 300
runCommand:
- '#!/bin/bash'
- dnf install -y git
- !Sub sudo -u ${VSCodeUser} git config --global user.email "participant@example.com"
- !Sub sudo -u ${VSCodeUser} git config --global user.name "Workshop Participant"
- !Sub sudo -u ${VSCodeUser} git config --global init.defaultBranch "main"
- echo "Git installed. Checking configuration"
- git --version
- !Sub |
if true;
then
dnf install -y python3-pip
echo "Installing git-remote-codecommit with pip3"
PIP_BREAK_SYSTEM_PACKAGES=1 pip3 install git-remote-codecommit
fi
- name: InstallGitApt
action: aws:runShellScript
precondition:
StringEquals:
- '{{ LinuxFlavor }}'
- ubuntu
inputs:
timeoutSeconds: 300
runCommand:
- '#!/bin/bash'
- dpkg --configure -a
- add-apt-repository ppa:git-core/ppa
- apt-get -q update && DEBIAN_FRONTEND=noninteractive apt-get install -y -q git
- !Sub sudo -u ${VSCodeUser} git config --global user.email "participant@example.com"
- !Sub sudo -u ${VSCodeUser} git config --global user.name "Workshop Participant"
- !Sub sudo -u ${VSCodeUser} git config --global init.defaultBranch "main"
- echo "Git installed. Checking configuration"
- git --version
- !Sub |
if true;
then
apt-get -q update && DEBIAN_FRONTEND=noninteractive apt-get install -y -q python3-pip
echo "Installing git-remote-codecommit with pip3"
PIP_BREAK_SYSTEM_PACKAGES=1 pip3 install git-remote-codecommit
fi
- name: CloneRepo
action: aws:runShellScript
inputs:
timeoutSeconds: 600
runCommand:
- '#!/bin/bash'
- !Sub |
if [[ -z "${RepoUrl}" ]]
then
echo "No Repo"
else
mkdir -p ${HomeFolder} && chown -R ${VSCodeUser}:${VSCodeUser} ${HomeFolder}
RepoUrlRegion=$(echo "${RepoUrl}" | sed "s/{.Region}/${AWS::Region}/g")
sudo -u ${VSCodeUser} git clone $RepoUrlRegion ${HomeFolder}
echo "Repo $(RepoUrlRegion) cloned. Checking configuration"
ls -la ${HomeFolder}
sudo -u ${VSCodeUser} git -C ${HomeFolder} remote -v
fi
- name: DownloadAssets
action: aws:runShellScript
inputs:
timeoutSeconds: 600
runCommand:
- '#!/bin/bash'
- !Sub |
if [[ -z "${AssetZipS3Path}" ]]
then
echo "No assets"
else
mkdir -p ${HomeFolder} && chown -R ${VSCodeUser}:${VSCodeUser} ${HomeFolder}
mkdir -p /tmp
aws s3 cp s3://${AssetZipS3Path} /tmp/asset.zip
chown -R ${VSCodeUser}:${VSCodeUser} /tmp/asset.zip
unzip -o /tmp/asset.zip -d ${HomeFolder}
chown -R ${VSCodeUser}:${VSCodeUser} ${HomeFolder}
if [[ -d ${HomeFolder}/.git ]] # Indicates that a repo has been cloned
then
sudo -u ${VSCodeUser} git -C ${HomeFolder} add .
sudo -u ${VSCodeUser} git -C ${HomeFolder} commit -m 'Workshop commit'
else
sudo -u ${VSCodeUser} git -C ${HomeFolder} init
sudo -u ${VSCodeUser} git -C ${HomeFolder} add .
sudo -u ${VSCodeUser} git -C ${HomeFolder} commit -m 'Initial commit'
fi
echo "Assets downloaded. Checking configuration: ${HomeFolder}"
ls -la ${HomeFolder}
sudo -u ${VSCodeUser} git -C ${HomeFolder} branch
fi
- name: DownloadFolders
action: aws:runShellScript
inputs:
timeoutSeconds: 600
runCommand:
- '#!/bin/bash'
- !Sub |
if [[ -z "${FolderZipS3Path}" ]]
then
echo "No folders"
else
rm -rf /tmp/folder
mkdir -p /tmp/folder && chown -R ${VSCodeUser}:${VSCodeUser} /tmp/folder
aws s3 cp s3://${FolderZipS3Path} /tmp/asset-folder.zip
chown -R ${VSCodeUser}:${VSCodeUser} /tmp/asset-folder.zip
unzip -o /tmp/asset-folder.zip -d /tmp/folder
chown -R ${VSCodeUser}:${VSCodeUser} /tmp/folder
mkdir -p ${HomeFolder} && chown -R ${VSCodeUser}:${VSCodeUser} ${HomeFolder}
cd "${HomeFolder}" && cd ..
if [[ $(pwd) == "/" ]]
then
targetRootFolder=""
else
targetRootFolder=$(pwd)
chown -R ${VSCodeUser}:${VSCodeUser} .
fi
find "/tmp/folder" -maxdepth 1 -mindepth 1 -type d | while read sourceFolder; do
folder="$(basename $sourceFolder)"
echo "Processing folder: $folder"
targetFolder=$targetRootFolder/$folder
if [[ $targetRootFolder == "" ]]
then
mv $sourceFolder /
else
mv $sourceFolder $targetRootFolder
fi
chown -R ${VSCodeUser}:${VSCodeUser} $targetFolder
sudo -u ${VSCodeUser} git -C $targetFolder init
sudo -u ${VSCodeUser} git -C $targetFolder add .
sudo -u ${VSCodeUser} git -C $targetFolder commit -m "Initial commit"
echo "Folder downloaded. Checking configuration: $targetFolder"
ls -la $targetFolder
done
rm -rf /tmp/folder
fi
- name: DownloadBranches
action: aws:runShellScript
inputs:
timeoutSeconds: 600
runCommand:
- '#!/bin/bash'
- !Sub |
if [[ -z "${BranchZipS3Path}" ]]
then
echo "No branches"
else
rm -rf /tmp/branch
rm -rf /tmp/git
mkdir -p /tmp/branch && chown -R ${VSCodeUser}:${VSCodeUser} /tmp/branch
mkdir -p /tmp/git && chown -R ${VSCodeUser}:${VSCodeUser} /tmp/git
aws s3 cp s3://${BranchZipS3Path} /tmp/asset-branch.zip
chown -R ${VSCodeUser}:${VSCodeUser} /tmp/asset-branch.zip
unzip -o /tmp/asset-branch.zip -d /tmp/branch
chown -R ${VSCodeUser}:${VSCodeUser} /tmp/branch
mkdir -p ${HomeFolder} && chown -R ${VSCodeUser}:${VSCodeUser} ${HomeFolder}
sudo -u ${VSCodeUser} git -C ${HomeFolder} init
mv ${HomeFolder}/.git /tmp/git
rm -rf ${HomeFolder}
mkdir -p ${HomeFolder} && chown -R ${VSCodeUser}:${VSCodeUser} ${HomeFolder}
mv /tmp/git/.git ${HomeFolder}
find /tmp/branch -maxdepth 1 -mindepth 1 -type d | while read sourceFolder; do
branch="$(basename $sourceFolder)"
echo "Processing branch: $branch"
sudo -u ${VSCodeUser} git -C ${HomeFolder} checkout -b $branch 2>&1
cp -a $sourceFolder/. ${HomeFolder}
sudo -u ${VSCodeUser} git -C ${HomeFolder} add .
sudo -u ${VSCodeUser} git -C ${HomeFolder} commit -m "Initial commit $branch"
mv ${HomeFolder}/.git /tmp/git
rm -rf ${HomeFolder}
mkdir ${HomeFolder} && chown -R ${VSCodeUser}:${VSCodeUser} ${HomeFolder}
mv /tmp/git/.git ${HomeFolder}
done
sudo -u ${VSCodeUser} git -C ${HomeFolder} checkout main 2>&1
sudo -u ${VSCodeUser} git -C ${HomeFolder} restore .
rm -rf /tmp/branch
rm -rf /tmp/git
echo "Branches downloaded. Checking configuration: $HomeFolder"
sudo -u ${VSCodeUser} git -C ${HomeFolder} branch
ls -la ${HomeFolder}
fi
- name: ConfigureCodeServer
action: aws:runShellScript
inputs:
timeoutSeconds: 600
runCommand:
- '#!/bin/bash'
- !Sub export HOME=/home/${VSCodeUser}
- curl -fsSL https://code-server.dev/install.sh | bash -s -- 2>&1
- !Sub systemctl enable --now code-server@${VSCodeUser} 2>&1
- !Sub |
tee /etc/nginx/conf.d/code-server.conf <<EOF
server {
listen 80;
listen [::]:80;
# server_name \$\{CloudFrontDistribution.DomainName\};
server_name *.cloudfront.net;
location / {
proxy_pass http://localhost:8080/;
proxy_set_header Host \$host;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection upgrade;
proxy_set_header Accept-Encoding gzip;
}
location /${DevServerBasePath} {
proxy_pass http://localhost:${DevServerPort}/${DevServerBasePath};
proxy_set_header Host \$host;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection upgrade;
proxy_set_header Accept-Encoding gzip;
}
}
EOF
- !Sub mkdir -p /home/${VSCodeUser}/.config/code-server
- !Sub |
tee /home/${VSCodeUser}/.config/code-server/config.yaml <<EOF
cert: false
auth: password
hashed-password: "$(echo -n {{ VSCodePassword }} | argon2 $(openssl rand -base64 12) -e)"
EOF
- !Sub mkdir -p /home/${VSCodeUser}/.local/share/code-server/User/
- !Sub touch /home/${VSCodeUser}/.hushlogin
- !Sub mkdir -p ${HomeFolder} && chown -R ${VSCodeUser}:${VSCodeUser} ${HomeFolder}
- !Sub |
tee /home/${VSCodeUser}/.local/share/code-server/User/settings.json <<EOF
{
"extensions.autoUpdate": false,
"extensions.autoCheckUpdates": false,
"telemetry.telemetryLevel": "off",
"security.workspace.trust.startupPrompt": "never",
"security.workspace.trust.enabled": false,
"security.workspace.trust.banner": "never",
"security.workspace.trust.emptyWindow": false,
"auto-run-command.rules": [
{
"command": "workbench.action.terminal.new"
}
]
}
EOF
- !Sub chown -R ${VSCodeUser}:${VSCodeUser} /home/${VSCodeUser}
- !Sub systemctl restart code-server@${VSCodeUser}
- systemctl restart nginx
- !Sub sudo -u ${VSCodeUser} --login code-server --install-extension AmazonWebServices.aws-toolkit-vscode --force
# - !Sub sudo -u ${VSCodeUser} --login code-server --install-extension AmazonWebServices.amazon-q-vscode --force
- !Sub sudo -u ${VSCodeUser} --login code-server --install-extension ms-vscode.live-server --force
- !Sub sudo -u ${VSCodeUser} --login code-server --install-extension synedra.auto-run-command --force
- !Sub chown -R ${VSCodeUser}:${VSCodeUser} /home/${VSCodeUser}
- echo "Nginx installed. Checking configuration"
- nginx -t 2>&1
- systemctl status nginx
- echo "CodeServer installed. Checking configuration"
- code-server -v
- !Sub systemctl status code-server@${VSCodeUser}
- name: InstallDockerDnf
action: aws:runShellScript
precondition:
StringEquals:
- '{{ LinuxFlavor }}'
- al2023
inputs:
timeoutSeconds: 300
runCommand:
- '#!/bin/bash'
- dnf install -y docker
- Fn::Sub: usermod -aG docker ${VSCodeUser}
- Fn::Sub: sudo -u ${VSCodeUser} --login code-server --install-extension ms-azuretools.vscode-docker --force
- systemctl start docker.service
- echo "Docker installed. Checking configuration"
- docker --version
- systemctl status docker.service
- newgrp docker
- Fn::Sub: systemctl restart code-server@${VSCodeUser}
- name: InstallDockerApt
action: aws:runShellScript
precondition:
StringEquals:
- '{{ LinuxFlavor }}'
- ubuntu
inputs:
timeoutSeconds: 300
runCommand:
- '#!/bin/bash'
- dpkg --configure -a
- curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
- echo "deb [signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release --codename --short) stable" > /etc/apt/sources.list.d/docker.list
- apt-get -q update && DEBIAN_FRONTEND=noninteractive apt-get install -y -q docker-ce docker-ce-cli containerd.io
- Fn::Sub: usermod -aG docker ${VSCodeUser}
- Fn::Sub: sudo -u ${VSCodeUser} --login code-server --install-extension ms-azuretools.vscode-docker --force
- systemctl start docker.service
- echo "Docker installed. Checking configuration"
- docker --version
- systemctl status docker.service
- newgrp docker
- Fn::Sub: systemctl restart code-server@${VSCodeUser}
# Install optional packages here - any of these blocks can be deleted if that software is not required for the workshop
# Install Workshop specific packages here
SSMDocLambdaRole:
Type: AWS::IAM::Role
Metadata:
cfn_nag:
rules_to_suppress:
- id: W11
reason: The Amazon EC2 ssm:*CommandInvocation API actions do not support resource-level permissions, so you cannot control which individual resources users can view in the console. Therefore, the * wildcard is necessary in the Resource element. See https://docs.aws.amazon.com/service-authorization/latest/reference/list_awssystemsmanager.html
Properties:
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service: !Sub lambda.${AWS::URLSuffix}
Action: sts:AssumeRole
ManagedPolicyArns:
- !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: SSMDocOnEC2
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- ssm:SendCommand
Resource:
- !Sub arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:document/${VSCodeSSMDoc}
- !Sub arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:document/AmazonCloudWatch-ManageAgent
- !Sub arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/${VSCodeInstance}
- Effect: Allow
Action:
- ssm:ListCommandInvocations
- ssm:GetCommandInvocation
Resource: '*'
RunSSMDocLambda:
Type: AWS::Lambda::Function
Metadata:
cfn_nag:
rules_to_suppress:
- id: W58
reason: Warning incorrectly reported. The role associated with the Lambda function has the AWSLambdaBasicExecutionRole managed policy attached, which includes permission to write CloudWatch Logs. See https://docs.aws.amazon.com/aws-managed-policy/latest/reference/AWSLambdaBasicExecutionRole.html
- id: W89
reason: CloudFormation custom function does not need the scaffolding of a VPC, to do so would add unnecessary complexity
- id: W92
reason: CloudFormation custom function does not need reserved concurrent executions, to do so would add unnecessary complexity
Properties:
Description: Run SSM document on EC2 instance
Handler: index.lambda_handler
Runtime: python3.12
MemorySize: 128
Timeout: 600
Environment:
Variables:
RetrySleep: 2900
AbortTimeRemaining: 3200
Architectures:
- arm64
Role: !GetAtt SSMDocLambdaRole.Arn
Code:
ZipFile: |
import boto3
import cfnresponse
import logging
import time
import os
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
logger.debug(f'event: {event}')
logger.debug(f'context: {context}')
if event['RequestType'] != 'Create':
cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData={}, reason='No action to take')
else:
sleep_ms = int(os.environ.get('RetrySleep'))
abort_time_remaining_ms = int(os.environ.get('AbortTimeRemaining'))
resource_properties = event['ResourceProperties']
instance_id = resource_properties['InstanceId']
document_name = resource_properties['DocumentName']
cloudwatch_log_group_name = resource_properties['CloudWatchLogGroupName']
logger.info(f'Running SSM Document {document_name} on EC2 instance {instance_id}. Logging to {cloudwatch_log_group_name}')
del resource_properties['ServiceToken']
if 'ServiceTimeout' in resource_properties:
del resource_properties['ServiceTimeout']
del resource_properties['InstanceId']
del resource_properties['DocumentName']
del resource_properties['CloudWatchLogGroupName']
if 'PhysicalResourceId' in resource_properties:
del resource_properties['PhysicalResourceId']
logger.debug(f'resource_properties filtered: {resource_properties}')
parameters = {}
for key, value in resource_properties.items():
parameters[key] = [value]
logger.debug(f'parameters: {parameters}')
retry = True
attempt_no = 0
time_remaining_ms = context.get_remaining_time_in_millis()
ssm = boto3.client('ssm')
while (retry == True):
attempt_no += 1
logger.info(f'Attempt: {attempt_no}. Time Remaining: {time_remaining_ms/1000}s')
try:
response = ssm.send_command(
InstanceIds = [instance_id],
DocumentName = document_name,
CloudWatchOutputConfig = {'CloudWatchLogGroupName': cloudwatch_log_group_name, 'CloudWatchOutputEnabled': True},
Parameters = parameters
)
logger.debug(f'response: {response}')
command_id = response['Command']['CommandId']
responseData = {'CommandId': command_id}
cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData, reason='OK')
retry = False
except ssm.exceptions.InvalidInstanceId as e:
time_remaining_ms = context.get_remaining_time_in_millis()
if (time_remaining_ms > abort_time_remaining_ms):
logger.info(f'Instance {instance_id} not ready. Sleeping: {sleep_ms/1000}s')
time.sleep(sleep_ms/1000)
retry = True
else:
logger.info(f'Instance {instance_id} not ready, timed out. Time remaining {time_remaining_ms/1000}s < Abort time remaining {abort_time_remaining_ms/1000}s')
logger.error(e, exc_info=True)
cfnresponse.send(event, context, cfnresponse.FAILED, responseData={}, reason='Timed out. Time remaining: ' + str(time_remaining_ms/1000) + 's < Abort time remaining: ' + str(abort_time_remaining_ms/1000) + 's')
retry = False
except Exception as e:
logger.error(e, exc_info=True)
cfnresponse.send(event, context, cfnresponse.FAILED, responseData={}, reason=str(e))
retry = False
RunVSCodeSSMDoc:
Type: Custom::RunSSMDocLambda
Properties:
ServiceToken: !GetAtt RunSSMDocLambda.Arn
ServiceTimeout: 305
InstanceId: !Ref VSCodeInstance
DocumentName: !Ref VSCodeSSMDoc
CloudWatchLogGroupName: !Sub /aws/ssm/${VSCodeSSMDoc}
VSCodePassword: !GetAtt SecretPlaintext.password
LinuxFlavor: !If [IsAL2023, 'al2023', 'ubuntu']
VSCodeInstanceBootstrapRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- !Sub ec2.${AWS::URLSuffix}
- !Sub ssm.${AWS::URLSuffix}
Action: sts:AssumeRole
ManagedPolicyArns:
- !Sub arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore
- !Sub arn:${AWS::Partition}:iam::aws:policy/CloudWatchAgentServerPolicy
- !Sub arn:${AWS::Partition}:iam::aws:policy/AmazonQDeveloperAccess
- !Sub arn:${AWS::Partition}:iam::aws:policy/ReadOnlyAccess
- !Sub arn:${AWS::Partition}:iam::aws:policy/AWSCodeCommitPowerUser
- Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AmazonEC2ContainerRegistryFullAccess
- Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/ElasticLoadBalancingFullAccess
VSCodeInstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
Roles:
- !Ref VSCodeInstanceBootstrapRole
VSCodeInstance:
Type: AWS::EC2::Instance
Properties:
ImageId: !If
- IsGraviton
- !FindInMap [ArmImage, !Ref InstanceOperatingSystem, ImageId]
- !FindInMap [AmdImage, !Ref InstanceOperatingSystem, ImageId]
InstanceType: !Ref InstanceType
BlockDeviceMappings:
- DeviceName: !If [IsAL2023, /dev/xvda, /dev/sda1]
Ebs:
VolumeSize: !Ref InstanceVolumeSize
VolumeType: gp3
DeleteOnTermination: true
Encrypted: true
Monitoring: true
SecurityGroupIds:
- !Ref SecurityGroup
IamInstanceProfile: !Ref VSCodeInstanceProfile
UserData:
Fn::Base64: !Sub |
#cloud-config
hostname: ${InstanceName}
runcmd:
- mkdir -p ${HomeFolder} && chown -R ${VSCodeUser}:${VSCodeUser} ${HomeFolder}
Tags:
- Key: Name
Value: !Ref InstanceName
VSCodeInstanceCachePolicy:
Type: AWS::CloudFront::CachePolicy
Properties:
CachePolicyConfig:
DefaultTTL: 86400
MaxTTL: 31536000
MinTTL: 1
Name: !Sub
- ${InstanceName}-${RandomGUID}
- RandomGUID: !Select [0, !Split ['-', !Select [2, !Split ['/', !Ref 'AWS::StackId']]]]
ParametersInCacheKeyAndForwardedToOrigin:
CookiesConfig:
CookieBehavior: all
EnableAcceptEncodingGzip: False
HeadersConfig:
HeaderBehavior: whitelist
Headers:
- Accept-Charset
- Authorization
- Origin
- Accept
- Referer
- Host
- Accept-Language
- Accept-Encoding
- Accept-Datetime
QueryStringsConfig:
QueryStringBehavior: all
CloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Metadata:
cfn_nag:
rules_to_suppress:
- id: W10
reason: CloudFront Distribution access logging would require setup of an S3 bucket and changes in IAM, which add unnecessary complexity to the template
- id: W70
reason: Workshop Studio does not include a domain that can be used to provision a certificate, so it is not possible to setup TLS. See PFR EE-6016
Properties:
DistributionConfig:
Enabled: True
HttpVersion: http2and3
CacheBehaviors:
- AllowedMethods:
- GET
- HEAD
- OPTIONS
- PUT
- PATCH
- POST
- DELETE
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html#managed-cache-policy-caching-disabled
Compress: False
OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3 # Managed-AllViewer - see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#:~:text=When%20using%20AWS,47e4%2Db989%2D5492eafa07d3
TargetOriginId: !Sub CloudFront-${AWS::StackName}
ViewerProtocolPolicy: allow-all
PathPattern: '/proxy/*'
DefaultCacheBehavior:
AllowedMethods:
- GET
- HEAD
- OPTIONS
- PUT
- PATCH
- POST
- DELETE
CachePolicyId: !Ref VSCodeInstanceCachePolicy
OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3 # Managed-AllViewer - see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html#:~:text=When%20using%20AWS,47e4%2Db989%2D5492eafa07d3
TargetOriginId: !Sub CloudFront-${AWS::StackName}
ViewerProtocolPolicy: allow-all
Origins:
- DomainName: !GetAtt VSCodeInstance.PublicDnsName
Id: !Sub CloudFront-${AWS::StackName}
CustomOriginConfig:
OriginProtocolPolicy: http-only
SecurityGroup:
Type: AWS::EC2::SecurityGroup
Metadata:
cfn_nag:
rules_to_suppress:
- id: F1000
reason: All outbound traffic should be allowed from this instance. The EC2 instance is provisioned in the default VPC, which already has this egress rule, and it is not possible to duplicate this egress rule in the default VPC
Properties:
GroupDescription: SG for VSCodeServer - only allow CloudFront ingress
SecurityGroupIngress:
- Description: Allow HTTP from com.amazonaws.global.cloudfront.origin-facing
IpProtocol: tcp
FromPort: 80
ToPort: 80
SourcePrefixListId: !FindInMap [AWSRegionsPrefixListID, !Ref 'AWS::Region', PrefixList]
VSCodeHealthCheckLambdaRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service: !Sub lambda.${AWS::URLSuffix}
Action: sts:AssumeRole
ManagedPolicyArns:
- !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
VSCodeHealthCheckLambda:
Type: AWS::Lambda::Function
Metadata:
cfn_nag:
rules_to_suppress:
- id: W58
reason: Warning incorrectly reported. The role associated with the Lambda function has the AWSLambdaBasicExecutionRole managed policy attached, which includes permission to write CloudWatch Logs. See https://docs.aws.amazon.com/aws-managed-policy/latest/reference/AWSLambdaBasicExecutionRole.html
- id: W89
reason: CloudFormation custom function does not need the scaffolding of a VPC, to do so would add unnecessary complexity
- id: W92
reason: CloudFormation custom function does not need reserved concurrent executions, to do so would add unnecessary complexity
Properties:
Description: Run health check on VS code-server instance
Handler: index.lambda_handler
Runtime: python3.12
MemorySize: 128
Timeout: 600
Environment:
Variables:
RetrySleep: 2900
AbortTimeRemaining: 5000
Architectures:
- arm64
Role: !GetAtt VSCodeHealthCheckLambdaRole.Arn
Code:
ZipFile: |
import json
import cfnresponse
import logging
import time
import os
import http.client
from urllib.parse import urlparse
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def healthURLOk(url):
# Using try block to catch connection errors and JSON conversion errors
try:
logger.debug(f'url: {url}')
parsed_url = urlparse(url)
if parsed_url.scheme == 'https':
logger.debug(f'Trying https: {parsed_url.netloc}. Parsed_url: {parsed_url}')
conn = http.client.HTTPSConnection(parsed_url.netloc)
else:
logger.debug(f'Trying http: {parsed_url.netloc}. Parsed_url: {parsed_url}')
conn = http.client.HTTPConnection(parsed_url.netloc)
conn.request("GET", parsed_url.path or "/")
response = conn.getresponse()
logger.debug(f'response: {response}')
logger.debug(f'response.status: {response.status}')
content = response.read()
logger.debug(f'content: {content}')
# This will be true for any return code below 4xx (so 3xx and 2xx)
if 200 <= response.status < 400:
response_dict = json.loads(content.decode('utf-8'))
logger.debug(f'response_dict: {response_dict}')
# Checking for expected keys and if the key has the expected value
if 'status' in response_dict and (response_dict['status'].lower() == 'alive' or response_dict['status'].lower() == 'expired'):
# Response code 200 and correct JSON returned
logger.info(f'Health check OK. Status: {response_dict['status'].lower()}')
return True
else:
# Response code 200 but the 'status' key is either not present or does not have the value 'alive' or 'expired'
logger.info(f'Health check failed. Status: {response_dict['status'].lower()}')
return False
else:
# Response was not ok (error 4xx or 5xx)
logger.info(f'Healthcheck failed. Return code: {response.status}')
return False
except http.client.HTTPException as e:
# URL malformed or endpoint not ready yet, this should only happen if we can not DNS resolve the URL
logger.error(e, exc_info=True)
logger.error(f'Healthcheck failed: HTTP Exception. URL invalid and/or endpoint not ready yet')
return False
except json.decoder.JSONDecodeError as e:
# The response we got was not a properly formatted JSON
logger.error(e, exc_info=True)
logger.info(f'Healthcheck failed: Did not get JSON object from URL as expected')
return False
except Exception as e:
logger.error(e, exc_info=True)
logger.info(f'Healthcheck failed: General error')
return False
finally:
if 'conn' in locals():
conn.close()
def is_valid_json(json_string):
try:
json.loads(json_string)
return True
except ValueError:
return False
def lambda_handler(event, context):
logger.debug(f'event: {event}')
logger.debug(f'context: {context}')
try:
if event['RequestType'] != 'Create':
cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData={}, reason='No action to take')
else:
sleep_ms = int(os.environ.get('RetrySleep'))
abort_time_remaining_ms = int(os.environ.get('AbortTimeRemaining'))
resource_properties = event['ResourceProperties']
url = resource_properties['Url']
logger.info(f'Testing url: {url}')
time_remaining_ms = context.get_remaining_time_in_millis()
attempt_no = 0
health_check = False
while (attempt_no == 0 or (time_remaining_ms > abort_time_remaining_ms and not health_check)):
attempt_no += 1
logger.info(f'Attempt: {attempt_no}. Time Remaining: {time_remaining_ms/1000}s')
health_check = healthURLOk(url)
if not health_check:
logger.debug(f'Healthcheck failed. Sleeping: {sleep_ms/1000}s')
time.sleep(sleep_ms/1000)
time_remaining_ms = context.get_remaining_time_in_millis()
if health_check:
logger.info(f'Health check successful. Attempts: {attempt_no}. Time Remaining: {time_remaining_ms/1000}s')
cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData={}, reason='VS code-server healthcheck successful')
else:
logger.info(f'Health check failed. Timed out. Attempts: {attempt_no}. Time remaining {time_remaining_ms/1000}s < Abort time remaining {abort_time_remaining_ms/1000}s')
cfnresponse.send(event, context, cfnresponse.FAILED, responseData={}, reason='VS code-server healthcheck failed. Timed out after ' + str(attempt_no) + ' attempts')
logger.info(f'Response sent')
except Exception as e:
logger.error(e, exc_info=True)
logger.info(f'Health check failed. General exception')
cfnresponse.send(event, context, cfnresponse.FAILED, responseData={}, reason=str(e))
Healthcheck:
Type: Custom::VSCodeHealthCheckLambda
Properties:
ServiceToken: !GetAtt VSCodeHealthCheckLambda.Arn
ServiceTimeout: 610
Url: !Sub https://${CloudFrontDistribution.DomainName}/healthz
CheckSSMDocLambda:
Type: AWS::Lambda::Function
Metadata:
cfn_nag:
rules_to_suppress:
- id: W58
reason: Warning incorrectly reported. The role associated with the Lambda function has the AWSLambdaBasicExecutionRole managed policy attached, which includes permission to write CloudWatch Logs. See https://docs.aws.amazon.com/aws-managed-policy/latest/reference/AWSLambdaBasicExecutionRole.html
- id: W89
reason: CloudFormation custom function does not need the scaffolding of a VPC, to do so would add unnecessary complexity
- id: W92
reason: CloudFormation custom function does not need reserved concurrent executions, to do so would add unnecessary complexity
Properties:
Description: Check SSM document on EC2 instance
Handler: index.lambda_handler
Runtime: python3.12
MemorySize: 128
Timeout: 600
Environment:
Variables:
RetrySleep: 2900
AbortTimeRemaining: 5000
Architectures:
- arm64
Role: !GetAtt SSMDocLambdaRole.Arn
Code:
ZipFile: "import boto3\nimport cfnresponse\nimport logging\nimport time\nimport os\n\nlogger = logging.getLogger()\nlogger.setLevel(logging.INFO)\n\ndef lambda_handler(event, context):\n logger.debug(f'event: {event}')\n logger.debug(f'context: {context}')\n\n if event['RequestType'] != 'Create':\n cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData={}, reason='No action to take')\n else:\n sleep_ms = int(os.environ.get('RetrySleep'))\n abort_time_remaining_ms = int(os.environ.get('AbortTimeRemaining'))\n resource_properties = event['ResourceProperties']\n instance_id = resource_properties['InstanceId']\n document_name = resource_properties['DocumentName']\n\n logger.info(f'Checking SSM Document {document_name} on EC2 instance {instance_id}')\n\n retry = True\n attempt_no = 0\n time_remaining_ms = context.get_remaining_time_in_millis()\n\n ssm = boto3.client('ssm')\n\n while (retry == True):\n attempt_no += 1\n logger.info(f'Attempt: {attempt_no}. Time Remaining: {time_remaining_ms/1000}s')\n try:\n # check to see if document has completed running on instance\n response = ssm.list_command_invocations(\n InstanceId=instance_id,\n Details=True\n )\n logger.debug(f'Response: {response}')\n for invocation in response['CommandInvocations']:\n if invocation['DocumentName'] == document_name:\n invocation_status = invocation['Status']\n if invocation_status == 'Success':\n logger.info(f'SSM Document {document_name} on EC2 instance {instance_id} complete. Status: {invocation_status}') \n cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData={}, reason='OK')\n retry = False\n elif invocation_status == 'Failed' or invocation_status == 'Cancelled' or invocation_status == 'TimedOut':\n logger.info(f'SSM Document {document_name} on EC2 instance {instance_id} failed. Status: {invocation_status}')\n reason = ''\n # Get information on step that failed, otherwise it's cancelled or timeout\n for step in invocation['CommandPlugins']:\n step_name = step['Name']\n step_status = step['Status']\n step_output = step['Output']\n logger.debug(f'Step {step_name} {step_status}: {step_output}')\n if step_status != 'Success':\n try:\n response_step = ssm.get_command_invocation(\n CommandId=invocation['CommandId'],\n InstanceId=instance_id,\n PluginName=step_name\n )\n logger.debug(f'Step details: {response_step}')\n step_output = response_step['StandardErrorContent']\n except Exception as e:\n logger.error(e, exc_info=True)\n logger.info(f'Step {step_name} {step_status}: {step_output}')\n if reason == '':\n reason = f'Step {step_name} {step_status}: {step_output}'\n else:\n reason += f'\\nStep {step_name} {step_status}: {step_output}'\n if reason == '':\n reason = f'SSM Document {document_name} on EC2 instance {instance_id} failed. Status: {invocation_status}'\n logger.info(f'{reason}')\n cfnresponse.send(event, context, cfnresponse.FAILED, responseData={}, reason=reason)\n retry = False\n else:\n logger.info(f'SSM Document {document_name} on EC2 instance {instance_id} not yet complete. Status: {invocation_status}')\n retry = True\n if retry == True:\n if (time_remaining_ms > abort_time_remaining_ms):\n logger.info(f'Sleeping: {sleep_ms/1000}s')\n time.sleep(sleep_ms/1000)\n time_remaining_ms = context.get_remaining_time_in_millis()\n else:\n logger.info(f'Time remaining {time_remaining_ms/1000}s < Abort time remaining {abort_time_remaining_ms/1000}s')\n logger.info(f'Aborting check as time remaining {time_remaining_ms/1000}s < Abort time remaining {abort_time_remaining_ms/1000}s')\n cfnresponse.send(event, context, cfnresponse.FAILED, responseData={}, reason='Timed out. Time remaining: ' + str(time_remaining_ms/1000) + 's < Abort time remaining: ' + str(abort_time_remaining_ms/1000) + 's')\n retry = False\n except Exception as e:\n logger.error(e, exc_info=True)\n cfnresponse.send(event, context, cfnresponse.FAILED, responseData={}, reason=str(e))\n retry = False\n"
CheckVSCodeSSMDoc:
Type: Custom::CheckSSMDocLambda
DependsOn: Healthcheck
Properties:
ServiceToken: !GetAtt CheckSSMDocLambda.Arn
ServiceTimeout: 610
InstanceId: !Ref VSCodeInstance
DocumentName: !Ref VSCodeSSMDoc
Outputs:
URL:
Description: VSCode-Server URL
Value: !Sub https://${CloudFrontDistribution.DomainName}/?folder=${HomeFolder}
PasswordURL:
Description: AWS Secrets Manager URL for VSCode-Server Password
Value:
Fn::Sub:
- https://${AWS::Region}.console.aws.amazon.com/secretsmanager/secret?name=${SecretName}®ion=${AWS::Region}
- SecretName:
Fn::Sub: ${InstanceName}
CloudFrontのURLを叩くとこのような画面が表示されます。
Secrets Managerから取得したパスワードを入れると、VS Code Serverの画面に遷移します。
1.2. VS Code Serverについて
そもそもVS Code Serverって何??という状況だったので、調べてみました。
その名の通り、リモートのLinuxサーバや仮想マシン上でコードを編集・実行するためのエンジンのようです。
これを利用することで、ChromeなどのWebブラウザーをVSCodeのフロントエンドとして、VS Codeを利用することができるようになります。
VS Code Server を使用すると、以下のような新しい方法で VS Code を利用できます:
- SSH サポートが制限されているリモートマシン上で開発を行ったり、Web ベースでのアクセスが必要な場合に使用する。
- iPad やタブレット、Chromebook のように VS Code デスクトップ版をインストールできないマシンで開発する。
- すべてのコードをブラウザのサンドボックス内で実行できるという、クライアント側のセキュリティ上の利点を活用する。
いい機能やん!と思ったのですが、ハンズオンページでは以下のように案内されていました。
VS Code Serverに何かデメリットがあるのかしらと思い、少し調べてみたところ、以下の記事がヒットしました。
接続した先の環境上に vscode-server というのをインストールして vscodeの機能を持ったサーバーを起動、そこに接続してなんやかんやするものでした
この vscode-server が曲者でメモリやディスクといったリソースを遠慮なくバカ食いしポートも空いているところを勝手に使用するそう
おお( ^ω^)・・・
まあ、EC2インスタンスを持っておくということだけでも面倒なので、確かに実際の運用を考えると他のサービスを考えるべきなのかもしれません。
推奨サービス名 | 特徴 |
---|---|
SageMaker Studio Code Editor | ブラウザベースの VS Code 環境。IAM 統合、VPC接続、セキュリティ制御が可能。業務利用に適する。 |
SageMaker Jupyter Lab | 機械学習用途に特化した統合開発環境。Notebookベースだが、ターミナルやコードエディタも使える。 |
ローカル環境(VS Code) | 開発者がローカルマシンにインストールしたVS Codeを使用。Git連携、SSH開発なども可能。構成管理が容易。 |
2. コンテナイメージの作成・実行
Dockerの復習です。
Rails アプリケーションのコンテナイメージを作成し、Docker上で実行していきます。
まずは環境の確認。
$ docker --version
Docker version 25.0.8, build 0bab007
続いてDockerfileを作成します。
# ruby:3.2.1 というベースイメージを取得する
FROM public.ecr.aws/docker/library/ruby:3.2.1
# 必要なパッケージ群を取得する
RUN apt-get update -qq && \
apt-get install -y nodejs postgresql-client npm && \
rm -rf /var/lib/apt/lists/\*
# ローカルにあるファイルをコンテナイメージ内にコピーする
WORKDIR /myapp
COPY Gemfile /myapp/Gemfile
# Rails アプリケーションを作成する
RUN bundle install && \
rails new . -O && \
sed -i -e "52a\ config.hosts.clear\n config.web_console.allowed_ips = '0.0.0.0/0'\n config.action_dispatch.default_headers.delete('X-Frame-Options')" config/environments/development.rb
# Rails を 3000 番ポートで起動する
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0", "-p", "3000"]
Dockerfileとは、コンテナイメージの作成手順をコードとして記述したファイルです。
次に、Gemfileを作成します。
source 'https://rubygems.org'
gem 'rails', '7.2.2'
Gemfileとは、Rubyアプリケーションで利用するgem(ライブラリ)の一覧を記述するファイルです。
Dockerfile内で COPY Gemfile /myapp/Gemfile
と記述されており、ビルドの過程で、このファイルを所定の場所にコピーするように想定されています。
コンテナイメージをビルドして、ビルドが完了したらイメージを確認します。
$ docker build -t rails-app .
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
rails-app latest d7f5691a5b22 52 seconds ago 1.43GB
-t のあとの引数では、ビルドするコンテナイメージの "名前" を指定しています。
最後のピリオド (.) は、ビルド時の "コンテクスト" としてカレントディレクトリを指定しています。
ビルドの過程では、指定したコンテクストにあるファイルを参照できます。
コンテナを実行します。
docker run -d -p 8081:3000 rails-app:latest
このコマンドでは、ホスト (VS Code Server インスタンス) の 8081 番ポートを、コンテナの 3000 番ポートにマッピングしています。
コンテナの 3000 番ポートでアプリケーションを実行するように Dockerfile を記述したので、ホストの 8081 番ポートにアクセスすることで Rails アプリケーションにアクセスできます。
8081 ポートで実行中の Rails アプリケーションが表示されました!
3. ECS ハンズオン
いよいよ本題です。
「クラスター」や「タスク」「サービス」といった概念については、事前に復習しておきましょう!
この記事が分かりやすかったです。
3.1. VPC とサブネットの作成
3.2. クラスターの作成
手順に従い、ECSクラスターを作成します。
ここでは、インフラストラクチャの設定で、FargateとEC2をどちらも選択しています。
ECSのインフラストラクチャでFargateを選ぶかEC2を選ぶか、という比較は、以下の記事が参考になると思います。
基本的にFargateでいいかなと!
Fargateを選べないときに、仕方なくEC2を選ぶ、という印象です。
クラスターの作成が完了しました。
今時点では、ECSサービスもタスクも存在していません。
3.3. コンテナイメージの保存
ハンズオンでは触れられていませんが、 イメージタグのミュータビリティ
については、こちらの記事が参考になります。
続いて、先ほど作成したコンテナイメージをリポジトリにプッシュします。
コマンドはECRの画面に全部書いてある!うれしい!
3.4. タスク定義の作成
手順に従い、タスク定義を作成します。
ECSで1つのタスクが使用するリソース(CPUとメモリ)の大きさを指定する「タスクサイズ」などを入れていきます。
この「タスクサイズ」がFargateの利用料金に直結します。
たくさんパラメータがありますが、それぞれのパラメータの説明は以下の記事にまとまっていました。感謝!
続いて、ALB 用のセキュリティグループを作成します。
手順では、0.0.0.0/0からのHTTP通信を許可するようなルールにしていますが、、必要に応じてIPレンジを絞ってもいいかもです。
3.5. EC2 へのデプロイ
手順に従い、ECSサービスを作成して、EC2にコンテナをデプロイします。
EC2インスタンスが1台しか動いてないので、AZ冗長ができていないのが分かります。
ECSサービスの設定のなかでFargateかEC2かを選ぶ、ということでしたが、具体的には、この「コンピューティング設定」の中で設定を行うようです。
デフォルトではキャパシティプロバイダーとして、クラスター作成時に自動生成されたAuto Scaling グループが指定されています。
これにより、Auto Scaling グループによって起動されたEC2で、ECSタスク(コンテナ)が動くことになります。
サービスの作成が完了すると、クラスターの画面にもそれが反映されていることが確認できます。
ブラウザからALBにアクセスします。
アプリケーションに接続できることが確認できました!
ハンズオンページの最後に大事なことが書いてありました!
Question
タスクを 3 つ以上に増やしてみるとどうなるでしょうか? 余力のある方は試してみてください。Answer
いくらタスクを増やしても、現在の設定では最大 2 つまでしかタスクを実行できないことが分かります。
これは、実行環境の t3.medium インスタンスには、ネットワークインターフェースを最大 3 つまでしか追加できないからです。
インスタンスのプライマリネットワークインターフェイスも1つとしてカウントされるため、通常の設定では、ECSタスクは1つのEC2につき2つまでしか起動できない、というお話です。
この問題の解決策として「ENI トランキング機能」が提供されています。
詳細は公式ページをご確認ください。
(参考)Amazon ECS Linux コンテナインスタンスのネットワークインターフェイスを増やす
ということでいよいよFargateへのデプロイを試していきます!
3.6. Fargate へのデプロイ
手順に従い、Fargate用のECSサービスを作成して、Faragateにコンテナをデプロイします。
キャパシティプロバイダーとして FARGATE を指定することで、Fargate でタスクを実行するように設定します。
サービスの作成が完了すると、クラスターの画面にもそれが反映されていることが確認できます。
ブラウザからALBにアクセスします。
アプリケーションに接続できることが確認できました!
3.7. オートスケーリングの設定
ということで、Faragate用のサービスの設定を変更していきます。
手順に従い、heyを使ってオートスケーリングを検証します。
実行基盤側の都合を気にせずスケーリングさせることができるのはいいですね!!
4. [発展] CI/CD ハンズオン
AWS Code ファミリーを使用して、ソースコードの変更からコンテナイメージのビルドと push、ECS on Fargate へのデプロイまでの一連の処理を自動化するパイプラインを作成していきます。
4.1. CodeCommitリポジトリとbuildspec.yamlの作成
まずCodeCommitリポジトリを作成します。
# リポジトリの作成
aws codecommit create-repository --repository-name rails-app
# リポジトリを VS Code Server 上の handson ディレクトリと連携
cd /workshop
git init -b main
git remote add origin codecommit::ap-northeast-1://rails-app
ここではハンズオン手順に従ってCodeCommitリポジトリを作成していますが、
今までCodeCommitを使っていなかったアカウント(もしくは既存リポジトリが無いアカウント)では、新規リポジトリの作成が不可となっていますので、ご注意ください!
以下の記事を参考に、CodeCommitの代わりにGitHubを利用することをご検討ください。
CodePipelineによる自動デプロイパイプラインの構築
続いて、/workshop
配下にbuildspec.yamlを作成します。
中身はこちら。
version: 0.2
env:
variables:
CONTAINER_NAME: "rails-app"
phases:
pre_build:
commands:
# AWS アカウント ID を取得する
- AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text)
# リポジトリの URI を設定する
- REPOSITORY_URI=${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/${CONTAINER_NAME}
# コミット ID をもとに、コンテナイメージのタグを生成する
- IMAGE_TAG=$(echo ${CODEBUILD_RESOLVED_SOURCE_VERSION} | cut -c 1-7)
# ECR にログインする
- aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com
build:
commands:
# コンテナイメージをビルドする
- docker build -t ${REPOSITORY_URI}:latest .
# 生成したイメージタグを付与する
- docker tag ${REPOSITORY_URI}:latest ${REPOSITORY_URI}:${IMAGE_TAG}
post_build:
commands:
# ビルドしたコンテナイメージを ECR にプッシュする
- docker push ${REPOSITORY_URI}:${IMAGE_TAG}
# "コンテナの名前" と "コンテナイメージの URI" を imagedefinitions.json に書き込む
- printf '[{"name":"%s","imageUri":"%s"}]' ${CONTAINER_NAME} ${REPOSITORY_URI}:${IMAGE_TAG} > imagedefinitions.json
artifacts:
files:
- imagedefinitions.json
作成した buildspec.yaml を含めて CodeCommit に push します。
git add .
git commit -m "Initial commit"
git push origin main
4.2. CI/CDパイプラインの作成
手順に従い、パイプラインを作成します。
簡単にパイプラインが作れてしまった。。
4.3. パイプラインの実行
Dockerfileの中身を変更して、CodeCommitにPushします。
# ruby:3.2.1 というベースイメージを取得する
FROM public.ecr.aws/docker/library/ruby:3.2.1
# 必要なパッケージ群を取得する
RUN apt-get update -qq && \
apt-get install -y nodejs postgresql-client npm && \
rm -rf /var/lib/apt/lists/\*
# ローカルにあるファイルをコンテナイメージ内にコピーする
WORKDIR /myapp
COPY Gemfile /myapp/Gemfile
# Rails アプリケーションを作成する
RUN bundle install && \
rails new . -O && \
sed -i -e "52a\ config.hosts.clear\n config.web_console.allowed_ips = '0.0.0.0/0'\n config.action_dispatch.default_headers.delete('X-Frame-Options')" config/environments/development.rb
+ # welcome ページの背景色を blue にする
+ RUN sed -i -e "s/background-color: #F0E7E9;/background-color: #99CCFF;/g" /usr/local/bundle/gems/railties-7.2.2/lib/rails/templates/rails/welcome/index.html.erb
# Rails を 3000 番ポートで起動する
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0", "-p", "3000"]
表示される画面が変わり、デプロイが完了したことが確認できました!
5. [発展] Fargate Spot ハンズオン
Fargate Spot は、Amazon ECS における購入オプションの 1 つです。AWS が持つ空きキャパシティを活用して大幅な割引 (最大 70%) を実現する一方で、リソースの需要が増えた際には、タスクの起動に失敗したり、「2 分前の中断通知」とともにタスクが中断される可能性があります。
今回は、Fargate 用のサービスと Fargate Spot 用のサービスを併用して、それぞれに同じメトリクスに基づくオートスケーリングを設定します。このとき「Fargate Spot の方が早くスケールアウトするように設定する」ことで、「できる限り Fargate Spot を利用して条件を満たそうとしつつ、それができない場合は通常の Fargate を利用する」構成を目指します。
工事中!
お片付けは忘れずにやりましょう!
終わりに
以上、ECSハンズオンをやってきました!
ハンズオンをやる前はECSの触りの部分しかわかっていませんでしたが、このハンズオンで理解が深まりました。
どの機能で何をコントロールするのか、具体的なイメージが得られたのは非常に大きな収穫でした。
皆様も是非お試しください!
以下、参考にさせていただいた記事です。
最後までお目通しいただき、ありがとうございました。