monorepoで複数のServerless frameworkプロジェクトをcodepipelineでサクッとデプロイする


sanojimaruです。
[Supership advent calendar 2021](https://qiita.com/advent-calendar/2021/supership)の8日目の記事です。

# 概要

新しいサービスをサクッと立ち上げる際に、シンプルなコマンド数回でデプロイできるamplifyは非常に強力な選択肢です。
しかしamplifyだけでは認証やユーザー情報のまわりで痒いところに手が届かないケースがあり、自前でサクッとサービスを立ち上げられるboilerplate的なものを作りました。

https://github.com/sanojimaru/serverless-multiservice-example

よくあるバックエンドAPI+フロントエンドSPA構成の場合、serverless frameworkとserverless-s3-syncなどのプラグインを組み合わせることで単一のパッケージで実現可能です。今回はバックエンドAPI+フロントエンドSPA+独立したウィジェット、といった構成を想定しmonorepoにすることで、ウィジェットのみnpmパッケージとして提供したり、機能毎にbuild targetを変えてウィジェットのみIE11にも対応させる、など柔軟な対応を可能にしました。

ざっくり中身の概要は下記の通りです。

1. バックエンドとしてTypescript + express + serverless framework
1. フロントエンドとして素のTypescript + serverless framework
1. yarn workspaceを利用したmonorepo構成
1. eslint, tsconfig, dotenvを各サービスで共有
1. 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の構築

実際のテンプレートがこちらです。

```yml:codepipeline.yml
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つの引数を取ります。

```yml:codepipeline.yml
  ServiceName:
    Description: serverless framework deploy pipeline example
    Type: String
    Default: serverless-framework-deploy-pipeline
```

このテンプレートで構築する各サービスのprefixになるキーワード。基本的にはサービス名。

```yml:codepipeline.yml
  Env:
    Type: String
    Default: dev
    AllowedValues:
      - dev
      - prd
```

githubのリポジトリID。`githubユーザー名/リポジトリ名`。

```yml:codepipeline.yml
  GithubRepositoryId:
    Type: String
```

AWS CodesuiteのGithub Connection ARN。`arn:aws:codestar-connections:ap-northeast-1:757134671900:connection/xxxxx`

```yml:codepipeline.yml
  GithubConnectionArn:
    Type: String
```

デプロイするgithubリポジトリのブランチ名。dev環境は`development`, production環境は`main`など。

```yml:codepipeline.yml
  GithubRepositoryBranchName:
    Type: String
    Default: develop
    AllowedValues:
      - develop
      - main
```

このstackをdeployする簡単なスクリプトもあります。
第一引数にstackの環境名（=DEPLOY_ENV）をセットします。
引数を省略するとDEPLOY_ENVにdevがセットされます。

```shell:bin/cfn-deploy.sh
#!/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環境のときでデプロイするブランチを分ける仕込みを忘れましたが投稿予定日が迫っているので何となく無視して進めます。

```shell:env/.env.local
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の方が楽じゃない？と思いましたが迷わず続けます。

```shell-session
$ 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`を入れるとよいです。

```yml:buildspec.yml
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を使ったパッケージ管理

今回２つのサービスいずれもServerless framework + Typescriptなので、共有できるパッケージはworkspaceで管理するようにします。

```json:package.json
{
  "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`を指定します。

```json
{
  "workspaces": {
    "packages": [
      "services/*"
    ],
}
```

ワークスペースに共通するdevDependenciesは`ROOT/package.json`に追加してワークスペース間で共有しています。このときは`yarn add -W [パッケージ名]`とします。

```json
{
  "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してくれるので便利です。

```json
{
  "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など追加してください。


```yml:services/frontend/serverless.yml
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をターゲットにしたトランスパイルをします。

```json:services/frontend/.babelrc
{
  "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や型チェックもやりたい](https://qiita.com/tmzkysk/items/35ef6be9b5162a6bf2fe)を参考にts-loaderを追加します。rules.N.useのbabel-loaderの下にts-loaderを追記しています。

```js:services/frontend/webpack.config.js
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でデプロイします。


```json:services/frontend/package.json
{
  "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ライブラリを読み込んでおきます。

```json:services/frontend/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "baseUrl": "./",
    "lib": ["ESNext", "DOM"],
    "outDir": "dist"
  }
}
```

# eslintとtsconfig

ROOT直下に置いてあるものを両方のワークスペースで利用します。tsconfigは継承した上で一部の値をオーバーライドしています。特に変わったことはしていません。

```js:eslintrc.js
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: {},
};
```

```json
{
  "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なども無視されるので都合が悪いからです。[参考](https://github.com/okonet/lint-staged/issues/825)

```json:package.json
{
  "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の開発環境をちゃんと作るのも初体験ですが、型安全の恩恵に加えて、１０数年前にeclipse+javaでエンジニアキャリアをスタートした私としてはvscodeによる補完バリバリな開発体験はクセになりそうです。

Supershipではプロダクト開発やサービス開発に関わる人を絶賛募集しております。Typescript+Next.js+Nest.jsのようなモダンなnode開発プロジェクトもありますので、ご興味がある方は以下リンクよりご確認ください。

 [Supership株式会社 採用サイト](https://supership-hd.jp/recruit/)
是非ともよろしくお願いします。