【IaC】今さらPulumiを触ってみる

# はじめに

[OpenStandiaアドベントカレンダー](https://qiita.com/advent-calendar/2024/nri-openstandia)16日目になります。
今回は、Infrastructure as Code（IaC）ツールで気になっていた Pulumi について紹介します。
私自身、AWS Cloud Development Kit（AWS CDK）や Azure Bicep を触れたことがありますが、クラウドに縛られない IaC ツールには触れたことがありませんでした。
クラウドに縛られない IaC ツールの例としては、Terraform が有名かと思いますが、個人的には TypeScript で書きたい派なので、以前から Pulumi に興味を持っていました。
そこで今回は、Pulumiを使いながら、その特徴や使い方について色々と触れてみたいと思います。

# Pulumiとは

Pulumi は、IaC ツールの一つで、プログラミング言語（TypeScript、JavaScript、Python、Go、.NET、Java、YAML）を使用して、クラウド環境のプロビジョニング、更新、管理を行うことができます。
Terraform と同様、クラウド環境に縛られず、AWS、Azure、Google Cloud など複数のクラウドプロバイダーやオンプレミス環境にも対応しており、統一されたインターフェースでインフラをコードとして管理できます。
PulumiとTeraformの違いについては、以下を参照して頂ければと思います。

https://www.pulumi.com/docs/iac/concepts/vs/terraform/


## Pulumiの構成
さっそくですが、Pulumiの構成を見ていきます。
Pulumiの構成は以下になります。
![pulumi-programming-model-diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2004450/10a0c731-8035-5318-2d46-2c791b636764.png)
引用：https://www.pulumi.com/docs/iac/concepts/
- Project
    - Programのソースコードとメタデータを格納するディレクトリ
- Program
    - インフラストラクチャのあるべき構成を定義
- Resource
    - インフラストラクチャを構成するオブジェクト。オブジェクトの設定値は、他のオブジェクトと共有可能
- Stack
    - Project ディレクトリ内で Program を pulumi up（Pulumi CLI）した後のインスタンス。本番環境やステージング環境など、環境に応じてインスタンスを作成可能。

## Pulumiの動作

[公式ドキュメント](https://www.pulumi.com/docs/intro/concepts/how-pulumi-works/)によると、Pulumiの動作は以下のようになります。
![engine-block-diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2004450/a31a5be5-c6f1-0cbc-e6b1-7caa529d4171.png)
引用：https://www.pulumi.com/docs/intro/concepts/how-pulumi-works/

上記の図に出てくる用語の定義は以下の通りです。

- Language Host
    - Pulumiプログラムを実行し、デプロイメントエンジンにリソースを登録できる環境
    - Pulumiプロジェクトに相当
- CLI and Engine（Deployment Engine）
    - Deployment Engine は Pulumi CLI 自体に組み込まれている
    - インフラストラクチャの現在の状態（State）をプログラムによって表現されるStateにするため必要な操作を計算する役割

Pulumi で定義したソースコードは`pulumi up` コマンドを実行することで、各プロバイダー（AWS/Azure/GCP/Kubernetes）にデプロイされます。
`pulumi up`コマンド実行時のPulumiの内部的な動作はざっくりですが、以下のようになります。
1. Language Host がリソースを Deployment Engine に登録
2. Deployment Engine は最新のデプロイ情報（State）を確認し、ソースコードで定義された状態と比較
3. 比較結果に基づいて、クラウド上のリソースの作成・更新・削除を行い、その結果をデプロイの最終状態（State）として保存

# Pulumi Chalenge

ここまで Pulumi の構成や動作について説明してきましたが、Pulumi は Pulumi AI というサービスも提供しています。
Pulumi AI は生成 AI を活用して、自然言語の指示に基づき Pulumi のコードを自動生成することが可能です。
今回は、Pulumi Challenge という Pulumi AI を学べるワークショップを通じて、Pulumi を触っていきます。

https://www.pulumi.com/challenge/ai-architecture/

## セットアップ

まずは環境のセットアップです。今回は、AWS環境を使用するので、AWSのアクセスキーとシークレットアクセスキー、Open AIのキーを設定します。
```sh
export AWS_ACCESS_KEY_ID=<YOUR_ACCESS_KEY_ID>
export AWS_SECRET_ACCESS_KEY=<YOUR_SECRET_ACCESS_KEY>
export OPENAI_API_KEY=<YOUR_API_KEY>
```
:::note warn
Pulumiのアカウントは作成済みということを前提としています。
:::

## Pulumi AI

以下のサイトにアクセスし、Pulumi AIを利用します。

https://www.pulumi.com/ai

`Deploy a Kinesis Data Stream, RDS database, DynamoDB database, and Timestream database.`
とコンソールに入力します。入力後、Pulumi AIが以下のコードを生成してくれました。
```ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

// Create a Kinesis Data Stream
const kinesisStream = new aws.kinesis.Stream("myKinesisStream", {
    shardCount: 1,
});

// Create an RDS database
const dbSubnetGroup = new aws.rds.SubnetGroup("myDbSubnetGroup", {
    subnetIds: ["subnet-12345678", "subnet-87654321"], // Replace with your subnet IDs
});

const rdsInstance = new aws.rds.Instance("myRdsInstance", {
    instanceClass: "db.t3.micro",
    allocatedStorage: 20,
    engine: "mysql",
    engineVersion: "8.0",
    username: "admin",
    password: "password", // Replace with a secure password
    dbSubnetGroupName: dbSubnetGroup.name,
    skipFinalSnapshot: true,
});

// Create a DynamoDB database
const dynamoDbTable = new aws.dynamodb.Table("myDynamoDbTable", {
    attributes: [
        { name: "Id", type: "S" },
    ],
    hashKey: "Id",
    billingMode: "PAY_PER_REQUEST",
});

// Create a Timestream database
const timestreamDatabase = new aws.timestreamwrite.Database("myTimestreamDatabase", {});

export const kinesisStreamName = kinesisStream.name;
export const rdsEndpoint = rdsInstance.endpoint;
export const dynamoDbTableName = dynamoDbTable.name;
export const timestreamDbName = timestreamDatabase.databaseName;
```

## Pulumiプロジェクトの作成

次に、Pulumi AI が生成したコードをデプロイするために、Pulumi のプロジェクトを作成します。
以下のコマンドで Pulumi プロジェクトを作成できます。

```sh
mkdir ai-challenge（任意名） && cd ai-challenge（任意名）
pulumi new aws-typescript
```

:::note info
今回は例でTypeScriptを選択していますが、他の言語（JavaScript、Python、Go、.NET、Java、YAML）でも選択可能です。
:::

Pulumiプロジェクトを作成すると、`index.ts`に先ほどのPulumi AIが作成したコードを貼り付けます。

## デプロイ

ここまで進めると、実際に Pulumi で定義したリソースをデプロイできるようになります。
Pulumi では、`pulumi up` コマンドを使用してリソースをデプロイすることが可能です。
しかし、上記のコードをデプロイすると、以下のエラーが発生しました。

```sh
pulumi up
Previewing update (dev)

View in Browser (Ctrl+O): https://app.pulumi.com/yasomaru/ai-challenge/dev/previews/a4702df2-1be4-47cc-a4f6-5308c01b7cbb

     Type                 Name              Plan       Info
 +   pulumi:pulumi:Stack  ai-challenge-dev  create     1 error

Diagnostics:
  pulumi:pulumi:Stack (ai-challenge-dev):
    error: Running program '/Users/syasoda/development/pulumi/ai-challenge/index.ts' failed with an unhandled exception:
    TSError: ⨯ Unable to compile TypeScript:
    index.ts(35,85): error TS2345: Argument of type '{}' is not assignable to parameter of type 'DatabaseArgs'.
      Property 'databaseName' is missing in type '{}' but required in type 'DatabaseArgs'.
```
このエラーは`DatabaseArgs`の型には、`databaseName`が必須のプロパティとして定義されており、空オブジェクト`{}`を、`DatabaseArgs`の型に渡しているためエラーが出ています。
このエラーを解消するために、以下のように`databaseName`プロパティを設定します。

```diff_typescript
// Create a Timestream database
const timestreamDatabase = new aws.timestream.Database("myTimestreamDatabase", {
-
+    databaseName: "myTimestreamDatabase" // Adding the required databaseName property
});

export const kinesisStreamName = kinesisStream.name;
```

こちらを修正し、再度デプロイ（`pulumi up`）すると別のエラーが出ました。

```sh
Diagnostics:
  aws:rds:Instance (myRdsInstance):
    error: aws:rds/instance:Instance resource 'myRdsInstance' has a problem: only lowercase alphanumeric characters and hyphens allowed in "identifier". Examine values at 'myRdsInstance.identifier'.

```
RDSインスタンスの識別子には、英小文字、数字、ハイフン（-）のみが使用可能なのに対し、大文字を含んでいるためエラーが出ています。
以下のように、RDSインスタンスの識別子を変更します。
```diff_typescript
const rdsInstance = new aws.rds.Instance(
- "myRdsInstance"
+ "my-rds-instance", {
    instanceClass: "db.t3.micro",
    allocatedStorage: 20,
    engine: "mysql",
    engineVersion: "8.0",
    username: "admin",
    password: "password", // Replace with a secure password
    dbSubnetGroupName: dbSubnetGroup.name,
    skipFinalSnapshot: true,
});
```

<details><summary>最終ソースコード</summary>

```ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

// Create a Kinesis Data Stream
const kinesisStream = new aws.kinesis.Stream("myKinesisStream", {
    shardCount: 1,
});

// Create an RDS database
const dbSubnetGroup = new aws.rds.SubnetGroup("my-db-subnet-group", {
    subnetIds: ["subnet-12345678", "subnet-87654321"], // Replace with your subnet IDs
});

const rdsInstance = new aws.rds.Instance("my-rds-instance", {
    instanceClass: "db.t3.micro",
    allocatedStorage: 20,
    engine: "mysql",
    engineVersion: "8.0",
    username: "admin",
    password: "password", // Replace with a secure password
    dbSubnetGroupName: dbSubnetGroup.name,
    skipFinalSnapshot: true,
});

// Create a DynamoDB database
const dynamoDbTable = new aws.dynamodb.Table("my-dynamodb-table", {
    attributes: [
        { name: "Id", type: "S" },
    ],
    hashKey: "Id",
    billingMode: "PAY_PER_REQUEST",
});

// Create a Timestream database
const timestreamDatabase = new aws.timestreamwrite.Database("my-timestream-database", {
    databaseName: "myTimestreamDatabase"
});

export const kinesisStreamName = kinesisStream.name;
export const rdsEndpoint = rdsInstance.endpoint;
export const dynamoDbTableName = dynamoDbTable.name;
export const timestreamDbName = timestreamDatabase.databaseName;


```
</details>

:::note warn
サブネットグループのサブネットIDは、各々のサブネットのIDに書き換える必要があるので注意してください。
```diff_typescript
const dbSubnetGroup = new aws.rds.SubnetGroup("my-db-subnet-group", {
+    subnetIds: ["subnet-12345678", "subnet-87654321"], // Replace with your subnet IDs
});
```
:::

修正後、再度`pulumi up`コマンドを実施し、リソースをデプロイします。

```sh
% pulumi up
Previewing update (dev)

View in Browser (Ctrl+O): https://app.pulumi.com/yasomaru/ai-challenge/dev/previews/86248c66-1ab8-4ea2-8f62-4e53054442d1

     Type                             Name                    Plan
 +   pulumi:pulumi:Stack              ai-challenge-dev        create
 +   ├─ aws:timestreamwrite:Database  my-timestream-database  create
 +   ├─ aws:dynamodb:Table            my-dynamodb-table       create
 +   ├─ aws:rds:SubnetGroup           my-db-subnet-group      create
 +   ├─ aws:kinesis:Stream            myKinesisStream         create
 +   └─ aws:rds:Instance              my-rds-instance         create

Outputs:
    dynamoDbTableName: "my-dynamodb-table-ad07034"
    kinesisStreamName: "myKinesisStream-09b754a"
    rdsEndpoint      : output<string>
    timestreamDbName : "myTimestreamDatabase"

Resources:
    + 6 to create

Do you want to perform this update? yes
Updating (dev)

View in Browser (Ctrl+O): https://app.pulumi.com/yasomaru/ai-challenge/dev/updates/6

     Type                             Name                    Status
 +   pulumi:pulumi:Stack              ai-challenge-dev        created (267s)
 +   ├─ aws:rds:SubnetGroup           my-db-subnet-group      created (2s)
 +   ├─ aws:dynamodb:Table            my-dynamodb-table       created (15s)
 +   ├─ aws:timestreamwrite:Database  my-timestream-database  created (2s)
 +   ├─ aws:kinesis:Stream            myKinesisStream         created (23s)
 +   └─ aws:rds:Instance              my-rds-instance         created (260s)

Outputs:
    dynamoDbTableName: "my-dynamodb-table-8cbe815"
    kinesisStreamName: "myKinesisStream-5e66aa4"
    rdsEndpoint      : "my-rds-instance4b0a2fb.cjm2qqq44112.us-east-1.rds.amazonaws.com:3306"
    timestreamDbName : "myTimestreamDatabase"

Resources:
    + 6 created

Duration: 4m29s


```

`pulumi up`コマンドを実行すると、まず更新のプレビューを実施します。
その後、更新が問題ないと判断されると、`Do you want to perform this update?`と聞かれるので`Yes`と回答することで、クラウド上へデプロイされます。
`View in Browser`のリンクをブラウザで開いても、実行結果を確認することができます。
![スクリーンショット 2024-11-27 17.07.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2004450/0a7a7101-75af-9d47-47cb-d09c8df1ba73.png)


# ドリフト検出

ここまで Pulumi を触っていて、気になったことがあります。
それは、AWS マネジメントコンソール側からリソースの設定を変更した場合、その差分を Pulumi が検出できるかです。
AWS の CloudFormation では、ドリフト検出という機能があり、コード以外から手動でクラウド上のリソースを変更した際に、コード側が実際のリソースとの差分を表示してくれます。

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/detect-drift-stack.html

[公式ドキュメント](https://www.pulumi.com/docs/iac/cli/commands/)によると、`pulumi refresh`でできそうです。
実際に、先ほど作成したDynamoDBのキャパシティーモードをオンデマンドからプロビジョンドに変更してみます。
![スクリーンショット 2024-12-04 23.36.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2004450/2175d3ca-3895-a29a-0938-191e8969c7c8.png)

その後、`pulumi refresh`コマンドを実行してみます。
```sh
% pulumi refresh                                                                                                                           ✘ 255
Previewing refresh (dev)

View in Browser (Ctrl+O): https://app.pulumi.com/yasomaru/ai-challenge/dev/previews/e18611bf-6bfb-404b-aeb3-4a1b6b8e3296

     Type                             Name                    Plan       Info
     pulumi:pulumi:Stack              ai-challenge-dev
 ~   ├─ aws:dynamodb:Table            my-dynamodb-table       update     [diff: ~billingMode]
     ├─ aws:rds:Instance              my-rds-instance
     ├─ aws:rds:SubnetGroup           my-db-subnet-group
     ├─ aws:kinesis:Stream            myKinesisStream
     └─ aws:timestreamwrite:Database  my-timestream-database

Resources:
    ~ 1 to update
    5 unchanged

Do you want to perform this refresh?
No resources will be modified as part of this refresh; just your stack's state will be.
 details
  pulumi:pulumi:Stack: (same)
    [urn=urn:pulumi:dev::ai-challenge::pulumi:pulumi:Stack::ai-challenge-dev]
    ~ aws:dynamodb/table:Table: (update)
        [id=my-dynamodb-table-8cbe815]
        [urn=urn:pulumi:dev::ai-challenge::aws:dynamodb/table:Table::my-dynamodb-table]
        [provider=urn:pulumi:dev::ai-challenge::pulumi:providers:aws::default_6_61_0::f603c308-587a-4836-a768-8dddd6515fc0]
      ~ billingMode: "PAY_PER_REQUEST" => "PROVISIONED"

Do you want to perform this refresh?
No resources will be modified as part of this refresh; just your stack's state will be.
 yes
Refreshing (dev)

View in Browser (Ctrl+O): https://app.pulumi.com/yasomaru/ai-challenge/dev/updates/9

     Type                             Name                    Status           Info
     pulumi:pulumi:Stack              ai-challenge-dev
     ├─ aws:rds:Instance              my-rds-instance
 ~   ├─ aws:dynamodb:Table            my-dynamodb-table       updated (1s)     [diff: ~billingMode]
     ├─ aws:timestreamwrite:Database  my-timestream-database
     ├─ aws:rds:SubnetGroup           my-db-subnet-group
     └─ aws:kinesis:Stream            myKinesisStream

Outputs:
    dynamoDbTableName: "my-dynamodb-table-8cbe815"
    kinesisStreamName: "myKinesisStream-5e66aa4"
    rdsEndpoint      : "my-rds-instance4b0a2fb.cjm2qqq44112.us-east-1.rds.amazonaws.com:3306"
    timestreamDbName : "myTimestreamDatabase"

Resources:
    ~ 1 updated
    5 unchanged

Duration: 6s

```
実行すると、変更したDynamoDBに`update`と表示されているので差分を検出できていますね。
`pulumi refresh`は[Pulumiの動作](#pulumiの動作)で紹介したPulumiのStateと実リソースの状態の同期を実行するコマンドとなるようです。
その後、`Do you want to perform this update?`と聞かれるので`Yes`と回答することでPulumiのStateが更新されます。

:::note info
PulumiのStateを更新した状態で、`pulumi preview --diff`と実行するとPulumiのStateとソースコードの差分を表示することができます。
```sh
% pulumi preview --diff                                                                                                                      ✘ 1
Previewing update (dev)

View Live: https://app.pulumi.com/yasomaru/ai-challenge/dev/previews/1af3dbda-c161-4b9e-a3b2-929efa071013

  pulumi:pulumi:Stack: (same)
    [urn=urn:pulumi:dev::ai-challenge::pulumi:pulumi:Stack::ai-challenge-dev]
    ~ aws:dynamodb/table:Table: (update)
        [id=my-dynamodb-table-8cbe815]
        [urn=urn:pulumi:dev::ai-challenge::aws:dynamodb/table:Table::my-dynamodb-table]
        [provider=urn:pulumi:dev::ai-challenge::pulumi:providers:aws::default_6_61_0::f603c308-587a-4836-a768-8dddd6515fc0]
      ~ billingMode: "PROVISIONED" => "PAY_PER_REQUEST"
Resources:
    ~ 1 to update
    5 unchanged
```
`pulumi preview`はPulumi上のStateとソースコードで定義したResourceの内容との差分を表示してくれるコマンドとなるようです。
:::

作成したリソースは以下のコマンドで削除できます。
```
% pulumi destory  
```

# さいごに

今回は個人的に気になっていたPulumiについて、調べてみました。
Pulumi Chalengeを通じて、Pulumiの基本的なコマンドの扱いや内部的な動きを理解することができました。
Pulumiには今回紹介したPulumi AIだけでなく、複数のソースに格納されたシークレットや変数、Configなどを統合管理できる[Pulumi ESC](https://www.pulumi.com/docs/esc/)など様々な機能があるので、色々触ってみたいと思います！