こんにちは、広野です。
AWS Cloud9 亡き後の IDE 環境をいろいろと考えてまして。結局のところシンプルな構成に落ち着きました。作成した構成を紹介します。自分の PC に VSCode をインストールしている人には全く役に立たない記事ですのでご放念ください。
ざっくり要件
- AWS Cloud9 の代わりとなる、クラウド上の IDE を構築したい。
- リモートアクセスはブラウザで (HTTPS で) で通信できるようにしたい。(特殊なポートは NG)
- HTTPS 通信で自己証明書を使用するのは NG。
- OS は Windows 以外にしたい。ライセンス面や価格面など、いろいろと。
アーキテクチャ
- VSCode をインストールした Ubuntu 24.04 arm64 にブラウザから GUI アクセスする環境です。
- EC2 には Elastic IP アドレスを割り当て、それをあらかじめ用意してある Amazon Route 53 パブリックホストゾーンに A レコード登録します。そのドメインおよびサブドメインに合わせて、Let’s Encrypt で SSL 証明書を作成します。証明書の有効期限は 90 日なので、更新するためのスクリプトを EC2 内に作成します。(自動更新ではなく、必要になったら実行)
- リモートアクセス機能は Amazon DCV が提供します。ただし 8443 ポートを使用するので、一応そのポートはそのまま利用できるようにしておき、8443 にアクセスできない環境のために 443 ポートでもアクセスできるよう、nginx でリバースプロキシを構築します。
- SSL 証明書は、Amazon DCV および nginx にカスタム証明書として組み込みます。
- 誰でもアクセスできるのはまずいので、とりあえず指定したソース IP アドレスでないと許可しないようセキュリティグループを設定します。セキュリティ面は引き続き強化を検討します。研修用途であれば現状で問題ないと思います。
要件から考えたこと
- AWS Cloud9 の代わりに、クラウド上の IDE を使用したい。
やはり VSCode もしくは VSCode 準拠のツールにしたいです。まずは純正 VSCode 使用で試行錯誤しています。そのため Amazon EC2 上で動く環境を作りました。今後は code-server も検証したいです。
- リモートアクセスはブラウザで (HTTPS で) で通信できるようにしたい。(特殊なポートは NG)
RDP や VNC ではなく、ブラウザでリモートのデスクトップを操作できる Amazon DCV を採用しました。会社のネットワーク通信の制約で、特殊ポートの通信ができない場合も 443 であれば通過できます。
- HTTPS 通信で自己証明書を使用するのは NG。
会社のネットワークセキュリティ等で、自己証明書の Web サイトにはアクセスできないことがあり、正規の SSL 証明書を用意する必要がありました。EC2 に ALB や Amazon CloudFront をかぶせることも考えましたが、コスト面を気にしたのと、CloudFront だと自己証明書のオリジンをサポートしていないために結局オリジン (EC2) に正規の証明書を実装せねばならず、採用をやめました。
- OS は Windows 以外にしたい。
OS が Windows であれば AWS Systems Manager Fleet Manager によるリモートアクセスはできたのですが、Windows だと高額だったり、会社のルールでセキュリティ対策などしないといけないので、Linux にしました。Linux の中でも、AWS で馴染み深い Amazon Linux 2023 を使用したかったのですが、本来サーバー用途での利用を想定された OS なのでデスクトップが使いにくく、次に馴染みのある Ubuntu にしました。Mint も考えましたが、純正 AMI が無かったのでやめました。インスタンスタイプは Graviton (arm64) を使用した t4g の方が t3 (x86_64) よりも若干安かったので、t4g を採用しています。幸い、使用するツールも arm64 に対応してました。
AWS CloudFormation テンプレート
補足はインラインコメントします。
EC2 ユーザーデータの中で以下のツールをインストールしているのですが、必要に応じて追加、削除しましょう。
- VSCode
- Amazon DCV
- nginx
- AWS CLI
- Amazon Q Developer CLI (サインインが必要なのでインストーラダウンロードのみ)
- Let’s Encrypt 証明書作成用モジュール
- NVM (Node.js バージョン管理ツール)
AWSTemplateFormatVersion: "2010-09-09" Description: The CloudFormation template that creates Ubuntu 24.04 ARM64 EC2 instance with GNOME 3, VSCode, and Amazon DCV. # ------------------------------------------------------------# # Input Parameters # ------------------------------------------------------------# Parameters: SystemName: Type: String Description: System name. use lower case only. (e.g. example) Default: example MaxLength: 10 MinLength: 1 SubName: Type: String Description: System sub name. use lower case only. (e.g. prod or dev) Default: dev MaxLength: 10 MinLength: 1 DomainName: Type: String Description: Domain name for URL. xxxxx.xxx Default: example.com MaxLength: 40 MinLength: 5 # VPC は既存にあるものを使用する想定です。 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 (ARM/Graviton) Default: t4g.large AllowedValues: - t4g.large - t4g.xlarge InstanceVolumeSize: Type: Number Description: The volume size in GB Default: 20 # アクセスを許可するソースIPアドレス(サブネット)です。 AllowedSubnet: Type: String Description: Allowed source IPv4 subnet and subnet mask. (e.g. xxx.xxx.xxx.xxx/32) Default: xxx.xxx.xxx.xxx/32 MaxLength: 18 MinLength: 9 TimeZone: Type: String Description: The specified time zone. Default: Asia/Tokyo MaxLength: 30 MinLength: 1 # ubuntu ユーザーの初期パスワードです。Amazon DCV のパスワードにもなります。リソース作成後は変更しましょう。 InitialPassword: Type: String Description: The initial OS password. You must change it after your first login. Default: Gx8jeTLc MaxLength: 128 MinLength: 8 # Route 53 パブリックホストゾーンは既存で持っている想定です。 Route53HostZoneId: Type: String Description: Route 53 public host zone ID for the SSL certificate. Default: XXXXXXXXXXXXXXXXXXXXX MaxLength: 30 MinLength: 1 # Let's Encrypt は証明書作成時にメールアドレスが必要になります。 YourEmail: Type: String Description: Your email address for SSL certificate application. Default: xxx@xxx.xxx MaxLength: 30 MinLength: 1 Metadata: AWS::CloudFormation::Interface: ParameterGroups: - Label: default: "General Configuration" Parameters: - SystemName - SubName - Label: default: "Domain Configuration" Parameters: - DomainName - Route53HostZoneId - YourEmail - Label: default: "Network Configuration" Parameters: - VpcId - InstanceSubnet - AllowedSubnet - Label: default: "EC2 Configuration" Parameters: - InstanceType - InstanceVolumeSize - Label: default: "OS Configuration" Parameters: - TimeZone - InitialPassword Resources: # ------------------------------------------------------------# # EC2 Launch template # ------------------------------------------------------------# EC2LaunchTemplate: Type: AWS::EC2::LaunchTemplate Properties: LaunchTemplateName: !Sub vscode-${SystemName}-${SubName} LaunchTemplateData: InstanceType: !Ref InstanceType ImageId: >- {{resolve:ssm:/aws/service/canonical/ubuntu/server/24.04/stable/current/arm64/hvm/ebs-gp3/ami-id}} BlockDeviceMappings: - Ebs: VolumeSize: !Ref InstanceVolumeSize VolumeType: gp3 DeleteOnTermination: true Encrypted: true DeviceName: /dev/sda1 NetworkInterfaces: - SubnetId: !Ref InstanceSubnet Groups: - !Ref Ec2SecurityGroup DeviceIndex: 0 AssociatePublicIpAddress: false MetadataOptions: HttpTokens: required Monitoring: Enabled: true 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 dcv-server access from specified subnets SecurityGroupIngress: - Description: Allow HTTPS (8443) from the specified client FromPort: 8443 IpProtocol: tcp ToPort: 8443 CidrIp: !Ref AllowedSubnet - Description: Allow QUIC from the specified client FromPort: 8443 IpProtocol: udp ToPort: 8443 CidrIp: !Ref AllowedSubnet - 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-vscode-${SystemName}-${SubName} # ------------------------------------------------------------# # EC2 Role / Instance Profile (IAM) # ------------------------------------------------------------# Ec2Role: Type: AWS::IAM::Role Metadata: cfn_nag: rules_to_suppress: - id: W11 reason: CodeWhisperer requires '*' as a resource, reference https://docs.aws.amazon.com/codewhisperer/latest/userguide/cloud9-setup.html#codewhisperer-IAM-policies Properties: RoleName: !Sub Ec2Role-vscode-${SystemName}-${SubName} Description: This role allows EC2 instance to invoke S3 and SSM. AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - ec2.amazonaws.com Action: - sts:AssumeRole Path: / # EC2 インスタンスには、とりあえず私が必要だった権限しか付与していません。用途に応じて追加が必要です。 ManagedPolicyArns: - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore - arn:aws:iam::aws:policy/AWSCodeCommitPowerUser - arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy Policies: - PolicyName: !Sub Ec2Policy-vscode-${SystemName}-${SubName} PolicyDocument: Version: 2012-10-17 Statement: # Amazon DCV のライセンスを読み取るために必要な権限です。 - Effect: Allow Action: s3:GetObject Resource: !Sub arn:aws:s3:::dcv-license.${AWS::Region}/* # Amazon Q Developer 関連 - Effect: Allow Action: - codewhisperer:GenerateRecommendations Resource: "*" # 以下は Let's Encrypt への証明書申請のために必要です。DNSによるドメイン所有者確認をします。 - Effect: Allow Action: - route53:ListHostedZones - route53:GetChange Resource: - "*" - Effect: Allow Action: - route53:ChangeResourceRecordSets Resource: - !Sub arn:aws:route53:::hostedzone/${Route53HostZoneId} 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-vscode-${SystemName}-${SubName} # ------------------------------------------------------------# # 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 export DEBIAN_FRONTEND=noninteractive cd /root # User password echo "ubuntu:${InitialPassword}" | chpasswd # Locale & timezone timedatectl set-timezone ${TimeZone} apt update -y apt upgrade -y apt install -y language-pack-ja-base language-pack-ja ibus-mozc unzip update-locale LANG=ja_JP.UTF-8 sed -i 's/^XKBLAYOUT=".*"/XKBLAYOUT="jp"/' /etc/default/keyboard dpkg-reconfigure -phigh console-setup systemctl restart console-setup # AWS CLI curl https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip -o awscliv2.zip unzip awscliv2.zip ./aws/install rm awscliv2.zip rm -fr aws # ubuntu desktop apt install -y ubuntu-desktop gdm3 xserver-xorg-video-dummy mesa-utils sed -i -e "s/^#WaylandEnable=false/WaylandEnable=false/g" /etc/gdm3/custom.conf systemctl restart gdm3 dpkg-reconfigure gdm3 systemctl set-default graphical.target systemctl isolate multi-user.target systemctl isolate graphical.target tee /etc/X11/xorg.conf << EOF Section "Device" Identifier "DummyDevice" Driver "dummy" Option "UseEDID" "false" VideoRam 512000 EndSection Section "Monitor" Identifier "DummyMonitor" HorizSync 5.0 - 1000.0 VertRefresh 5.0 - 200.0 Option "ReducedBlanking" EndSection Section "Screen" Identifier "DummyScreen" Device "DummyDevice" Monitor "DummyMonitor" DefaultDepth 24 SubSection "Display" Viewport 0 0 Depth 24 Virtual 4096 2160 EndSubSection EndSection EOF systemctl isolate multi-user.target systemctl isolate graphical.target # VSCode apt install -y gpg apt-transport-https software-properties-common wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > packages.microsoft.gpg install -o root -g root -m 644 packages.microsoft.gpg /etc/apt/trusted.gpg.d/ sh -c 'echo "deb [arch=arm64] https://packages.microsoft.com/repos/code stable main" > /etc/apt/sources.list.d/vscode.list' apt update -y apt install -y code # Amazon DCV wget https://d1uj6qtbmh3dt5.cloudfront.net/NICE-GPG-KEY gpg --import NICE-GPG-KEY wget https://d1uj6qtbmh3dt5.cloudfront.net/nice-dcv-ubuntu2404-aarch64.tgz tar -xvzf nice-dcv-ubuntu2404-aarch64.tgz cd nice-dcv-2024.0-19030-ubuntu2404-aarch64 apt install -y ./nice-dcv-server_2024.0.19030-1_arm64.ubuntu2404.deb ./nice-dcv-web-viewer_2024.0.19030-1_arm64.ubuntu2404.deb ./nice-xdcv_2024.0.654-1_arm64.ubuntu2404.deb usermod -aG video dcv systemctl enable dcvserver cd /root rm nice-dcv-ubuntu2404-aarch64.tgz rm -fr nice-dcv-2024.0-19030-ubuntu2404-aarch64 sed -i "/^\[session-management\/automatic-console-session/a owner=\"ubuntu\"\nstorage-root=\"%home%\"" /etc/dcv/dcv.conf sed -i "s/^#create-session/create-session/g" /etc/dcv/dcv.conf # Create SSL cert apt install -y software-properties-common certbot python3-certbot-dns-route53 certbot certonly --dns-route53 -d vscode-${SystemName}-${SubName}.${DomainName} --agree-tos -m ${YourEmail} --non-interactive --preferred-challenges dns-01 cp /etc/letsencrypt/live/vscode-${SystemName}-${SubName}.${DomainName}/fullchain.pem /etc/dcv/dcv.pem cp /etc/letsencrypt/live/vscode-${SystemName}-${SubName}.${DomainName}/privkey.pem /etc/dcv/dcv.key chown dcv:dcv /etc/dcv/dcv.pem /etc/dcv/dcv.key chmod 600 /etc/dcv/dcv.pem /etc/dcv/dcv.key # Install nginx apt install -y nginx systemctl enable --now nginx mkdir -p /etc/nginx/ssl chmod 700 /etc/nginx/ssl cp /etc/letsencrypt/live/vscode-${SystemName}-${SubName}.${DomainName}/fullchain.pem /etc/nginx/ssl/dcv-chain.crt cp /etc/letsencrypt/live/vscode-${SystemName}-${SubName}.${DomainName}/privkey.pem /etc/nginx/ssl/dcv.key chmod 600 /etc/nginx/ssl/*.key chmod 644 /etc/nginx/ssl/*.crt tee /etc/nginx/conf.d/dcv.conf << EOF server { listen 443 ssl; server_name vscode-${SystemName}-${SubName}.${DomainName}; ssl_certificate_key /etc/nginx/ssl/dcv.key; ssl_certificate /etc/nginx/ssl/dcv-chain.crt; location / { proxy_pass https://127.0.0.1:8443; proxy_http_version 1.1; proxy_set_header Upgrade \$http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto \$scheme; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_ssl_verify on; proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; proxy_ssl_verify_depth 2; proxy_ssl_name vscode-${SystemName}-${SubName}.${DomainName}; proxy_ssl_server_name on; } } EOF systemctl restart nginx # Create cert renew script cat << 'EOF' > /home/ubuntu/renew-cert.sh #!/bin/bash set -e sudo certbot renew --cert-name "vscode-${SystemName}-${SubName}.${DomainName}" sudo cp -f /etc/letsencrypt/live/vscode-${SystemName}-${SubName}.${DomainName}/fullchain.pem /etc/dcv/dcv.pem sudo cp -f /etc/letsencrypt/live/vscode-${SystemName}-${SubName}.${DomainName}/privkey.pem /etc/dcv/dcv.key sudo chown dcv:dcv /etc/dcv/dcv.pem /etc/dcv/dcv.key sudo chmod 600 /etc/dcv/dcv.pem /etc/dcv/dcv.key sudo cp -f /etc/letsencrypt/live/vscode-${SystemName}-${SubName}.${DomainName}/fullchain.pem /etc/nginx/ssl/dcv-chain.crt sudo cp -f /etc/letsencrypt/live/vscode-${SystemName}-${SubName}.${DomainName}/privkey.pem /etc/nginx/ssl/dcv.key sudo chmod 600 /etc/nginx/ssl/dcv.key sudo chmod 644 /etc/nginx/ssl/dcv-chain.crt sudo systemctl restart nginx echo "Certificate renewed." EOF chmod +x /home/ubuntu/renew-cert.sh chown ubuntu:ubuntu /home/ubuntu/renew-cert.sh # Install nvm sudo -u ubuntu bash -c "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash" # Download Amazon Q Developer CLI sudo -i -u ubuntu bash << EOF curl --proto '=https' --tlsv1.2 -sSf "https://desktop-release.q.us-east-1.amazonaws.com/latest/q-aarch64-linux.zip" -o "q.zip" unzip q.zip EOF # Finally, reboot reboot Tags: - Key: Cost Value: !Sub ${SystemName}-${SubName} - Key: Name Value: !Sub vscode-${SystemName}-${SubName} DependsOn: - Ec2InstanceProfile - Route53RecordA - S3BucketMyDocuments # ------------------------------------------------------------# # Route 53 # ------------------------------------------------------------# Route53RecordA: Type: AWS::Route53::RecordSet Properties: HostedZoneName: !Sub ${DomainName}. Name: !Sub vscode-${SystemName}-${SubName}.${DomainName}. Type: A TTL: 300 ResourceRecords: - !GetAtt EipEc2.PublicIp DependsOn: EipEc2 # ------------------------------------------------------------# # Output Parameters # ------------------------------------------------------------# Outputs: # EIP DcvServerUrl8443: Value: !Sub "https://vscode-${SystemName}-${SubName}.${DomainName}:8443" DcvServerUrl: Value: !Sub "https://vscode-${SystemName}-${SubName}.${DomainName}"
リソース作成後の動作
出来上がりはこんな感じです。
AWS CloudFormation テンプレートの出力に、ブラウザアクセス用の URL があります。ubuntu ユーザーでログインします。ユーザーデータの実行に時間がかかるので、ブラウザアクセスはスタックの完了後 10 分程度待った方がいいです。気になる方は /var/log/cloud-init-output.log で状況を確認してください。
再度 ubuntu のログイン画面が表示されるので、ログインします。
VSCode も起動できました。クリップボードの共有もできます。
passwd コマンドで忘れずにパスワード変更しておきましょう。
ubuntu ユーザーのホームディレクトリに、Let’s Encrypt の証明書更新用スクリプト renew-cert.sh を作ってあります。これを実行すると証明書を更新してくれますが、有効期限が 30 日を切らないと更新できないようです。オプションで強制更新する設定もありましたが、そこまでするのはやめました。
まとめ
いかがでしたでしょうか?
要件によってカスタマイズすることで、さらにいろいろなバリエーションを作れそうです。今後この環境を使って Kiro を試したいと思っています。
AWS Cloud9 は軽い点が良かったのですが、VSCode は重いですね・・・。インスタンスタイプが large 以上じゃないときついです。
本記事が皆様のお役に立てれば幸いです。