SSTでサーバーレス環境を構築してみたらすごく良かった

# はじめに
SST とは、サーバーレス含むフルスタック・アプリケーションを簡単に構築できるフレームワークです。

https://sst.dev/docs/

v2ではCDKを基盤として構築されていましたが、
v3ではPulumiやTerraformを基盤とする形に移行しています。

https://sst.dev/blog/sst-v3/#background

Pulumiは、JavaScript、TypeScript、Python、Go、C#など、一般的なプログラミング言語をサポートしています。また、AWS、GCP、Azure、Kubernetesなど、150以上のクラウドプロバイダーやサービスに対応しています。

https://www.pulumi.com/

https://www.terraform.io/


移行理由について記載された記事を見ましたが、非常に面白い記事でした。
CDKの循環参照問題とか、エラー発生時のロールバックが極端に遅い時があるとか、確かに〜！！っといった感じでした。
また、マルチクラウド戦略もあるようなので、そういった目的も鑑みた上での移行のようです。

https://sst.dev/blog/moving-away-from-cdk

元々気にはなっていたのですが、従来通りAWS上に環境構築できると言う点と、PulumiやTerraformまわりの設定は特に必要ないと言う点で、心おきなく環境構築にトライすることができました。



# 本記事でやること

本記事では、以下環境構成をSSTで作成します。

**フロントエンド環境：**
CloudFront + S3（Nuxt3）

**バックエンド環境：**
API Gateway（HTTP API）+ Lambda + DynamoDB

ドキュメントに記載されている **NuxtアプリのGET STARTED** を参考に手順を進めつつ、所々アレンジしていきます。

https://sst.dev/docs/start/aws/nuxt

# 前準備

GET STARTEDを始める前に、いくつかの準備が必要です。

## 1. AWS Credentials
SSTはAWSアカウント内に各種リソースを作成します。
なので、GET STARTEDを始める前に、AWS Credentialsの設定をしておく必要があります。

https://sst.dev/docs/iam-credentials#credentials

#### ファイルから読むパターン
以下IAMユーザー作成時に生成されるアクセスキーやシークレットアクセスキーをローカルの設定ファイルに設定しておきます。
そうすることで、sstコマンド実行時にこの認証情報が参照されます。
```~/.aws/credentials
[default]
aws_access_key_id = <YOUR_ACCESS_KEY_ID>
aws_secret_access_key = <YOUR_SECRET_ACCESS_KEY>
```

#### 環境変数で渡すパターン
3つのパターンが記載されています。
- AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
- AWS_SESSION_TOKEN
- AWS_PROFILE

CLIの環境変数としていずれかを渡すことで認証情報が付与された状態でコマンドの実行ができます。

弊社では、
- 案件毎・各環境毎（dev, stage, prod)にAWSアカウントを分けている
- SSOログインを実行し、profileを指定してCLIからAWSアカウントにアクセスしている

という状況なので、今回の記事では以下のようにAWS_PROFILE環境変数を指定する形で説明します。

```
AWS_PROFILE=my-account npx sst dev
```

## 2. Bunのインストール
SSTは内部でBunが使われているようです。
BunがPCに入っていない場合、インストールしてください。
※Bunを入れていなかった私のPCではsstコマンドがうまく機能しませんでした。

:::note
後から知ったので私の環境では未検証ですが、sstコマンドに `NO_BUN=true` の環境変数をつけるとBUNなしで動作するみたいです。
:::

https://github.com/sst/sst/issues/4191

# GET STARTED

以下順序で説明していきます。

- Nuxt SPAアプリを用いたフロントエンド環境をAWSに構築するフロー
- API Gateway、Lambda、DynamoDBを用いたバックエンド環境をAWSに構築するフロー
- 開発環境の起動
- Secretsの扱いについて

## Nuxtアプリを作成する

まずは、Nuxtのプロジェクトを新規作成します。

```sh
npx nuxi@latest init aws-nuxt
cd aws-nuxt
```

## Init SST

### 1. sst init
次に、以下を実行します。

```sh
npx sst@latest init
```

コマンド実行時にプロバイダーの選択を求められるので、AWSを選択します。
そうすると、設定ファイルのsst.config.tsがプロジェクトのルートに作成されます。

### 2. npm install
その後、GET STARTED通り以下を実行しておきます。

```sh
npm install
```

## フロントエンド環境構築
Nuxt SPAにより生成された静的ファイルをS3に格納し、CloudFrontで配信するまでのフローを記載します。
### 1. nuxt.config.tsの編集

GET STARTED通りpresetの記述を追加します。
また、NuxtをSPAアプリケーションとしたいので、ssr: falseとします。

```nuxt.config.ts
export default defineNuxtConfig({
  compatibilityDate: '2024-04-03',

  // 以下追記する
  ssr: false,
  nitro: {
    preset: 'aws-lambda'
  },
  
  devtools: { enabled: true }
})
```

