Try T.M Engineer Blog

多摩市で生息するエンジニアが「アウトプットする事は大事だ」と思って初めたブログ

試しにCopilotを使ってAWS App Runnerを動かしてみた話

はじめに

最近発表されたAWSの新サービス「AWS App Runner」

コンテナ化したウェブアプリケーションを直接デプロイできるフルマネージドなサービス。

近いところで言うとGCPの「Cloud Run」のAWS版といった感じです。

こいつは便利そうだぜ。。。というわけで、さっそく試してみることにしました。

Copilot

コンソール画面からポチポチしても良かったのですが、これに合わせてかCopilotのバージョンも上がり、「AWS App Runner」にも対応しているとのことなので、こちらも試してみることにしました。(ちなみに、私は今回初めてCopilotを使います)

Copilotは、コンテナアプリケーションのビルド、 リリース、運用を更に加速させたいという思いから産まれたCLIツールだそうです。詳細は以下を参照ください。

aws.github.io

コンテナ

今回は、簡単にNginxのコンテナをアップします。

// Dockerfile
FROM alpine:latest

# nginxのインストール
RUN apk update && \
    apk add --no-cache nginx

# ドキュメントルート
ADD app /app
ADD default.conf /etc/nginx/conf.d/default.conf

# ポート設定
EXPOSE 80

RUN mkdir -p /run/nginx

# フォアグラウンドでnginx実行
CMD nginx -g "daemon off;"
// default.conf
server {
        listen 80 default_server;
        listen [::]:80 default_server;

        root /app;

        location / {
        }
}
// app/index.html
<h1>Copilot - App Runner</h1>

デプロイ

copilot init を入力すると、名前(Application Name, Service Name)とDockerfileを指定すればデプロイが始まります。(はやぃ・・・)

$ copilot init
Note: It's best to run this command in the root of your Git repository.
Welcome to the Copilot CLI! We're going to walk you through some questions
to help you get set up with a containerized application on AWS. An application is a collection of
containerized services that operate together.

Application name: sample-app
Workload type: Request-Driven Web Service
Service name: nginx
Dockerfile: ./Dockerfile
Ok great, we'll set up a Request-Driven Web Service named nginx in application sample-app listening on port 80.

✔ Created the infrastructure to manage services and jobs under application sample-app.

✔ Wrote the manifest for service nginx at copilot/nginx/manifest.yml
Your manifest contains configurations like your container size and port (:80).

✔ Created ECR repositories for service nginx.

All right, you're all set for local development.
Deploy: Yes

✔ Linked account XXXXXXXXXXX and region ap-northeast-1 to application sample-app.

✔ Proposing infrastructure changes for the sample-app-test environment.
- Creating the infrastructure for the sample-app-test environment.       [create complete]  [83.3s]
  - An IAM Role for AWS CloudFormation to manage resources               [create complete]  [22.0s]
  - An ECS cluster to group your services                                [create complete]  [11.0s]
  - Enable long ARN formats for the authenticated AWS principal          [create complete]  [2.4s]
  - An IAM Role to describe resources in your environment                [create complete]  [22.2s]
  - A security group to allow your containers to talk to each other      [create complete]  [4.4s]
  - An Internet Gateway to connect to the public internet                [create complete]  [16.5s]
  - Private subnet 1 for resources with no internet access               [create complete]  [18.7s]
  - Private subnet 2 for resources with no internet access               [create complete]  [18.7s]
  - Public subnet 1 for resources that can access the internet           [create complete]  [18.7s]
  - Public subnet 2 for resources that can access the internet           [create complete]  [18.7s]
  - A Virtual Private Cloud to control networking of your AWS resources  [create complete]  [16.5s]
