kakakakakku blog

Weekly Tech Blog: Keep on Learning!

cfn-diagram: CloudFormation のリソース関係図を生成しよう

AWS CloudFormation テンプレートのリソース関係図を生成できる「cfn-diagram」を紹介する❗️cfn-diagram を使うと HTML / アスキーアート / Mermaid / Draw.io などのフォーマットで出力できる.特に Mermaid で出力できるのは便利で,Markdown や GitHub との相性も良く,今回は GitHub Actions と組み合わせた Tips も紹介したいと思う💡

github.com

セットアップ 🎲

$ npm i -g @mhlabs/cfn-diagram
$ cfn-dia -v
1.1.36

AWS CloudFormation テンプレート 🎲

今回はサンプルとして GitHub の awslabs/aws-cloudformation-templates リポジトリに公開されている VPC 関連のテンプレート VPC_With_Managed_NAT_And_Private_Subnet.yaml を使う.ザッと以下のリソースが含まれている(テンプレートから Type: を抜き出した).

  • AWS::EC2::EIP
  • AWS::EC2::InternetGateway
  • AWS::EC2::NatGateway
  • AWS::EC2::NetworkAcl
  • AWS::EC2::NetworkAclEntry
  • AWS::EC2::Route
  • AWS::EC2::RouteTable
  • AWS::EC2::Subnet
  • AWS::EC2::SubnetNetworkAclAssociation
  • AWS::EC2::SubnetRouteTableAssociation
  • AWS::EC2::VPC
  • AWS::EC2::VPCGatewayAttachment

github.com

cfn-diagram を試す 🎲

1. html

vis.js を組み込んだ HTML を生成できる.アイコンをグリグリと引っ張ったり(試せばわかる!),不要なリソースを非表示にできたりして,便利❗️見た目も良くてリソース関係をうまく表現できていると思う.

$ cfn-dia html -t VPC_With_Managed_NAT_And_Private_Subnet.yaml

2. ascii-art

リソース関係図をテキストで生成できる.見た目に限界はあるけど,テキストファイルとして管理のしやすさはメリットだと思う.あと iTerm2 で試すと色も付いていて良かった🎨

$ cfn-dia ascii-art -t VPC_With_Managed_NAT_And_Private_Subnet.yaml

3. mermaid

Mermaid 記法でも生成できる.個人的には Mermaid に対応しているのは使いやすくて,GitHub の README.md に載せたりするのも良さそう❗️以下のキャプチャは VS Code で表示したところ.

$ cfn-dia mermaid -t VPC_With_Managed_NAT_And_Private_Subnet.yaml > vpc.mermaid.md

4. draw.io

Draw.io でアーキテクチャ図を描いている人は多いと思う.なんと Draw.io 形式でも生成できる.おすすめは GitHub にも載っている VS Code の拡張機能 Draw.io VS Code Integration との組み合わせで,VS Code 上で Draw.io を操作できる.また Filter オプションを使えば不要なリソースを非表示にできたりもする.

marketplace.visualstudio.com

$ cfn-dia draw.io -t VPC_With_Managed_NAT_And_Private_Subnet.yaml
Diagram will be written to template.drawio
? Options (Use arrow keys)
❯ Filter resources by type
  Filter resources by name
  Edge labels: On
  Quit

GitHub Actions と組み合わせる 🎲

最後に GitHub Actions と組み合わせた Tips を紹介する💡

GitHub Actions の「ジョブサマリー機能」を使うと任意の情報を GitHub Actions の実行画面に表示できる.GitHub では Mermaid 記法をサポートしているため,ジョブサマリー用の環境変数 ${GITHUB_STEP_SUMMARY} に cfn-diagram の結果を流し込むだけでリソース関係図を記録できてしまう❗️

以下にサンプルで作ってみたワークフロー .github/workflows/cfn-dia.yaml を載せておく.AWS CloudFormation を実行するワークフローの途中にジョブサマリーへの出力を追加してみるのはどうでしょー❓

