🐙

AWSでWebアプリをお手軽導入―SonarQube、Kiwi TCMSを導入してみる

に公開
2025/12/27

はじめに

ひと昔前は、社内でお手軽に未使用のPCを利用してJenkins、MLflow、TestLink、Redmineのような開発に使うWebアプリケーションをお試しに構築することが容易でした。
しかし昨今のリモートワーク環境やクラウド前提の開発においては、お手軽にWebアプリケーションを構築するのにはハードルがあります。
Webアプリケーションを構築すること自体は容易であっても、それをインターネットに公開する場合のリスクが常に付きまといます。
HTTPSやWAFの導入はAWSで行えば簡単にできるとはいえ、その分のコストが上乗せされて、お手軽に環境を作るというわけにはいきません。
IP制限を使用できるのであれば、それが最善ですが、常にその選択肢が使用できるとも限りません。

今回は、なるべく安全に、そしてなるべく安価に開発に使用する方法について検討して、コードの静的解析をおこなうSonarQubeとテストケース管理ツールであるKiwi TCMSをAWS上で作成してみることにします。

本記事は以下の知識と環境を前提とします。

  • AWSについてWebアプリケーションを構築した経験がある
  • 実験対象のAWSに対してCDKによるインフラ構築経験がある
  • aws-cdkは2.1100.1を使用

EC2にWebサーバーを立てる方法

安価かつ安全にEC2にWebサーバーを立てる方法の一つはEC2に対してSSM Session Managerを使用して、特定のポートについて安全な通信経路を作成することです。

SSM Session Managerを用いたEC2との接続

この構成の場合、クライアント端末は以下のようなコマンドでSSM Session Managerのポートフォワードを作成します。

aws ssm start-session --target i-xxxxxxxxxxx --document-name AWS-StartPortForwardingSession --parameters '{"portNumber": ["9000"], "localPortNumber": ["9000"]}'

この例ではhttp://localhost:9000にリクエストした内容が、SSM Session Managerが提供する暗号化された経路を経由してEC2インスタンス(i-xxxxxxxxxxx)上のWebサーバー(HTTP)に転送されます。この構成では、インターネット上を平文HTTPが流れることはありません。

この構成のメリットとしてはEC2に対するインバウンドでポートを空けることなく通信が行えます。
このため、WAFの構築やHTTPSにするためのドメインの確保をせずにWebアプリケーションを限られた人間で使用することができます。

EC2を配置するサブネットは理想論的にはPRIVATEサブネットが望ましいですが今回はPUBLICサブネットとします。
これはEC2から外部のRESTAPIや、pipやnpmによるアウトバウンドアクセスを行う必要がある場合にNATゲートウェイが不要になりコストが下げられるためです。

また、今回はスポットインスタンスを使用しないものとします。
スポットインスタンスを使用することでEC2のコスト自体を削減することが可能ですが、スポットインスタンスは状況によりAWS側から終了させられるケースがあります(停止ではない)。そのケースを考慮するとデータは外部のDBなどに配置したほうが望ましいと考えられます。

今回はスポットインスタンスを使用せずにDBについてはsqliteやh2を採用して、EC2内のファイルに格納することにします。多くのWebアプリケーションでは本番稼働では、sqliteやh2ではなくPostgreSQLの使用が推奨されますが、今回はデータが失われても問題ないことを前提にコストを削減する方式をとっています。

以下で実際に簡単なWebサービスをEC2インスタンスで起動して、ローカルのクライアントからアクセスする例を試します。

CDKによる簡易なWebアプリケーションの環境例

まず簡単なWebアプリケーションをCDKで作成してみます。
任意の空のフォルダで以下のコマンドを実行します。

npx aws-cdk@latest init app --language typescript

これによりテンプレートが作成されます。
次にsample_ec2/lib/sample_ec2-stack.tsに以下の実装を行います。

import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';

