Serverless Frameworkを使ったローカル環境構築、AWSデプロイ、GitLabを使ったCI/CDをまとめました。
言語はTypeScript、バンドラーにはesbuildを使っています。
以前書いた記事とかぶる部分がありますが、1つの記事にまとめたほうが見やすいので、CI/CDを参考にする場合にはこちらの記事をご覧ください。
目次
1章. ローカル開発環境を構築し、AWSにデプロイしてみる
2章. DynamoDBを使って、データの登録/更新/削除 をしてみる
3章. JestによるUnitテスト(ローカル版)
4章. JestによるUnitテスト&デプロイ(GitLabにPushでCI/CDが発火)
1章. ローカル開発環境を構築し、AWSにデプロイしてみる
開発環境
OS
- Windows11上のWSL2で動作するUbuntu
以下がインストールされている事
- node.js(v20.11.0以上)
- npm(v10.2.4以上)
- Java(v11.0.25以上)
前提
プロジェクト名は serverless-cicd として進めます。
AWSプロファイルは sample-profile として進めます。
準備
デプロイ先のAWSでIAMユーザーを作成しておく。その際の権限は「AdministratorAccess」にしておく。
次に以下のようにローカルでAWSプロファイルを作成する。
$ aws configure --profile sample-profile
AWS Access Key ID [None]: <作成したIAMのアクセスキーを入力>
AWS Secret Access Key [None]: <作成したIAMのシークレットキーを入力>
Default region name [None]: ap-northeast-1
Default output format [None]: json
GitLabでプロジェクトを作成して Cloneしておく
GitLabサイトでプロジェクトを新規作成し、その後 tmp_dir のディレクトリに cloneしておく。
$ git clone http://xxxxxxxxxxxxxx.git tmp_dir
Serverless Framework プロジェクトを作成
以下を入力して、serverless-cicd プロジェクトを作成する。
$ npx serverless # 入力してEnter
Creating a new serverless project
? What do you want to make?
AWS - Node.js - Starter
AWS - Node.js - HTTP API
AWS - Node.js - Scheduled Task
AWS - Node.js - SQS Worker
❯ AWS - Node.js - Express API # 選択してEnter
AWS - Node.js - Express API with DynamoDB
AWS - Python - Starter
AWS - Python - HTTP API
AWS - Python - Scheduled Task
AWS - Python - SQS Worker
AWS - Python - Flask API
AWS - Python - Flask API with DynamoDB
Other
Creating a new serverless project
? What do you want to make? AWS - Node.js - Express API
? What do you want to call this project? serverless-cicd # 入力してEnter ※プロジェクト名を serverless-cicdとする
? What org do you want to add this service to? (Use arrow keys)
sample # sample=組織名
❯ [Skip] # 選択してEnter
? Do you want to deploy now? (Y/n) n # 入力してEnter
Git関連のファイルコピー
npx serverless コマンドで作成した serverless-cicd ディレクトリに、tmp_dir ディレクトリから Git関連ファイルを移動する。
その後不要になった tmp_dir ディレクトリ を削除しておく。
$ cd serverless-cicd
$ mv ../tmp_dir/.git .
$ mv ../tmp_dir/README.md .
$ rmdir ../tmp_dir
.gitignoreファイルへの追加
後ほど自動生成される「.dynamodb」「.esbuild」を .gitignoreに含める。
node_modules
.serverless
+ .dynamodb
+ .esbuild
GitLabにGit push
現時点のファイルを GitLabに git pushしておく。
また、現時点のファイル構成は以下となる。
$ tree -L 1
├── README.md
├── index.js
├── node_modules
├── package-lock.json
├── package.json
└── serverless.yml
現時点のpackage.jsonは以下になる
{
"name": "serverless-cicd",
"version": "1.0.0",
"description": "",
"dependencies": {
"express": "^4.18.2",
"serverless-http": "^3.1.1"
}
}
各種ライブラリのインストール
$ npm i -D serverless@3 # serverless ver3 コマンド
$ npm i -D serverless-offline@13 # serverlessをローカルで使う為(@13はserverless ver3に対応したバージョン13の意味)
$ npm i -D esbuild serverless-esbuild typescript @types/express @types/node # TypeScript化用
$ npm i @vendia/serverless-express body-parser # serverless express用
$ npm i -D @types/aws-lambda # Lambda関連
$ npm i date-fns # 日付関連
$ npm rm serverless-http # serverless express を使用するので削除
$ npm i aws-cli # AWS CLI用
$ npm i @aws-sdk/client-dynamodb uuid # DynamoDB操作用、UUID用
$ npm i @aws-sdk/lib-dynamodb # DynamoDB操作用
$ npm i -D serverless-dynamodb # serverless DynamoDB
$ npm i dynamodb-admin # DynamoDB-Admin用
Unitテスト用ライブラリのインストール
$ npm i -g jest
$ npm i -D jest @types/jest
$ npm i -D esbuild-jest
$ npm i -D supertest @types/supertest
package.jsonにコマンドを追加
{
"name": "serverless-cicd",
"version": "1.0.0",
"description": "",
+ "scripts": {
+ "dev": "serverless offline"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.751.0",
"@aws-sdk/lib-dynamodb": "^3.751.0",
"@vendia/serverless-express": "^4.12.6",
"aws-cli": "^0.0.2",
"body-parser": "^1.20.3",
"date-fns": "^4.1.0",
"express": "^4.18.2",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.147",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.13.4",
"@types/supertest": "^6.0.2",
"esbuild": "^0.24.2",
"esbuild-jest": "^0.5.0",
"jest": "^29.7.0",
"serverless": "^3.40.0",
"serverless-esbuild": "^1.54.6",
"serverless-offline": "^13.9.0",
"supertest": "^7.0.0",
"typescript": "^5.7.3"
}
}
esbuildを使ってTypeScript化させる
service: serverless-cicd
frameworkVersion: '3'
+ package:
+ individually: true
+ custom:
+ defaultStage: local
+ esbuild:
+ bundle: true
+ minify: false
+ sourcemap: false
+ exclude: ["aws-sdk"]
+ target: "node20"
+ define: { "require.resolve": undefined }
+ platform: "node"
+ concurrency: 10
+ watch:
+ pattern: ["src/**/*.ts"]
+ ignore: ["temp/**/*"]
+ region:
+ local: "local"
+ dev: "ap-northeast-1"
provider:
name: aws
+ logs:
+ restApi: false
+ region: ${self:custom.region.${opt:stage, 'local'}}
+ stage: ${opt:stage, self:custom.defaultStage}
+ runtime: nodejs20.x # Lambda側はこのバージョンでデプロイされる)
functions:
# API処理
api:
+ handler: src/api/index.handler
+ events:
+ - http:
+ method: ANY
+ path: "/"
+ cors: true
+ - http:
+ method: ANY
+ path: "/{any+}"
+ cors: true
+ plugins:
+ - serverless-esbuild
+ - serverless-offline
tsconfig.jsonの新規作成
{
"compilerOptions": {
"lib": ["ESNext"],
"moduleResolution": "node",
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"removeComments": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
"preserveConstEnums": true,
"strictNullChecks": true,
"allowJs": true,
"sourceMap": false,
"target": "ES2020",
"outDir": "lib"
},
"include": ["src/**/*.ts", "serverless.ts"],
"exclude": ["node_modules/**/*", ".serverless/**/*", ".webpack/**/*", "_warmup/**/*", ".vscode/**/*"]
}
TypeScriptなので、index.jsは削除しておく
$ rm index.js
TypeScript用ファイル作成
$ mkdir -p src/api
import serverlessExpress from "@vendia/serverless-express";
import express, { Request, Response, NextFunction } from "express";
import bodyParser from "body-parser";
const app = express();
const router = express.Router();
router.use(bodyParser.json());
router.use(bodyParser.urlencoded({ extended: true }));
router.get("/user", async (_req: Request, res: Response, _next: NextFunction) => {
const json = { message: "Hello from path!" };
res.status(200).json(json);
});
// 404エラーハンドラ(未定義ルート用)
router.use((_req: Request, res: Response) => {
res.status(404).json({
error: "404 Not Found",
});
});
// 500エラーハンドラ(すべてのルートの後に配置)
router.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
console.error(err.stack);
res.status(500).send("System Error");
});
app.use("/", router);
export const handler = serverlessExpress({ app });
ローカル環境を起動してみる
$ npm run dev # 入力してEnter
> serverless-cicd@1.0.0 dev
> serverless offline
Warning: Invalid configuration encountered
at 'provider.region': must be equal to one of the allowed values [us-east-1, us-east-2, us-gov-east-1, us-gov-west-1, us-iso-east-1, us-iso-west-1, us-isob-east-1, us-west-1, us-west-2, af-south-1, ap-east-1, ap-northeast-1, ap-northeast-2, ap-northeast-3, ap-south-1, ap-south-2, ap-southeast-1, ap-southeast-2, ap-southeast-3, ap-southeast-4, ca-central-1, cn-north-1, cn-northwest-1, eu-central-1, eu-central-2, eu-north-1, eu-south-1, eu-south-2, eu-west-1, eu-west-2, eu-west-3, il-central-1, me-central-1, me-south-1, sa-east-1]
Learn more about configuration validation here: http://slss.io/configuration-validation
Starting Offline at stage local (local)
Offline [http for lambda] listening on http://localhost:3002
Function names exposed for local invocation by aws-sdk:
* api: serverless-cicd-local-api
┌───────────────────────────────────────────────────────────────────────┐
│ │
│ ANY | http://localhost:3000/local │
│ POST | http://localhost:3000/2015-03-31/functions/api/invocations │
│ ANY | http://localhost:3000/local/{any*} │
│ POST | http://localhost:3000/2015-03-31/functions/api/invocations │
│ │
└───────────────────────────────────────────────────────────────────────┘
Server ready: http://localhost:3000 🚀
別ターミナルからローカル環境のAPIに接続してみる
$ curl http://localhost:3000/local/user # 入力してEnter
{"message":"Hello from path!"} # 表示されればOK
AWSにdev環境としてデプロイしてみる
※ローカルの時刻が正しくないと失敗するので注意
$ serverless deploy --stage dev --aws-profile sample-profile
<プロジェクト名>-<環境名>-api という名前のLambda関数ができる。
例)serverless-cicd-dev-api
<環境名>-<プロジェクト名> という名前のAPI Gatewayができる。
例)dev-serverless-cicd
<プロジェクト名>-<環境名> という名前の CloudFormationスタックができる。
例)serverless-cicd-dev
デプロイしたAPIに接続してみる
API Gatewayのコンソールに入り、左ペイン「ステージ」をクリックして、ステージ「dev」の「URLを呼び出す」に記載のURLをコピー。
$ curl <コピーしたURL>/user # 入力してEnter
{"message":"Hello from path!"} # 表示されればOK
developブランチの作成
$ git branch develop
$ git checkout develop
その後コミット&プッシュしておく。以降はdevelopブランチで実施する。
環境構築(デプロイ)用と、環境削除用のコマンドの作成
AWSへのデプロイもコマンド1つで簡単に出来てしまいますが、それがかえって怖いので、
AWS環境へのデプロイおよび環境削除については、間違ってデプロイしないように、Yes/Noの確認をするロジックを入れたいと思います。
環境構築(デプロイ)用コマンドの新規作成
const readline = require("readline");
const { exec } = require("child_process");
const environment = process.argv[2]; // 第一パラメーターを環境名として取得
const profile = process.argv[3]; // 第二パラメーターをプロファイル名として取得
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(`${environment}へデプロイしますか? (yes/no) `, (answer) => {
if (answer.toLowerCase() === "yes") {
exec(`serverless deploy --stage ${environment} --aws-profile ${profile}`, (error, stdout, stderr) => {
if (error) {
console.log(`error: ${error.message}`);
return;
}
if (stderr) {
console.log(`stderr: ${stderr}`);
return;
}
console.log(`stdout: ${stdout}`);
});
} else {
console.log("キャンセルしました。");
}
rl.close();
});
環境削除用コマンドの新規作成
const readline = require("readline");
const { exec } = require("child_process");
const environment = process.argv[2]; // 第一パラメーターを環境名として取得
const profile = process.argv[3]; // 第二パラメーターをプロファイル名として取得
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(`${environment}を削除しますか? (yes/no) `, (answer) => {
if (answer.toLowerCase() === "yes") {
exec(`serverless remove --stage ${environment} --aws-profile ${profile}`, (error, stdout, stderr) => {
if (error) {
console.log(`error: ${error.message}`);
return;
}
if (stderr) {
console.log(`stderr: ${stderr}`);
return;
}
console.log(`stdout: ${stdout}`);
});
} else {
console.log("キャンセルしました。");
}
rl.close();
});
package.jsonに上記コマンドを追加
{
"name": "sample-api",
"version": "1.0.0",
"description": "",
"scripts": {
"dev": "serverless offline",
+ "deploy:dev": "node confirmDeploy.js dev sample-profile",
+ "remove:dev": "node comfirmRemove.js dev sample-profile"
},
:
:
これで、デプロイするときには、npm run deploy:dev と入力すれば「デプロイしますか?」と聞いてきて、逆に削除するときは、npm run deploy:remove と入力すれば「削除しますか?」と聞いてきてくれます。
誤ってデプロイor削除しないように事前に確認できます。
TypeScriptの型チェックコマンド
esbuildはTypeScriptの型チェックをしてくれる訳ではないので、型チェックコマンドも追加します。
package.json に 型チェックコマンドを追加します。
{
"name": "sample-api",
"version": "1.0.0",
"description": "",
"scripts": {
+ "type-check": "tsc --noEmit",
"dev": "serverless offline",
"deploy:dev": "node confirmDeploy.js dev sample-profile",
"remove:dev": "node comfirmRemove.js dev sample-profile"
},
:
:
これで「npm run type-check」と入力すると型チェックができます。
今のところのディレクトリ構成は以下になります。
$ tree -L 1
├── README.md
├── confirmDeploy.js
├── confirmRemove.js
├── node_modules
├── package-lock.json
├── package.json
├── serverless.yml
├── src
└── tsconfig.json
2章. DynamoDBを使って、データの登録/更新/削除 をしてみる
DynamoDBにはRDMSのようなユニーク制約が無いので、間違って同じレコードを重複登録しないように、疑似的なユニーク制約も作ってみます。
DynamoDBには以下のようなテーブルを作成します。
また、signatureで検索できるようにするため、GSIも作成します。
テーブル名には環境変数を使用して、環境毎のプレフィックスを指定します。
{
"id": "<UUID>", ※パーティションキー
"signature": "<ユーザーを識別する署名>", ※今回は署名の改ざんチェックなどはなく、単純な識別子とします。
"name": "山田",
"gender": "男性",
"created_at": "2025-02-26 00:00:00",
"updated_at": "2025-02-26 00:00:00"
}
serverless.yml に DynamoDBプラグインを追加
:
:
plugins:
+ - serverless-dynamodb
- serverless-esbuild
- serverless-offline
package.json に DynamoDB関連コマンドを追加
:
:
"scripts": {
"type-check": "tsc --noEmit",
"dev": "serverless offline",
"deploy:dev": "node confirmDeploy.js dev sample-profile",
"remove:dev": "node comfirmRemove.js dev sample-profile",
+ "dynamodb:install": "serverless dynamodb install",
+ "dynamodb:start": "serverless dynamodb start",
+ "dynamodb:migrate": "serverless dynamodb migrate"
},
:
:
DynamoDBの初期化
$ npm run dynamodb:install # 入力してEnter
> sample-api@1.0.0 dynamodb:install
> serverless dynamodb install
Installing DynamoDB Local from https://d1ni2b6xgvw0s0.cloudfront.net/dynamodb_local_latest.tar.gz...
Installation of DynamoDB Local complete. # 表示されればOK
serverless.yml にIAMロールとテーブルの定義を追加
custom:
defaultStage: local
esbuild:
bundle: true
minify: false
sourcemap: false
exclude: ["aws-sdk"]
target: "node20"
define: { "require.resolve": undefined }
platform: "node"
concurrency: 10
watch:
pattern: ["src/**/*.ts"]
ignore: ["temp/**/*"]
+ dynamodb:
+ start:
+ port: 8000
+ inMemory: false
+ migrate: false
+ dbPath: "./.dynamodb"
region:
local: "local"
dev: "ap-northeast-1"
provider:
name: aws
logs:
restApi: false
region: ${self:custom.region.${opt:stage, 'local'}}
stage: ${opt:stage, self:custom.defaultStage}
runtime: nodejs20.x
+ iam:
+ role:
+ statements:
+ - Effect: Allow
+ Action:
+ - dynamodb:Query
+ - dynamodb:Scan
+ - dynamodb:GetItem
+ - dynamodb:PutItem
+ - dynamodb:UpdateItem
+ - dynamodb:DeleteItem
+ Resource:
+ - Fn::GetAtt: [User, Arn]
+ - !Sub "${User.Arn}/index/*"
+ - Fn::GetAtt: [Signature, Arn]
:
:
plugins:
- serverless-dynamodb
- serverless-esbuild
- serverless-offline
+ resources:
+ Resources:
+ User: # 更新対象のテーブル
+ Type: AWS::DynamoDB::Table
+ Properties:
+ TableName: ${self:provider.stage}-users
+ AttributeDefinitions:
+ - AttributeName: id
+ AttributeType: S
+ - AttributeName: signature
+ AttributeType: S
+ KeySchema:
+ - AttributeName: id
+ KeyType: HASH
+ GlobalSecondaryIndexes:
+ - IndexName: signature_gsi
+ KeySchema:
+ - AttributeName: signature
+ KeyType: HASH
+ Projection:
+ ProjectionType: ALL
+ BillingMode: PAY_PER_REQUEST
+ PointInTimeRecoverySpecification:
+ PointInTimeRecoveryEnabled: true
+ Signature: # 疑似的なユニーク制約を実現する為のテーブル
+ Type: AWS::DynamoDB::Table
+ Properties:
+ TableName: ${self:provider.stage}-signatures
+ AttributeDefinitions:
+ - AttributeName: stext
+ AttributeType: S
+ KeySchema:
+ - AttributeName: stext
+ KeyType: HASH
+ BillingMode: PAY_PER_REQUEST
+ PointInTimeRecoverySpecification:
+ PointInTimeRecoveryEnabled: true
signature はクライアントから送信され、DB内では必ず一意になるようにします。
しかしながら DynamoDBのテーブル仕様の特性上、ユニーク制約が無いため、以下に記す「疑似的なユニーク制約」を実装します。
疑似的なユニーク制約とは
具体的には、usersテーブル(idをパーティションキー)と、signaturesテーブル(stextをパーティションキー)としたテーブルをトランザクションで囲って
signatures.stext が同じものが2つ以上登録されたら例外処理ではじいてトランザクションを終了することにより、usersテーブルの更新もはじくようにします。
※textは予約語なので、stextにしました。
DynamoDBの起動
別ウィンドウを開き以下を入力
$ npm run dynamodb:start # 入力してEnter
> serverless-cicd@1.0.0 dynamodb:start
> serverless dynamodb start
Initializing DynamoDB Local with the following configuration:
Port: 8000
InMemory: false
Version: 1.25.1
DbPath: /xxxxxx/xxxxxx/xxxxxx/serverless-cicd/.dynamodb
SharedDb: true
shouldDelayTransientStatuses: true
CorsParams: *
DynamoDBテーブルの作成(migrate)
別ウィンドウを開き以下を入力
$ npm run dynamodb:migrate # 入力してEnter
DynamoDB - created table local-signatures
DynamoDB - created table local-users
DynamoDB-ADMINの起動
別ウィンドウを開き以下を入力
$ DYNAMO_ENDPOINT=http://localhost:8000 dynamodb-admin # 入力してEnter(起動)
ブラウザを http://localhost:8001/ で開いて画面表示し、local-sigunatures と local-users テーブルが出来ていればOK
DynamoDBローカルの注意点
以下のページでも言われていますが、DynamoDBローカルはパーティションキーを指定しているにも関わらず、稀に重複登録されることがあります。
その際はこちらのページを参考ください。
https://qiita.com/nozaki-amazia/items/c8c58f7647c4fec927ce
環境変数の設定
src ディレクトリの下に config ディレクトリを作る。
$ mkdir -p src/config
ローカル用の環境変数ファイルを新規作成
ENV: "local"
TZ: "Asia/Tokyo"
Dev用の環境変数ファイルを新規作成
ENV: "dev"
TZ: "Asia/Tokyo"
serverless.ymlに環境変数の参照を追加
:
:
region:
local: "local"
dev: "ap-northeast-1"
+ otherfile:
+ environment:
+ local: ${file(src/config/local.yml)}
+ dev: ${file(src/config/dev.yml)}
:
:
functions:
# API処理
api:
handler: src/api/index.handler
+ timeout: 30 # APIのタイムアウト拡張
+ environment:
+ ENV: ${self:custom.otherfile.environment.${self:provider.stage}.ENV}
+ TZ: ${self:custom.otherfile.environment.${self:provider.stage}.TZ}
events:
- http:
method: ANY
path: "/"
cors: true
- http:
method: ANY
path: "/{any+}"
cors: true
Modelファイルの実装
src ディレクトリの下に model ディレクトリを作り、その中に User.ts ファイルを新規作成する。
$ mkdir -p src/model
import { DynamoDBClient, QueryCommand, QueryCommandInput, UpdateItemCommand, ReturnValue, DeleteItemCommand } from "@aws-sdk/client-dynamodb";
import { TransactWriteCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { v4 as uuidv4 } from "uuid";
import { format } from "date-fns";
export class User {
private userTableName: string = process.env.ENV + "-users";
private signatureTableName: string = process.env.ENV + "-signatures";
private indexName: string = "signature_gsi";
private client: DynamoDBClient;
private documentClient: DynamoDBDocumentClient;
constructor() {
if (global.dynamodbClient) {
this.client = global.dynamodbClient; // <===== https://qiita.com/chinone0711/items/13d57f9dc6cd13e5a2b8 の対応
} else {
if (process.env.ENV === "local") {
const endpoint = process.env.CI ? "http://dynamodb:8000" : "http://localhost:8000";
this.client = new DynamoDBClient({
region: process.env.AWS_REGION,
endpoint: endpoint, // <===== これを指定しないと、JestでlocalのDynamoDBアクセス時、AWS profile(./aws/credentials)のデフォルトを見てAWS側にアクセスしてしまうので注意!
credentials: {
accessKeyId: "dummy",
secretAccessKey: "dummy",
},
});
});
} else {
this.client = new DynamoDBClient({
region: process.env.AWS_REGION,
});
}
}
this.documentClient = DynamoDBDocumentClient.from(this.client);
}
/**
* SignatureからユーザーIDを取得
* @param signature
* @returns
*/
getIdBySignature = async (signature: string) => {
let ret: string = "";
const params: QueryCommandInput = {
TableName: this.userTableName,
IndexName: this.indexName,
KeyConditionExpression: "#gsiKey = :gsiValue",
ExpressionAttributeNames: {
"#gsiKey": "signature",
},
ExpressionAttributeValues: {
":gsiValue": { S: signature },
},
};
try {
const command = new QueryCommand(params);
const data = await this.client.send(command);
if (data && data.Items) {
for (const item of data.Items) {
ret = item.id.S?.toString() || "";
break;
}
}
} catch (err) {
console.error("Unable to query. Error users(1):", JSON.stringify(err, null, 2));
throw new Error(`Unable to query. Error users(1): ${err.message}`);
}
return ret;
};
/**
* IDからユーザーを取得
* @param id
* @returns
*/
getById = async (id: string) => {
let ret;
const params: QueryCommandInput = {
TableName: this.userTableName,
KeyConditionExpression: "#key = :value",
ExpressionAttributeNames: {
"#key": "id",
},
ExpressionAttributeValues: {
":value": { S: id },
},
};
try {
const command = new QueryCommand(params);
const data = await this.client.send(command);
if (data && data.Items) {
for (const item of data.Items) {
ret = item;
break;
}
}
} catch (err) {
console.error("Unable to query. Error users(2):", JSON.stringify(err, null, 2));
throw new Error(`Unable to query. Error users(2): ${err.message}`);
}
return ret;
};
/**
* ユーザーを作成
* @param signature
* @param name
* @param gender
* @returns
*/
createUser = async (signature: string, name: string, gender: string) => {
let ret: string = "";
const id = uuidv4();
const date = new Date();
const now = format(date, "yyyy-MM-dd HH:mm:ss");
const params = {
TransactItems: [
{
Put: {
TableName: this.userTableName,
Item: {
id: id,
signature: signature,
name: name,
gender: gender,
created_at: now,
updated_at: now,
},
// ユニーク制約:id が存在しないことを確認
ConditionExpression: "attribute_not_exists(id)",
},
},
{
Put: {
TableName: this.signatureTableName,
Item: {
stext: signature,
},
// ユニーク制約:stext が存在しないことを確認
ConditionExpression: "attribute_not_exists(stext)",
},
},
],
};
try {
const command = new TransactWriteCommand(params);
await this.documentClient.send(command);
ret = id;
} catch (err) {
// console.error("Unable to put item. Error users(3):", JSON.stringify(err, null, 2));
throw new Error(`Unable to put item. Error users(3): ${err.message}`);
}
return ret;
};
/**
* ユーザー名更新
* @param signature
* @param name
*/
updateUserById = async (req) => {
const userId = req.userId!;
const postData = req.body!;
const name = postData.user.name;
const gender = postData.user.gender;
const params = {
TableName: this.userTableName,
Key: {
id: { S: userId },
},
ExpressionAttributeNames: {
"#Y1": "name",
"#Y2": "gender",
"#Y3": "updated_at",
},
ExpressionAttributeValues: {
":y1": { S: name },
":y2": { S: gender },
":y3": { S: format(new Date(), "yyyy-MM-dd HH:mm:ss") },
},
UpdateExpression: "SET #Y1 = :y1, #Y2 = :y2, #Y3 = :y3",
ReturnValues: "UPDATED_NEW" as ReturnValue,
};
try {
const command = new UpdateItemCommand(params);
this.documentClient.send(command);
} catch (err) {
console.error("Unable to put item. Error users(4):", JSON.stringify(err, null, 2));
throw new Error(`Unable to put item. Error users(4): ${err.message}`);
}
};
/**
* ユーザー削除
* @param signature
*/
deleteUserById = async (req) => {
const signature = req.headers["signature"]!.toString();
const userId = req.userId!;
const userParams = {
TableName: this.userTableName,
Key: {
id: { S: userId },
},
};
const signatureParams = {
TableName: this.signatureTableName,
Key: {
stext: { S: signature },
},
};
try {
const command1 = new DeleteItemCommand(userParams);
this.documentClient.send(command1);
const command2 = new DeleteItemCommand(signatureParams);
this.documentClient.send(command2);
} catch (err) {
console.error("Unable to delete item. Error users(5):", JSON.stringify(err, null, 2));
throw new Error(`Unable to delete item. Error users(5): ${err.message}`);
}
};
}
Serviceファイルの実装
src ディレクトリの下に service ディレクトリを作り、その中に SignatureService.ts ファイルを新規作成する。
$ mkdir -p src/service
import { Request } from "express";
import { User } from "../model/User";
export class SignatureService {
/**
* SignatureからユーザーIDを取得、存在しない場合は新規作成
* @param req
* @returns
*/
getUserId = async (req: Request) => {
let userId: string = "";
const signature = req.headers["signature"];
if (signature !== undefined) {
if (typeof signature === "string") {
const user = new User();
userId = await user.getIdBySignature(signature);
if (userId === "") {
const postData = req.body!;
const name = postData.user.name;
const gender = postData.user.gender;
userId = await user.createUser(signature, name, gender);
console.log("ユーザーID:" + userId + " を作成。");
} else {
console.log("ユーザーID:" + userId + " を取得。");
}
}
}
return userId;
};
}
index.tsの修正
src/api/index.tsを以下のように書き換える。
import serverlessExpress from "@vendia/serverless-express";
import express, { Request, Response, NextFunction } from "express";
import bodyParser from "body-parser";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { SignatureService } from "../service/SignatureService";
import { User } from "../model/User";
const app = express();
const router = express.Router();
router.use(bodyParser.json());
router.use(bodyParser.urlencoded({ extended: true }));
declare global {
namespace Express {
interface Request {
userId?: string;
}
}
}
/**
* トップページ
* ※認証処理対象外
*/
router.get("/top", async (_req: Request, res: Response, _next: NextFunction) => {
const json = { message: "Top Page!" };
res.status(200).json(json);
});
/**
* 認証処理(API共通のミドルウェア)
* @param req
* @param res
* @param next
*/
router.use(async (req: Request, res: Response, next: NextFunction) => {
console.log("signature", req.headers["signature"]);
console.log("リクエスト", req.body);
const signatureService = new SignatureService();
const userId = await signatureService.getUserId(req);
if (userId) {
req.userId = userId;
next();
} else {
if (req.path === "/favicon.ico") {
res.status(201).json(null);
}
res.status(401).json({
error: "401 Unauthorized",
});
}
});
/**
* ユーザー作成orログイン
*/
router.post("/user", async (_req: Request, res: Response, _next: NextFunction) => {
const json = { message: "User create or login" };
res.status(200).json(json);
});
/**
* ユーザー更新
*/
router.patch("/user", async (req: Request, res: Response, _next: NextFunction) => {
const user = new User();
await user.updateUserById(req);
const json = { message: "User update" };
res.status(200).json(json);
});
/**
* ユーザー削除
*/
router.delete("/user", async (req: Request, res: Response, _next: NextFunction) => {
const user = new User();
await user.deleteUserById(req);
const json = { message: "User delete" };
res.status(200).json(json);
});
/**
* 404エラーハンドラ(未定義ルート用)
*/
router.use((_req: Request, res: Response) => {
res.status(404).json({
error: "404 Not Found",
});
});
/**
* 500エラーハンドラ(すべてのルートの後に配置)
*/
router.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
console.error(err.stack);
res.status(500).send("System Error");
});
app.use("/", router);
global.dynamodbClient = new DynamoDBClient({
region: process.env.AWS_REGION, // <===== https://qiita.com/chinone0711/items/13d57f9dc6cd13e5a2b8 の対応
});
export const handler = serverlessExpress({ app });
APIの動作確認
再度 npm run dev を実行。
postman等のクライアントソフトで以下を設定して送信。
トップページの動作確認
名称 | 値 |
---|---|
アクセスURL | http://localhost:3000/local/top |
メソッド | GET |
ボディ(Json) | なし |
OK時の結果 | { "message": "Top Page!" } |
ユーザーの新規作成orログインの動作確認
名称 | 値 |
---|---|
アクセスURL | http://localhost:3000/local/user |
メソッド | POST |
ヘッダー | signatureに任意の値 |
ボディ(Json) | { "user": { "name": "山田", "gender": "男性" } } |
OK時の結果 | DynamoDBへのデータ登録 |
ユーザー情報の更新の動作確認
名称 | 値 |
---|---|
アクセスURL | http://localhost:3000/local/user |
メソッド | PATCH |
ヘッダー | signatureに任意の値 |
ボディ(Json) | { "user": { "name": "鈴木", "gender": "女性" } } |
OK時の結果 | DynamoDBのデータ更新 |
ユーザー情報の削除の動作確認
名称 | 値 |
---|---|
アクセスURL | http://localhost:3000/local/user |
メソッド | DELETE |
ヘッダー | signatureに任意の値 |
ボディ(Json) | なし |
OK時の結果 | DynamoDBのデータ削除 |
3章. JestによるUnitテスト(ローカル版)
Jest関連ライブラリをインストール
$ npm i -g jest
$ npm i -D jest @types/jest
$ npm i -D esbuild-jest
Jestの設定ファイルを新規作成
module.exports = {
testEnvironment: 'node', // テスト環境
transform: {
"^.+\\.tsx?$": "esbuild-jest" // transformerにesbuild-jest
},
globalSetup: "./testEnv.ts", // テスト用の環境変数ファイルを取得
};
テスト用の環境変数ファイルを新規作成
※ローカルテストの環境は「local」とします。
export default (): void => {
console.log("\nSetup test environment");
process.env.ENV = "local";
process.env.TZ = "Asia/Tokyo";
process.env.AWS_REGION = "ap-northeast-1";
console.log("process.env.ENV", process.env.ENV);
console.log("process.env.TZ", process.env.TZ);
console.log("process.env.AWS_REGION", process.env.AWS_REGION);
return;
};
Userモデルの Unitテストを書いてみる
ローカルのDynamoDBに対して、以下を順番に実施します。
- User登録に関するテスト
- User取得に関するテスト
- User更新に関するテスト
- User削除に関するテスト
補足説明
describe : テストのグルーピングに使います。
it : testと同じ意味で、テスト関数になります。
テストディレクトリを作成する。
$ mkdir test
テストコードを新規作成する。
import { User } from "../src/model/User"; // テストするModelを読み込みます
import { setTimeout } from "node:timers/promises"; // スリープ用のsetTimeoutを読み込みます
const USER_SIGNATURE = "test1234"; // 今回のSignature(本番データと被らなければなんでも良いです)
describe("User登録に関するテスト", () => {
it("新規登録のテスト", async () => {
const user = new User();
// ユーザー作成
const createdUserId = await user.createUser(USER_SIGNATURE, "山田", "男性");
// SignatureからユーザーIDを取得
const getUserId = await user.getIdBySignature(USER_SIGNATURE);
// 作成したユーザーが存在するかチェック
expect(createdUserId).toBe(getUserId);
});
it("二重登録エラーのテスト", async () => {
const user = new User();
// わざと同一のSignatureでユーザーを登録してエラーが出るかチェック
try {
const result = await user.createUser(USER_SIGNATURE, "山田", "男性");
} catch (e) {
// 許容するエラーメッセージ(Transaction cancelled ~)かチェック
expect(e.message).toContain("Unable to put item. Error users(3): Transaction cancelled");
}
});
});
describe("User更新に関するテスト", () => {
it("更新テスト", async () => {
const user = new User();
// SignatureからユーザーIDを取得
const getUserId = await user.getIdBySignature(USER_SIGNATURE);
// 更新後のユーザー情報
const req = {
userId: getUserId,
body: {
user: {
name: "鈴木",
gender: "女性",
},
},
};
// ユーザー情報の更新
await user.updateUserById(req);
// データ更新されるまで1秒待つ(ローカルのDynamoDBが更新に時間かかるので待つようにしました)
await setTimeout(1000);
// 更新されたユーザー情報をDBから取得
const updatedUser = await user.getById(getUserId);
// 正しく更新されたかチェック
expect(updatedUser.name.S).toBe("鈴木");
expect(updatedUser.gender.S).toBe("女性");
});
});
describe("User削除に関するテスト", () => {
it("削除テスト", async () => {
const user = new User();
// SignatureからユーザーIDを取得
const getUserId = await user.getIdBySignature(USER_SIGNATURE);
// 削除するユーザー情報
const req = {
headers: {
signature: USER_SIGNATURE,
},
userId: getUserId,
};
// ユーザー情報の削除
await user.deleteUserById(req);
// データ削除されるまで1秒待つ(ローカルのDynamoDBが削除に時間かかるので待つようにしました)
await setTimeout(1000);
// 正しく削除されたかチェック(ユーザー情報が取れていない(空白)ならOK)
const checkUserId = await user.getIdBySignature(USER_SIGNATURE);
expect(checkUserId).toBe("");
});
});
Unitテストコマンドの追加
:
:
"scripts": {
"type-check": "tsc --noEmit",
"dev": "serverless offline",
"deploy:dev": "node confirmDeploy.js dev sample-profile",
"remove:dev": "node comfirmRemove.js dev sample-profile",
"dynamodb:install": "serverless dynamodb install",
"dynamodb:start": "serverless dynamodb start",
"dynamodb:migrate": "serverless dynamodb migrate",
+ "test": "jest"
},
:
:
Userモデルの Unitテストを実行してみる
$ npm run test # 入力してEnter
> serverless-cicd@1.0.0 test
> jest
Determining test suites to run...
Setup test environment
process.env.ENV local
process.env.TZ Asia/Tokyo
process.env.AWS_REGION ap-northeast-1
PASS test/User.test.ts
User登録に関するテスト
✓ 新規登録のテスト (283 ms) # テスト成功
✓ 二重登録エラーのテスト (25 ms) # テスト成功
User更新に関するテスト
✓ 更新テスト (1017 ms) # テスト成功
User削除に関するテスト
✓ 削除テスト (1047 ms) # テスト成功
Test Suites: 1 passed, 1 total
Tests: 4 passed, 4 total
Snapshots: 0 total
Time: 2.792 s, estimated 3 s
Ran all test suites.
ソースを監視し、変更があると自動でテストを実行
以下コマンドを実行します。
すると、プログラム変更があったら自動でテストしてくれます。
$ jest --watchAll
4章. JestによるUnitテスト&デプロイ(GitLabにPushでCI/CDが発火)
GitLab CI/CD設定ファイルを新規作成
stages:
- test
- deploy
variables:
ENV: "local"
TZ: "Asia/Tokyo"
DOCKER_TLS_CERTDIR: "" # TLSを無効化、これをしないとDockerデーモンがTLS経由で通信しようとし、エラーが発生する。
DOCKER_HOST: tcp://docker:2375 # Dockerデーモンのホストを指定
# テスト
test-job:
image: node:alpine3.20
stage: test
services:
- name: docker:dind
command: ["--tls=false"] # TLS 無効化
- name: amazon/dynamodb-local
alias: dynamodb # GitLab Runner から `dynamodb:8000` でアクセス可能
before_script:
- echo "before_script"
# ダミーのAWS認証情報(これが無いとaws-cliがエラーを出す)
- export AWS_ACCESS_KEY_ID=dummy # User.tsの中にあるAWS_ACCESS_KEY_IDと同じダミー値でないとデータ取得できないので注意!
- export AWS_SECRET_ACCESS_KEY=dummy # User.tsの中にあるAWS_ACCESS_KEY_IDと同じダミー値でないとデータ取得できないので注意!
- export AWS_DEFAULT_REGION=ap-northeast-1
# Docker、docker-compose をインストール
- apk update && apk add --no-cache docker docker-compose aws-cli curl
- docker --version # Docker バージョンを確認
- docker info
# npm パッケージをインストール
- npm ci
# Docker Networkを作成(既に存在する場合はエラーを無視)
- docker network create docker_network || true
# DynamoDBの Dockerコンテナを起動
- docker-compose up -d
# DynamoDBの Dockerが healthy になるまで待機(5秒ごとに再試行)
- until docker ps --format "{{.Status}}" | grep -q "(healthy)"; do
echo "Waiting for Docker container to become healthy...";
sleep 5;
done
# DynamoDB の起動待機後にテストアクセス
- curl http://dynamodb:8000 || (echo "DynamoDB is not running!" && exit 1)
# Dockerの状態表示
#- docker ps
# Dockerにログインしてテストアクセス
#- docker exec dynamodb curl -v http://localhost:8000
# Dockerのログを表示
#- docker logs $(docker ps -q -f name=dynamodb)
# DynamoDBコンテナにUnitテストで使用するテーブルを作成
- aws dynamodb create-table --endpoint-url http://dynamodb:8000 --table-name local-users --attribute-definitions AttributeName=id,AttributeType=S AttributeName=signature,AttributeType=S --key-schema AttributeName=id,KeyType=HASH --billing-mode PAY_PER_REQUEST --global-secondary-indexes '[{"IndexName":"signature_gsi","KeySchema":[{ "AttributeName":"signature", "KeyType":"HASH"}],"Projection":{"ProjectionType":"ALL"}}]'
- aws dynamodb create-table --endpoint-url http://dynamodb:8000 --table-name local-signatures --attribute-definitions AttributeName=stext,AttributeType=S --key-schema AttributeName=stext,KeyType=HASH --billing-mode PAY_PER_REQUEST
# DynamoDBのテーブル作成は非同期の為、10秒待機
- sleep 10
# DynamoDBに作成したテーブル一覧を確認
- aws dynamodb list-tables --endpoint-url http://dynamodb:8000
script:
- echo "script"
# Unitテストを実行
- npm run test
only:
# - merge_requests
- develop
dependencies: []
# デプロイ(Dockerコンテナ内からserverlessコマンドを実行)
deploy-job:
stage: deploy
before_script:
- npm i -g serverless@3
- serverless --version
- npm ci
script:
# GitLabの変数から環境情報を取得
- export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
- export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
- export AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION
- export SERVERLESS_ACCESS_KEY=$SERVERLESS_ACCESS_KEY
# Dev環境へのデプロイ
- serverless deploy --stage dev
only:
- develop
dependencies: [test-job]
テスト用のDynamoDBコンテナを新規作成
version: "3.8"
services:
dynamodb:
image: amazon/dynamodb-local
container_name: dynamodb
ports:
- "8000:8000"
command: "-jar DynamoDBLocal.jar -sharedDb -dbPath /data"
volumes:
- dynamodb_data:/data
healthcheck:
test: ["CMD", "sh", "-c", "curl -s http://localhost:8000 || exit 1"]
interval: 5s
retries: 5
timeout: 5s
start_period: 10s
networks:
- docker_network
networks:
docker_network:
driver: bridge
volumes:
dynamodb_data:
AWS EC2に GitLabランナーのインストールと登録
以下GitLab設定画面の赤枠部分「registration token」をコピーしておく。
Amazon Linux 2 でEC2インスタンスを起動する(※Amazon Linux 2023だと動かなかった)
EC2にログインしたあと以下コマンドを実行
$ # gitlab-runnerのインストール
$ sudo yum update -y
$ sudo yum install -y git curl
$ curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh | sudo bash
$ sudo yum install -y gitlab-runner
$ # Dockerのインストール
$ sudo amazon-linux-extras enable docker
$ sudo yum install -y docker
$ sudo usermod -aG docker gitlab-runner
$ # Dockerの有効化
$ sudo systemctl enable docker
$ sudo systemctl start docker
$ # GitLabランナーの登録
$ sudo gitlab-runner register #基本は画面にしたがって
Enter the GitLab instance URL (for example, https://gitlab.com/):
<gitlabのURLを入力 ※パスは不要> #Enter
Enter the registration token:
<上記でコピーしていた「registration token」を貼り付け> # Enter
[ip-xx-xx-xx-xx.ap-northeast-1.compute.internal]:
<任意の名称 ※GitLab側で表示される名称、ここでは serverless-cicdとする> # Enter)
Enter tags for the runner (comma-separated):
<なにも入力しない> # Enter
Enter optional maintenance note for the runner:
<なにも入力しない> # Enter
Registering runner... succeeded runner=GR1348941UWSRzxK6
Enter an executor: parallels, docker-windows, docker+machine, kubernetes, shell, ssh, docker, docker-autoscaler, instance, custom, virtualbox:
docker # Enter
Enter the default Docker image (for example, ruby:2.7):
node:alpine3.20 # Enter ※.gitlab-ci.ymlと合わせる
$ # GitLabランナーの起動と自動起動設定
$ sudo gitlab-runner start # 既に起動中の場合には restart
$ sudo systemctl enable gitlab-runner
すると、GitLab側で 以下図の赤枠の部分のように、「利用可能な Specific Runnner」が表示される。
GitLabの設定
すると以下画面になるので、「タグのないジョブの実行」に✓する。
GItLabランナーの設定編集
再びEC2に戻り、/etc/gitlab-runner/config.toml を以下のように編集する。
※GitLabのコンソールから「利用可能な Specific Runner」を削除しても、ここの定義は消えないので注意
$ sudo vi /etc/gitlab-runner/config.toml
:
:
[runners.cache.azure]
[runners.docker]
tls_verify = false
image = "node:20"
+ privileged = true # trueにする
disable_entrypoint_overwrite = false
oom_kill_disable = false
disable_cache = false
:
:
Serverless Frameworkのアクセスキーを取得(v4から必須)
Serverless Framework v4からアクセスキーが必須になりました。
ローカルのターミナルから以下を入力し、Serverless Framework のページをブラウザで開く。
$ serverless login # ブラウザが自動で開くのでログインする
次に以下のURLにアクセスしすると、Access Keys生成ページが表示されるので、画面右上の「Add」ボタンクリックして、Nameに任意の名称(例:gitlab)を入力して「Create」ボタンクリック。
するとアクセスキーが表示されるのでコピーしておく
URL:https://app.serverless.com/<組織名>/settings/accessKeys
GitLabの変数に、認証情報をセットする
GitLabの左メニューの設定>CI/CD>変数 を展開>「変数の追加」をクリック。
下図のように、以下に変数をセットする。
- AWS_ACCESS_KEY_ID = <デプロイ先AWSのアクセスキー>
- AWS_SECRET_ACCESS_KEY = <デプロイ先AWSのシークレット>
- AWS_DEFAULT_REGION = <デプロイ先AWSのリージョン>
- SERVERLESS_ACCESS_KEY = 上記ででコピーしたキー
GitLabにPushでCI/CDが実行
最後に、何かファイルを変更して、git commit し、GitLabにPushしてみる。
下図のように、TestとDeployが成功することを確認する。
念のため、AWS側でもCloudFormationがのスタック(serverless-cicd-dev)が更新されていることも確認する。
以上