✔ Created environment test in region ap-northeast-1 under application sample-app.
Environment test is already on the latest version v1.4.0, skip upgrade.
[+] Building 2.8s (11/11) FINISHED
 => [internal] load build definition from Dockerfile                                                                                                                            0.0s
 => => transferring dockerfile: 356B                                                                                                                                            0.0s
 => [internal] load .dockerignore                                                                                                                                               0.0s
 => => transferring context: 2B                                                                                                                                                 0.0s
 => [internal] load metadata for docker.io/library/alpine:latest                                                                                                                2.0s
 => [auth] library/alpine:pull token for registry-1.docker.io                                                                                                                   0.0s
 => [1/5] FROM docker.io/library/alpine:latest@sha256:69e70a79f2d41ab5d637de98c1e0b055206ba40a8145e7bddb55ccc04e13cf8f                                                          0.0s
 => [internal] load build context                                                                                                                                               0.0s
 => => transferring context: 277B                                                                                                                                               0.0s
 => CACHED [2/5] RUN apk update &&     apk add --no-cache nginx                                                                                                                 0.0s
 => [3/5] ADD app /app                                                                                                                                                          0.0s
 => [4/5] ADD default.conf /etc/nginx/conf.d/default.conf                                                                                                                       0.0s
 => [5/5] RUN mkdir -p /run/nginx                                                                                                                                               0.5s
 => exporting to image                                                                                                                                                          0.1s
 => => exporting layers                                                                                                                                                         0.1s
 => => writing image sha256:caf72e9509ffe1e28b2070021afe000b6304b8a1fda457160f373aa2d38f4f2c                                                                                    0.0s
 => => naming to XXXXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/sample-app/nginx                                                                                             0.0s
Login Succeeded
Using default tag: latest
The push refers to repository [XXXXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/sample-app/nginx]
4056d7cdbba7: Pushed
27836a6a132d: Pushed
1d8fedb1407f: Pushed
4f56318aa220: Pushed
b2d5eeeaba3a: Pushed
latest: digest: sha256:81d1c8846ae62ca6c4e86bd5c277b65b9e5ef8ec088df9be4e4e5422c8099b89 size: 1360
✔ Proposing infrastructure changes for stack sample-app-test-nginx
- Creating the infrastructure for stack sample-app-test-nginx                     [create complete]  [290.1s]
  - An IAM Role for App Runner to use on your behalf to pull your image from ECR  [create complete]  [20.1s]
  - An IAM role to control permissions for the containers in your service         [create complete]  [20.1s]
  - An App Runner service to run and manage your containers                       [create complete]  [258.6s]
✔ Deployed nginx, you can access it at https://yrqh7trqcy.ap-northeast-1.awsapprunner.com.
$

作成されたURLに接続すると・・・見れました。

デプロイ成功です。

f:id:special-moucom:20210601094556p:plain

AWS App Runner」の設定もコンソール画面から見てみると、構成(CPU/メモリ)は最小のようですね。

f:id:special-moucom:20210601094616p:plain

Copilotは、コンテナに特化しているCLIツールだけあって、CDKを使って書くよりも全然早い。。。裏側ではCloudFormationのテンプレートを作成して、それを使ってデプロイを行っている模様。

デプロイしたリソースの削除もcopilot app deleteコマンドで一発削除。

$ copilot app ls
sample-app
$ copilot app delete --name sample-app
Are you sure you want to delete application sample-app? Yes
✔ Deleted service nginx from environment test.
✔ Deleted resources of service nginx from application sample-app.
✔ Deleted service nginx from application sample-app.
✔ Deleted environment test from application sample-app.
✔ Cleaned up deployment resources.
✔ Deleted application resources.
✔ Deleted application configuration.
✔ Deleted local .workspace file.
$

感想

AWS App Runner」は、まさにGCPの「Cloud Run」のAWS版。Copilotを使用することで、CLIによるデプロイも可能。

気になる料金ですが、「AWS App Runner」はコンテナインスタンスを0にすることはできない模様。なので、最低1つのコンテナは動き続ける分、課金が発生します。