name: cfn-dia

on:
  push:
    branches:
      - master

jobs:
  cfn-dia:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Setup cfn-dia
        run: npm i -g @mhlabs/cfn-diagram
      - name: Run cfn-dia
        run: cfn-dia mermaid -t VPC_Single_Instance_In_Subnet.template >> ${GITHUB_STEP_SUMMARY}

まとめ

「cfn-diagram」を使うと AWS CloudFormation テンプレートのリソース関係図を簡単に生成できる❗️便利〜

Amazon API Gateway から Amazon EventBridge への直接統合を AWS SAM と OpenAPI で構築する

Amazon API Gateway (REST API) から Amazon EventBridge にリクエストを流すように直接統合する構成を AWS SAM (AWS CloudFormation) と OpenAPI で実装する検証をしていたので紹介する❗️ハマりどころもあって,困ったときに参考になるドキュメントも少なく,まとめておこうと思った.誰かの参考になれば💡

アーキテクチャ図

Amazon API Gateway (REST API) の /eventsPOST リクエストを投げると Amazon EventBridge を経由して Amazon CloudWatch Logs に流れるように構築した.Amazon CloudWatch Logs のところは何でも良くて,今回は簡単に疎通確認できるものを選んだ❗️

ディレクトリ構成

検証環境として最低限必要なディレクトリ構成は以下!

$ tree .
.
├── openapi
│   └── openapi.yaml
├── samconfig.toml
└── template.yaml

2 directories, 3 files

template.yaml

AWS SAM テンプレート(AWS CloudFormation テンプレート)で構築しているリソースはザッと以下になる.

  • Amazon API Gateway
  • Amazon EventBridge
  • Amazon CloudWatch Logs

ポイントは Amazon API Gateway の API 定義を OpenAPI を使って openapi.yaml ファイルから読む込むようにしてるところ👌

AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31

Resources:
  ApiGateway:
    Type: AWS::Serverless::Api
    Properties:
      Name: sandbox-api-gateway
      StageName: v1
      DefinitionUri: ./openapi/openapi.yaml

  ApiGatewayExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: sandbox-api-gateway-execution-role
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - apigateway.amazonaws.com
            Action: sts:AssumeRole
      Path: '/'
      Policies:
        - PolicyName: ExecutionPolicy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - events:PutEvents
                Resource: '*'

  EventBus:
    Type: AWS::Events::EventBus
    Properties:
      Name: sandbox-event-bus

  EventRule:
    Type: AWS::Events::Rule
    Properties:
      EventBusName: !Ref EventBus
      EventPattern:
        source:
          - sandbox-source
        detail-type:
          - sandbox-detail-type
      Targets:
        - Id: sandbox-log-group
          Arn: !GetAtt LogGroup.Arn

  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /aws/events/sandbox-log-group

openapi/openapi.yaml

次に OpenAPI ファイル(最低限の設定)を載せる❗️今回は /eventsPOST リクエストを送る API にした.

openapi: 3.0.0

info:
  title: Sandbox API
  version: 1.0.0

paths:
  /events:
    post:
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event1:
                  type: string
                event2:
                  type: string
                event3:
                  type: string
              required:
                - event1
                - event2
                - event3
      x-amazon-apigateway-integration:
        type: aws
        uri: arn:aws:apigateway:${AWS::Region}:events:action/PutEvents
        httpMethod: POST
        credentials: arn:aws:iam::${AWS::AccountId}:role/sandbox-api-gateway-execution-role
        responses:
          default:
            statusCode: 200
        requestTemplates:
          application/json: "#set($context.requestOverride.header.X-Amz-Target = \"\
            AWSEvents.PutEvents\")\n#set($context.requestOverride.header.Content-Type\
            \ = \"application/x-amz-json-1.1\")\n{\n\t\"Entries\": [{\n\t\t\"EventBusName\"\
            : \"sandbox-event-bus\",\n\t\t\"Detail\": \"$util.escapeJavaScript($input.json('$'))\"\
            ,\n\t\t\"DetailType\": \"sandbox-detail-type\",\n\t\t\"Source\": \"sandbox-source\"\
            \n\t}]\n}"
        passthroughBehavior: when_no_templates
      responses:
        '200':
          description: OK