export class SampleEc2Stack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    // 1) VPC: Publicサブネット
    const vpc = new ec2.Vpc(this, 'Vpc', {
      natGateways: 0,
      maxAzs: 1, // まずは1AZに寄せて固定費を減らす
      subnetConfiguration: [
        {
          name: "public",
          subnetType: ec2.SubnetType.PUBLIC,
          cidrMask: 24
        },

      ],
    });

    // 2) EC2 SG: Inbound 0
    const instanceSg = new ec2.SecurityGroup(this, 'InstanceSg', {
      vpc,
      description: 'No inbound. Access only via SSM.',
      allowAllOutbound: true,
    });

    // 4) IAM Role: SSM Managed Instance Core
    const role = new iam.Role(this, 'InstanceRole', {
      assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
    });
    role.addManagedPolicy(
      iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'),
    );

    // 5) EC2: 安いのはARMのt4g系
    const instance = new ec2.Instance(this, 'Instance', {
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
      securityGroup: instanceSg,
      role,
      instanceType: new ec2.InstanceType('t4g.nano'),
      machineImage: ec2.MachineImage.latestAmazonLinux2023({
        cpuType: ec2.AmazonLinuxCpuType.ARM_64,
      }),
    });

    // 6) 例: EC2内でHTTPサーバを 9000 で待ち受けたいならUserDataで起動してもOK
    instance.addUserData(
      'dnf -y install python3',
      'cat > /home/ec2-user/app.py << "EOF"\n' +
        'from http.server import BaseHTTPRequestHandler, HTTPServer\n' +
        'class H(BaseHTTPRequestHandler):\n' +
        '  def do_GET(self):\n' +
        '    self.send_response(200); self.end_headers(); self.wfile.write(b"ok")\n' +
        'HTTPServer(("0.0.0.0", 9000), H).serve_forever()\n' +
        'EOF\n',
      'nohup python3 /home/ec2-user/app.py >/tmp/app.log 2>&1 &',
    );
  }
}

これはVPCとEC2を作成するCDKになります。
今回は非常に単純なWebアプリケーションですが、instance.addUserDataの内容を変更することで様々なWebアプリケーションを構築できます。

以下のコマンドを実行することでCDKをデプロイします

npx cdk deploy

この例だと以下のようなEC2インスタンスが起動します。
EC2インスタンス作成後画面

このインスタンスにはインターネットからのインバウンド通信を許可するルールは存在しないため、パブリックIP経由での直接アクセスはすべて遮断されます。
EC2インスタンス作成後画面・セキュリティ

実際、パブリックのIPv4に対してアクセスしても応答はありません。
アクセスできない例

以下のようにaws + Session Manager プラグインを使用することで通信経路を作成します。

aws ssm start-session --target i-09038e63d4eaf90ff --document-name AWS-StartPortForwardingSession --parameters '{"portNumber": ["9000"], "localPortNumber": ["9000"]}'

その後、ローカルのブラウザでhttp://localhost:9000にアクセスするとi-09038e63d4eaf90ffが提供するWebアプリケーションが動作します。
アクセスできる例

EC2インスタンスが起動している間、時間による課金が発生していることに留意してください。

実験が終了した場合は以下のコマンドで全ての環境を削除することができます。

npx cdk destroy

SonarQubeの導入

SonarQubeは様々なプログラミング言語の静的解析を行うツールです。
https://www.sonarsource.com/jp/products/sonarqube/

きわめて強力なツールで、問題のあるコードの指摘、複雑度の計測、カバレッジレポートの管理などを行います。
数年前の時点ではVueファイルの複雑度の計測が行えた数少ない静的解析ツールでした。

SonarQubeは強力なツールである一方、Webサーバーの構築が必須なため、その導入にはハードルがありました。
今回はSonarQubeをEC2インスタンスに構築してコードの品質管理を行うことを目標とします。

CDKによるSonarQubeの構築例