0.009 USD/GB/時なので、0.009 * 2(最小構成が2GB) * 24時間 * 30日 = 約 13 USD/月といったところでしょう。うーん、これならGCPの「Cloud Run」の方が低コストですね。。。

でも、AWSには他の様々なサービスと連携できるメリットもあるので、「AWS App Runner」という新しい選択肢ができた。というだけでも、素晴らしい事だと思います。

今後は、ちょっと試したい!ちょっと動かして確認したい!ってな時に「AWS App Runner」を使う機会はやってくでしょう!!いやぁ、便利なサービスがまた1つできましたね(= =)ノ

Objectのデータを取得する時、ブラケット記法で書くと型がanyになって辛かった話

TypeScriptでObjectからデータを取得する時、ブラケット記法で書くと強制的に型がanyになって、大変辛い思いをしたので、以下に残しておく。

Objectのサンプルは以下の通り。ポイントは、exampleAB.a以降とexampleAB.b以降とで型が違うこと。

interface ExampleAB {
  'a': {
    a_example: {
      example: string
    }
  },
  'b': {
    b_example: {
      example: number
    }
  }
}

const exampleAB: ExampleAB = {
  'a': {
    a_example: {
      example: 'dummy'
    }
  },
  'b': {
    b_example: {
      example: 1
    }
  }
}

たとえば、以下のようにするとObjectからデータを取得することができる。

当たり前ですね。

console.log(exampleAB.a.a_example.example) // 'dummy'

しかし、これをブラケット記法で書くとTypeScript側で型を判断できずにエラーになる。

( T 0 T ) ォォォォ...

const k = 'a'
console.log(exampleAB[k as strng].a_example.example) // 型 'any' の式を使用して型 'ExampleAB' にインデックスを付けることはできないため、要素は暗黙的に 'any' 型になります。

keyofを使えばワンチャンあるかも・・・と思い、試すもダメだった。

const k = 'a'
console.log(exampleAB[k as keyof ExampleAB].a_example.example) // プロパティ 'a_example' は型 '{ a_example: { example: string; }; } | { b_example: { example: number; }; }' に存在しません。
  プロパティ 'a_example' は型 '{ b_example: { example: number; }; }' に存在しません。

どうやら、Objectの構造が深いとダメな模様。

Objectのデータ構造が、'exampleAB.a'と'exampleAB.b'までであれば、TypeScriptが判断してくれることを確認した。

interface ExampleAB2 {
  'a': string
  'b': number
}

const exampleAB2: ExampleAB2 = {
  'a': 'dummy1',
  'b': 1
}
const i = 'a'
console.log(exampleAB2[i as keyof ExampleAB2]) // 'dummy'

もうすこし、根気よくやってみよう。

「そうだ!interfaceを分けてみよう!」と思い、試してみた結果がこちら。。。

interface ExampleA {
  'a': {
    a_example: {
      example: string
    }
  }
}

interface ExampleB {
  'b': {
    b_example: {
      example: number
    }
  }
}

const exampleAB: (ExampleA | ExampleB) = {
  'a': {
    a_example: {
      example: 'dummy'
    }
  },
  'b': {
    b_example: {
      example: 1
    }
  }
}

const k = 'a'
const i = 'b'
console.log(exampleAB[k as keyof ExampleA].a_example.example) // 型 '"a"' の式を使用して型 'ExampleA | ExampleB' にインデックスを付けることはできないため、要素は暗黙的に 'any' 型になります。
  プロパティ 'a' は型 'ExampleA | ExampleB' に存在しません。
console.log(exampleAB[i as keyof ExampleB].b_example.example) // 型 '"b"' の式を使用して型 'ExampleA | ExampleB' にインデックスを付けることはできないため、要素は暗黙的に 'any' 型になります。
  プロパティ 'b' は型 'ExampleA | ExampleB' に存在しません。

orz... ダメだ...

ならば・・・と、exampleABに対して型アサーションを定義してあげればうまくいきました。