1番のハマりどころは拡張構文 x-amazon-apigateway-integration を使った記述と Amazon API Gateway のマッピングテンプレート回りだった.実際に動作確認できるまで何度も試行錯誤をした🌀

docs.aws.amazon.com

マッピングテンプレートに関しては Amazon API Gateway コンソールに表示される整形された形だと以下となる.OpenAPI ファイルに載せているエスケープされた形は Amazon API Gateway のエクスポート機能(OpenAPI 3 + API Gateway 拡張の形式でエクスポート)で取得したものをそのまま貼った.以下のドキュメントを参考に VTL (Apache Velocity Template Language) を実装して Amazon EventBridge にリクエストを流すように設定した.

docs.aws.amazon.com

#set($context.requestOverride.header.X-Amz-Target = "AWSEvents.PutEvents")
#set($context.requestOverride.header.Content-Type = "application/x-amz-json-1.1")
{
    "Entries": [{
        "EventBusName": "sandbox-event-bus",
        "Detail": "$util.escapeJavaScript($input.json('$'))",
        "DetailType": "sandbox-detail-type",
        "Source": "sandbox-source"
    }]
}

HTTP ヘッダー X-Amz-TargetAWSEvents.PutEvents を設定し,Content-Typeapplication/x-amz-json-1.1 を設定するところは Amazon EventBridge の PutEvents API リファレンスを参考にした.

docs.aws.amazon.com

動作確認

iTerm2 から Amazon API Gateway エンドポイントに POST リクエストを送る❗️期待通りに Amazon EventBridge の PutEvents API からレスポンスが返ってくる.

$ curl -s -X POST \
  -H 'Content-Type: application/json' \
  -d '{"event1": "event1", "event2": "event2", "event3": "event3"}' \
  https://dxqv7m4o28.execute-api.ap-northeast-1.amazonaws.com/v1/events | jq .
{
  "Entries": [
    {
      "EventId": "b6673a3d-b591-cfde-5ea1-208c77db482a"
    }
  ],
  "FailedEntryCount": 0
}

Amazon API Gateway (REST API) → Amazon EventBridge → Amazon CloudWatch Logs と流れて最終的にイベントをログ出力できた✌️

Amazon API Gateway コンソール

構築するときは Amazon API Gateway コンソールを使って動作確認をすることもあると思う.以下にキャプチャを載せておく❗️AWS サービスで EventBridge を選べず CloudWatch Events を選ぶところは要注意〜

関連記事

マッピングテンプレートの設定で参考になるドキュメントが少なく,以下のブログ記事 Capturing client events using Amazon API Gateway and Amazon EventBridge と GitHub リポジトリが1番参考になった.イイ事例だからブログ記事は日本語に翻訳してもらえると良いなぁ💡

aws.amazon.com

github.com

まとめ

Amazon API Gateway (REST API) から Amazon EventBridge にリクエストを流すように直接統合する構成を AWS SAM (AWS CloudFormation) と OpenAPI で実装してみた❗️

参考になれば💡

github.com

blank-go: Go で動く AWS Lambda 関数に入門しよう

AWS Lambda 関数を Go ランタイムで動かす Hello World レベルの初学者コンテンツを探していて,ドキュメントにも載っている blank-go プロジェクトがお手軽に使えて良かったので紹介したいと思う❗️初学者に教えるときに使えるぞ〜 \( 'ω')/

github.com

ちなみに AWS Lambda に入門するための blank-xxx は Go 以外にも Node.js / Python / Java など多くある❗️

docs.aws.amazon.com

blank-go 紹介