### 2. sst.config.tsの編集

ドキュメントによると、Nuxtコンポーネントを用いる形で記載されています。
これを用いると、CloudFrontの設定において、静的ファイル専用のビヘイビアだけでなくNuxtのAPIサーバー専用のビヘイビアも追加されます。

```sst.config.ts
new sst.aws.Nuxt("MyWeb");
```

https://sst.dev/docs/component/aws/nuxt

しかし、今回Nuxtアプリはフロントエンドの静的ファイルを生成するのみの用途としたいので、StaticSiteコンポーネントを用いる形とします。

https://sst.dev/docs/component/aws/static-site


```sst.config.ts
new sst.aws.StaticSite("MyWeb", {
  domain: {
    // Route53のホストゾーンを参照する
    // Route53にAレコードやCNAMEの追加をしてくれる
    // 自動でus-east-1でSSL証明書を取得し、CloudFrontに証明書を紐づけてくれる
    name: "ドメイン名"
  },
  // 全てのパスのCloudFrontキャッシュが削除されるまで待機する設定
  // https://sst.dev/docs/component/aws/static-site#invalidation
  invalidation: {
    wait: true
  },
  build: {
    command: "npm run generate",
    output: ".output/public"
  },
});
```

:::note warn
SSTではなくAWS側の話ですが、CloudFrontキャッシュ削除は1000回/月まで無料で行う事ができ、その後は$0.005/回 かかるようです。
:::

### 3. Route53ホストゾーン作成
sst.config.tsに記述したドメイン名で、Route53のホストゾーン作成とNSレコードまわりの設定（ムームードメインやお名前.comなどへの名前解決設定）をしておきます。

**以上で設定は完了です。**
この記述だけでCloudFront、S3、Route53、ACMまわりの諸々の作成・設定を自動でやってくれます。

### 4. デプロイする

```sh
# stageは環境のようなもので、アプリの別のバージョン
# 例えば、開発ステージ、本番ステージ、個人ステージなど
# 今回、開発ステージをdevとし、CLIの引数に与える
AWS_PROFILE=my-account npx sst deploy --stage dev
```

これでフロントエンド環境の構築が完了します。

## バックエンド環境構築
次に、API Gateway、Lambda、DynamoDBを用いたバックエンド環境を構築するフローを説明します。

### 1. API Gateway

ApiGatewayV2コンポーネントを使って、API Gateway（HTTP API）を作成します。

https://sst.dev/docs/component/aws/apigatewayv2

corsの許可設定だけしておきます。

```sst.config.ts
const api = new sst.aws.ApiGatewayV2("MyApi", {
  cors: {
    allowOrigins: [
      // cors許可するドメイン名
      "ドメイン名"
    ]
  }
});
```

### 2. Lambda

API GatewayのRouteに対してLambda関数を紐づける部分はApiGatewayV2のドキュメントを見つつ、Lambda関数自体の細かい設定はFunctionのドキュメントを確認し、設定値を調整する形となります。

https://sst.dev/docs/component/aws/function#architecture


```sst.config.ts
// GET
api.route("GET /api/get", // API GatewayのRoute指定
  {
    handler: "src/get.handler", // Lambda関数のパス.関数名
    architecture: "arm64", // 指定しなければx86_64
    logging: {
      format: "json", // CloudWatchへ送信されるログのフォーマット
    },
  }
);

// POST
api.route("POST /api/post",
  {
    handler: "src/post.handler",
    architecture: "arm64",
    logging: {
      format: "json",
    },
  },
);
```

Lambda関数の中身の記述は、後に説明する「Links」にて記載します。
先にDynamoDBの説明をします。

### 3. DynamoDB

DynamoDBドキュメントを見ながら設定を追加していきます。
※今回単純な構成で説明をしたいため、セカンダリインデックスの設定は行いません。

https://sst.dev/docs/component/aws/dynamo

まずはDynamoDBのテーブルを作成します。

```sst.config.ts
const table = new sst.aws.Dynamo("MyTable", {
  // codeをパーティションキーとする
  fields: {
    code: "string"
  },
  primaryIndex: { hashKey: "code" }
});
```

### 4. Links
Lambda関数は、何も設定していないとDynamoDBへアクセスする事ができません。
Lambda関数自体のポリシーの設定や、ランタイムでのDynamoDBのテーブルへの参照が必要になります。

SSTにはlinkという便利なSDKが用意されています。

https://sst.dev/docs/reference/sdk/#links

これを使うことでポリシーの設定や、ランタイムでのAWSリソースへの参照が容易になります。