const k = 'a'
const i = 'b'
console.log((exampleAB as ExampleA)[k as keyof ExampleA].a_example.example) // 'dummy'
console.log((exampleAB as ExampleB)[i as keyof ExampleB].b_example.example) // 1

まさかブラケット記法で書くと型が強制的にanyになってしまうとは。。。。

TypeScriptで型を定義するのは、なかなか慣れないものですね。

最近は、QiitaでTypeScriptの型定義の演習問題を作ってくれている方がいて、こちらを使って修行しています。

qiita.com

とはいえ、なかなか問題が難しくて軽々解けない。

これが軽々解けるようになると、大幅にレベルアップできるかもしれない。。。そんな事を思いながら日々修行を続けていますm( = = )m

Jestについて勉強してみた

最近、Jestを使ってテストコードを頻繁に書き始めたので、自身の使い方整理のため、以下雑にメモを残しておく。

test('null', () => {
  const n = null;
  expect(n).toBeNull(); // null一致
  expect(n).toBeDefined(); // undefined以外一致
  expect(n).not.toBeUndefined(); // undefinedではない
  expect(n).not.toBeTruthy(); // Truthyではない
  expect(n).toBeFalsy(); // Falsyである
});

test('0', () => {
  const z = 0;
  expect(z).not.toBeNull(); // nullではない
  expect(z).toBeDefined(); // undefined以外一致
  expect(z).not.toBeUndefined(); // undefinedではない
  expect(z).not.toBeTruthy(); // Truthyではない
  expect(z).toBeFalsy(); // Falsyである
});

test('数値', () => {
  const value = 2 + 2;
  expect(value).toBeGreaterThan(3); // value > 3
  expect(value).toBeGreaterThanOrEqual(3.5); // value >= 3.5
  expect(value).toBeLessThan(5); // value < 5
  expect(value).toBeLessThanOrEqual(4.5); // value <= 4.5
  expect(value).toBe(4); // value = 4
  expect(value).toEqual(4); // value = 4
});

test('浮動小数点', () => {
  const value = 0.1 - 0.2;
  //expect(value).toBe(-0.1);         このように書くと、丸め込み誤差が原因で期待通りに動作しない
  expect(value).toBeCloseTo(-0.1); // これならば正しく動く
});

test('正規表現', () => {
  expect('team').not.toMatch(/I/); // 正規表現に一致しない
  expect('Christoph').toMatch(/stop/); // 正規表現に一致
});

test('配列', () => {
  const shoppingList = [
    'diapers',
    'kleenex',
    'trash bags',
    'paper towels',
    'milk',
  ];
  
  expect(shoppingList).toContain('milk'); // 配列に含まれているか
  expect(new Set(shoppingList)).toContain('milk'); // Setに含まれているか
});

test('例外', () => {
  function compileAndroidCode() {
    throw new Error('you are using the wrong JDK');
  }

  expect(() => compileAndroidCode()).toThrow(); // throwされているか
  expect(() => compileAndroidCode()).toThrow(Error); // throwのErrorが呼ばれているか
  expect(() => compileAndroidCode()).toThrow('you are using the wrong JDK'); // messageの一致
  expect(() => compileAndroidCode()).toThrow(/JDK/); // 正規表現も可
});

test('予定', () => {
  test.todo('テストの予定を立てれる')
})