blank-go では AWS CLI を使って Go ランタイムで動く AWS Lambda 関数をデプロイできる.実装としては,Amazon SQS のイベント情報(実際に Amazon SQS キューと連携するのではなく event.json を渡す)や AWS Lambda のコンテキスト情報をログ出力する感じで,Go で AWS Lambda 関数を実装するときのお作法なども参考になる.特にコードを書く必要はなく,準備された Shell を実行していく.試すだけならすぐ終わるし簡単❗️

$ ./1-create-bucket.sh
$ ./2-deploy.sh
$ ./3-invoke.sh

docs.aws.amazon.com

登場する代表的なサービス/リソースは以下など.

  • AWS Lambda(blank-go アプリケーション)
  • Amazon S3(デプロイ用)
  • AWS CloudFormation(AWS SAM デプロイ用)
  • Amazon CloudWatch Logs(AWS Lambda 関数のログ出力用)
  • AWS X-Ray(AWS Lambda サービスと AWS Lambda 関数のトレース用)

ちなみに M1 / M2 など Apple Silicon の macOS で実行すると Lambda 関数の実行時に以下のエラーが出る.今回は MacBook Pro (Apple M1 Max) を使っていて遭遇した.

{"errorMessage":"fork/exec /var/task/main: exec format error","errorType":"PathError"}

その場合は ./2-deploy.shgo build コマンドに GOARCH=amd64 を追加する必要がある.Apple Silicon を使っている人ならすぐ気付くとは思うけど,blank-go は初学者コンテンツなので,手順に書いておくと良さそうでプルリクエストも出しておいた.

$ GOOS=linux GOARCH=amd64 go build main.go

github.com

またお掃除用の 4-cleanup.sh を実行すると AWS Lambda 関連だけではなく Amazon S3 と Amazon CloudWatch Logs も含めてすべて消してくれるので,ゴミが残らないのも良かった👍

$ ./4-cleanup.sh

さらに一歩踏み込む(追加課題)

もし blank-go を使って初学者に AWS Lambda x Go を教えるなら,ただ実行するだけではなく,もう少し視野を広げて体験してもらうのが良さそう❗️追加課題的なものを考えながら実際に試してみた.

1. AWS Lambda 関数をローカル実行してみよう✌️

blank-go のデプロイには AWS SAM が使われているため sam local invoke コマンドを実行すれば AWS Lambda 関数を実環境にデプロイせずにローカル実行できる.

$ sam --version
SAM CLI, version 1.78.0

$ sam build

$ sam local invoke -e event.json
Invoking main (go1.x)
Using local image: public.ecr.aws/lambda/go:1-rapid-x86_64.

START RequestId: f5633c0b-79c7-44e2-bdec-4ba460c7a42a Version: $LATEST
(中略)
END RequestId: f5633c0b-79c7-44e2-bdec-4ba460c7a42a
REPORT RequestId: f5633c0b-79c7-44e2-bdec-4ba460c7a42a  Init Duration: 0.87 ms    Duration: 690.84 ms   Billed Duration: 691 ms    Memory Size: 128 MB    Max Memory Used: 128 MB
"{\"FunctionCount\":1,\"TotalCodeSize\":6399551}"

さらに AWS Toolkit for Visual Studio CodeAWS Toolkit for JetBrains を使えば,エディタ上で AWS Lambda 関数のデバッグをすることもできる.AWS Lambda 関数だと開発体験が悪そう...と言われたりもする懸念の一部は解消できると思う.

2. テストを実行してみよう✌️

blank-go の手順では特に紹介されていないけど,function ディレクトリには main_test.go も含まれていて,Go のテストも実行できる.今回のコードは環境変数 AWS_REGION をログ出力する実装になっているため,以下のように実行すれば OK!

$ cd function
$ AWS_REGION='ap-northeast-1' go test
PASS
ok      github.com/awsdocs/aws-lambda-developer-guide/sample-apps/blank-go  0.484s