```sst.config.ts
// GET
api.route("GET /api/get",
  {
    handler: "src/get.handler",
    architecture: "arm64",
    link: [table], // tableをリンクすることで必要なポリシーが自動で付与される
    logging: {
      format: "json",
    },
  }
);

// POST
api.route("POST /api/post",
  {
    handler: "src/post.handler",
    architecture: "arm64",
    link: [table], // tableをリンクすることで必要なポリシーが自動で付与される
    logging: {
      format: "json",
    },
  },
);
```

```src/get.ts
import { Resource } from "sst";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';

export async function handler() {
  const client = DynamoDBDocumentClient.from(new DynamoDBClient());
  
  const data = await client.send(new GetCommand({
    TableName: Resource.MyTable.name, // DynamoDBテーブルを参照
    Key: { code: "1" },
  }));

  return {
    statusCode: 200,
      body: JSON.stringify(data.Item),
  };
}
```

```src/post.ts
import { Resource } from "sst";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';

export async function handler() {
  const client = DynamoDBDocumentClient.from(new DynamoDBClient());

  await client.send(new PutCommand({
    TableName: Resource.MyTable.name, // DynamoDBテーブルを参照
    Item: {
      code: "1",
    },
  }));

  return {
    statusCode: 200,
    body: "POST success",
  };
}
```

Linksを使うことで以下のメリットがあります。

- AWSリソースをランタイムで参照したい時、.envなどの環境変数経由で値を渡す必要がなくなるので、環境変数で管理すべき対象が減る
- CDKやTerraformなどのIaCでは、Lambda関数やS3などのAWSリソースに付与するポリシーを設定するためにそれぞれ専用の記述が必要だったが、そんなことを考える必要がなく、記述も一行で済むため非常に楽である

### 5. デプロイする

フロントエンド環境構築のデプロイと同じコマンドです。
これでAPI Gateway、Lambda、DynamoDBリソースが作成されます。

```sh
AWS_PROFILE=my-account npx sst deploy --stage dev
```

これで、ひとまずdev環境の構築が完了となります。

## 開発環境を起動

こちらのドキュメントを参考にします。

https://sst.dev/docs/workflow#dev

開発環境は、以下コマンドで起動できます。

```sh
AWS_PROFILE=my-account npx sst dev
```

これにより、指定したAWSアカウントに対して、アプリが開発モードで個人ステージにデプロイされます。

また、sst deployと違い、ローカル開発用に最適化されています。

- Lambda関数をライブで実行します。これにより、すべてのリクエストがローカルマシンにプロキシされるため、**ローカルマシン上のLambda関数の修正が即時反映される** ようになります

- **Lambda関数実行時のログがターミナルで確認**できるようになります（console.logなど）

- フロントエンドやコンテナサービスはデプロイされません。代わりに、**ローカルでdevサーバーが起動します**
- また、**VPC にデプロイされているすべてのリソースに接続できるトンネルも作成されます**

#### Lambda関数用ターミナル

![スクリーンショット 2025-01-08 14.21.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730383/a8f62594-f5c1-eb8b-0939-b0d4cc0e035e.png)


#### フロントエンドdev server用ターミナル
![スクリーンショット 2025-01-08 14.21.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730383/cfe68101-7a8d-e2d5-4b03-fd23196b5dec.png)

#### Lambda関数
以下は、sst devにより生成されたLambda関数です。

![スクリーンショット 2025-01-08 14.22.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730383/ac1b9973-24d6-7ff0-2310-fcb88c893e39.png)