test('モック', () => {
  // モック関数を作る
  const mockFn = jest.fn()
  mockFn
    .mockImplementationOnce( () => {
      return '返り値1'
    })
    .mockImplementationOnce( () => {
      return '返り値2'
    })
    .mockImplementationOnce( () => {
      return '返り値3'
    })

  expect(mockFn()).toBe('返り値1') // 1回目の呼び出し
  expect(mockFn()).toBe('返り値2') // 2回目の呼び出し
  expect(mockFn()).toBe('返り値3') // 3回目の呼び出し

  // モックインスタンスを作る
  const mockInsFn = jest.fn()
  mockInsFn
    .mockImplementation( () => {
      return {
        hoge: jest.fn()
          .mockImplementationOnce( () => {
            return 'hoge関数の返り値1'
          })
          .mockImplementationOnce( () => {
            return 'hoge関数の返り値2'
          }),
        fuga: () => {
          return 'fuga関数の返り値'
        }
      }
    })

  const mockIns = new mockInsFn()
  expect(mockIns.hoge()).toBe('hoge関数の返り値1') // hoge関数の1回目の呼び出し
  expect(mockIns.hoge()).toBe('hoge関数の返り値2') // hoge関数の2回目の呼び出し
  expect(mockIns.fuga()).toBe('fuga関数の返り値') // fuga関数の呼び出し
})

// CDKを呼び出す上で必要なものを定義
import { SynthUtils } from '@aws-cdk/assert'
import * as cdk from '@aws-cdk/core'
import { SampleStack } from '../src/deploy/sampleStack'

test('スナップショット', () => {
  const app = new cdk.App()
  const stack = new SampleStack(app, `SampleStack`, {
    stage: 'dev',
    roleName: 'sampleRole'
  })
  expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot()
})

/*
// スナップショットの結果
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`スナップショット 1`] = `
Object {
  "Resources": Object {
    "sampleRole5138FD4B": Object {
      "Properties": Object {
        "AssumeRolePolicyDocument": Object {
          "Statement": Array [
            Object {
              "Action": "sts:AssumeRole",
              "Effect": "Allow",
              "Principal": Object {
                "Service": "lambda.amazonaws.com",
              },
            },
          ],
          "Version": "2012-10-17",
        },
        "ManagedPolicyArns": Array [
          Object {
            "Fn::Join": Array [
              "",
              Array [
                "arn:",
                Object {
                  "Ref": "AWS::Partition",
                },
                ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
              ],
            ],
          },
        ],
        "RoleName": "sampleRole",
      },
      "Type": "AWS::IAM::Role",
    },
  },
}
`;
*/

感想

Jest は使ってみた感じ、むちゃくちゃ便利だ。

とにかくモックが簡単で、関数やインスタンスが作りやすい印象を受けた。

スナップショットも取得することができ、イマドキ感も感じる。

試しに、CDKのCloudFormationテンプレートのスナップショットを取得してみたが、これは使えそうだ。個人的には、CloudFromationのテンプレートテストはそこまで必要性を感じていないのだが、スナップショット残しておくだけでも、差分確認ができるという点で便利だと思えた。

まだまだ使い慣れていないので、ドキュメントを読みつつ、さらなる使い方を学んでいきたい。

Node.js Streamを勉強してみた

Node.jsを理解していくにあたり、Streamの理解は必須になってくるので、Streamのドキュメントを(まずは、さらっと理解程度に)読んでみた。

Streamとは?

Node.jsでストリーミングデータを操作するためのインターフェースのこと。インターフェースなので、ストリームという実装があるわけではなく、概念が決まっているだけ。

その概念は色々なもの( fs, process.stdin/stdout/stderrなど, HTTP request on the server/response on the client)で使用される。

ストリームには、以下4種類のインターフェースが定義されている。

  • Writable: データを書き込むことができるストリーム
  • Readable: データを読み取ることができるストリーム
  • Duplex: ReadableとWritableの両方ができるストリーム
  • Transform: Duplexデータ読み込みおよび書き込み時に、データの変更、変換ができるストリーム

オブジェクトモード

Node.js APIによって作成されたすべてのストリームは、文字列または、Bufferオブジェクトでのみ動作する。

バッファリング

WritableとReadable共にデータを内部バッファに保存する仕組みを持つ。

理由としては、一度に大量のデータを読み込み、または書き込みを行わないために、予め閾値(highWaterMark)を決めており、その分のデータを内部バッファに保存するためだそう。