ちなみにドキュメントに載っているベストプラクティスに Lambda ハンドラーをコアロジックから分離します。 とあって,もう少し工夫するなら main.gohandleRequest() 関数とコアロジックの依存を減らせばもっとテストしやすくなると思う.とは言え,今回の blank-go ではコアロジックと言える実装がないのでこれはこれで OK!

AWS Lambda 関数を実装するときも今までと同じく「テストのしやすさ」という観点は大切だよね〜❗️というディスカッションに繋げられると良いと思う👍

docs.aws.amazon.com

3. GitHub Actions で自動デプロイをしてみよう✌️

blank-go だと AWS CLI (aws cloudformation deploy コマンドなど)をラップした Shell を使ってデプロイをしてるけど,せっかくなら GitHub Actions で自動デプロイする仕組みを作ってみる.blank-go のコードを抜き出して新しく GitHub リポジトリを作って GitHub Actions ワークフロー (.github/workflows/deploy.yml) を以下のように作る.

name: blank-go

on:
  push:
    branches: master

permissions:
  id-token: write
  contents: read

jobs:
  build:
    name: Deploy
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: aws-actions/setup-sam@v2
      - uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ap-northeast-1
      - run: sam build --use-container
      - run: sam deploy

aws cloudformation deploy コマンドじゃなく sam deploy コマンドを使ってデプロイしたり,AWS IAM 権限を取得するところをアクセスキーではなく OpenID Connect (OIDC) の仕組みを使って一時的な認証情報を使ったりという工夫をしている❗️関連する設定は割愛するけど以下のドキュメントなどに載っている👌

docs.github.com

期待通りに自動デプロイできた〜

4. ドキュメントを調べてみよう✌️

などなど📝

まとめ

AWS Lambda x Go に入門できる Hello World レベルのコンテンツ blank-go「さらに一歩踏み込む追加課題」の紹介でした❗️

github.com

Air: Go アプリケーションでホットリロードをする

Air は Go で実装されたアプリケーションで「ホットリロード」をするツールで,Go コードなど関連するファイルの変更を監視して,変更があったら自動的にビルドをしてアプリケーションを再起動できる.例えば Go で API を開発しているときに,コードを修正した後に毎回 go run main.go を実行し直すのは面倒なので,Air を使うと開発者体験を向上できる✌️

github.com

Air を試す

今回はサンプルとして Echo を使った API に Air を組み込む.GitHub に載っている Echo のサンプルコードを抜粋して main.go を準備した.

echo.labstack.com

package main

import (
    "net/http"

    "github.com/labstack/echo/v4"
)

func main() {
    e := echo.New()

    e.GET("/", hello)

    e.Logger.Fatal(e.Start(":1323"))
}

func hello(c echo.Context) error {
    return c.String(http.StatusOK, "Hello, World!")
}

Air を試すのは簡単で,air コマンドを go install コマンドでセットアップして,プロジェクトのルートディレクトリで air コマンドを実行するだけ❗️以下のように API が起動されて,main.go を更新すると自動的にビルドをして API が再起動される.

$ go install github.com/cosmtrek/air@latest

$ air
  __    _   ___
 / /\  | | | |_)
/_/--\ |_| |_| \_ , built with Go

watching .
!exclude tmp
building...
running...

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.10.2
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:1323

#
# `main.go` を更新すると...
#

main.go has changed
building...
running...

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.10.2
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:1323

.air.toml ファイル

Air では .air.toml ファイルを使ってデフォルトの挙動を上書きできる.例えば include_ext で監視対象にする拡張子を設定したり,exclude_dir で監視対象外にするディレクトリを設定したり.プロジェクトごとに細かく挙動をカスタマイズできるので .air.toml ファイルを使う場面は多いと思う❗️

[build]
  include_ext = ["go", "tpl", "tmpl", "html"]
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]

他には bin で一時的にビルドしたバイナリファイルの保存場所を設定したり,cmd で Go のビルドコマンドをオプション含めて細かく設定したり.バイナリファイルを保存する tmp ディレクトリは .gitignore に追加しておくと良さそう👌

