code-server と ALB で AWS Cloud9 代替の研修用 IDE を提供する – 実装編 3 EC2

こんにちは、広野です。

AWS Cloud9 は研修用途では非常に使い勝手が良かったのですが、AWS が新規アカウントへの提供を終了してしまいました。今回は私が試みた代替ソリューションの実装編です。本記事は Application Load Balancer に対してリスナールールとターゲットグループを作成し、Amazon EC2 インスタンスを関連付けます。

 

アーキテクチャ

アーキテクチャ概要編で、以下の図を紹介しておりました。

  • code-server サーバを配置する VPC です。何の変哲もない一般的なサブネット構成にしています。
  • NAT Gateway は課金節約のため、1 つのパブリックサブネットにしか配置していません。
  • code-server サーバは 1 ユーザーあたり 1 台を割り当てます。図では 1 つしかありませんが、人数分作成される想定です。ALB はユーザー全体で共用します。
  • code-server のログインパスワードはインストール時に設定しますが、AWS Secrets Manager で生成したものを使用します。
  • ALB には HTTPS アクセスさせるため、図には書いていませんが独自ドメインを使用します。そのための SSL 証明書はそのリージョンの AWS Certificate Manager で作成されていることが前提になります。
  • ALB から EC2 インスタンスへの通信は 80 番ポート (HTTP) を使用します。
  • ALB はインターネットに公開されますが、研修用途を想定して会社のソース IP アドレスからのみアクセスできるようにセキュリティグループを設定しています。

 

前提

前回記事を参考に、ALB や Amazon EC2 起動テンプレート等を作成してください。

 

AWS CloudFormation テンプレートの構成

テンプレートは 3 つに分けています。

  • VPC
  • Application Load Balancer
  • Amazon EC2 インスタンス ← 今回はここ

 

テンプレートの範囲を図にすると、以下のようになります。ここに書かれているリソースが作成されます。

  • 作成済みの ALB に、ターゲットグループごとにリスナールールを作成します。このリスナールールは、/ide1/ というパスであれば User 1 用のターゲットグループ (= User 1 用の code-server サーバ) にトラフィックをルーティングします。上限を 100 としているのは、ALB のクォータで 1 台の ALB に対してリスナールールを 100 個までしか作成できないためです。
  • 今回のテンプレートで、Amazon EC2 インスタンスを作成します。起動テンプレートやセキュリティグループは共用しているので、前回記事のテンプレート内で作成しています。
  • AWS Secrets Manager は code-server へのログインパスワードを生成、Amazon EC2 インスタンスから取得するために使用しています。

 

Amazon EC2 インスタンスの中の構成

アーキテクチャ概要編でも説明しましたが、以下の図のように Amazon EC2 インスタンスには nginx と code-server をインストールします。インストールには userdata スクリプトを使用します。userdata スクリプトも後述する AWS CloudFormation テンプレートの中に含まれていますので、その中で細かい補足をします。

パスの変換について

上の図のように例えば /ide2/ で渡されたパスを / に変換するようにしています。/ide2/ は ALB のパスベースルーティングのためにこちらが勝手に追加したパスなので、code-server が知らないパスだからです。

nginx の設定ファイルは userdata スクリプトの中で作成しており、その設定ファイルの中で以下の記述があります。

location /ide${InstanceId}/

${InstanceId} はテンプレートのパラメータとして入力する、ユーザーを一意に扱うための 1 – 100 の数字になります。つまり、パスベースルーティングの判断条件となる ide1 や ide2 などのパス名になります。

nginx が、これを受けて code-server に / としてトラフィックをパスするときには、location の後に /ide1/ のようにパス名を / で囲むように書くと機能します。

Amazon EC2 インスタンスの IAM ロールについて

テンプレートでは、以下の権限を付与しています。必要に応じて追加が必要です。

  • AWS Systems Manager と連携する権限。セッションマネージャ経由でログインできます。
  • AWS CodeCommit と連携する権限。
  • Amazon CloudWatch と連携する権限。
  • Amazon S3 バケット (前回記事で作成したログ保存用バケット) に書き込む権限。
  • AWS CDK と連携する権限
  • Amazon Q Developer と連携する権限
  • AWS Secrets Manager で、自分が作成したシークレットにアクセスする権限。

AWS Secrets Manager のリソースベースポリシーについて