注意点として、highWaterMarkは閾値であり、制限ではないとのこと。「一般に、厳密なメモリ制限は適用されません」とのこと。

EventEmitter

ストリームは、EventEmitterを継承している。EventEmitterとはイベント駆動でプログラムを動かすライブラリのこと。

たとえば以下の場合、 .onで、イベント名と実行内容を定義して、 .emit(イベント名)でイベントを発火させて実行する。

import EventEmitter from 'events'
const event = new EventEmitter();

console.log('1')
event.on('event', () => { // `.on`でイベントと実行内容を定義
    console.log('2')
})
console.log('3')

event.emit('event') // `.emit`でイベントを発火。結果は、132となる

ストリームもこのEventEmitterを継承しているため、動かし方も上記のような方法になる。

import fs from "fs"

const readStream = fs.createReadStream("assets/sample1.txt", { encoding: "utf-8" })
readStream.on("data", chunk => console.log(chunk)); // `.on`でイベント名と実行内容を定義

ストリームの種類とイベント・API

Writable: 書き込み

用意されているイベント: close, drain, error, finish, pipe, unpipe

Readable: 読み込み

用意されているイベント: close, data, end, error, pause, readable, resume

Duplex & Transform: 書き込み、読み込み、変換

用意されているイベント: WritableとReadableの両方

stream API

ストリームを実装できるように、同期版APIが用意されている。

stream/promises API

ストリームを実装できるように、非同期版APIが用意されている。(ぇ、そうなの?)

感覚を掴んでみる

Node.jsの Fyle System (fs) モジュールは、Streamの仕組みが使われている。
createReadStreamcreateWriteStreamを使えば、Streamを生成できるので、 createReadStream を使ってStreamの感覚を掴んでみることにする。

import * as fs from "fs"

// こうすることで、28byteずつ読み込む
const readStream = fs.createReadStream("assets/sample1.txt", {
  encoding: "utf-8",
  highWaterMark: 28
});

// 28byteずつ"data"イベントが発火する
readStream.on("data", chunk => console.log(chunk))
// すべての読み込みが完了したときにイベントが発火する
readStream.on("end", () => console.log('すべての読み込みが完了しました。'))

Streamについて、調べていると以下のような事を仰っている記事に辿り着いた。

qiita.com

Promise におけるフロー制御に Stream を導入することが難しい

なるほど。これを読むと、たしかにStreamとPromiseの相性の悪さがわかります。

"data"イベントを使用するのではなくfor awaitを使って制御するのが良さそうですね。以後、参考にさせていただきますm(= =)m

import * as fs from "fs"

// こうすることで、28byteずつ読み込む
const readStream = fs.createReadStream("assets/sample1.txt", {
  encoding: "utf-8",
  highWaterMark: 28
});

// すべての読み込みが完了したときにイベントが発火する
readStream.on("end", () => console.log('すべての読み込みが完了しました。'))

// async/awaitを使用する
async function main() {
  for await ( const chunk of readStream ) {
    console.log(chunk)
  }  
}

main()

まとめ

というわけで、今回は以下を学んだ。

そもそもStreamを学びたいと思った理由は、AWS S3のファイルを取得した際、型がStreamだったので「やべぇ・・・わからん!」となったのがキッカケ。 とりあえず、それを解決する程度には理解できたと思う。。。

  • Streamはインターフェースであり、Node.jsの様々なモジュールで使用されている。
  • StreamはEventEmitterであり、イベント駆動型でPromiseでのフロー制御には気をつけること。

AWS CDKを使ってSwagger + API Gateway + Lambdaを作る話

最近、AWS CDKに挑戦している。

正直、最初はCDKを使うメリットをあまり感じられなかったのだが、慣れてくるとCDKを使うほうがテンプレートの管理がしやすく、書くスピードも上がって効率が良くなっていることが実感できる。

後から気づいた事なのだが、CDKにはCfnテンプレートの内容をそのままコードで書けるCfnXXX関数が用意されている。

