sanojimaruです。
Supership advent calendar 2021の8日目の記事です。
概要
新しいサービスをサクッと立ち上げる際に、シンプルなコマンド数回でデプロイできるamplifyは非常に強力な選択肢です。
しかしamplifyだけでは認証やユーザー情報のまわりで痒いところに手が届かないケースがあり、自前でサクッとサービスを立ち上げられるboilerplate的なものを作りました。
よくあるバックエンドAPI+フロントエンドSPA構成の場合、serverless frameworkとserverless-s3-syncなどのプラグインを組み合わせることで単一のパッケージで実現可能です。今回はバックエンドAPI+フロントエンドSPA+独立したウィジェット、といった構成を想定しmonorepoにすることで、ウィジェットのみnpmパッケージとして提供したり、機能毎にbuild targetを変えてウィジェットのみIE11にも対応させる、など柔軟な対応を可能にしました。
ざっくり中身の概要は下記の通りです。
- バックエンドとしてTypescript + express + serverless framework
- フロントエンドとして素のTypescript + serverless framework
- yarn workspaceを利用したmonorepo構成
- eslint, tsconfig, dotenvを各サービスで共有
- Cloudformation + Codepipelineを利用したシンプルなデプロイ手順とCD環境の構築
ディレクトリ構成
下記のように落ち着きました。
.
├── bin
│ └── cfn-deploy.sh
├── cfn
│ └── codepipeline.yml
├── env
│ └── .env.*
├── services
│ ├── backend
│ │ ├── package.json
│ │ ├── serverless.yml
│ │ └── src
│ └── frontend
│ ├── dist
│ ├── package.json
│ ├── serverless.yml
│ ├── src
│ └── webpack.config.js
├── node_modules
├── tsconfig.json
├── buildspec.yml
├── package.json
└── yarn.lock
backend, frontendなどの各サービスは /services
配下にディレクトリを設置します。
サービス間で共有するものはroot直下に配置します。
cloudformation templateによるcodepipelineの構築
実際のテンプレートがこちらです。
AWSTemplateFormatVersion: 2010-09-09
Description: serverless framework deploy pipeline example
Parameters:
ServiceName:
Description: serverless framework deploy pipeline example
Type: String
Default: serverless-framework-deploy-pipeline
Env:
Type: String
Default: dev
AllowedValues:
- dev
- prd
GithubRepositoryId:
Type: String
GithubConnectionArn:
Type: String
GithubRepositoryBranchName:
Type: String
Default: develop
AllowedValues:
- develop
- main
Resources:
ArtifactsBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub ${ServiceName}-artifacts-${Env}
LifecycleConfiguration:
Rules:
- Id: DeleteRule
Status: Enabled
ExpirationInDays: 7
CodeBuildProject:
Type: AWS::CodeBuild::Project
Properties:
Name: !Sub ${ServiceName}-${Env}
Artifacts:
Type: CODEPIPELINE
Source:
Type: CODEPIPELINE
Environment:
ComputeType: BUILD_GENERAL1_SMALL
Image: aws/codebuild/standard:5.0
PrivilegedMode: true
Type: LINUX_CONTAINER
EnvironmentVariables:
- Name: DEPLOY_ENV
Value: !Sub ${Env}
ServiceRole: !GetAtt CodeBuildServiceRole.Arn
CodeBuildServiceRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub ${ServiceName}-CodeBuildServiceRole-${Env}
Policies:
- PolicyName: !Sub ${ServiceName}-CodeBuild-ServiceRolePolicy-${Env}
PolicyDocument:
Version: '2012-10-17'
Statement:
- Action:
- logs:*
- codebuild:*
- lambda:*
- dynamodb:*
- apigateway:*
- s3:*
- cloudformation:*
- cognito-idp:*
- iam:CreateRole
- iam:DeleteRole
- iam:DeleteRolePolicy
- iam:PutRolePolicy
- iam:GetRole
- iam:PassRole
Resource:
- '*'
Effect: Allow
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: ''
Effect: Allow
Principal:
Service: codebuild.amazonaws.com
Action: sts:AssumeRole
CodePipelineProject:
Type: AWS::CodePipeline::Pipeline
Properties:
Name: !Sub ${ServiceName}-${Env}
RoleArn: !GetAtt CodePipelineServiceRole.Arn
Stages:
- Name: Source
Actions:
- Name: SourceAction
ActionTypeId:
Category: Source
Owner: AWS
Version: 1
Provider: CodeStarSourceConnection
OutputArtifacts:
- Name: SourceArtifact
Configuration:
FullRepositoryId: !Ref GithubRepositoryId
ConnectionArn: !Ref GithubConnectionArn
BranchName: !Ref GithubRepositoryBranchName
RunOrder: 1
- Name: Build
Actions:
- Name: BuildAction
InputArtifacts:
- Name: SourceArtifact
OutputArtifacts:
- Name: BuildArtifact
ActionTypeId:
Category: Build
Owner: AWS
Version: 1
Provider: CodeBuild
Configuration:
ProjectName: !Ref CodeBuildProject
RunOrder: 2
ArtifactStore:
Type: S3
Location: !Ref ArtifactsBucket
CodePipelineServiceRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub ${ServiceName}-CodePipelineServiceRole-${Env}
Policies:
- PolicyName: !Sub ${ServiceName}-CodePipelineServiceRolePolicy-${Env}
PolicyDocument:
Version: '2012-10-17'
Statement:
- Action:
- s3:GetObject
- s3:GetObjectVersion
- s3:GetBucketVersioning
- s3:PutObject
Resource:
- arn:aws:s3:::codepipeline*
Effect: Allow
- Action:
- codecommit:CancelUploadArchive
- codecommit:GetBranch
- codecommit:GetCommit
- codecommit:GetUploadArchiveStatus
- codecommit:UploadArchive
Resource:
- '*'
Effect: Allow
- Action:
- codebuild:BatchGetBuilds
- codebuild:StartBuild
Resource:
- '*'
Effect: Allow
- Action:
- codedeploy:CreateDeployment
- codedeploy:GetApplication
- codedeploy:GetApplicationRevision
- codedeploy:GetDeployment
- codedeploy:GetDeploymentConfig
- codedeploy:RegisterApplicationRevision
Resource:
- '*'
Effect: Allow
- Action:
- codestar-connections:UseConnection
Resource:
- '*'
Effect: Allow
- Action:
- s3:*
Resource:
- '*'
Effect: Allow
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service:
- codepipeline.amazonaws.com
Action:
- sts:AssumeRole
このテンプレートは4つの引数を取ります。
ServiceName:
Description: serverless framework deploy pipeline example
Type: String
Default: serverless-framework-deploy-pipeline
このテンプレートで構築する各サービスのprefixになるキーワード。基本的にはサービス名。
Env:
Type: String
Default: dev
AllowedValues:
- dev
- prd
githubのリポジトリID。githubユーザー名/リポジトリ名
。
GithubRepositoryId:
Type: String
AWS CodesuiteのGithub Connection ARN。arn:aws:codestar-connections:ap-northeast-1:757134671900:connection/xxxxx
GithubConnectionArn:
Type: String
デプロイするgithubリポジトリのブランチ名。dev環境はdevelopment
, production環境はmain
など。
GithubRepositoryBranchName:
Type: String
Default: develop
AllowedValues:
- develop
- main
このstackをdeployする簡単なスクリプトもあります。
第一引数にstackの環境名(=DEPLOY_ENV)をセットします。
引数を省略するとDEPLOY_ENVにdevがセットされます。
#!/bin/sh
SCRIPT_DIR=$(
cd $(dirname $0)
pwd
)
ROOT_DIR=$(dirname $SCRIPT_DIR)
source $ROOT_DIR/env/.env.local
if [ $# -eq 1 ]; then
echo "set env=$1" 1>&2
ENV_VALUE=$1
else
echo "set env=dev" 1>&2
ENV_VALUE=dev
fi
aws cloudformation deploy \
--template-file $ROOT_DIR/cfn/codepipeline.yml \
--capabilities=CAPABILITY_NAMED_IAM \
--stack-name ${SERVICE_NAME}-${ENV_VALUE} \
--parameter-overrides \
ServiceName=${SERVICE_NAME} \
GithubRepositoryId=${GITHUB_REPOSITORY_ID} \
GithubConnectionArn=${GITHUB_CONNECTION_ARN} \
GithubRepositoryBranchName=${GITHUB_REPOSITORY_BRANCH_NAME} \
Env=${ENV_VALUE}
/env/.env.local
を環境変数として読み込み、必要な値を設置しています。
dev環境とprd環境のときでデプロイするブランチを分ける仕込みを忘れましたが投稿予定日が迫っているので何となく無視して進めます。
SERVICE_NAME={Service name}
GITHUB_REPOSITORY_ID={Github repository id like username/repositoryname}
GITHUB_CONNECTION_ARN={Codestar connection arn}
GITHUB_REPOSITORY_BRANCH_NAME={Branch name}
このスクリプトを実行すると、Cloud FormationにCodePipelineのstackがデプロイされ、githubの該当リポジトリ/ブランチにpushするとデプロイされる一式が出来ました。CircleCIの方が楽じゃない?と思いましたが迷わず続けます。
$ chmod +x bin/cfn-deploy.sh
$ bin/cfn-deploy.sh
monorepoにはyarn workspaceを使っています。
workspace毎にserverless.ymlがあって、それぞれ独立したスタックをデプロイするようになっているので、buildspecはシンプルにworkspace分のdeployを実行するだけです。pre_build:
のところにyarn check-types
とyarn lint
を入れるとよいです。
version: 0.2
phases:
install:
runtime-versions:
nodejs: 14
commands:
- yarn
build:
commands:
- yarn workspace backend deploy:${DEPLOY_ENV}
- yarn workspace frontend deploy:${DEPLOY_ENV}
yarn workspaceを使ったパッケージ管理
今回2つのサービスいずれもServerless framework + Typescriptなので、共有できるパッケージはworkspaceで管理するようにします。
{
"private": true,
"scripts": {
"prepare": "husky install",
"lint": "eslint --fix --ext .ts,.tsx,.js,.jsx services/*/src",
"check-types": "tsc --noEmit"
}
"workspaces": {
"packages": [
"services/*"
],
"nohoist": []
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.5.0",
"@typescript-eslint/parser": "^5.5.0",
"eslint": "^8.3.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.1.1",
"prettier": "^2.5.0",
"rimraf": "^3.0.2",
"serverless": "^2.67.0",
"serverless-bundle": "^5.2.0",
"serverless-dotenv-plugin": "^3.10.0",
"serverless-offline": "^8.3.1",
"ts-loader": "^9.2.6",
"typescript": "^4.5.2",
"serverless-deployment-bucket": "^1.5.1",
"serverless-s3-sync": "^1.17.3"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx}": [
"bash -c 'yarn check-types'",
"yarn lint"
]
}
}
yarn workspaceはこのように書くとservices配下のディレクトリを走査し、発見したpackage.jsonをworkspaceとして認識します。workspaceにnpmパッケージを追加する場合、yarn workspace [ワークスペース名] add [パッケージ名]
などとしますが、このときワークスペース名はディレクトリ名ではなくpackage.jsonのname
を指定します。
{
"workspaces": {
"packages": [
"services/*"
],
}
ワークスペースに共通するdevDependenciesはROOT/package.json
に追加してワークスペース間で共有しています。このときはyarn add -W [パッケージ名]
とします。
{
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.5.0",
"@typescript-eslint/parser": "^5.5.0",
"eslint": "^8.3.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.1.1",
"prettier": "^2.5.0",
"rimraf": "^3.0.2",
"serverless": "^2.67.0",
"serverless-bundle": "^5.2.0",
"serverless-dotenv-plugin": "^3.10.0",
"serverless-offline": "^8.3.1",
"ts-loader": "^9.2.6",
"typescript": "^4.5.2",
"serverless-deployment-bucket": "^1.5.1",
"serverless-s3-sync": "^1.17.3"
}
}
.eslintrc.jsなども共有したかったので、lintコマンドもROOT/package.json
に書きました。両方のワークスペースを一気にlint&prettierしてくれるので便利です。
{
"scripts": {
"lint": "yarn backend:lint && yarn frontend:lint",
"backend:lint": "eslint --fix --ext .ts,.tsx,.js,.jsx services/backend/src",
"frontend:lint": "eslint --fix --ext .ts,.tsx,.js,.jsx services/frontend/src"
},
}
frontendのデプロイ。SPAやらJavascript SDKやら
frontendはreact等々のSPAや、javascript SDK的にちょっとしたタグ配信ができるように静的ファイルを配信することを想定しています。Serverless frameworkはdeployの仕組みだけを使っているイメージです。
静的ファイルのデプロイにはserverless-s3-syncプラグインを使い、distディレクトリ配下のファイルを任意のs3バケットにアップロードしています。s3バケットのpublic_readは推奨されていないので適宜CloudFrontなど追加してください。
plugins:
- serverless-dotenv-plugin
- serverless-s3-sync
- serverless-deployment-bucket
custom:
defaultStage: dev
defaultRegion: ${env:AWS_REGION}
deploymentBucket:
name: ${self:service}-deployment
serverSideEncryption: AES256
dotenv:
path: ../../env
publishBucketName: ${self:service}-pub-${self:provider.stage}
s3Sync:
- bucketName: ${self:custom.publishBucketName}
localDir: dist
resources:
Resources:
S3Bucket:
Type: AWS::S3::Bucket
Properties:
AccessControl: PublicRead
WebsiteConfiguration:
IndexDocument: index.html
ErrorDocument: error.html
BucketName: ${self:custom.publishBucketName}
DeletionPolicy: Retain
backendの方はnodeで動くのでtsconfig.jsonのcompilerOptions.targetはESNextにしていますが、frontendはブラウザが対象になるのでそれだと色々不都合があります。特にIE11対応など。そのため、frontendの方はwebkpack+babelでIE11をターゲットにしたトランスパイルをします。
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"ie": 11
},
"corejs": 3,
"useBuiltIns": "usage"
}
],
["@babel/preset-typescript"]
],
"plugins": [
"@babel/proposal-class-properties",
"@babel/proposal-object-rest-spread"
]
}
typescriptをwebpackする場合、babel-loader単体だと型チェックまでしてくれないようです。typescriptをbabelでビルドしつつlintや型チェックもやりたいを参考にts-loaderを追加します。rules.N.useのbabel-loaderの下にts-loaderを追記しています。
const path = require('path');
module.exports = {
entry: {
bundle: './src/app.ts',
},
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].min.js',
},
resolve: {
extensions: ['.ts', '.js', '.json'],
},
devServer: {
static: {
directory: path.join(__dirname, 'dist'),
},
port: 9000,
},
module: {
rules: [
{
test: /\.(ts|js)$/,
exclude: /node_modules/,
use: [{ loader: 'babel-loader' }, { loader: 'ts-loader' }],
},
],
},
};
フロントエンド側のpackage.jsonはこんな感じです。webpackコマンドでビルドし、sls deployでデプロイします。
{
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "app.ts",
"scripts": {
"dev": "webpack serve --mode=development",
"build:dev": "webpack --mode=development",
"build:prd": "webpack --mode=production",
"deploy:dev": "yarn build:prd && sls deploy --stage dev",
"deploy:prd": "yarn build:prd && sls deploy --stage prd"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.16.0",
"@babel/plugin-proposal-class-properties": "^7.16.0",
"@babel/plugin-proposal-object-rest-spread": "^7.16.0",
"@babel/preset-env": "^7.16.4",
"@babel/preset-typescript": "^7.16.0",
"babel-loader": "^8.2.3",
"core-js": "^3.19.2",
"webpack": "^5.64.4",
"webpack-cli": "^4.9.1",
"webpack-dev-server": "^4.6.0"
}
}
試しにローカルでyarn workspace frontend build:dev
とやると、dist/
ディレクトリ以下にjsファイルが生成されていると思います。このファイルがそのままS3に配置され、インターネットに公開されるイメージです。
今回のfrontendはjavascriptタグを想定していて、外部から呼び出すためにブラウザのwindowオブジェクトにプロパティを生やしたい感じでした。tsconfigには継承機能があるので、DOMライブラリを読み込んでおきます。
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./",
"lib": ["ESNext", "DOM"],
"outDir": "dist"
}
}
eslintとtsconfig
ROOT直下に置いてあるものを両方のワークスペースで利用します。tsconfigは継承した上で一部の値をオーバーライドしています。特に変わったことはしていません。
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'prettier',
],
parser: '@typescript-eslint/parser',
parserOptions: {
sourceType: 'module',
project: ['./tsconfig.json'],
},
plugins: ['@typescript-eslint'],
rules: {},
};
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"removeComments": true,
"preserveConstEnums": true,
"strict": true,
"alwaysStrict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowJs": false,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"exclude": ["node_modules", "**/*.test.ts"]
}
ROOT直下のpackage.jsonファイルにこのようなscriptを書くことで、ワークスペースを横断してlintや型チェックができます。huskyでgit-hookに引っ掛けています。tscがbash -c
になっているのは、git-hookの実行時にパスが通っておらずtsconfigなども無視されるので都合が悪いからです。参考
{
"scripts": {
"prepare": "husky install",
"lint": "eslint --fix --ext .ts,.tsx,.js,.jsx services/*/src",
"check-types": "tsc --noEmit"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx}": [
"bash -c 'yarn check-types'",
"yarn lint"
]
}
}
終わりに
色々変更した上でgit pushすれば、backendとfrontendが自動的にデプロイされるサーバーレスな一式ができましました。codepipelineも含めて全てが従量課金のインフラなので非常にヘルシーにサービスが作れてよいですね。Typescriptの開発環境をちゃんと作るのも初体験ですが、型安全の恩恵に加えて、10数年前にeclipse+javaでエンジニアキャリアをスタートした私としてはvscodeによる補完バリバリな開発体験はクセになりそうです。
Supershipではプロダクト開発やサービス開発に関わる人を絶賛募集しております。Typescript+Next.js+Nest.jsのようなモダンなnode開発プロジェクトもありますので、ご興味がある方は以下リンクよりご確認ください。
Supership株式会社 採用サイト
是非ともよろしくお願いします。