SST ionとは
SSTは、自分のインフラ上で最新のフルスタックアプリケーションを簡単に構築できるフレームワークです。
v2までCDKベースでしたが、v3からPulumiとTerraformを使用したEngine(ion)になります。
ionはGAされていますが、日々アップデートされています。
なぜ、SSTの内部エンジンをCDKからpulumiに移行したかは、下記リンクの記事に解説があります。
https://sst.dev/blog/moving-away-from-cdk.html
PulumiやTerraformは、各クラウドプロバイダーのAPIに直接アクセスし、状態管理やリソースの作成・更新を行います。この直接的なアプローチは、AWS CDKのようにCloudFormationを介する必要がないため、リソースのプロビジョニングが速くなる可能性があるそうです。
なので、AWS CDKユーザには、一度使ってみていただきたいです。
筆者について
私は外資のセンサ会社で、組み込みシステムの開発を行っています。
1年半前に本社で3ヶ月ほどIoTプラットフォームの開発をしていた際に、このSSTに出会いましたが、当時はまだCDKベースだったため、CDKの実行が遅く、チームメンバのドイツ人はコーヒータイムだとかなんとかいいながら、のんびりやっていました。
今回、ionを触ってみて開発体験がよく、楽しかったので共有してみます。
間違いやアドバイスあれば、コメントいただければと思います。
今回のアプリのアーキテクチャ
SSTは、Nextはもちろん、RemixやAstro、Svelte等のモダンなWebフレームワークに対応しており、プラットフォームは、現在AWSとCloudFlareに対応しているようです。
今回は、AWSでReactとLambdaのモノレポ構成とという選択にしました。
リポジトリ
インフラ部分、フロントエンド、LambdaすべてTypeScriptで書いています。
コメント、スター、issueなどいただければ嬉しいです。
Github Actionsを使ったproductionデプロイ
SSTでは通常localユーザのステージで開発し、下記のように、stageオプションをつけると、productionステージにデプロイできます。
--stage production
今回、Github Actionsでmainブランチのpushで、デプロイできるようにしてみました。
ポイントは、実行時間を短縮するために、pnpmはもちろんSSTをキャッシュできるようにしたところです。
ちなみに、AWSのOIDC認証と、GoogleのSocial SignInにむけて、Secretを使っています。
env:
AWS_REGION: ap-northeast-1
AWS_OIDC_ROLE_ARN: ${{ secrets.AWS_OIDC_ROLE_ARN }}
PNPM_STORE_PATH:
permissions:
id-token: write
contents: read
on:
push:
paths-ignore:
- '**.md'
- 'docs/**'
- '.vscode/**'
- '.prettierignore'
branches:
- main
jobs:
deploy:
name: deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Assume Role
uses: aws-actions/configure-aws-credentials@v1
with:
role-to-assume: ${{ env.AWS_OIDC_ROLE_ARN }}
aws-region: ${{env.AWS_REGION}}
- name: Setup sst ion cache
uses: actions/cache@v4
id: ion-cache
with:
path: ~/.sst
key: ${{ runner.os }}-sst-ion-${{ hashFiles('**/sst-version-lock.txt') }}
restore-keys: |
${{ runner.os }}-sst-ion-
- name: Setup sst from cache
if: steps.ion-cache.outputs.cache-hit == 'true'
run: 'echo "~/.sst/bin" >> $GITHUB_PATH'
- name: Install SST ion if not from cache
if: steps.ion-cache.outputs.cache-hit != 'true'
run: bash ./install_sst.sh
- name: Disable SST telemetry
run: sst telemetry disable
- name: Set secret for sst
env:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
run: |
sst secret set GoogleClientId $GOOGLE_CLIENT_ID --stage uat
sst secret set GoogleClientSecret $GOOGLE_CLIENT_SECRET --stage uat
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
id: pnpm-cache
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Check pnpm cache hit
run: 'echo "Cache hit: ${{ steps.pnpm-cache.outputs.cache-hit }}"'
- name: Install dependencies
if: steps.pnpm-cache.outputs.cache-hit != 'true'
run: pnpm install
- name: Deploy
run: sst deploy --stage uat
このように、だいぶややこしくなってしまったので、テスト段階ではactを使って、local検証してました。
SST version lock
SSTの本体リポジトリのshell scriptをつかってしまうと、latestが自動的に入ってしまうため、version lockができる機構を作ってみました。
sstのversionをファイルに落とし込み、このファイル内の値をもとにキャッシュヒットできるようにしています。
sst version >> sst-version-lock.txt
sstのinstallスクリプトをすこし修正して、sst-version-lock.txt内のバージョンをInstallできるようにしました。
Productionデプロイ時の構成を変化する機構(ドメインやCORS)
まだ開発途中なので、uatステージにしていますが、productionステージで、Route53のdomainを設定できるようにしています。
import { api } from './api';
import { authUrl, userPool, userPoolClient } from './auth';
import { domain } from './domain';
const commonConfig = {
path: 'packages/frontend',
build: {
output: 'dist',
command: 'pnpm run build',
},
environment: {
VITE_API_URL: api.url,
VITE_REGION: aws.getRegionOutput().name,
VITE_USER_POOL_ID: userPool.id,
VITE_USER_POOL_CLIENT_ID: userPoolClient.id,
VITE_USER_POOL_DOMAIN: authUrl,
},
};
// TODO: change stage to 'production' when ready
const webConfig =
$app.stage === 'uat' ? { ...commonConfig, domain } : commonConfig;
export const web = new sst.aws.StaticSite('StaticSite', webConfig);
開発時には、localのフロントエンドでAPIのテストをするため、CORSをゆるくして、
productionでは、ドメインを設定できるように、変更してます。
import { userPool, userPoolClient } from './auth';
import { table } from './database';
import { domain } from './domain';
const corsCommonConfig = {
allowHeaders: ['Content-Type', 'Authorization'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
};
// TODO: change stage to 'production' when ready
const corsConfiguration =
$app.stage === 'uat'
? { ...corsCommonConfig, allowOrigins: [$interpolate`https://${domain}`] }
: { ...corsCommonConfig, allowOrigins: ['*'] };
export const api = new sst.aws.ApiGatewayV2('Api', {
transform: {
api: {
corsConfiguration,
},
route: {
args: (props) => {
props.auth = {
jwt: {
audiences: [userPoolClient.id],
issuer: $interpolate`https://cognito-idp.${aws.getRegionOutput().name}.amazonaws.com/${userPool.id}`,
},
};
},
},
},
});
api.route('GET /todos', {
link: [table],
handler: 'packages/backend/src/taskService/list.main',
});
api.route('POST /todos', {
link: [table],
handler: 'packages/backend/src/taskService/create.main',
});
api.route('PUT /todos/{id}', {
link: [table],
handler: 'packages/backend/src/taskService/update.main',
});
api.route('DELETE /todos/{id}', {
link: [table],
handler: 'packages/backend/src/taskService/delete.main',
});
Lambda functionまわり
SSTはLambda functionのLive Developができることが特徴の一つです。
TypescriptでLambdaを書き換えたら、hot reloadで、クラウドのfunctionが書き換わり、デバックができます。
今回、Lambdaのミドルウェアであるmiddyを使ってみました。
ValidationはZodを使ってみたかったので、zod用のmiddyのmiddlewareを作って、使っています。
import middy from '@middy/core';
import { MiddlewareObj } from '@middy/core';
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { ZodError, ZodSchema } from 'zod';
class ZodValidationError extends Error {
public statusCode: number;
public details: ZodError;
constructor(message: string, details: ZodError) {
super(message);
this.name = 'ValidationError';
this.statusCode = 400; // 400 Bad Request
this.details = details;
}
}
const zodValidationMiddleware = <T>(
schema: ZodSchema<T>,
): MiddlewareObj<APIGatewayProxyEvent, APIGatewayProxyResult> => {
const before: middy.MiddlewareFn<
APIGatewayProxyEvent,
APIGatewayProxyResult
> = async (request) => {
try {
const body = request.event.body;
schema.parse(body);
} catch (error: unknown) {
if (error instanceof ZodError) {
throw new ZodValidationError('Invalid request', error);
} else {
throw error;
}
}
};
return {
before,
};
};
export default zodValidationMiddleware;
DynamoDBはSingle Table Architecureで使ってみたいと思っていたので、ElectroDBを使いました。
今回は、TaskとUserの2つのEntityを作ってます。
こんな感じで、DynamoDBの操作ができます。
import middy from '@middy/core';
import httpErrorHandler from '@middy/http-error-handler';
import jsonBodyParser from '@middy/http-json-body-parser';
import {
UpdateTodoRequest,
UpdateTodoRequestSchema,
} from '@sst-ion-serverless-todoapp/types';
import {
APIGatewayProxyEventV2,
APIGatewayProxyEventV2WithJWTAuthorizer,
APIGatewayProxyResult,
} from 'aws-lambda';
import { TaskEntity } from '../entities/task';
import zodValidationMiddleware from '../lib/middleware/zod-validator';
import HttpStatusCode from '../lib/utils/HttpStatusCode';
const lambdaHandler = async (
event: APIGatewayProxyEventV2WithJWTAuthorizer & { body: UpdateTodoRequest },
): Promise<APIGatewayProxyResult> => {
if (!event?.pathParameters?.id) {
throw new Error('Missing taskId in path parameters');
}
const claims = event.requestContext.authorizer.jwt.claims;
const result = await TaskEntity.update({
userId: claims.sub.toString(),
taskId: event?.pathParameters?.id,
})
.set({ completed: event.body.completed })
.go({ response: 'all_new' });
return {
statusCode: HttpStatusCode.OK,
headers: { contentType: 'application/json' },
body: JSON.stringify(result.data),
};
};
export const main = middy<APIGatewayProxyEventV2, APIGatewayProxyResult>()
.use(jsonBodyParser())
.use(zodValidationMiddleware(UpdateTodoRequestSchema))
.use(httpErrorHandler())
.handler(lambdaHandler);
単純なREST APIだけだと面白くないので、CognitoのTriggerでUserItemを操作する機構もいれてみました。
import { Handler, PostAuthenticationTriggerEvent } from 'aws-lambda';
import { UserEntity } from '../entities/user';
export const main: Handler<PostAuthenticationTriggerEvent> = async (
event: PostAuthenticationTriggerEvent,
) => {
console.log(event);
const { sub } = event.request.userAttributes;
await UserEntity.update({ userId: sub })
.set({ lastSignedInAt: new Date().toISOString() })
.go();
return event;
};
おわり
SST ion使っていて楽しいので、ぜひ使ってみてください。