そのため「うわっ、これ書くの難しそう・・・」と思ったら、CfnXXX関数を使うのが良さそうだ。

CDKを使うと試してみたくなるのが、Swagger + API Gateway + Lambdaのパターン。

今までのCfnテンプレートでSwaggerを使うとなると、どうしても手作業のコピー&ペーストが必要だった。しかし、CDKを使うとimportで読み取ることができるので、コピー&ペーストが不要。

というわけで、さっそくやってみる。

Swaggerファイル )を用意する。

{
  "openapi": "3.0.1",
  "info": {
    "title": "Swagger Sample",
    "description": "Swagger Sample",
    "version": "1.0.0"
  },
  "paths": {
    "/request": {
      "get": {
        "operationId": "testGetOperation",
        "responses": {
          "200": {
            "description": "successful.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "x-amazon-apigateway-integration": {
          "type": "AWS_PROXY",
          "httpMethod": "POST",
          "uri": "あとで上書きする",
          "connectionType": "INTERNET",
          "payloadFormatVersion": "2.0",
          "timeoutInMillis": 30000
        },
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Body"
              }
            }
          }
        },
        "x-codegen-request-body-name": "body"
      }
    }
  },
  "components": {
    "schemas": {
      "Body": {
        "type": "object",
        "properties": {
          "key": {
            "type": "string"
          }
        }
      }
    }
  }
}

というわけで、さっそくStackの中身を書いていこう。

import * as cdk from '@aws-cdk/core'
import * as httpApi from '@aws-cdk/aws-apigatewayv2'
import * as iam from '@aws-cdk/aws-iam'
import * as logs from '@aws-cdk/aws-logs'
import * as lambda from '@aws-cdk/aws-lambda'
import openapiJson from '../swagger/openapi.json'

export class ApigwSample extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    const stack = cdk.Stack.of(this)

    // Lambdaの定義
    const rolelambda = new iam.Role(this, 'lambdaSampleRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          'service-role/AWSLambdaBasicExecutionRole'
        ),
      ],
      path: '/lambda/',
    })

    const lambdaSampleFunction = new lambda.Function(this, 'lambdaSampleFunction', {
      runtime: lambda.Runtime.NODEJS_14_X,
      code: lambda.Code.fromAsset('lambda/functions'),
      functionName: 'lambdaSampleFunction',
      handler: 'index.lambdaHandler',
      environment: {
        TZ: 'Asia/Tokyo',
      },
      role: rolelambda
    })

    const lambdaFunctionCurrentVersionAlias = new lambda.Alias(
      this,
      'lambdaSampleAlias',
      {
        aliasName: 'dev',
        version: lambdaSampleFunction.currentVersion,
      }
    )
    lambdaFunctionCurrentVersionAlias.addPermission(
      'lambdaSampleCurrentAlias',
      {
        principal: new iam.ServicePrincipal('apigateway.amazonaws.com'),
        action: 'lambda:InvokeFunction',
        sourceArn: `arn:aws:execute-api:${stack.region}:${stack.account}:*/*/*/request`
      }
    )

    new logs.LogGroup(this, 'lambdaSampleLogGroup', {
      logGroupName: '/aws/lambda/' + lambdaSampleFunction.functionName,
      retention: logs.RetentionDays.ONE_DAY,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    })

    // APIGateway(HttpAPI)の定義
    const apigwSampleHttp = new httpApi.HttpApi(this, 'apigwSampleHttp', {
      apiName: 'ApigwSample',
      createDefaultStage: false,
    })
    const apigwSampleHttpNode = apigwSampleHttp.node.findChild('Resource') as httpApi.CfnApi
    apigwSampleHttpNode.body = openapiJson
    delete apigwSampleHttpNode.name
    delete apigwSampleHttpNode.protocolType

    new httpApi.HttpStage(this, 'apigwSampleHttpStage', {
      httpApi: apigwSampleHttp,
      autoDeploy: true,
      stageName: 'dev',
    })

        // 上記で作成したlambdaのArnを設定する必要があるため、openapi.jsonを上書きする
    openapiJson['paths']['/request']['get']['x-amazon-apigateway-integration'][
      'uri'
    ] = `arn:aws:apigateway:${stack.region}:lambda:path/2015-03-31/functions/${lambdaFunctionCurrentVersionAlias.functionArn}/invocations`

    new cdk.CfnOutput(this, 'apigwSampleHttpUrl', {
      value: apigwSampleHttp.apiEndpoint
    })
  }
}