以下のようなCDKのコードを記述します。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as iam from "aws-cdk-lib/aws-iam";
// import * as autoscaling from "aws-cdk-lib/aws-autoscaling"; // ★ ASGは使わない

export class SonarqubeStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // VPC: Public subnetのみ(NATなし)
    const vpc = new ec2.Vpc(this, "Vpc", {
      natGateways: 0,
      maxAzs: 2,
      subnetConfiguration: [
        { name: "public", subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
      ],
    });

    // SG: インバウンド 0(ルールを一切追加しない)
    const sg = new ec2.SecurityGroup(this, "SonarSg", {
      vpc,
      description: "SonarQube: inbound 0, SSM only",
      allowAllOutbound: true,
    });

    // IAM: SSM用
    const role = new iam.Role(this, "InstanceRole", {
      assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
    });
    role.addManagedPolicy(
      iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore"),
    );

    // UserData: DockerでSonarQube起動(★そのまま流用)
    const userData = ec2.UserData.forLinux();
    userData.addCommands(
      "set -euxo pipefail",
      "dnf -y update",
      "dnf -y install docker",
      "systemctl enable --now docker",
      "mkdir -p /opt/sonarqube/{data,extensions,logs}",
      "chown -R 1000:1000 /opt/sonarqube",
      "sysctl -w vm.max_map_count=524288 || true",
      "sysctl -w fs.file-max=131072 || true",
      "docker rm -f sonarqube || true",
      'docker run -d --name sonarqube --restart unless-stopped ' +
        "-p 9000:9000 " +
        "-v /opt/sonarqube/data:/opt/sonarqube/data " +
        "-v /opt/sonarqube/extensions:/opt/sonarqube/extensions " +
        "-v /opt/sonarqube/logs:/opt/sonarqube/logs " +
        "sonarqube:community"
    );

    // ================================
    // ↓↓↓ ここから ASG / LaunchTemplate を無効化 ↓↓↓
    // ================================

    /*
    // Launch Template(削除)
    const lt = new ec2.LaunchTemplate(this, "SonarLt", {
      machineImage: ec2.MachineImage.latestAmazonLinux2023({
        cpuType: ec2.AmazonLinuxCpuType.X86_64,
      }),
      instanceType: new ec2.InstanceType("t3.medium"),
      role,
      securityGroup: sg,
      userData,
      associatePublicIpAddress: true,
      blockDevices: [
        {
          deviceName: "/dev/xvda",
          volume: ec2.BlockDeviceVolume.ebs(40, {
            volumeType: ec2.EbsDeviceVolumeType.GP3,
            encrypted: true,
          }),
        },
      ],
    });

    // AutoScalingGroup(削除)
    const asg = new autoscaling.AutoScalingGroup(this, "SonarAsg", {
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
      minCapacity: 1,
      maxCapacity: 1,
      mixedInstancesPolicy: {
        launchTemplate: lt,
        launchTemplateOverrides: [
          { instanceType: new ec2.InstanceType("t3.medium") },
          { instanceType: new ec2.InstanceType("t3a.medium") },
        ],
        instancesDistribution: {
          onDemandPercentageAboveBaseCapacity: 0,
          spotAllocationStrategy: autoscaling.SpotAllocationStrategy.CAPACITY_OPTIMIZED,
        },
      },
    });

    cdk.Tags.of(asg).add("Name", "sonarqube");
    */

    // ================================
    // ↑↑↑ ASG 無効化ここまで ↑↑↑
    // ================================

    // ★ 単一 EC2 Instance を作成(停止可能)
    const instance = new ec2.Instance(this, "SonarInstance", {
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
      instanceType: new ec2.InstanceType("t3.medium"),
      machineImage: ec2.MachineImage.latestAmazonLinux2023({
        cpuType: ec2.AmazonLinuxCpuType.X86_64,
      }),
      role,
      securityGroup: sg,
      userData,
      associatePublicIpAddress: true,
      blockDevices: [
        {
          deviceName: "/dev/xvda",
          volume: ec2.BlockDeviceVolume.ebs(40, {
            volumeType: ec2.EbsDeviceVolumeType.GP3,
            encrypted: true,
          }),
        },
      ],
    });

    // Nameタグ(SSM/CodeBuild 探索用)
    cdk.Tags.of(instance).add("Name", "sonarqube");

    new cdk.CfnOutput(this, "InstanceId", { value: instance.instanceId });
  }
}