[build]
  bin = "./tmp/main"
  cmd = "go build -o ./tmp/main ."

air init コマンド

.air.toml ファイルの雛形は air init コマンドを使って簡単に作れる.設定項目の一覧とデフォルト値を確認するためにも一度作ってみると良いと思う.

$ air init
.air.toml file created to the current directory with the default settings

そして,.air.toml を指定して Air を実行するときは以下のように -c オプションを使う.

$ air -c .air.toml

まとめ

API など Go でアプリケーションを実装するときは Air を導入してホットリロードをしよう❗️開発者体験〜

testing/fstest: Go でファイルシステムに依存したテストを書く

Go で testing/fstest を使うと,ファイルシステムに依存したテストコードを書くときに実際のファイルシステムへの依存度を減らせる.テストコードを書こうとすると,テスト項目(バリエーション)ごとにファイルを用意する必要があるため,ファイルの管理が面倒になることもある.最近使う機会があって簡単にまとめておく📝

pkg.go.dev

io/fs interface と testing/fstest.MapFS{}

testing/fstestfstest.MapFS{} を使うと任意のファイルで構成されるファイルシステム(仕組みとしてはインメモリとドキュメントに書いてある)を作れる.そして,ファイルシステムを抽象化した io/fs.FS interface を満たすオブジェクトになっているため,ビジネスロジック側で fs.FS を受け取るように実装しておけば,テストコードでは fstest のオブジェクトを渡せる.

pkg.go.dev

fs := fstest.MapFS{
    "helloworld.md":  {Data: []byte("helloworld")},
    "helloworld2.md": {Data: []byte("helloworld2")},
    "helloworld3.md": {Data: []byte("helloworld3")},
    "helloworld4.md": {Data: []byte("helloworld4")},
    "helloworld5.md": {Data: []byte("helloworld5")},
}

サンプルコード

以下のように main.gomain_test.go を書いてみた.

main.go

実装に特に意味はなくサンプルではあるけど,"files ディレクトリのファイル数を返す" というロジックを考える.ポイントはファイル数を返す countFiles() 関数の引数として fs.FS interface を受け取るようにしているところ❗️

package main

import (
    "fmt"
    "io/fs"
    "os"
)

func countFiles(fsys fs.FS) (int, error) {
    files, err := fs.ReadDir(fsys, ".")
    if err != nil {
        return 0, err
    }
    return len(files), nil
}

func main() {
    count, err := countFiles(os.DirFS("files"))
    if err != nil {
        fmt.Println(err)
    }
    fmt.Println(count)
}

main_test.go

テストコード側では testing/fstest.MapFS{} を使って,テスト項目ごとにファイルシステムを作っている(ファイルなし・1ファイル・2ファイル).もっともっとテスト項目が増えることを想像すると fstest を使うメリットが感じられると思う✌

package main

import (
    "testing"
    "testing/fstest"
)

func TestCount(t *testing.T) {
    t.Run("File does not exist.", func(t *testing.T) {
        fs := fstest.MapFS{}
        want := 0
        got, _ := countFiles(fs)
        assertCount(t, got, want)
    })

    t.Run("One file exists.", func(t *testing.T) {
        fs := fstest.MapFS{
            "helloworld.md": {Data: []byte("helloworld")},
        }
        want := 1
        got, _ := countFiles(fs)
        assertCount(t, got, want)
    })

    t.Run("Two files exist.", func(t *testing.T) {
        fs := fstest.MapFS{
            "helloworld.md":  {Data: []byte("helloworld")},
            "helloworld2.md": {Data: []byte("helloworld2")},
        }
        want := 2
        got, _ := countFiles(fs)
        assertCount(t, got, want)
    })
}

func assertCount(t *testing.T, got, want int) {
    t.Helper()
    if got != want {
        t.Errorf("got %d, want %d", want, got)
    }
}

まとめ

testing/fstest は便利だから覚えておこう〜❗️