TypeScriptで書いているけど、型推論してくれるので、自分で型を定義することはほとんどありません。

APIGatewayを作成する時、Swaggerを使用すると、Swagger内にAPIGatewayの設定内容がほとんど書かれているため、Cfnのテンプレートを書く時にはbodyにSwaggerの内容を転記する必要があった。

なので、CDKではhttpApi.CfnApiを使用してbodyに直接をSwaggerの内容を設定する方がすっきり書けそう・・・
今回はhttpApi.HttpApiを使用してみたのだが、 自動生成されてしまうnameprotocolTypeを生成後に削除する必要があった。

あとは、x-amazon-apigateway-integration。さすがにArnの情報まではSwagger内に書けないので、Lambda定義後に上書きが必要。

あとは、簡単にStackを書けば、CDKからCfnのテンプレートを作るところまでが完成。

import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import { ApigwSample } from '../lib/apigw-sample';

const app = new cdk.App();
new ApigwSample(app, 'CdkSampleStack');

次は、Lambdaを書いていきます。

CDKに合わせてLambdaもTypeScriptで書いていくことにします。

import { APIGatewayProxyEventV2 } from 'aws-lambda'

export interface LambdaHandlerResult {
  StatusCode: number,
  Message: string
}

export async function lambdaHandler(event: APIGatewayProxyEventV2): Promise<LambdaHandlerResult> {
  return {
    StatusCode: 200,
    Message: 'Successfully'
  }
}

「ビルドどうしよう・・・」と悩んでいたら、クラメソさんのココの記事を参考にさせていただきました。

const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  mode: 'development',
  target: 'node',
  entry: {
    functions: path.resolve(
      __dirname,
      './lambda/functions/index.ts',
    ),
  },
  // 依存ライブラリをデプロイ対象とするか設定(対象はpackage.json参照)
  // devDependencies:開発時に必要なライブラリを入れる
  // dependencies:実行時に必要なライブラリを入れる
  externals: [
    nodeExternals({
      modulesFromFile: {
        exclude: ['dependencies'],
        include: ['devDependencies'],
      },
    }),
  ],
  output: {
    filename: '[name]/index.js',
    path: path.resolve(__dirname, './lambda'),
    libraryTarget: 'commonjs2',
  },
  // 変換後ソースと変換前ソースの関連付け
  devtool: 'inline-source-map',
  module: {
    rules: [
      {
        // ローダーが処理対象とするファイルを設定
        test: /\.ts$/,
        exclude: /node_modules/,
        // 先ほど追加したts-loaderを設定
        use: [
          {
            loader: 'ts-loader',
          },
        ],
      },
    ],
  },
  // import時のファイル指定で拡張子を外す
  // https://webpack.js.org/configuration/module/#ruleresolve
  resolve: {
    extensions: ['.ts', '.js'],
  },
};

また、クラメソさんの別記事で、aws-lambda-nodejsというモジュールが追加されたというのがあり、最初はこれを使おうかな・・・と思ったのですが、まだベータ版なので、今回は見送りました。

AWSのリソースをTypeScriptを使って全部書けると意外と楽だったので、Now!!でJavaScript/TypeScriptを勉強している身としては、CDKやLambdaをどんどん書いていこう!という気持ちになれました。

いやぁ・・・CDKすごいっ!