基本的には先ほどの単純なWebアプリケーションと同じようなCDKになりますが、今回はDockerコンテナで動かしています。
また、理想的にはServerlessのDBなどをDBにすることが望ましいですが、今回はH2というローカルDBを使用しています。
このため、バージョンアップや障害によるデータの保証には難があることを留意してください。必要であれば、/opt/sonarqube/dataをS3に定期的にバックアップをするなどを検討してください。

また、インスタンスの性能がt3.mediumを使用しているので常時起動だと、それなりのコストになりますので、使わないときの停止を忘れないようにしてください。

SonarQubeでの静的解析

EC2インスタンスが起動したら対象のインスタンスに対してaws ssm start-sessionを使用したSSM Session Managerのポートフォワードを行います。

http://localhost:9000にアクセスするとSonarQubeの画面が表示されます。
初期パスワードは以下の通りです。

user: admin
pass: admin

SonarQubeはプロジェクト単位で静的解析の結果を管理します。
そのため、まずはプロジェクトを作成します。

  1. Projectsを選択

  2. Create a local Projectを選択
    SonarQubeプロジェクト作成

  3. プロジェクト名などを入力してNext
    SonarQubeプロジェクト作成の入力項目1

  4. Use the global settingを選択してCreate Projectボタン
    SonarQubeプロジェクト作成の入力項目2

  5. プロジェクトが生成される
    SonarQubeプロジェクト作成後

プロジェクトが作成されたら、そのプロジェクトに対して静的解析のデータを送信する際に利用するトークンを作成する必要があります。

  1. Locallyを選択
    トークンの作成1

2.名前や期限を指定してgenerateを押下
トークンの作成2

  1. キーが発行される。(本来、このキーは隠すようにすること。今回はローカル実行なので許容)
    トークンの発行

  2. continueを押下

  3. 各プラットフォームに応じてどうやって解析が登録できるかが表示される。

SonarQube使用例

今回はNode.jsを使用するものとして、以下のコマンドを実行するとSonarQubeのクライアントコマンドがインストールされます。

npm install -g @sonar/scan

あとは解析を行うコードがあるフォルダで以下のコマンドを実行します。

sonar -D sonar.host.url=http://localhost:9000 -D sonar.token=sqp_xxxxxxxxxxxxxxx -D sonar.projectKey=local_test

sonar.projectKeyとsonar.tokenについては、トークン作成時に作成したキー名とトークンを入力してください。

コードの解析が終了するとブラウザ上で、その結果を確認することができます。

SonarQube画面1
SonarQube画面2

CodePipeline+CodeBuildとの連携

AWSにはCodePipelineとCodeBuildといった継続的インテグレーションを可能にするサービスが存在します。
ここではCodePipeline+CodeBuildから、SSM Session Managerのポートフォワードを使用したEC2のWebアプリケーションとの連携を確認します。

まずはCodePipeline+CodeBuildを作成するCDKを用意します。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as iam from "aws-cdk-lib/aws-iam";
import * as codebuild from "aws-cdk-lib/aws-codebuild";
import * as codepipeline from "aws-cdk-lib/aws-codepipeline";
import * as cpactions from "aws-cdk-lib/aws-codepipeline-actions";
import * as codestar from "aws-cdk-lib/aws-codestarconnections";
import * as ssm from "aws-cdk-lib/aws-ssm";

