CDKでEC2を立ち上げたときに初期設定コマンドを実行する方法

CDKでEC2を立ち上げるのはよいのだけれど、インスタンスが立ち上がった後にSSHやセッションマネージャーを使って初期設定作業を毎回するのは面倒だし、せっかくインフラのコード化（IaC）したのにこの作業のみ手作業なんてイケてない。

絶対にCDKで何かしらの方法があるはずだ、ということで調べてみました。
調べた結果以下の2箇所で設定できることがわかった。

- ##### 方法(1)  ec2.UserDataのaddCommandsにストリング配列で設定し、userDataプロパティに設定する方法

    ``` typescript
    const userData = ec2.UserData.forLinux({ shebang: '#!/bin/bash' })
    userData.addCommands(
        '何かしらのコマンド'
        'sudo apt-get update',
        'echo test'
    )
    
    // EC2インスタンスを作成する
    const ec2Instance = new ec2.Instance(this, 'hogehogeInstance', {
            vpc,
            instanceType: ec2.InstanceType.of(ec2.InstanceClass.T2,         
            userData: userData,   //<=== この行が必要
            ・
            ・
            ・
    });
    ```

- ##### 方法(2) 実行したいコマンドをシェルにして、アセット化しS3にアップロード後、デプロイ時にダウンロードして実行する方法。
  - 作成例 `src` フォルダ内に実行したいコマンドをシェル `(例えば config.sh)` にして配置する。(アセット)

    ``` bash
    #!/bin/bash
    # src/config.sh として作成する
    # このシェルはrootユーザで実行されるため ubuntu ユーザで実行したい場合は 
    # su コマンドを利用する
    
    apt-get install curl
    
    sudo su - ubuntu <<EOF
    curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh
    EOF
    ```

  - 上記で作成したアセットをCDKでインスタンス作成時に実行するようにするコード 

    ``` typescript
    
    // s3にアップロードされる
    const asset = new Asset(this, 'Asset', { path: path.join(__dirname, '../src/config.sh') });
    
    // s3からダウンロードする
    asset.grantRead(ec2Instance.role);
    const localPath = ec2Instance.userData.addS3DownloadCommand({
        bucket: asset.bucket,
        bucketKey: asset.s3ObjectKey,
    });
    
    // EC2インスタンスを作成する
    const ec2Instance = new ec2.Instance(this, 'hogehogeInstance', {
            vpc,
            instanceType: ec2.InstanceType.of(ec2.InstanceClass.T2,         
            ・
            ・
            ・
    });
    
    // インスタンス作成時にコマンドとして実行する。
    ec2Instance.userData.addExecuteFileCommand({ filePath: localPath, });
    ```

:::note info
どちらの方法でも初期設定コマンドが実行される、また両方設定しても問題はなく、両方設定した場合は方法(1)が実行されたあとに方法(2)が実行される
:::

:::note info
実行ログは `/var/log/cloud-init-output.log` に吐き出されているのでエラーが発生した場合はこのファイルをみると確認できる
:::

## 注意点
- あくまで初期設定(EC2のインスタンスが作成されるときに一回コッキリで実行される)なのでEC2が置き換わらない変更をCDKに加えた場合は初期設定コマンドは実行されない。

- アセット(この場合 src/config.sh)を書き換えてもEC2のリプレースは発生しないので上記と同様に変更したシェルの内容は実行されない。(s3のアップロードのみ行われる)

:::note info
いきなり`cdk deploy`するのではなく、一旦 `cdk diff` してみて  EC2インスタンスが置き換わるかチェックするのをおすすめします。
:::

 
## 最後に

ElasticIPを取得、SSHの設定、セッションマネージャーからの接続が可能なCDKのサンプルコードを記述しておきます。
お好きなように改変してお使いください。

``` typescript
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as cdk from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam'
import * as path from 'path';
import { Asset } from 'aws-cdk-lib/aws-s3-assets';
import { Construct } from 'constructs';
import { RemovalPolicy, Token } from "aws-cdk-lib";

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

    const stackName = 'Sample'

    // VPC
    const vpc = new ec2.Vpc(this, `${stackName}VPC`, {
      natGateways: 0,
      subnetConfiguration: [{
        cidrMask: 24,
        name: `${stackName}PublicSubnet`,
        subnetType: ec2.SubnetType.PUBLIC
      }],
      maxAzs: 1
    });

    const ami = ec2.MachineImage.genericLinux({
      //Ubuntu Server 20.04 LTS (HVM), SSD Volume Type
      'ap-northeast-1': 'ami-09b18720cb71042df',
    });

    // セッションマネージャーから接続できるように
    const role = new iam.Role(this, `${stackName}ec2Role`, {
      assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com')
    })
    role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'))

    // あとでログなどをClowdWatchで出力できるように
    role.addToPolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: [
        //https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/QuickStartEC2Instance.html
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents",
        "logs:DescribeLogStreams"
      ],
      resources: ["*"],
    }));

    // ユーザーデータを S3 にアップロードする
    const asset = new Asset(this, 'Asset', { path: path.join(__dirname, '../src/config.sh') });

    // https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/user-data.html#user-data-shell-scripts
    // cat /var/log/cloud-init-output.log に debug情報あり
    const userData = ec2.UserData.forLinux({ shebang: '#!/bin/bash' })
    userData.addCommands(
      'sudo apt-get update',
      `echo test`
    )

    // キーペア作成
    const cfnKeyPair = new ec2.CfnKeyPair(this, `hogehogeCfnKeyPair`, {
      keyName: `hogehogekey-pair`,
    })
    cfnKeyPair.applyRemovalPolicy(RemovalPolicy.DESTROY)

    // SSH (TCP Port 22)
    const securityGroup = new ec2.SecurityGroup(this, `hogehogeSecurityGroup`, {
      vpc,
      description: 'outbound all',
      allowAllOutbound: true
    });
    securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(22), 'Allow SSH Access')

    const ec2Instance = new ec2.Instance(this, `hogehogeInstance`, {
      vpc,
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T2, ec2.InstanceSize.NANO),
      machineImage: ami,
      securityGroup: securityGroup,
      keyName: Token.asString(cfnKeyPair.ref),
      role: role,
      userData: userData,
    });

    asset.grantRead(ec2Instance.role);
    const localPath = ec2Instance.userData.addS3DownloadCommand({
      bucket: asset.bucket,
      bucketKey: asset.s3ObjectKey,
    });

    ec2Instance.userData.addExecuteFileCommand({ filePath: localPath, });

    //Elastic IPを設定
    let ec2Assoc = new ec2.CfnEIPAssociation(this, `hogehogeEc2Association`, {
      eip: 'xxx.xxx.xxx.xxx',
      instanceId: ec2Instance.instanceId
    });

    new cdk.CfnOutput(this, `hogehoge IP Address`, { value: ec2Instance.instancePublicIp });
    new cdk.CfnOutput(this, `hogehogeGetSSHKeyCommand`, {
      value: `aws ssm get-parameter --name /ec2/keypair/${cfnKeyPair.getAtt('KeyPairId')} --region ${this.region} --with-decryption --query Parameter.Value --output text`,
    })
    new cdk.CfnOutput(this, `hogehoge ssh command`, { value: 'ssh -i cdk-key.pem -o IdentitiesOnly=yes ec2-user@' + ec2Instance.instancePublicIp })

  }
}