AWS Secrets Manager で作成したシークレットには、リソースベースポリシーを作成しています。自分のシークレットを他人に見られないようにするのが目的です。以下の設定が入っています。

  • 自分の IAM ユーザであればアクセスできます。テンプレートのパラメータで、自分の IAM ユーザー名を入力する仕様にしています。
  • 自分の IDE (EC2 インスタンス) であればアクセスできます。code-server の自動インストール時に必要なため。

ALB のヘルスチェックについて

ALB は仕様上、ヘルスチェックに失敗している状態のターゲットグループにトラフィックをルーティングしてくれません。今回の構成では、ヘルスチェックをなるべく成功させるため、nginx が持っている empty_gif という画像ファイルをチェックするようターゲットグループに設定しています。また、ヘルスチェックであればアクセスログを残さないよう設定しています。

ヘルスチェック用パス: /healthcheck.html

その実体は nginx の設定にある empty_gif

 

AWS CloudFormation テンプレート

AWS CloudFormation テンプレートです。

細かいことはインラインのコメントで補足します。

AWSTemplateFormatVersion: "2010-09-09"
Description: The CloudFormation template that creates an EC2 instance with an ALB target group for code-server.

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  SystemName:
    Type: String
    Description: The system name. use lower case only. (e.g. example)
    Default: example
    MaxLength: 10
    MinLength: 1

  SubName:
    Type: String
    Description: The system sub name. use lower case only. (e.g. prod or dev)
    Default: dev
    MaxLength: 10
    MinLength: 1

  InstanceId:
    Type: Number
    Description: The individual instance id (number) specified from the administrator. The valid range is 1 to 100.
    Default: 1
    MinValue: 1
    MaxValue: 100

  YourIamUserName:
    Type: String
    Description: Your IAM user name. This is used for the permission of your code-server credential.
    Default: youriamusername
    MaxLength: 30
    MinLength: 1

  DomainName:
    Type: String
    Description: The domain name for the internet-facing URL.
    Default: example.com
    MaxLength: 40
    MinLength: 5

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