export class SampleBuildStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // --- 1) GitHub への接続(CodeConnections)
    // これを作ったあと、コンソールで “Connect to GitHub” を完了させて AVAILABLE にする
    const ghConn = new codestar.CfnConnection(this, "GitHubConnection", {
      connectionName: "github-mima3-connection",
      providerType: "GitHub",
    });
    const sonarTokenParam = new ssm.StringParameter(this, "SonarTokenParam", {
      parameterName: "/sonarqube/token",
      stringValue: "sqp_xxxxxxxxxxxxxxxxxxxxxx", // TODO本来は環境変数などにする
      tier: ssm.ParameterTier.STANDARD,
    });
    // --- 2) CodeBuild Project(ソースは CodePipeline から受け取る)
    const project = new codebuild.Project(this, "SonarScanProject", {
      projectName: "sonar-scan-via-ssm",
      environment: {
        buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
        computeType: codebuild.ComputeType.SMALL,
      },
      // Sonar token は Parameter Store から注入(/sonarqube/token を事前に作成)
      environmentVariables: {
        AWS_REGION: { value: this.region },
        SONAR_HOST_URL: { value: "http://localhost:9000" },
        SONAR_PROJECT_KEY: { value: "local_test" },
        LOCAL_FORWARD_PORT: { value: "9000" },
        REMOTE_SONAR_PORT: { value: "9000" },
        SONAR_TOKEN: {
          type: codebuild.BuildEnvironmentVariableType.PARAMETER_STORE,
          value: "/sonarqube/token",
        },
      },
      buildSpec: codebuild.BuildSpec.fromObject({
        version: "0.2",
        phases: {
          install: {
            "runtime-versions": { nodejs: 20 },
            commands: [
              "set -eu",
              "aws --version",
              "echo Install session-manager-plugin if missing",
              'if ! command -v session-manager-plugin >/dev/null 2>&1; then ' +
                "sudo apt-get update -y; " +
                'curl -fsSL "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" -o /tmp/session-manager-plugin.deb; ' +
                "sudo dpkg -i /tmp/session-manager-plugin.deb || sudo apt-get -f install -y; " +
              "fi",
            ],
          },
          pre_build: {
            commands: [
              'echo "Find running EC2 instance (tag Name=sonarqube)"',
              'INSTANCE_ID="$(aws ec2 describe-instances ' +
                '--region "$AWS_REGION" ' +
                '--filters "Name=tag:Name,Values=sonarqube" "Name=instance-state-name,Values=running" ' +
                '--query "Reservations[].Instances[].InstanceId | [0]" --output text)"',
              'if [ "$INSTANCE_ID" = "None" ] || [ -z "$INSTANCE_ID" ]; then echo "ERROR: No running instance found"; exit 1; fi',
              'echo "INSTANCE_ID=$INSTANCE_ID"',
              'echo "Start SSM port-forward: local:${LOCAL_FORWARD_PORT} -> ${INSTANCE_ID}:${REMOTE_SONAR_PORT}"',
              'nohup aws ssm start-session --region "$AWS_REGION" --target "$INSTANCE_ID" ' +
                '--document-name "AWS-StartPortForwardingSession" ' +
                '--parameters "portNumber=${REMOTE_SONAR_PORT},localPortNumber=${LOCAL_FORWARD_PORT}" ' +
                "> /tmp/ssm-portforward.log 2>&1 &",
              'echo "Wait for SonarQube API..."',
              'for i in $(seq 1 60); do ' +
                'if curl -fsS "${SONAR_HOST_URL}/api/system/status" >/dev/null 2>&1; then echo "SonarQube reachable"; break; fi; ' +
                "sleep 2; " +
                'if [ "$i" -eq 60 ]; then echo "ERROR: SonarQube not reachable"; tail -n 200 /tmp/ssm-portforward.log || true; exit 1; fi; ' +
              "done",
            ],
          },
          build: {
            commands: [
              'echo "Run Sonar scan"',
              // あなたが使っている @sonar/scan でOK(PowerShell問題もここでは関係ない:Linuxコンテナなので)
              'npx -y @sonar/scan -Dsonar.host.url="${SONAR_HOST_URL}" -Dsonar.token="${SONAR_TOKEN}" -Dsonar.projectKey="${SONAR_PROJECT_KEY}"',
            ],
          },
        },
      }),
    });

    // CodeBuild の権限(最小限の目安)
    project.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ["ec2:DescribeInstances"],
        resources: ["*"],
      })
    );
    project.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ["ssm:StartSession", "ssm:DescribeSessions", "ssm:TerminateSession"],
        resources: ["*"],
      })
    );
    project.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ["ssmmessages:*", "ec2messages:*"],
        resources: ["*"],
      })
    );

    // --- 3) CodePipeline(Source: GitHub via CodeConnections → Build: CodeBuild)
    const pipeline = new codepipeline.Pipeline(this, "Pipeline", {
      pipelineName: "puppeteer-recorder-sonar",
    });

    const sourceOutput = new codepipeline.Artifact();

    // TODOここはGitHubに合わせて修正
    pipeline.addStage({
      stageName: "Source",
      actions: [
        new cpactions.CodeStarConnectionsSourceAction({
          actionName: "GitHubSource",
          owner: "mima3",
          repo: "puppeteer-recorder",
          branch: "main",
          connectionArn: ghConn.attrConnectionArn,
          output: sourceOutput,
          triggerOnPush: true,
        }),
      ],
    });

    pipeline.addStage({
      stageName: "Build",
      actions: [
        new cpactions.CodeBuildAction({
          actionName: "SonarScan",
          project,
          input: sourceOutput,
        }),
      ],
    });

    new cdk.CfnOutput(this, "GitHubConnectionArn", { value: ghConn.attrConnectionArn });
    new cdk.CfnOutput(this, "PipelineName", { value: pipeline.pipelineName });
  }
}

