皆さんこんにちは、急に寒くなりましたね。おっくんです。
虎の穴ラボ Advent Calendar 2021 - Qiitaと Deno | Advent Calendar 2021 - Qiitaの8日目の記事です。
7日目は、はっとりさんによるコンテナ開発に必須! VSCode拡張機能 Remote - Containers、
@access3151fq さんによる【Deno】標準ライブラリを使ってテスタブルなサーバーを書くでした。
こちらもぜひご覧ください。
Deno の本番運用に向けて、最近はいろいろ試しています。
今回は、AWS Lambda 関数 で Deno を動かし、簡単な基本的なデータの CRUD 処理が動作する REST API を作成します。
併せて、データベースのマイグレーションなどの操作もすべて Deno で行い、Github Actions で自動化します。
参考
- AWS - コンテナイメージを使用して Go Lambda 関数をデプロイする
- Github - hayd/deno-lambda
- dockerhub - hayd/deno-lambda
- Github - revgum/serverless_oak
- Github - aws-actions/configure-aws-credentials
- Github - aws-actions/amazon-ecr-login
- Qiita - GitHub Actions でリリースする
- AWS CLI Command Reference - update-function-code
- Github - halvardssm/deno-nessie
- Github - denodrivers/mysql
- Github - manyuanrong/sql-builder
- ヤマムギ - AWS Lambda を VPC 設定したときに「The provided execution role does not have permissions to call CreateNetworkInterface on EC2」
実装と構築
今回の REST API の作成に当たっては、以下の段階を踏みながら説明します。
- AWS Lambda 関数 用のコンテナイメージの選定
- AWS Lambda 関数 用のコンテナイメージの作成と簡単な実装
- AWS Lambda 関数 用のコンテナイメージのデプロイ
- API Gateway を調整
- AWS Lambda 関数のパスパラメータ対応
- データベースの利用と、マイグレーション
- AWS Lambda 関数 と データベース の連携
- GitHub Actions で、マイグレーション
- 本実装
- 動作確認
- AWS Lambda 関数 を調整
それでは、順を追って解説します。
AWS Lambda 関数 用のコンテナイメージの選定
AWS Lambda 関数 には、コードだけではなくコンテナイメージとしてのデプロイも可能です。
AWS - コンテナイメージを使用して Go Lambda 関数をデプロイするには、
ベースの Docker イメージとして、public.ecr.aws/lambda/provided:al2
を使用し、Go アプリケーションのデプロイについて書かれています。
この例に則り、public.ecr.aws/lambda/provided:al2
をベースとした Deno アプリケーションのコンテナを作成しデプロイできれば、動作させられるはずです。
そのうえで、 deno.land/x で、lambda で検索すると、 deno on AWS Lambdaというものが公開されています。
deno.land/xで公開されているものは、ライブラリ群だけですが、GitHub の
hayd/deno-lambdaでは、Docker コンテナの利用例が公開されています。
この Docker イメージは、public.ecr.aws/lambda/provided:al2
をベースに作成されています。
念のため、docker history でも確認してみます。
$ docker pull public.ecr.aws/lambda/provided:al2 $ docker pull hayd/deno-lambda docker history public.ecr.aws/lambda/provided:al2 --no-trunc IMAGE CREATED CREATED BY SIZE COMMENT sha256:b228d8c47d2feb64236c056967f90f61a550b162473c7673a2cb7d4b84181e7b 9 days ago ENTRYPOINT [ "/lambda-entrypoint.sh" ] 0B <missing> 9 days ago ENV LAMBDA_RUNTIME_DIR=/var/runtime 0B <missing> 9 days ago ENV LAMBDA_TASK_ROOT=/var/task 0B <missing> 9 days ago ENV LD_LIBRARY_PATH=/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib 0B <missing> 9 days ago ENV PATH=/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin 0B <missing> 9 days ago ENV TZ=:/etc/localtime 0B <missing> 9 days ago ENV LANG=en_US.UTF-8 0B <missing> 9 days ago WORKDIR /var/task 0B <missing> 9 days ago ADD file:a255bd5066e818e03b2981273d1021319564f22f1802f433259f39f6327858a5 / 8.2MB <missing> 9 days ago ADD file:58f1486b5ac294f83f3b0fbc58ca7662258fa652983f174cbee214581279c2dd / 397B <missing> 9 days ago ADD file:8e10ebd36716b0298cef45a9b36d1e72a92a0909ce3cf5c270d3d3dc2a382848 / 816kB <missing> 9 days ago ADD file:971324dd0103ce69ea34f36783c652aaf43c7ff062753a39f386a214ed536d89 / 295MB <missing> 9 days ago ARCHITECTURE amd64 0B docker history hayd/deno-lambda --no-trunc IMAGE CREATED CREATED BY SIZE COMMENT sha256:21e02867e6a08807599c40950a505e1f1ee7ae07152c6cac499f86286916f5c4 8 days ago /bin/sh -c yum install -q -y unzip [省略] 95.2MB <missing> 8 days ago /bin/sh -c #(nop) ADD file:53644fddbb3c6351485a12f20096d1a6d31dd683e1732b89c9926e09da83fd0c in /var/runtime/bootstrap 9.05kB <missing> 8 days ago /bin/sh -c #(nop) ENV DENO_INSTALL_ROOT=/usr/local 0B <missing> 8 days ago /bin/sh -c #(nop) ENV DENO_DIR=.deno_dir 0B <missing> 8 days ago /bin/sh -c #(nop) ENV DENO_VERSION=1.16.0 0B <missing> 9 days ago ENTRYPOINT [ "/lambda-entrypoint.sh" ] 0B <missing> 9 days ago ENV LAMBDA_RUNTIME_DIR=/var/runtime 0B <missing> 9 days ago ENV LAMBDA_TASK_ROOT=/var/task 0B <missing> 9 days ago ENV LD_LIBRARY_PATH=/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib 0B <missing> 9 days ago ENV PATH=/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin 0B <missing> 9 days ago ENV TZ=:/etc/localtime 0B <missing> 9 days ago ENV LANG=en_US.UTF-8 0B <missing> 9 days ago WORKDIR /var/task 0B <missing> 9 days ago ADD file:a255bd5066e818e03b2981273d1021319564f22f1802f433259f39f6327858a5 / 8.2MB <missing> 9 days ago ADD file:58f1486b5ac294f83f3b0fbc58ca7662258fa652983f174cbee214581279c2dd / 397B <missing> 9 days ago ADD file:8e10ebd36716b0298cef45a9b36d1e72a92a0909ce3cf5c270d3d3dc2a382848 / 816kB <missing> 9 days ago ADD file:971324dd0103ce69ea34f36783c652aaf43c7ff062753a39f386a214ed536d89 / 295MB <missing> 9 days ago ARCHITECTURE amd64 0B
https://github.com/hayd/deno-lambda/blob/master/docker/base.dockerfileに記載されている、
public.ecr.aws/lambda/provided:al2
への操作が記載されているのが確認できます。
この、hayd/deno-lambda を使って、AWS Lambda 関数 へのデプロイを進めていきます。
AWS Lambda 関数 用のコンテナイメージの作成と簡単な実装
選定したhayd/deno-lambdaを使用して、手始めにドキュメントを参考にしながら動作確認を行います。
後ほど、 GitHub Actions を使うので、リポジトリを作成し、任意のディレクトリにクローンしてください。
次の Dockerfile と、hello.ts を作成します。
[Dockerfile]
FROM hayd/deno-lambda:1.6.1 COPY hello.ts . RUN deno cache hello.ts CMD ["hello.handler"]
[hello.ts]
// ソースは、https://github.com/hayd/deno-lambda/blob/master/example-docker-container/hello.ts にあるものそのまま import { APIGatewayProxyEventV2, APIGatewayProxyResultV2, Context, } from "https://deno.land/x/lambda/mod.ts"; // deno-lint-ignore require-await export async function handler( event: APIGatewayProxyEventV2, context: Context ): Promise<APIGatewayProxyResultV2> { return { statusCode: 200, headers: { "content-type": "text/html;charset=utf8" }, body: `Welcome to deno ${Deno.version.deno} 🦕`, }; }
続けて次の操作を行います。
$ docker build -t tmp_build . # <= tmp_build は任意のタグ名でOKです $ docker run -it -p 8080:8080 tmp_build
コンテナが起動しますので、任意の REST クライアントで、http://localhost:8080/2015-03-31/functions/function/invocations
に {"payload":"[この部分はなんでもいい]"}
を JSON として載せた POST リクエストを送って下さい
curl では、次のようになります。
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"payload":"hello world!"}'
後々作成するアプリケーションは hello に当たるような最初期の確認用途ではなくなるので、この時点で Dockerfile を修正し、app.tsをエントリポイントにするようにしておきましょう。
- 実行対象のスクリプト名を変更
- コンテナ内へのファイルコピーを hello.ts から、すべてのファイルに変更
変更した、Dockerfile は次のようになります。
FROM hayd/deno-lambda:1.6.1 COPY . . RUN deno cache app.ts CMD ["app.handler"]
以降は、この Dockerfile を使用します。
AWS Lambda 関数 用のコンテナイメージのデプロイ
ローカル環境で、Docker コンテナを使い AWS Lambda 関数 としての実行を確認できましたので、実際に AWS Lambda 関数 にデプロイを行います。
ここでは、次のことを順に行います。
- AWS IAM ユーザーを作成
- AWS ECR(Elastic Container Registry) にリポジトリを作成
- GitHub に Actions secrets を登録
- GitHub Actions を設定(ECR への push まで)
- AWS Lambda 関数の作成
- GitHub Actions を設定(AWS Lambda 関数の更新まで)
では、始めていきます。
AWS IAM ユーザーを作成
AWS IAM に新たなユーザーを作成し、次の 2 つのポリシーを付与してください。
- AmazonEC2ContainerRegistryPowerUser
- AWSLambda_FullAccess
ユーザー名はなんでも構いませんが、今回は、lambda-deploy-user
としました。
作成時に発行される、キーとシークレットキーは後ほど使うので控えておきます。
AWS ECR(Elastic Container Registry) にリポジトリを作成
AWS ECR は、コンテナイメージの保管先になります。
こちらに今回使用するリポジトリを作成します。
名前はなんでも構いません。むやみに公開する必要も無いと思いますので、プライベートにだけしておきましょう。
今回は、lambda-deno
としました。
GitHub に Actions secrets を登録
GitHub に Actions secrets を登録します。 ここでは、4 つのシークレットを登録します。
- AWS_ACCESS_KEY_ID:IAM ユーザー作成時のキー
- AWS_SECRET_ACCESS_KEY:IAM ユーザー作成時のシークレットキー
- AWS_ECR_REPO_NAME:AWS ECR に作成したリポジトリ名(今回は、
lambda-deno
) - LAMBDA_FUNCTION_NAME:後ほど作成する AWS Lambda 関数名(今回は、
lambda-deno-func
とします)
GitHub Actions を設定(ECR への push まで)
用意が整ったので、GitHub Actions を使用し、ECR にコンテナイメージを push します。
.github\workflows\deploy.yml
を作成します。
[.github\workflows\deploy.yml]
name: AWS ECR Image Push & Lambda function deploy on: push: tags: - deploy-* jobs: build-and-push: runs-on: ubuntu-18.04 timeout-minutes: 30 steps: # ソースをチェックアウト - uses: actions/checkout@v1 # AWS クレデンシャルの設定 - name: Configure AWS credentials from Test account uses: aws-actions/configure-aws-credentials@v1 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ap-northeast-1 # ECR へログイン - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v1 # ECR へ Push - name: AWS ECR push id: ecr-push env: ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} ECR_REPOSITORY: ${{ secrets.AWS_ECR_REPO_NAME }} run: | IMAGE_TAG=$(echo ${{ github.ref }} | sed -e "s#refs/tags/##g") docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" # echo "::set-output name=hoge::fuga すると次以降のstepで step.ecr-push.hoge.outputs.fuga の形式で使えるので出力しておく
これを作成し、次のように操作すると GitHub Actiins が動きます。
git add -A git commit -m "GitHub Actions 設定を追加" git push git tag deploy-0.0.1 git push --tags
GitHub Actions 画面で成功できていることを確認したら、ECR のリポジトリを確認します。 イメージが登録されています。(キャプチャしたときは何回か実行した後でした。)
AWS Lambda 関数の作成
Docker イメージが、AWS ECR に登録されたので、AWS Lambda 関数 を作成します。 次の内容を設定して、作成します。
- 「コンテナイメージ」を選択
- 先に決めておいた、
lambda-deno-func
を関数名に指定 - コンテナイメージ URI に「イメージを参照」で、ECR に登録したイメージを指定
続けて、「トリガーを追加」で、「API GateWay」を選択し、次の設定を行い、追加します。
- API タイプ:HTTP API を選択
- セキュリティ:オープンを選択(本番運用の際は検討が必要でしょう。)
作成できると、API エンドポイントが記載されているので、アクセスしてみます。
ローカルで、REST クライアントを使い確認したのと同じ出力が確認できます。
GitHub Actions を設定(AWS Lambda 関数の更新まで)
AWS Lambda 関数の動作確認まで出来たので、GitHub Actions で、AWS Lambda 関数 の更新まで行ってみます。
[.github\workflows\deploy.yml(AWS Lambda 関数の更新まで)]
name: AWS ECR Image Push & Lambda function deploy on: push: tags: - deploy-* jobs: build-and-push: runs-on: ubuntu-18.04 timeout-minutes: 30 steps: # ソースをチェックアウト - uses: actions/checkout@v1 # AWS クレデンシャルの設定 - name: Configure AWS credentials from Test account uses: aws-actions/configure-aws-credentials@v1 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ap-northeast-1 # ECR へログイン - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v1 # ECR へ Push - name: AWS ECR push id: ecr-push env: ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} ECR_REPOSITORY: ${{ secrets.AWS_ECR_REPO_NAME }} run: | IMAGE_TAG=$(echo ${{ github.ref }} | sed -e "s#refs/tags/##g") docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" # echo "::set-output name=hoge::fuga すると次以降のstepで step.ecr-push.hoge.outputs.fuga の形式で使えるので出力しておく # AWS Lambda 関数の更新 <= 追加部分 - name: Lambda function deploy id: lambda-function-deploy env: LAMBDA_FUNCTION_NAME: ${{ secrets.LAMBDA_FUNCTION_NAME }} LAMBDA_CONTAINER_IMAGE: ${{ steps.ecr-push.outputs.image }} run: | echo $LAMBDA_FUNCTION_NAME echo $LAMBDA_CONTAINER_IMAGE aws lambda update-function-code --function-name $LAMBDA_FUNCTION_NAME --image-uri $LAMBDA_CONTAINER_IMAGE
併せて、デプロイしたものとの差がわかるように app.ts は任意の書き換えを行っておくといいでしょう。 作成できたら、再度 push します。
git add -A git commit -m "GitHub Actions で AWS Lambda 関数の更新設定を追加" git push git tag deploy-0.0.2 git push --tags
アクセスしてみて、変更内容が反映されていれば OK です。
API Gateway を調整
現在は、/default/lambda-deno-func
のような関数名のパスでのアクセスしかコンテナ側に転送してくれません。
任意のパスでもアクセスできるように、API Gateway の調整を行います。
lambda-deno-func
のリソースを削除します。
新しいリソースを作成します。
- リソース名:任意
- リソースパス:
{}
でくくって+
をつければパス名は任意{param+}
のようになればいい。
ANY メソッドの作成で、先に作成している AWS Lambda 関数 lambda-deno-func
を指定する。
ここまで出来たら API をデプロイします。
すると、/default/hoge/fuga
、/default/foo/bar
のように任意のパスにアクセスしてもコンテナから応答が返ってくるようになります。
AWS Lambda 関数のパスパラメータ対応
アプリケーションもパスパラメータに対応するようにします。 今回は、URLPattern を使用し実装します。
実装内容は次の通りです。 [app.ts(パスパラメータ対応)]
import { APIGatewayProxyEvent, Context, } from "https://deno.land/x/lambda/mod.ts"; export async function handler(event: APIGatewayProxyEvent, context: Context) { const path = event.path; const method = event.httpMethod; const body = event.body; const message: { path: string, param: string | boolean | undefined, } = { path: "", param: "", }; if ( method === "GET" && new URLPattern({ pathname: "/a/:id" }).test({ pathname: path }) ) { message.path = "GroupA"; message.param = new URLPattern({ pathname: "/a/:id" })?.exec({ pathname: path, })?.pathname?.groups?.id; } else if ( method === "POST" && new URLPattern({ pathname: "/b/:id" }).test({ pathname: path }) ) { message.path = "GroupB"; message.param = new URLPattern({ pathname: "/b/:id" })?.exec({ pathname: path, })?.pathname?.groups?.id; } else { message.path = "GroupC"; } return { body: `path:${path} method:${method} message.path = ${message.path} message.param = ${message.param}`, headers: { "content-type": "text/html;charset=utf8" }, statusCode: 200, }; }
実装できたら、AWS Lambda 関数を更新します。 REST クライアントを使用して、動作確認します。 次のように振り分けされます。
メソッド | パス | 結果 |
---|---|---|
GET | /a/1234 | path:/a/1234 method:GET message.path = GroupA message.param = 1234 |
POST | /b/5678 | path:/a/5678 method:POST message.path = GroupB message.param = 5678 |
- | - | path:[任意] method:[任意] message.path = GroupC message.param = |
データベースの利用と、マイグレーション
パスパラメータに対応したので、引き続き、データベースとの接続を行います。
ローカルでの開発用に次のファイルを用意します。
- docker-compose.yml
- MigrationDockerfile
- .env
[docker-compose.yml]
version: "3" services: migration: build: dockerfile: ./MigrationDockerfile networks: - default privileged: true entrypoint: - /sbin/init volumes: - .:/usr/src/app:cached tty: true db: image: mysql:5.7 command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci ports: - "${MYSQL_PORT:-3306}:3306" volumes: - ./db/mysql_data:/var/lib/mysql environment: - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-password_root}
[MigrationDockerfile]
# Deno 1.15を使います、後ほど使う dotenv がDeno 1.16 環境で上手く動かなかったため FROM denoland/deno:centos-1.15.0 RUN yum update -y && \ yum install -y wget which systemd-sysv crontabs && \ yum install -y git tar gcc gcc-c++ make openssl-devel zlib-devel sqlite sqlite-devel bzip2 readline-devel mysql-devel mysql redis RUN mkdir /usr/src/app WORKDIR /usr/src/app
[.env]
MYSQL_PORT=3309 DATABASE_HOST=db DATABASE_USER=[任意] DATABASE_PASSWORD=[任意] DATABASE_NAME=test_app
作成できたら、次の操作でコンテナに入り nessie を導入します。
nessieは、Deno 向けのマイグレーションツールです。
$ docker-compose up -d $ docker compose exec migrate bash $ deno install --unstable --allow-net=db:3306,deno.land --allow-env --allow-read=. --allow-write=nessie.config.ts,db -f https://deno.land/x/nessie/cli.ts $ nessie init --dialect mysql
nessie.config.ts が作成されているので、次のように書き換えます。
[nessie.config.ts]
import "https://deno.land/x/dotenv/load.ts"; import { ClientMySQL, NessieConfig, } from "https://deno.land/x/nessie@2.0.1/mod.ts"; const client = new ClientMySQL({ hostname: Deno.env.get("DATABASE_HOST"), port: 3306, username: Deno.env.get("DATABASE_USER"), password: Deno.env.get("DATABASE_PASSWORD"), db: Deno.env.get("DATABASE_NAME"), }); /** This is the final config object */ const config: NessieConfig = { client, migrationFolders: ["./db/migrations"], seedFolders: ["./db/seeds"], }; export default config;
追々、RDS にマイグレーションすることもあるのでパラメーターは一通り、環境変数にしておきます。 続けて、マイグレーションファイルを作りましょう。
$ nessie make:migration create_items Created migration /usr/src/app/db/migrations/[数字列]_create_items.ts
db/migrations/[数字列]_create_users.ts が作成されるので、次のように書き換えます
[db/migrations/[数字列]_create_users.ts(修正後))]
import { AbstractMigration, Info, ClientMySQL, } from "https://deno.land/x/nessie@2.0.1/mod.ts"; export default class extends AbstractMigration<ClientMySQL> { /** Runs on migrate */ async up(info: Info): Promise<void> { await this.client.query( "CREATE TABLE items (id int AUTO_INCREMENT, name varchar(256), INDEX(id))" ); } /** Runs on rollback */ async down(info: Info): Promise<void> { await this.client.query("DROP TABLE items"); } }
データベースの作成は、nessie が見てくれない部分です。 先にデータベースを作っておきます。 続けて、マイグレーションを行います。
nessie migrate
これで、テーブルが作成されました。
AWS Lambda 関数 と データベース の連携
それでは、AWS Lambda 関数の実装に戻ります。 AWS Lambda 関数用コンテナのアプリケーション から データベースに接続する仮実装を行います。
データベースの接続には、denodrivers/mysqlを使用します。 github.com
[app.ts(データベース接続仮実装)]
import "https://deno.land/x/dotenv@v2.0.0/load.ts"; import { APIGatewayProxyEvent, Context, } from "https://deno.land/x/lambda/mod.ts"; import { Client } from "https://deno.land/x/mysql/mod.ts"; import { configLogger } from "https://deno.land/x/mysql/mod.ts"; // Item 型定義 interface Item { id: number; name: string; } // コネクションの作成 const connectionParam = { hostname: Deno.env.get("DATABASE_HOST") as string, username: Deno.env.get("DATABASE_USER") as string, password: Deno.env.get("DATABASE_PASSWORD") as string, db: Deno.env.get("DATABASE_NAME") as string, }; const client = await new Client().connect(connectionParam); export async function handler(event: APIGatewayProxyEvent, context: Context) { const path = event.path; const method = event.httpMethod; const body = event.body; const items: Item[] = await client.query(`select * from items`); const message: { path: string; param: string | boolean | undefined; } = { path: "", param: "", }; if (method === "GET" && new URLPattern({ pathname: "/a/:id" }).test({ pathname: path })) { message.path = "GroupA"; message.param = new URLPattern({ pathname: "/a/:id" })?.exec({ pathname: path, })?.pathname?.groups?.id; } else if (method === "POST" && new URLPattern({ pathname: "/b/:id" }).test({ pathname: path })) { message.path = "GroupB"; message.param = new URLPattern({ pathname: "/b/:id" })?.exec({ pathname: path, })?.pathname?.groups?.id; } else { message.path = "GroupC"; message.param = JSON.stringify(items) } return { body: `//path:${path} method:${method} message.path = ${message.path} message.param = ${message.param}`, headers: { "content-type": "text/html;charset=utf8" }, statusCode: 200, }; }
すべてのリクエストで、データベースにアクセスし、パターンマッチできなかったときに結果を返します。
実行環境に mysql クライアントのインストールが必要なので、Dockerfile を修正します。
[Dockerfile]
FROM hayd/deno-lambda:1.16.0 # 必要そうなものをまとめて導入 RUN yum update -y && \ yum install -y tar gcc gcc-c++ make openssl-devel zlib-devel bzip2 readline-devel mysql-devel mysql COPY . . RUN deno cache app.ts CMD ["app.handler"]
docker compose で立ち上げた環境の docker ネットワークを確認し、AWS Lambda 関数のコンテナに --net
オプションを設定し起動します。
$ docker network ls NETWORK ID NAME DRIVER SCOPE dd6c1d0b5cd4 XXXXXXXXXXXXXXXXXXXXXXX_default bridge local bd5619c85f89 YYYYYYYYYYYYYYYYYYYYYYY_default bridge local 5467fdcea349 ZZZZZZZZZZZZZZZZZZZZZZZ_default bridge local <= これがdocker compose で立ち上げた環境の ネットワークとします $ docker build -t tmp_build . $ docker run -it -p 8080:8080 --net 5467fdcea349 tmp_build
REST クライアントで、アクセスすると、次のような結果が返ってきます。
レスポンスにデータが載って返ってくることを確認できます。
GitHub Actions で、マイグレーション
AWS Lambda 関数が、データベースに接続できることを確認したので、引き続き GitHub Actions で、マイグレーションを行います。
ここでは、次の順に進めます。
- マイグレーション先のデータベースインスタンスを作成
- GitHub Actions secrets を設定
- GitHub Actions を修正
- AWS Lambda 関数に 環境変数を設定
マイグレーション先のデータベースインスタンスを作成
ローカルの確認で、MySQL を使用したので AWS RDS も、MySQL を使用します。
- データベース作成方法を選択:簡単に作成
- エンジンのタイプ:MySQL
- DB インスタンスサイズ:無料利用枠
- DB インスタンス識別子:lambda-deno-db
- マスターユーザー名:任意
- パスワード:任意
- パブリックアクセス:パブリックアクセス可能
エンドポイント、ユーザー名、パスワードを控えておきます。
GitHub Actions secrets を設定
次のキーを設定します。
- DATABASE_HOST:RDS に作成したデータベースのエンドポイント
- DATABASE_USER:RDS に作成したデータベースのユーザー
- DATABASE_PASSWORD:RDS に作成したデータベースのパスワード
- DATABASE_NAME:test_app
GitHub Actions を修正
.github/workflows/deploy.yml を修正し、データベースの作成と、データベースのマイグレーションを行うステップを追加します。 修正したのが次のものです。
[.github/workflows/deploy.yml(データベースの作成と、マイグレーションを追加)]
name: AWS ECR Image Push & Lambda function deploy on: push: tags: - deploy-* jobs: build-and-push: runs-on: ubuntu-18.04 timeout-minutes: 30 steps: # ソースをチェックアウト - uses: actions/checkout@v1 # データベースを作成、マイグレーションを実行 - name: Create database & migration id: create-database env: DATABASE_HOST: ${{ secrets.DATABASE_HOST }} DATABASE_USER: ${{ secrets.DATABASE_USER }} DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} DATABASE_NAME: ${{ secrets.DATABASE_NAME }} run: | echo [client] > ./my.cnf echo host = $DATABASE_HOST >> ./my.cnf echo user = $DATABASE_USER >> ./my.cnf echo password = $DATABASE_PASSWORD >> ./my.cnf cat ./my.cnf chmod 600 ./my.cnf mysql --defaults-file=./my.cnf -e "CREATE DATABASE IF NOT EXISTS $DATABASE_NAME" echo DATABASE_HOST=$DATABASE_HOST >> ./.env echo DATABASE_USER=$DATABASE_USER >> ./.env echo DATABASE_PASSWORD=$DATABASE_PASSWORD >> ./.env echo DATABASE_NAME=$DATABASE_NAME >> ./.env cat ./.env echo deno install --unstable --allow-net=$DATABASE_HOST:3306,deno.land --allow-env --allow-read=. --allow-write=nessie.config.ts,db -f https://deno.land/x/nessie/cli.ts >> fullmigrate.sh echo nessie migrate >> fullmigrate.sh docker compose up -d migrate docker compose exec migrate bash fullmigrate.sh rm -y ./my.cnf rm -y ./.env rm -y ./fullmigrate.sh # AWS クレデンシャルの設定 - name: Configure AWS credentials from Test account uses: aws-actions/configure-aws-credentials@v1 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ap-northeast-1 # ECR へログイン - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v1 # ECR へ Push - name: AWS ECR push id: ecr-push env: ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} ECR_REPOSITORY: ${{ secrets.AWS_ECR_REPO_NAME }} run: | IMAGE_TAG=$(echo ${{ github.ref }} | sed -e "s#refs/tags/##g") docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" # AWS Lambda 関数の更新 <= 追加部分 - name: Lambda function deploy id: lambda-function-deploy env: LAMBDA_FUNCTION_NAME: ${{ secrets.LAMBDA_FUNCTION_NAME }} LAMBDA_CONTAINER_IMAGE: ${{ steps.ecr-push.outputs.image }} run: | echo $LAMBDA_FUNCTION_NAME echo $LAMBDA_CONTAINER_IMAGE aws lambda update-function-code --function-name $LAMBDA_FUNCTION_NAME --image-uri $LAMBDA_CONTAINER_IMAGE
ポイントになる、Create database & migration ステップだけ切り出します。
# データベースを作成、マイグレーションを実行 - name: Create database & migration id: create-database env: DATABASE_HOST: ${{ secrets.DATABASE_HOST }} DATABASE_USER: ${{ secrets.DATABASE_USER }} DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} DATABASE_NAME: ${{ secrets.DATABASE_NAME }} run: | # 環境変数を参照し、my.cnf を作成、データベースが作成されていない時に作成する echo [client] > ./my.cnf echo host = $DATABASE_HOST >> ./my.cnf echo user = $DATABASE_USER >> ./my.cnf echo password = $DATABASE_PASSWORD >> ./my.cnf chmod 600 ./my.cnf mysql --defaults-file=./my.cnf -e "CREATE DATABASE IF NOT EXISTS $DATABASE_NAME" # 環境変数を参照し、.env を作成、マイグレーションを行うコンテナで使用する echo DATABASE_HOST=$DATABASE_HOST >> ./.env echo DATABASE_USER=$DATABASE_USER >> ./.env echo DATABASE_PASSWORD=$DATABASE_PASSWORD >> ./.env echo DATABASE_NAME=$DATABASE_NAME >> ./.env # 環境変数を参照し、接続先ホストを取得して nessie をインストールするスクリプトを作成 echo deno install --unstable --allow-net=$DATABASE_HOST:3306,deno.land --allow-env --allow-read=. --allow-write=nessie.config.ts,db -f https://deno.land/x/nessie/cli.ts >> fullmigrate.sh echo nessie migrate >> fullmigrate.sh # マイグレーション実行 docker compose up -d migrate docker compose exec migrate bash fullmigrate.sh # 後でビルドするコンテナの中に含まれないように削除 rm -y ./my.cnf rm -y ./.env rm -y ./fullmigrate.sh
やり方はかなり悩みましたが、my.cnf、 .env、fullmigrate.sh を作成し実行する・実行するときに使う形を取りました。 ローカルの開発時に.env を使用していることから互換性も考慮して、このようにしています。
ここまでできたら、改めて GitHub へ push します。
git add -A git commit -m "GitHub Actions でデータベースをマイグレーション" git push git tag deploy-0.0.3 git push --tags
データベースにアクセスし、マイグレーションが行われていることを確認できていれば OK です。
AWS Lambda 関数に 環境変数を設定
アプリケーションが使用する次の 4 つの環境変数を、AWS Lambda 関数 に設定します。
- DATABASE_HOST
- DATABASE_USER
- DATABASE_PASSWORD
- DATABASE_NAME
本実装
ここまでで、GitHub Actions で、次のことができるようになりました。
- データベースマイグレーション
- AWS Lambda 関数用コンテナの登録
- AWS Lambda 関数のデプロイ
ここでは、最終的に作成したコードを紹介します。 始めにディレクトリ構成から示します。
$ tree
.
├── app
│ ├── controller
│ │ ├── base_controller.ts コントローラー機能の基礎部分を提供
│ │ ├── error_controller.ts /items に対応しないリクエストの対応用コントローラ
│ │ └── items_controller.ts /items に対応したコントローラー機能を提供
│ ├── model
│ │ ├── base.ts テーブル問い合わせ基礎機能を提供
│ │ └── item.ts items テーブルへの問い合わせ
│ └── query
│ └── item_query.ts items テーブル用クエリ作成を機能提供
├── app.ts
├── db
│ ├── migrations
│ │ └── 20211119084431_create_items.ts
│ ├── mysql_data
│ │ └── 以下ディレクトリは省略 .gitignore で管理対象から除外
│ └── seeds
├── deps.ts サードパーティモジュール管理
├── docker-compose.yml
├── Dockerfile
├── MigrationDockerfile
├── nessie.config.ts
├── router.ts ルーター機能を提供
├── type.d.ts 複数ファイルで使用する型定義
└── util.ts 便利関数登録先として使用
それぞれのファイルの詳細を確認します
deps.ts
deps.ts で外部依存しているライブラリ群を管理します。
[deps.ts]
export { APIGatewayProxyEvent, Context, } from "https://deno.land/x/lambda@1.16.0/mod.ts"; export { Query, Where } from "https://deno.land/x/sql_builder@v1.9.1/mod.ts"; export { Client as MySqlClient } from "https://deno.land/x/mysql@v2.10.1/mod.ts";
app.ts
エントリポイントとなる app.ts は、各機能を分離した実装に切り替えたのでシンプルになります。
[app.ts]
import "https://deno.land/x/dotenv@v2.0.0/load.ts"; import { APIGatewayProxyEvent, Context } from "./deps.ts"; import { LambdaRequest } from "./type.d.ts"; import { router } from "./router.ts"; import { requestMethod } from "./util.ts"; export async function handler(event: APIGatewayProxyEvent, context: Context) { const request: LambdaRequest = { path: event.path, method: requestMethod(event.httpMethod), body: event.body, }; return await router(request); }
type.d.ts
複数のファイルで使用する型定義は、type.d.ts に分離しました。
[type.d.ts]
export type Methods = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; interface LambdaRequest { method: Methods; path: string; body?: any; } interface Param { id?: number; } export { LambdaRequest, Param };
util.ts
便利関群の登録先として、util.ts を用意しました。 機能を複数作っていく場合には、utils ディレクトリを切りいくつかのファイルに分割する必要があります。
[util.ts]
import { Methods } from "./type.d.ts"; // string で渡された リクエストメソッドを定義したものでフィルタする export const requestMethod = (src: string): Methods => { if ("GET" === src) { return src; } else if ("POST" === src) { return src; } else if ("PUT" === src) { return src; } else if ("DELETE" === src) { return src; } else if ("PATCH" === src) { return src; } throw new Error("Unkown method"); };
router.ts
ルーティングとコントローラーの呼び出しを担当します。
[router.ts]
import { LambdaRequest, Param } from "./type.d.ts"; import { ItemsController } from "./app/controller/items_controller.ts"; import { ErrorController } from "./app/controller/error_controller.ts"; const router = async (request: LambdaRequest) => { const errorController = new ErrorController(request); if ( request.method === "GET" && new URLPattern({ pathname: "/items/:id" }).test({ pathname: request.path }) ) { const id = new URLPattern({ pathname: "/items/:id" })?.exec({ pathname: request.path, })?.pathname?.groups?.id; if ("string" !== typeof id) { return errorController.error(); } const param: Param = { id: Number(id), }; const controller = new ItemsController(request, param); return controller.show(); } else if ( request.method === "PATCH" && new URLPattern({ pathname: "/items/:id" }).test({ pathname: request.path }) ) { const id = new URLPattern({ pathname: "/items/:id" })?.exec({ pathname: request.path, })?.pathname?.groups?.id; if ("string" !== typeof id) { return errorController.error(); } const param: Param = { id: Number(id), }; const controller = new ItemsController(request, param); return controller.update(); } else if ( request.method === "DELETE" && new URLPattern({ pathname: "/items/:id" }).test({ pathname: request.path }) ) { const id = new URLPattern({ pathname: "/items/:id" })?.exec({ pathname: request.path, })?.pathname?.groups?.id; if ("string" !== typeof id) { return errorController.error(); } const param: Param = { id: Number(id), }; const controller = new ItemsController(request, param); return controller.destroy(); } else if ( request.method === "GET" && new URLPattern({ pathname: "/items" }).test({ pathname: request.path }) ) { const controller = new ItemsController(request); return controller.index(); } else if ( request.method === "POST" && new URLPattern({ pathname: "/items" }).test({ pathname: request.path }) ) { const controller = new ItemsController(request); return controller.create(); } else { return errorController.error(); } }; export { router };
app/controller/base_controller.ts
各コントローラーの基礎部分を提供します。
[app/controller/base_controller.ts]
import { LambdaRequest, Param } from "../../type.d.ts"; export class BaseController { readonly request: LambdaRequest; readonly param?: Param; constructor(request: LambdaRequest, param?: Param) { this.request = request; if (!!param) { this.param = param; } } }
app/controller/error_controller.ts
該当のルーティングが存在しなかったときに対応するコントローラーです。
[app/controller/error_controller.ts]
import { BaseController } from "./base_controller.ts"; export class ErrorController extends BaseController { error() { return { headers: { "content-Type": "application/json; charset=utf-8" }, statusCode: 404, }; } }
app/controller/items_controller.ts
/items のルーティングに対応した処理を行うコントローラーです。
[app/controller/items_controller.ts]
import { BaseController } from "./base_controller.ts"; import { createItem, deleteItemById, getItemById, getItems, updateItemById, Result, } from "../model/item.ts"; export class ItemsController extends BaseController { async index() { const result = await getItems(); return this.responce(result); } async show() { if (!this.param) { throw new Error("Not have param"); } if (typeof this.param.id !== "number") { throw new Error("Not have param.id"); } const result = await getItemById(this.param.id); return this.responce(result); } async create() { const body = JSON.parse(this.request.body); if (typeof body.name !== "string") { throw new Error("Not have name"); } const result = await createItem(body); return this.responce(result); } async update() { if (!this.param) { throw new Error("Not have param"); } if (typeof this.param.id !== "number") { throw new Error("Not have param.id"); } const body = JSON.parse(this.request.body); if (typeof body.name !== "string") { throw new Error("Not have name"); } const result = await updateItemById(this.param.id, body); return this.responce(result); } async destroy() { if (!this.param) { throw new Error("Not have param"); } if (typeof this.param.id !== "number") { throw new Error("Not have param.id"); } const result = await deleteItemById(this.param.id); return this.responce(result); } responce(result: Result) { return { body: JSON.stringify(result), headers: { "content-Type": "application/json; charset=utf-8" }, statusCode: 200, }; } }
app/model/base.ts
データベースへの接続機能の基礎を提供します。
[app/model/base.ts]
import { MySqlClient } from "../../deps.ts"; // コネクションの作成 const connectionParam = { hostname: Deno.env.get("DATABASE_HOST") as string, username: Deno.env.get("DATABASE_USER") as string, password: Deno.env.get("DATABASE_PASSWORD") as string, db: Deno.env.get("DATABASE_NAME") as string, }; const client = await new MySqlClient().connect(connectionParam); export { client };
app/model/item.ts
items テーブルに対する CRUD 処理を担当する関数群です。
[app/model/item.ts]
import { client } from "./base.ts"; import { queryCreateItem, queryDeleteItemById, queryGetItemById, queryGetItemsAll, queryUpdateItemById, } from "../query/item_query.ts"; export interface CreateItem { name: string; } // Item 型定義 export interface Item { id: number; name: string; } export interface Result { status: boolean; record?: Item; records?: Item[]; } const getItems = async (): Promise<Result> => { const { rows } = (await client.execute(queryGetItemsAll())) as { fields: any; rows: Item[]; }; return { status: true, records: rows }; }; const getItemById = async (id: number | string): Promise<Result> => { const { rows } = (await client.execute(queryGetItemById(id))) as { fields: any; rows: Item[]; }; return rows.length == 1 ? { status: true, record: rows[0] } : { status: false }; }; const updateItemById = async ( id: number | string, param: { name: string }, ): Promise<Result> => { const result = (await client.execute(queryUpdateItemById(id, param))) as { affectedRows: number; lastInsertId: number; }; return result.affectedRows == 1 ? { status: true, record: (await getItemById(id)).record } : { status: false }; }; const deleteItemById = async (id: number | string): Promise<Result> => { const result = (await client.execute(queryDeleteItemById(id))) as { affectedRows: number; lastInsertId: number; }; return { status: result.affectedRows == 1 }; }; const createItem = async (item: CreateItem): Promise<Result> => { const result = (await client.execute(queryCreateItem(item))) as { affectedRows: number; lastInsertId: number; }; return result.affectedRows == 1 ? { status: true, record: (await getItemById(result.lastInsertId)).record } : { status: false }; }; export { createItem, deleteItemById, getItemById, getItems, updateItemById };
app/query/item_query.ts
items テーブルへの操作 SQL を作成する機能を提供する関数群です。 SQL の作成には、manyuanrong/sql-builderを使用しています。
[app/query/item_query.ts]
import { Query, Where } from "../../deps.ts"; const queryGetItemById = (id: number | string): string => { const builder = new Query(); const sql = builder .table("items") .where(Where.field("id").eq(id)) .select("*") .build(); return sql; }; const queryUpdateItemById = ( id: number | string, param: { name: string } ): string => { const builder = new Query(); const sql = builder .table("items") .where(Where.field("id").eq(id)) .update(param) .build(); return sql; }; const queryDeleteItemById = (id: number | string): string => { const builder = new Query(); const sql = builder .table("items") .where(Where.field("id").eq(id)) .delete() .build(); return sql; }; const queryGetItemsAll = (): string => { const builder = new Query(); const sql = builder.table("items").select("*").build(); return sql; }; const queryCreateItem = (param: { name: string }): string => { const builder = new Query(); const sql = builder.table("items").insert(param).build(); return sql; }; export { queryCreateItem, queryDeleteItemById, queryGetItemById, queryGetItemsAll, queryUpdateItemById, };
ここまでのファイル群により REST API サーバーアプリケーションを構成しています。 細かいところでの調整は必要に思いますが基本的に必要な機能は提供できているのでは無いでしょうか?
動作確認
改めて GitHub に新たにタグも設定して push を行いデプロイします。 コンソールでリクエストを行い基本的 CRUD 動作を確認すると、次のようになります。
# データの登録と確認 $ curl -XGET "https://[AWS Lamda 関数エンドポイント]/default/items" { "status": true, "records": [] } $ curl -XPOST "https://[AWS Lamda 関数エンドポイント]/default/items" -d '{"name":"AAA"}' -s |jq { "status": true, "record": { "id": 22, "name": "AAA" } } $ curl -XPOST "https://[AWS Lamda 関数エンドポイント]/default/items" -d '{"name":"BBB"}' -s |jq { "status": true, "record": { "id": 23, "name": "BBB" } } $ curl -XGET "https://[AWS Lamda 関数エンドポイント]/default/items" -s | jq { "status": true, "records": [ { "id": 22, "name": "AAA" }, { "id": 23, "name": "BBB" } ] } $ curl -XGET "https://[AWS Lamda 関数エンドポイント]/default/items/22" -s | jq { "status": true, "record": { "id": 22, "name": "AAA" } } # データの更新 $ curl -XPATCH "https://[AWS Lamda 関数エンドポイント]/default/items/22" -d '{"name":"AAA-2"}' -s |jq { "status": true, "record": { "id": 22, "name": "AAA-2" } } $ curl -XGET "https://[AWS Lamda 関数エンドポイント]/default/items" -s | jq { "status": true, "records": [ { "id": 22, "name": "AAA-2" }, { "id": 23, "name": "BBB" } ] } $ curl -XGET "https://[AWS Lamda 関数エンドポイント]/default/items/22" -s | jq { "status": true, "record": { "id": 22, "name": "AAA-2" } } # データの削除 $ curl -XDELETE "https://[AWS Lamda 関数エンドポイント]/default/items/22" -s |jq { "status": true } $ curl -XGET "https://[AWS Lamda 関数エンドポイント]/default/items/22" -s | jq { "status": false } $ curl -XGET "https://[AWS Lamda 関数エンドポイント]/default/items" -s | jq { "status": true, "records": [ { "id": 23, "name": "BBB" } ] }
AWS Lambda 関数 を調整
現在の使用している データベースは、接続元 IP を制限していない パブリックアクセス許可になっており、消して褒められる状態ではありません。 AWS Lambda 関数からデータベースへの接続を内部ネットワークを使用、データベースのパブリックアクセスを不可にして解説を終わりたいと思います。
AWS Lambda 関数のロールを修正
AWS Lambda 関数の設定から、実行ロールを開きます。
AWSLambdaVPCAccessExecutionRole ポリシーをロールに追加します。
AWS Lambda 関数の設定から、VPC の編集を開きます。
次のように設定します。
- VPC:RDS の使用している VPC を指定
- サブネット:一旦すべての AZ を指定
- セキュリティグループ:一旦 VPC 内で有ればどこでも良いものとします。
パブリックアクセスを閉じる
RDS の設定から、パブリックアクセスの許可を外します。 ここまで設定すると、AWS Lambda 関数からはデータベースにアクセスできながら、外部ネットワークから直接アクセスすることは防ぐことができます。
まとめ
今回は AWS Lambda 関数を Deno が動作するカスタムコンテナで作成、データベースのマイグレーションを含めた操作も Deno を使用し、GitHub Actions でデプロイを自動化しました。
Deno を本番サービスで導入を検討するにあたり、AWS EC2 のようなインスタンスで個別に稼動、AWS ECS のようなコンテナオーケストレーションサービス、そして今回の AWS Lambda のようなサーバーレスなどの実行環境が選択肢として挙げられるかと思います。
これらを検討する方への検討の一助となれば幸いです。
これに、先んじて、個人的に AWS Fargate で Deno アプリケーションを 2 週間ほど稼動させたのですが、思ったよりも費用がかかってしまいました。 AWS Lambda でのアプリケーション開発は、リクエスト回数とリクエスト実行時間に依存したうえで無料枠でまかなうことができる部分もあり、 規模感があまり大きくなければ、Deno の実行環境として AWS Lambda はおすすめできる実行環境になると感じました。
今回の実装とインフラ構築では、デプロイ時には事前にデータベースのパブリックアクセスの許可をする必要があります。 この点まで自動化できるとより完全な自動化になります。
また今回の自動化を実現する中で、タスクの実行イメージである ubuntu-18.04 には、mysql や AWS cli もインストール済みになっていました。 他にもできることはまだまだありそうです。
明日は、@undefsan さんより、ワーケーションに関する記事と、 @biga816 さんより、「DenoでイケハヤのNFTを監視する対話型CLIを作る。」 です。
P.S.
採用情報
■募集職種
yumenosora.co.jp
カジュアル面談も随時開催中です
■お申し込みはこちら!
news.toranoana.jp
■ToraLab.fmスタートしました!
メンバーによるPodcastを配信中!
是非スキマ時間に聞いて頂けると嬉しいです。
anchor.fm
■Twitterもフォローしてくださいね!
ツイッターでも随時情報発信をしています
twitter.com