Resources:
# ------------------------------------------------------------#
# EC2
# ------------------------------------------------------------#
  Ec2Instance:
    Type: AWS::EC2::Instance
    Properties:
      IamInstanceProfile: !Ref Ec2InstanceProfile
      LaunchTemplate:
        LaunchTemplateId:
          Fn::ImportValue:
            !Sub ec2LtId-code-server-${SystemName}-${SubName}
        Version:
          Fn::ImportValue:
            !Sub ec2LtVersion-code-server-${SystemName}-${SubName}
      UserData:
        Fn::Base64: !Sub |
          #!/bin/bash
          # ec2-user を使用するつくりにしています。
          export HOME=/home/ec2-user
          # userdata スクリプトのログを見やすくするための設定です。
          set -euxo pipefail
          # Install packages - nginx, git, Node.js だけをインストールする構成にしています。
          dnf update -y
          dnf install -y nginx git nodejs
          # Install code-server
          curl -fsSL https://code-server.dev/install.sh | bash
          mkdir -p /home/ec2-user/.config/code-server
          mkdir -p /home/ec2-user/.local/share/code-server/User
          mkdir -p /home/ec2-user/environment
          chown -R ec2-user:ec2-user /home/ec2-user/.config /home/ec2-user/.local /home/ec2-user/environment
          # Get credential from Secrets Manager
          # ここで、AWS Secrets Manager で生成されたパスワードを取得して code-server に設定しています。
          PASSWORD=$(aws secretsmanager get-secret-value --secret-id "${SecretCodeServer.Id}" --region "${AWS::Region}" --query SecretString --output text)
          # Update code-server config
          cat <<EOF > /home/ec2-user/.config/code-server/config.yaml
          bind-addr: 127.0.0.1:8008
          auth: password
          password: ${!PASSWORD}
          cert: false
          EOF
          chown ec2-user:ec2-user /home/ec2-user/.config/code-server/config.yaml
          tee home/ec2-user/.local/share/code-server/User/settings.json <<EOF
          {
            "extensions.autoUpdate": false,
            "extensions.autoCheckUpdates": false,
            "terminal.integrated.cwd": "/home/ec2-user/environment",
            "telemetry.telemetryLevel": "off",
            "security.workspace.trust.startupPrompt": "never",
            "security.workspace.trust.enabled": false,
            "security.workspace.trust.banner": "never",
            "security.workspace.trust.emptyWindow": false,
            "editor.indentSize": "tabSize",
            "editor.tabSize": 2,
            "python.testing.pytestEnabled": true,
            "auto-run-command.rules": [
              {
                "command": "workbench.action.terminal.new"
              }
            ]
          }
          EOF
          chown ec2-user:ec2-user /home/ec2-user/.local/share/code-server/User/settings.json
          # Service start
          systemctl enable --now code-server@ec2-user
          # Configure nginx
          tee /etc/nginx/nginx.conf <<EOF
          user nginx;
          worker_processes auto;
          error_log /var/log/nginx/error.log warn;
          pid /run/nginx.pid;
          events {
            worker_connections 1024;
          }
          http {
            include       /etc/nginx/mime.types;
            default_type  application/octet-stream;
            log_format  main  '\$remote_addr - \$remote_user [\$time_local] "\$request" '
                              '\$status \$body_bytes_sent "\$http_referer" '
                              '"\$http_user_agent" "\$http_x_forwarded_for"';
            access_log  /var/log/nginx/access.log  main;
            sendfile        on;
            keepalive_timeout  65;
            include /etc/nginx/conf.d/*.conf;
            server {
              listen 80;
              server_name code-server-${SystemName}-${SubName}.${DomainName};
              location = /healthcheck.html {
                empty_gif;
                access_log off;
                break;
              }
              location /ide${InstanceId}/ {
                proxy_pass http://localhost:8008/;
                proxy_set_header Host \$host;
                proxy_http_version 1.1;
                proxy_set_header X-Real-IP \$remote_addr;
                proxy_set_header Upgrade \$http_upgrade;
                proxy_set_header Connection upgrade;
                proxy_set_header Accept-Encoding gzip;
                proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
              }
            }
          }
          EOF
          # Service start
          systemctl enable --now nginx
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}
        - Key: Name
          Value: !Sub code-server-ide${InstanceId}-${SystemName}-${SubName}
    DependsOn:
      - Ec2InstanceProfile

# ------------------------------------------------------------#
# 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-code-server-ide${InstanceId}-${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: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
        - arn:aws:iam::aws:policy/AWSCodeCommitPowerUser
        - arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy
      Policies:
        - PolicyName: !Sub CDKAssumeRolePolicy-ide${InstanceId}-${SystemName}-${SubName}
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - sts:AssumeRole
                Resource:
                  - !Sub arn:${AWS::Partition}:iam::*:role/cdk-*
        - PolicyName: !Sub QDeveloperPolicy-ide${InstanceId}-${SystemName}-${SubName}
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Action:
                  - "codewhisperer:GenerateRecommendations"
                Resource: "*"
                Effect: Allow
        - PolicyName: !Sub Ec2S3Policy-ide${InstanceId}-${SystemName}-${SubName}
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Action:
                  - "s3:PutObject"
                  - "s3:ListBucket"
                Resource:
                  - Fn::ImportValue:
                      !Sub S3BucketLogsArn-code-server-${SystemName}-${SubName}
                  - Fn::Join:
                      - ""
                      - - Fn::ImportValue:
                            !Sub S3BucketLogsArn-code-server-${SystemName}-${SubName}
                        - "/*"
                Effect: Allow
        - PolicyName: !Sub Ec2SecretsPolicy-ide${InstanceId}-${SystemName}-${SubName}
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - secretsmanager:GetResourcePolicy
                  - secretsmanager:GetSecretValue
                  - secretsmanager:DescribeSecret
                  - secretsmanager:ListSecretVersionIds
                Resource:
                  - !GetAtt SecretCodeServer.Id
    DependsOn:
      - SecretCodeServer

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

# ------------------------------------------------------------#
# ALB Target Group / Listener rule
# ------------------------------------------------------------#
  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      VpcId: !Ref VpcId
      Port: 80
      Protocol: HTTP
      TargetType: instance
      HealthCheckEnabled: true
      HealthCheckPath: /healthcheck.html
      HealthCheckIntervalSeconds: 60
      HealthCheckPort: traffic-port
      HealthCheckProtocol: HTTP
      HealthCheckTimeoutSeconds: 10
      HealthyThresholdCount: 5
      UnhealthyThresholdCount: 10
      Name: !Sub tg-ide${InstanceId}
      IpAddressType: ipv4
      ProtocolVersion: HTTP1
      Targets:
        - Id: !GetAtt Ec2Instance.InstanceId
          Port: 80
      TargetGroupAttributes:
        - Key: deregistration_delay.timeout_seconds
          Value: 30
        - Key: stickiness.enabled
          Value: false
        - Key: load_balancing.algorithm.type
          Value: round_robin
        - Key: slow_start.duration_seconds
          Value: 0
        - Key: stickiness.app_cookie.cookie_name
          Value: APPCOOKIE
        - Key: stickiness.app_cookie.duration_seconds
          Value: 86400
        - Key: stickiness.lb_cookie.duration_seconds
          Value: 86400
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}
    DependsOn:
      - Ec2Instance

  ListenerRule:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      ListenerArn:
        Fn::ImportValue:
          !Sub AlbListenerArn-${SystemName}-${SubName}
      Priority: !Ref InstanceId
      Conditions:
        - Field: path-pattern
          Values:
            - !Sub /ide${InstanceId}/*
      Actions:
        - Type: forward
          TargetGroupArn: !Ref TargetGroup
    DependsOn:
      - TargetGroup

# ------------------------------------------------------------#
# Secrets Manager
# ------------------------------------------------------------#
  SecretCodeServer:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: !Sub code-server-ide${InstanceId}-${SystemName}-${SubName}
      Description: code-server credential
      GenerateSecretString:
        PasswordLength: 8
        ExcludePunctuation: true
        IncludeSpace: false
        RequireEachIncludedType: true
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}

  SecretCodeServerResourcePolicy:
    Type: AWS::SecretsManager::ResourcePolicy
    Properties:
      BlockPublicPolicy: true
      SecretId: !Ref SecretCodeServer
      ResourcePolicy:
        Version: "2012-10-17"
        Statement:
          - Effect: Deny
            Principal: "*"
            Action: secretsmanager:GetSecretValue
            Resource: "*"
            Condition:
              StringNotEquals:
                aws:PrincipalArn:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:user/${YourIamUserName}"
                  - !GetAtt Ec2Role.Arn

# ------------------------------------------------------------#
# Output Parameters
# ------------------------------------------------------------#
Outputs:
  CodeServerUrl:
    Value:
      Fn::Join:
        - ""
        - - Fn::ImportValue:
              !Sub AlbUrl-${SystemName}-${SubName}
          - !Sub /ide${InstanceId}/?folder=/home/ec2-user/environment

 

テンプレート実行後の作業

テンプレートを実行すると、スタックの出力欄に作成した自分用 IDE の URL が表示されます。それにアクセスすると、以下のようにログイン画面が表示されます。

このパスワードは前述の通り AWS Secrets Manager で生成されているので、お手数ですが AWS マネジメントコンソールで自分のシークレットを確認しに行きます。以下のスクリーンショットのようにシークレットの値を表示して確認します。※モザイクかけています。
自分以外の人が作成したシークレットは権限で見ることができなくなっています。

無事ログインが成功すると、以下のように code-server の UI が表示されます。もちろん git が使用できます。npm などで必要なモジュールをインストールして開発していきます。aws cli もインストール済みなので使用できます。

 

まとめ

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

3つ目の記事は内容が多く説明を細かく書くときりがなくなってしまい、若干省略してしまいました。大変お手数ですが AWS CloudFormation テンプレートを生成 AI に説明させると細かい説明を聞けると思います。すみません。。。

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

著者について
広野 祐司

AWS サーバーレスアーキテクチャを駆使して社内クラウド人材育成アプリとコンテンツづくりに勤しんでいます。React で SPA を書き始めたら快適すぎて、他の言語には戻れなくなりました。サーバーレス & React 仲間を増やしたいです。AWS は好きですが、それよりもバックエンド構築を簡単にしてくれたことに対する感謝の気持ちの方が強いです。
取得資格:AWS 認定は15資格、IT サービスマネージャ、ITIL v3 Expert 等
2020 - 2024 Japan AWS Top Engineer 受賞
2022 - 2024 AWS Ambassador 受賞
2023 当社初代フルスタックエンジニア認定
好きなAWSサービス:AWS Amplify / AWS AppSync / Amazon Cognito / AWS Step Functions / AWS CloudFormation

広野 祐司をフォローする

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

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

AWSクラウド
シェアする
タイトルとURLをコピーしました