このCDKは一般的なCodePipelineとCodeBuildのCDKです。
GitHubの設定自体はお手持ちの環境に合わせて修正してください。

    const ghConn = new codestar.CfnConnection(this, "GitHubConnection", {
      connectionName: "github-mima3-connection",
      providerType: "GitHub",
    });
...
    // TODOここはGitHubに合わせて修正
    pipeline.addStage({
      stageName: "Source",
      actions: [
        new cpactions.CodeStarConnectionsSourceAction({
          actionName: "GitHubSource",
          owner: "mima3",
          repo: "puppeteer-recorder",
          branch: "main",
          connectionArn: ghConn.attrConnectionArn,
          output: sourceOutput,
          triggerOnPush: true,
        }),
      ],
    });

また、他にはSSM Session Managerを使用するための対応が必要です。
まず、CodeBuild内からEC2のSSM Session Managerを使用したEC2接続をするための権限を割り当てます。本来はリソースは制限した方が望ましいですが、今回は検証用なのでそのままとします。

    project.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ["ec2:DescribeInstances"],
        resources: ["*"],
      })
    );
    project.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ["ssm:StartSession", "ssm:DescribeSessions", "ssm:TerminateSession"],
        resources: ["*"],
      })
    );
    project.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ["ssmmessages:*", "ec2messages:*"],
        resources: ["*"],
      })
    );

CodeBuild中のbuildSpecでsession-manager-pluginをインストールしています。

            commands: [
              "set -eu",
              "aws --version",
              "echo Install session-manager-plugin if missing",
              'if ! command -v session-manager-plugin >/dev/null 2>&1; then ' +
                "sudo apt-get update -y; " +
                'curl -fsSL "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" -o /tmp/session-manager-plugin.deb; ' +
                "sudo dpkg -i /tmp/session-manager-plugin.deb || sudo apt-get -f install -y; " +
              "fi",
            ],