赤枠部分は個人名になるのと、説明欄には(live）という記述が追加されます。

#### API Gateway
個人用のAPI Gatewayが作成されています。

![スクリーンショット 2025-01-08 18.13.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730383/4070c632-2c4f-ac54-5634-b667d3fc317b.png)


#### DynamoDB
個人用のDBが作成されています。

![スクリーンショット 2025-01-08 18.13.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730383/ad1b55a4-0748-2424-fcb2-e498c7282bd3.png)


## Secretsの扱いについて
SST では、.env による管理は推奨されていません。
先ほど説明したLinksと、これから説明するSecretsの利用を推奨しています。

環境毎に.envファイルが存在してしまう事や、それらをチームメイトと共有する事に対する指摘のようです。

https://sst.dev/docs/environment-variables/#secrets

sst secret コマンドを利用します。

https://sst.dev/docs/reference/cli#secret

### 一つずつ作成

```
AWS_PROFILE={{profile名}} npx sst secret set <name> [value] // local環境
AWS_PROFILE={{profile名}} npx sst secret set <name> [value] --stage dev // dev環境
```

### ファイル読み込みして作成

```
AWS_PROFILE={{profile名}} npx sst secret load load ./secrets.env // local環境
AWS_PROFILE={{profile名}} npx sst secret load load ./secrets.env --stage dev // dev環境
```

**ファイル内**
```secrets.env
KEY_1=VALUE1
KEY_2=VALUE2
```

### 内容確認

```
AWS_PROFILE={{profile名}} npx sst secret list // local環境
AWS_PROFILE={{profile名}} npx sst secret list --stage dev // dev環境
```

### 削除

```
AWS_PROFILE={{profile名}} npx sst secret remove <name> // local環境
AWS_PROFILE={{profile名}} npx sst secret remove <name> --stage dev // dev環境
```

### Secretsの保存先

S3バケットが自動で生成され、その中に環境毎のSecretsが保存されます。
赤枠はsst devにより生成された私の名前です。
dev.jsonは、AWS_PROFILE=my-account sst secret set <name> [value] --stage dev により生成されたSecretsです。

![スクリーンショット 2025-01-08 14.43.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2730383/a7c4ada0-7a81-a0ba-9ceb-43fcabbed759.png)

### Secretsの利用方法

例えばDomainというキーをSecretsに登録しておくと、以下のようにして参照できます。

```sst.config.ts
const domainSecret = new sst.Secret("Domain");

new sst.aws.StaticSite("MyWeb", {
  domain: {
    name: domainSecret.value
  },
})

api.route("POST /api/post",
  {
    handler: "src/post.handler",
    architecture: "arm64",
    link: [table, domainSecret], // これでランタイムで使用できるようになる
    logging: {
      format: "json",
    },
  },
);
```

# CI/CD

GithubActionsか、auto deployというSSTで提供されている機能を使うかの2択のようです。

https://sst.dev/docs/workflow#autodeploy

https://sst.dev/docs/console/#autodeploy

:::note warn
auto deploy機能ですが、Beta版の間は無料との事です。
:::

裏を返せばいつか有料になるという事だと思うので、今回はGitHub Actions で sst deploy を実行する形とします。

### 事前準備
Assume Role するための IAM ロールの作成を行います。

また、GitHub Actions の環境作成（prod, stage, dev）・シークレット（AWS_ACCOUNT_ID, ASSUME_ROLE_NAME） の登録も行っておきます。

ブランチの運用は main, stage, dev とします。

SSOログインまわりについて詳しくは以下参考ください：

https://qiita.com/generoKoki/items/bcf87549a18a1481d62e

### 構成ファイル
以下に、PRがマージされたタイミングで環境毎にdeployが走るシンプル構成のyamlを記載します。

```sstDeploy.yaml
name: sst deploy

on:
  pull_request:
    branches:
      - main
      - stage
      - dev
    types:
      - closed

permissions:
  id-token: write
  contents: read

jobs:
  set-stage-name:
    runs-on: ubuntu-latest
    outputs:
      stage_name: ${{ steps.set-stage.outputs.stage_name }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set stage name
        id: set-stage
        run: |
          if [ "${GITHUB_REF#refs/heads/}" = "main" ]; then
            echo "stage_name=prod" >> $GITHUB_OUTPUT
          elif [ "${GITHUB_REF#refs/heads/}" = "stage" ]; then
            echo "stage_name=stage" >> $GITHUB_OUTPUT
          else
            echo "stage_name=dev" >> $GITHUB_OUTPUT
          fi

  deploy:
    needs: set-stage-name
    runs-on: ubuntu-latest
    environment: ${{ needs.set-stage-name.outputs.stage_name }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: "arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.ASSUME_ROLE_NAME }}"
          aws-region: ap-northeast-1

      - name: Install Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install npm packages
        run: npm install

      - name: SST Deploy
        run: npx sst deploy --stage ${{ needs.set-stage-name.outputs.stage_name }}
```

# さいごに
カスタマイズ編として、CloudFrontにおけるedge function設定（Basic認証）や、オリジン・ビヘイビア追加、API GatewayのLambdaAuthorizer設定追加など書こうとしたのですが、ボリューム的に別記事にしようと思います。

以前はCDK、SAM、LocalStackを使った記事を書き、この時も嬉しい気持ちになりましたが、今回さらにSSTの方がメリット多いと感じます。

https://qiita.com/generokenken/private/1f1361d40a30fb6b760f

SSTでは開発環境におけるLambda関数の修正が即時反映されるというのは知っていましたが、IaCとしての設定ファイルの記述の簡潔さが想像以上ですし、LinksやSecretsの仕組みにより管理すべき対象もシンプルになったと感じています。

あと、ターミナルでdeploy時に発生したエラー内容もCDKより具体的ですし、レスポンスが返ってくるスピードも速いので、IaC記述・構築もスムーズに行きました。

早速アプリに組み込んで動かしてみましたが、従来よりプロジェクト内のディレクトリ構造がシンプルになり、IaC設定ファイルもsst.config.ts一つになったので、これから新しく入ってくるメンバーのキャッチアップが従来より楽になると思います。
それではまた〜。