そしてビルドコマンド中にSSMポートフォワードを行っています。

              'echo "Start SSM port-forward: local:${LOCAL_FORWARD_PORT} -> ${INSTANCE_ID}:${REMOTE_SONAR_PORT}"',
              'nohup aws ssm start-session --region "$AWS_REGION" --target "$INSTANCE_ID" ' +
                '--document-name "AWS-StartPortForwardingSession" ' +
                '--parameters "portNumber=${REMOTE_SONAR_PORT},localPortNumber=${LOCAL_FORWARD_PORT}" ' +
                "> /tmp/ssm-portforward.log 2>&1 &",

このcdkに対してcdk deployを行うとGitHubの接続情報の処理が保留されているため、パイプラインの実行はエラーとなっています。
この保留中の処理を完了させるために、左側のメニューより、パイプライン>設定>接続を選択します。
CodePipe line作成直後

保留中の接続名を選択します
保留中の接続

保留中の接続を更新を行います。
接続の更新

ポップアップが表示されるのでAWSとGitHubの接続の認証をします。
GitHubとの連携1
GitHubとの連携2

接続が完了すると保留中が利用可能になります。
CodePipeline正常

以後、CodePipeline上で「変更をリリースする」を実行するとCodeBuildが動作して静的解析が実行されます。
CodePipeline実行方法

課題と別解

なお、今回使用したCommunity版ではmainブランチ以外の使用ができません。
そのため、プルリクエスト中のブランチの解析には人毎にプロジェクトを用意するなどの回避策を使用するか、mainブランチのマージ後に修正を行うという運用にする必要がありそうです。

また、ローカルのDockerにSonarQubeを構築する運用はやったことがありますが、微妙でした。
レビュー時に、ブランチを取得して、ローカル上のSonarQubeに検査させて、その結果をレビューに反映するという運用でしたが、レビュー数が多くなると破綻しました。

なお、SonarQubeにはクラウド版が提供されています。
https://www.sonarsource.com/jp/plans-and-pricing/
ただし、フリー版ではプライベートプロジェクトについては、最大5万行のコードという制限があります。

Kiwi TCMSの導入

Kiwi TCMSはテストケースの管理をするツールです。概要と使い方は以下を参考にしてください。

https://zenn.dev/mima_ita/articles/e29343a62602b8

ここではKiwi TCMSをEC2に構築してみます。

CDKによるKiwi TCMSの構築例

以下のようなCDKのコードを記述します。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as iam from "aws-cdk-lib/aws-iam";
// import * as sqs from 'aws-cdk-lib/aws-sqs';

export class KiwiStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // VPC: Public subnetのみ(NATなし)
    const vpc = new ec2.Vpc(this, "Vpc", {
      natGateways: 0,
      maxAzs: 1,
      subnetConfiguration: [
        { name: "public", subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 },
      ],
    });

    // SG: インバウンド 0(ルールを一切追加しない)
    const sg = new ec2.SecurityGroup(this, "KiwiSg", {
      vpc,
      description: "Kiwi: inbound 0, SSM only",
      allowAllOutbound: true,
    });

    // IAM: SSM用
    const role = new iam.Role(this, "InstanceRole", {
      assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
    });
    role.addManagedPolicy(
      iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore"),
    );

    // UserData: DockerでKiwi起動
    const userData = ec2.UserData.forLinux();
    userData.addCommands(
      "set -euxo pipefail",
      "dnf -y update",
      "dnf -y install docker",
      "systemctl enable --now docker",
      "mkdir -p /opt/kiwi/{uploads,sqlite}",
      "chown -R 1001:root /opt/kiwi",
      "sysctl -w vm.max_map_count=524288 || true",
      "sysctl -w fs.file-max=131072 || true",
      "docker rm -f kiwi_web_dev || true",
      "docker run -d --name kiwi_web_dev --restart unless-stopped " +
      "  -p 8080:8080 " +
      "  -p 8443:8443 " +
      "  -e KIWI_DB_ENGINE=django.db.backends.sqlite3 " +
      "  -e KIWI_DB_NAME=sqlite/kiwi.sqlite3 " +
      `  -e EMAIL_BACKEND="django.core.mail.backends.console.EmailBackend" ` +
      "  -v /opt/kiwi/uploads:/Kiwi/uploads " +
      "  -v /opt/kiwi/sqlite:/Kiwi/sqlite " +
      "  pub.kiwitcms.eu/kiwitcms/kiwi:latest "
    );
    // ★ 単一 EC2 Instance を作成(停止可能)
    const instance = new ec2.Instance(this, "KiwiInstance", {
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
      instanceType: new ec2.InstanceType("t3.medium"),
      machineImage: ec2.MachineImage.latestAmazonLinux2023({
        cpuType: ec2.AmazonLinuxCpuType.X86_64,
      }),
      role,
      securityGroup: sg,
      userData,
      associatePublicIpAddress: true,
      blockDevices: [
        {
          deviceName: "/dev/xvda",
          volume: ec2.BlockDeviceVolume.ebs(40, {
            volumeType: ec2.EbsDeviceVolumeType.GP3,
            encrypted: true,
          }),
        },
      ],
    });

    // Nameタグ(SSM/CodeBuild 探索用)
    cdk.Tags.of(instance).add("Name", "kiwi");

    new cdk.CfnOutput(this, "InstanceId", { value: instance.instanceId });

  }
}

chownの設定内容がsonarqubeと変わっていますが、これはdockerコンテナ内の設定に依存するものなので、使用するイメージに合わせて調整してください。

このCDKに対してデプロイを実施します。

npx cdk deploy

インスタンスが起動したら、初期化用のスクリプトを実行するため、SSM Session Managerを使用してEC2インスタンスに入ります。

aws ssm start-session --target i-0974b6f8443445413

EC2に入ったら、初期化スクリプトを実行します。

sudo -u ec2-user -i
sudo docker exec -it kiwi_web_dev sh
cd /Kiwi
./manage.py initial_setup

初期化実行中にユーザー名やパスワードを問われるので入力します。

# 実行中に以下を聞かれる
Username (leave blank to use '1001'): admin # ユーザ名
Email address: admin@test.co.jp # メールアドレス
Password:  # パスワードを入力
Password (again): 
Bypass password validation and create user anyway? [y/N]: y # 簡単なパスワードなどだときかれる
Superuser created successfully.
3. Setting the domain name:
Enter Kiwi TCMS domain: localhost # メールなどの通知につかうドメイン名

初期化が終了したらセッションを終了します。
その後、SSM Session Managerでポートフォワードをします。

aws ssm start-session \
  --target i-0974b6f8443445413 \
  --document-name AWS-StartPortForwardingSession \
  --parameters '{"portNumber":["8443"],"localPortNumber":["8443"]}'

以下にアクセスすると、Kiwi TCMSが操作できます。
https://localhost:8443/

kiwi画面

その他、詳しい使い方については下記を参照してください。
https://zenn.dev/mima_ita/articles/e29343a62602b8

この例ではsqliteを使用しているため、障害耐性や同時接続については限界があります。
もし本格稼働をする場合は、sqliteをS3に定期バックアップをとったり、ServerlessによるDBの構築について検討してください。

まとめ

今回はWebアプリケーションを、なるべく安全に安価にAWS上で構築する方法を紹介しました。
社内でWebアプリケーションをお試しに立てるといったお手軽さほどではないにせよ、SSM Session Managerのポートフォワードを使用することで、WAFやHTTPSの構築なしでWebアプリケーションを構築できることが確認できます。
また、それは権限の設定を適切にこなすことでCodeBuildなどのCIからの連携も可能です。

もし、開発に使用するWebアプリケーションがインターネットに公開することが障害になっていた場合は、今回の手法を利用することで、そのハードルを多少下げることができるのではないかと思います。

Discussion