less is more

心のフルスタックエンジニア👨‍💻バイブスでコードを書いています🤘

CloudFormationでECSのBlue/Greenデプロイができるようになったので試す

概要

これまで CodeDeploy と連携させて実現していた ECS の Blue/Green デプロイが CloudFormation にインテグレートされて、Externalなデプロイメントコントローラーとして使用できるようになりました。これによって、一連のデプロイパイプラインをテンプレート上に集約して構築することができます。

プレスリリース
aws.amazon.com

ドキュメント

docs.aws.amazon.com

サンプルのテンプレートで試す

Fargate で Nginx コンテナを動かして、ALB経由で80番ポートでアクセスするアプリを作成するようです。

AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  Vpc:
    Type: 'AWS::EC2::VPC::Id'
  Subnet1:
    Type: 'AWS::EC2::Subnet::Id'
  Subnet2:
    Type: 'AWS::EC2::Subnet::Id'
Transform:
  - 'AWS::CodeDeployBlueGreen'
Hooks:
  CodeDeployBlueGreenHook:
    Properties:
      TrafficRoutingConfig:
        Type: AllAtOnce
      Applications:
        - Target:
            Type: 'AWS::ECS::Service'
            LogicalID: ECSDemoService
          ECSAttributes:
            TaskDefinitions:
              - BlueTaskDefinition
              - GreenTaskDefinition
            TaskSets:
              - BlueTaskSet
              - GreenTaskSet
            TrafficRouting:
              ProdTrafficRoute:
                Type: 'AWS::ElasticLoadBalancingV2::Listener'
                LogicalID: ALBListenerProdTraffic
              TargetGroups:
                - ALBTargetGroupBlue
                - ALBTargetGroupGreen
    Type: 'AWS::CodeDeploy::BlueGreen'
Resources:
  ExampleSecurityGroup:
    Type: 'AWS::EC2::SecurityGroup'
    Properties:
      GroupDescription: Security group for ec2 access
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: '80'
          ToPort: '80'
          CidrIp: 0.0.0.0/0
        - IpProtocol: tcp
          FromPort: '8080'
          ToPort: '8080'
          CidrIp: 0.0.0.0/0
        - IpProtocol: tcp
          FromPort: '22'
          ToPort: '22'
          CidrIp: 0.0.0.0/0
  ALBTargetGroupBlue:
    Type: 'AWS::ElasticLoadBalancingV2::TargetGroup'
    Properties:
      HealthCheckIntervalSeconds: 5
      HealthCheckPath: /
      HealthCheckPort: '80'
      HealthCheckProtocol: HTTP
      HealthCheckTimeoutSeconds: 2
      HealthyThresholdCount: 2
      Matcher:
        HttpCode: '200'
      Port: 80
      Protocol: HTTP
      Tags:
        - Key: Group
          Value: Example
      TargetType: ip
      UnhealthyThresholdCount: 4
      VpcId: !Ref Vpc
  ALBTargetGroupGreen:
    Type: 'AWS::ElasticLoadBalancingV2::TargetGroup'
    Properties:
      HealthCheckIntervalSeconds: 5
      HealthCheckPath: /
      HealthCheckPort: '80'
      HealthCheckProtocol: HTTP
      HealthCheckTimeoutSeconds: 2
      HealthyThresholdCount: 2
      Matcher:
        HttpCode: '200'
      Port: 80
      Protocol: HTTP
      Tags:
        - Key: Group
          Value: Example
      TargetType: ip
      UnhealthyThresholdCount: 4
      VpcId: !Ref Vpc
  ExampleALB:
    Type: 'AWS::ElasticLoadBalancingV2::LoadBalancer'
    Properties:
      Scheme: internet-facing
      SecurityGroups:
        - !Ref ExampleSecurityGroup
      Subnets:
        - !Ref Subnet1
        - !Ref Subnet2
      Tags:
        - Key: Group
          Value: Example
      Type: application
      IpAddressType: ipv4
  ALBListenerProdTraffic:
    Type: 'AWS::ElasticLoadBalancingV2::Listener'
    Properties:
      DefaultActions:
        - Type: forward
          ForwardConfig:
            TargetGroups:
              - TargetGroupArn: !Ref ALBTargetGroupBlue
                Weight: 1
      LoadBalancerArn: !Ref ExampleALB
      Port: 80
      Protocol: HTTP
  ALBListenerProdRule:
    Type: 'AWS::ElasticLoadBalancingV2::ListenerRule'
    Properties:
      Actions:
        - Type: forward
          ForwardConfig:
            TargetGroups:
              - TargetGroupArn: !Ref ALBTargetGroupBlue
                Weight: 1
      Conditions:
        - Field: http-header
          HttpHeaderConfig:
            HttpHeaderName: User-Agent
            Values:
              - Mozilla
      ListenerArn: !Ref ALBListenerProdTraffic
      Priority: 1
  ECSTaskExecutionRole:
    Type: 'AWS::IAM::Role'
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: ''
            Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: 'sts:AssumeRole'
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy'
  BlueTaskDefinition:
    Type: 'AWS::ECS::TaskDefinition'
    Properties:
      ExecutionRoleArn: !Ref ECSTaskExecutionRole
      ContainerDefinitions:
        - Name: DemoApp
          Image: 'nginxdemos/hello:latest'
          Essential: true
          PortMappings:
            - HostPort: 80
              Protocol: tcp
              ContainerPort: 80
      RequiresCompatibilities:
        - FARGATE
      NetworkMode: awsvpc
      Cpu: '256'
      Memory: '512'
      Family: ecs-demo
  ECSDemoCluster:
    Type: 'AWS::ECS::Cluster'
    Properties: {}
  ECSDemoService:
    Type: 'AWS::ECS::Service'
    Properties:
      Cluster: !Ref ECSDemoCluster
      DesiredCount: 1
      DeploymentController:
        Type: EXTERNAL
  BlueTaskSet:
    Type: 'AWS::ECS::TaskSet'
    Properties:
      Cluster: !Ref ECSDemoCluster
      LaunchType: FARGATE
      NetworkConfiguration:
        AwsVpcConfiguration:
          AssignPublicIp: ENABLED
          SecurityGroups:
            - !Ref ExampleSecurityGroup
          Subnets:
            - !Ref Subnet1
            - !Ref Subnet2
      PlatformVersion: 1.3.0
      Scale:
        Unit: PERCENT
        Value: 1
      Service: !Ref ECSDemoService
      TaskDefinition: !Ref BlueTaskDefinition
      LoadBalancers:
        - ContainerName: DemoApp
          ContainerPort: 80
          TargetGroupArn: !Ref ALBTargetGroupBlue
  PrimaryTaskSet:
    Type: 'AWS::ECS::PrimaryTaskSet'
    Properties:
      Cluster: !Ref ECSDemoCluster
      Service: !Ref ECSDemoService
      TaskSetId: !GetAtt 
        - BlueTaskSet
        - Id

VPC とサブネットをパラメータとして指定します。
作るのは面倒なのでデフォルトVPCのものを使います。

また、テンプレートには IAM リソースが含まれるので CAPABILITY_IAM を指定する必要があります。さらにマクロを使って変更セットを作成するのでCAPABILITY_AUTO_EXPANDの指定も必要です。

aws cloudformation create-stack \
  --stack-name ecs-blue-green-sample \
  --template-body file://./sample.cf.yml \
  --parameters ParameterKey=Vpc,ParameterValue=vpc-e6023881 \
               ParameterKey=Subnet1,ParameterValue=subnet-34955b1f \
               ParameterKey=Subnet2,ParameterValue=subnet-8d8fb9d6 \
  --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND

スタックが作成されたら、ALBのDNS名を調べてアクセスしてみます。
200が返ってくることを確認します。

curl -I $(aws elbv2  describe-load-balancers --load-balancer-arns 
    $(aws cloudformation describe-stack-resource \
      --stack-name ecs-blue-green-sample \
      --logical-resource-id ExampleALB | jq -r ".StackResourceDetail.PhysicalResourceId") 
    | jq -r ".LoadBalancers[0].DNSName"
)

HTTP/1.1 200 OK
Date: Sat, 23 May 2020 07:28:44 GMT
Content-Type: text/html
Connection: keep-alive
Server: nginx/1.13.8
Expires: Sat, 23 May 2020 07:28:43 GMT
Cache-Control: no-cache

ブラウザ上ではこう見えます。

f:id:bluepixel:20200523163405p:plain

テンプレートの説明は後にして、ここから実際に新しいバージョンをデプロイしてみます。

ざっくり言うと TaskDefinition に変更が加えられたらそれがフックされてデプロイが走るので、イメージのタグをlatestからplain-textに変更します。

Image: 'nginxdemos/hello:plain-text'
aws cloudformation update-stack \
  --stack-name ecs-blue-green-sample \
  --template-body file://./sample.cf.yml \
  --parameters ParameterKey=Vpc,ParameterValue=vpc-e6023881 \
               ParameterKey=Subnet1,ParameterValue=subnet-34955b1f \
               ParameterKey=Subnet2,ParameterValue=subnet-8d8fb9d6 \
  --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND

実行すると、CloudFormation のマクロが変更セットを作成して、
CodeDeploy や GreenTaskDefinition などを作り始めます。

f:id:bluepixel:20200523164414p:plain

f:id:bluepixel:20200523164507p:plain

CodeDeploy を見て、トラフィックが新しい環境に流れ始めたことを確認します。

f:id:bluepixel:20200523164626p:plain

再びブラウザからアクセスすると、画像のようにplain-textイメージが使われていることが確認できます。

f:id:bluepixel:20200523164641p:plain

基本的な動きはこんな感じです。

マクロとフックの仕組み

テンプレートにはTransformHooksのブロックがあります。

Transformはテンプレートに対して適用できるマクロです。
通常の宣言的な記述とは違って、このマクロでテンプレートを書き換えたりします。

Hooksはそのマクロをいつどのように適用するかを定義するブロックです。 今回の ECS の Blue/Green デプロイの場合、TaskDefinitionまたはTaskSetに対する変更がフックされます。

ECSAttributes:
  TaskDefinitions:
    - BlueTaskDefinition
    - GreenTaskDefinition
  TaskSets:
    - BlueTaskSet
    - GreenTaskSet

最初にここで気になったのがGreenTaskDefinitionという論理IDです。
これはテンプレートには存在しませんが、マクロで作るリソースの論理IDをあらかじめ指定するという感じで動作しているみたいです。 名前を Red/Black に変えても動きました。
存在しない論理IDを参照してるようでちょっと違和感ある🤔

さっきは直接スタックの更新を走らせましたが、今度は変更セットを作ってマクロがどのような動作をするのかを見てみます。

f:id:bluepixel:20200523190748p:plain

f:id:bluepixel:20200523190800p:plain

以上のように、マクロは古いタスクを削除して、新しいGreen環境のリソースを作成するようなテンプレートを生成します。

これが裏側で起こっていることです。

Green環境が安定するまでにエラーが発生した場合は、通常どおりロールバックが行われ、Blue環境が維持されます。

ルーティング

サンプルではトラフィックが一気に切り替わります。
これはTrafficRoutingConfig: Type: AllAtOnceによるものです。

次は1分おきに20%ずつ新しい環境へ流していく戦略を取ります。

TrafficRoutingConfig:
  Type: TimeBasedLinear
  TimeBasedLinear: 
    StepPercentage: 20
    BakeTimeMins: 1

f:id:bluepixel:20200523173646p:plain

f:id:bluepixel:20200523173707p:plain

リロードするたびに変わるのが見て取れると思います。
20%はplain-textに流れる状態です。

f:id:bluepixel:20200523173733g:plain

他にTimeBasedCanaryという指定もできます。 これは Linear とは違って、段階的でも2段階で切り替えを行う設定のようです。

次に、テストトラフィックの指定に関してです。
時間ベースで徐々にトラフィックを切り替えるとはいえ、明示的にGreen環境の確認をしたいこともあると思います。

Optional でTestTrafficRouteを指定することで実現できます。
8080番ポートで受けるテスト用リスナーを作成します。

ALBListenerTestTraffic:
  Type: 'AWS::ElasticLoadBalancingV2::Listener'
  Properties:
    DefaultActions:
      - Type: forward
        ForwardConfig:
          TargetGroups:
          - TargetGroupArn: !Ref ALBTargetGroupGreen
             Weight: 1
    LoadBalancerArn: !Ref ExampleALB
    Port: 8080
    Protocol: HTTP

セキュリティグループに追加するのを忘れないでください。
サンプルだとすでに8080への許可がついてますが。
なぜ使ってないのに許可してるのかわからなかったがこのためか。
サンプル分かりにくいよ😂

ルーティングの設定を追加します。

TrafficRouting:  
  ProdTrafficRoute:
    Type: 'AWS::ElasticLoadBalancingV2::Listener'
    LogicalID: ALBListenerProdTraffic
  TestTrafficRoute:
    Type: 'AWS::ElasticLoadBalancingV2::Listener'
    LogicalID: ALBListenerTestTraffic

これで、トラフィックの切り替え中は、:8080にアクセスすると必ずGreen環境にフォワードされます。

その他の制約

  • デプロイ以外の変更を含んだ更新はできない

例えば、イメージの変更に加えて、ALBに新しくタグをつけたテンプレートを実行しようとすると以下のエラーが発生します。

'CodeDeployBlueGreenHook' of type AWS::CodeDeploy::BlueGreen failed with message: Additional resource diff other than ECS application related resource update is detected,CodeDeploy can't perform BlueGreen style update properly. Diff resource logical Ids: [ExampleALB]

デプロイと、それ以外の変更は分けて適用する必要があります。

  • NoEchoなパラメータや、SSMなど外部サービスから動的に参照するパラメータは利用できない

  • Outputs, ImportValue は利用できない

  • ネストしたスタックを含むテンプレートでは利用できない

  • ネストされたスタックでは利用できない

  • 既存リソースのImportでは利用できない

所感

正直、まだ導入するには尚早かなと思う。
サポートされてないものもあるし、CodeDeployを自分で構築した方が取り回しやすいかな。

運用保守がBlue/Greenデプロイを中心に考えないといけないので、柔軟性は損なわれる。

あとマクロってちょっと怖くないですか。
宣言的記述ってどこまで言っていいのかわからない。
Terraformだったらforループ使うことはよくあると思うけど、このマクロはブラックボックスすぎて変更セット見るまでdiff分からんし中身もわからん。

cobraでGoのCLIツールを作る

概要

この記事で作ったCLIツールの裏側の話です。

bluepixel.hatenablog.com

OSSとしてバイナリの配布も始めました。

github.com

GoでCLIツールを作る手段

f:id:bluepixel:20200506182434p:plain いくつか候補が見つかったんですが、何分 Go に入門したばかりなのでPros/Consが判断できず。

とりあえず Kuberntes や Hugo など大手プロジェクトに採用実績があり、viper での設定の注入など機能が豊富そうな cobra で雛形を作ってみることにしました。

github.com

サブコマンドで実装

cobraの使い方自体は日本語記事もいくつかあるので特に解説はしません。
今回作るコマンドはサブコマンドとして実装します。

このABCというライブラリには将来的に別の機能をもったサブコマンドをどんどん生やしていく予定です。

cobraでは以下のコマンドを実行するとサブコマンドの雛形が生成されます。

cobra add サブコマンド名
$ cobra add sub_command
subCommand created at /xxxx/xxx/xxx
$ ls cmd
root.go     subCommand.go
// subCommand.go
package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
)

var subCommandCmd = &cobra.Command{
    Use:   "subCommand",
    Short: "A brief description of your command",
    Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("subCommand called")
    },
}

func init() {
    rootCmd.AddCommand(subCommandCmd)
}

Runに中身を実装していくわけですが、その前に、このままだと扱いづらいので、この雛形自体を作り直します。

具体的には、ユニットテストがしづらい、入出力のストリームを握りづらい、エントリーポイントは薄く保ちたい、などです。

雛形の改修

まずはコマンドの初期化をはがします。 lib/に新しくパケージを切って、そのコマンドに関する定義は全てそこに閉じ込めます。

そして新たに*cobra.Commandを返す関数を作って以下を置き換えます。
そうすることでテストなどで取り回しやすくなります。

var subCommandCmd = &cobra.Command{
    Use:   "subCommand",
    ...
// lib/sub/sub.go
package sub

import (
    "github.com/spf13/cobra"
)

func NewCmd() *cobra.Command {
    cmd := &cobra.Command{
        Use:   "sub",
        Short: "this is sub command",
        Long:  `this is sub command. this is sub command. this is sub command. this is sub command. this is sub command. this is sub command.`,
        RunE: func(cmd *cobra.Command, args []string) error {
            cmd.Println("hello from sub command")
            return nil
        },
    }
    return cmd
}

エントリーポイントの方はこう。

// cmd/sub.go
package cmd

import (
    "github.com/Blue-Pix/abc/lib/sub"
)

var subCmd = sub.NewCmd()

func init() {
    rootCmd.AddCommand(subCmd)
}

次にサブコマンドからルートコマンドへ出力を流すようにします。
これは、テストで出力ストリームを見る際に、ルートコマンド経由でサブコマンドをテストできるようにするためです。

OutOrStdout()で現在設定されている出力ストリーム(デフォルトでは標準出力)が取れる。

func init() {
    subCmd.SetOut(rootCmd.OutOrStdout())
    rootCmd.AddCommand(subCmd)
}

一つ注意しなければいけないのは、コマンドの処理内ではcmd.Printlnのようにcmd.*を使うこと。
いつも通りカジュアルにfmt.Printlnにしているとコマンドに設定した出力ストリームに流れない。

こんな感じの雛形の変更をデフォルトのrootCmdに対しても行います。

中身の実装

エントリーポイントから分離したパッケージに内部実装を書いていきます。

ここからは実際に実装したabc amiコマンドの例で見ていきます。 github.com

まずはフラグの取り方から。
NewCmd()の初期化処理に挟み込みます。

複数のコマンド間で共通で取れるPersistentFlagsと当該コマンド内のみで有効なLocalFlagsの2種類があるのですが、今回はLocalFlagsにする。

func NewCmd() *cobra.Command {
    cmd := &cobra.Command{
        ....
    }
    cmd.Flags().StringP("version", "v", "", "os version(1 or 2)")
    cmd.Flags().StringP("virtualization-type", "V", "", "virtualization type(hvm or pv)")
    cmd.Flags().StringP("arch", "a", "", "cpu architecture(x86_64 or arm64)")
    cmd.Flags().StringP("storage", "s", "", "storage type(gp2, ebs or s3)")
    cmd.Flags().StringP("minimal", "m", "", "if minimal image or not(true or false)")
    return cmd
}

intやbooleanにできるものもあるのだが、JSON文字列で扱ったりもするので、stringの方が都合が良いのでこうした。

受ける側ではcmd.Flags().GetString("version")のようにしてコマンドラインから与えられた引数を受け取り、フィルタリングの関数を呼んでいく。

amis := getAMIList()
if version, err := cmd.Flags().GetString("version"); version != "" && err == nil {
    amis = filterByVersion(version, amis)
}
if virtualizationType, err := cmd.Flags().GetString("virtualization-type"); virtualizationType != "" && err == nil {
    amis = filterByVirtualizationType(virtualizationType, amis)
}
if arch, err := cmd.Flags().GetString("arch"); arch != "" && err == nil {
    amis = filterByArch(arch, amis)
}

そしてこちらでもテスタビリティのために、出力処理を境界に分離する。

func run(cmd *cobra.Command, args []string) {
    str := Run(cmd, args)
    cmd.Println(str)
}

func Run(cmd *cobra.Command, args []string) string {
    amis := getAMIList()

    if version, err := cmd.Flags().GetString("version"); version != "" && err == nil {
        amis = filterByVersion(version, amis)
    }
    if virtualizationType, err := cmd.Flags().GetString("virtualization-type"); virtualizationType != "" && err == nil {
        amis = filterByVirtualizationType(virtualizationType, amis)
    }
    if arch, err := cmd.Flags().GetString("arch"); arch != "" && err == nil {
        amis = filterByArch(arch, amis)
    }
    if storage, err := cmd.Flags().GetString("storage"); storage != "" && err == nil {
        amis = filterByStorage(storage, amis)
    }
    if minimal, err := cmd.Flags().GetString("minimal"); minimal != "" && err == nil {
        amis = filterByMinimal(minimal, amis)
    }
    str := toJSON(amis)
    return str
}

テストを書く

当初はサブコマンドごとにテストを書こうとしていたのだが、
argsとして渡した引数がフラグとしてパースされずにargsに残ってしまう問題があり、
普通にルートコマンド経由でサブコマンドの文字列も含めて渡してテストするようにした。

assertは以下のように出力ストリームを奪って比較する。

b := bytes.NewBufferString("")
cmd.SetOut(b)
cmd.Execute()
out, err := ioutil.ReadAll(b)
if err != nil {
    t.Fatal(err)
}
// if out == ???

実際のテストコード。
出力されたJSON文字列の正規表現のマッチを見る。
通常のオプション、短縮オプションを両方試す。

// lib/root/root_test.go
package root

func prepareCmd(args []string) *cobra.Command {
    cmd := NewCmd()
    cmd.SetArgs(args)
    amiCmd := ami.NewCmd()
    cmd.AddCommand(amiCmd)
    return cmd
}

func TestExecute(t *testing.T) {
    t.Run("ami", func(t *testing.T) {
        t.Run("query by --version", func(t *testing.T) {
            args := []string{"ami", "--version", "2"}
            cmd := prepareCmd(args)
            b := bytes.NewBufferString("")
            cmd.SetOut(b)
            cmd.Execute()
            out, err := ioutil.ReadAll(b)
            if err != nil {
                t.Fatal(err)
            }
            r := regexp.MustCompile("\"version\":\"([^\"]+)\"")
            list := r.FindAllStringSubmatch(string(out), -1)
            if len(list) == 0 {
                t.Fatal("there is no much result")
            }
            for _, l := range list {
                if l[1] != args[2] {
                    t.Fatal(fmt.Sprintf("expected: %s, actual: %s", args[2], l[1]))
                }
            }
        })

        t.Run("query by -v", func(t *testing.T) {
            args := []string{"ami", "-v", "1"}
            cmd := prepareCmd(args)
            b := bytes.NewBufferString("")
            cmd.SetOut(b)
            cmd.Execute()
            out, err := ioutil.ReadAll(b)
            if err != nil {
                t.Fatal(err)
            }
            r := regexp.MustCompile("\"version\":\"([^\"]+)\"")
            list := r.FindAllStringSubmatch(string(out), -1)
            if len(list) == 0 {
                t.Fatal("there is no much result")
            }
            for _, l := range list {
                if l[1] != args[2] {
                    t.Fatal(fmt.Sprintf("expected: %s, actual: %s", args[2], l[1]))
                }
            }
        })
    })
}

goの標準パッケージの正規表現はre2で実装されていて否定的先読みが使えなかったので、愚直にマッチした内容を見ている。

正規表現一発でいける場合はassert.Regexpが使える。
否定はassert.NotRegexpで。
NotRegexpって。

所感

CLIツールのテストどこまでやるか問題の課題。
今回書いたテストは結構E2Eまで踏み込んでいるが、
ユニットテストだけで終わらせるとなかなかCLIとのインターフェースのつなぎこみ部分でチェックできないところが多く出てくるので難しいところ。

あとは外部APIとのモックに関して。
今回はRead系の操作なのでモックせずに実際にAPIを叩いている。
AWSの認証まわりは実際に動かさないと確認できないし、
ツールとして根幹の機能が提供できていることを担保するためにもこのあたりはなるべく動かして見るべきだと思っている。
Create系はリソース後片付けがあるのでなかなか難しいが。

cobraに関しては、良いかどうかよくわからない。
viper使ったり、共通のフラグがある場合は、そのあたりの透過的な扱いの恩恵を受けられると思うが、構成によっては自前でflagなりpflagなりを管理した方がやりやすいのかもしれない。

もう2,3コマンド実装してみよう。

Amazon Linuxの最新のAMI IDを正しく取得する

言わずと知れたAWS製のLinuxマシンイメージですが、
結構頻繁にアップデートされるので、最新のIDを追う必要があります。

f:id:bluepixel:20200513202721p:plain

特にIaCが当たり前のいまでは、CloudFormationでもTerraformでもAMI IDをあらかじめ変数化しておくことが多いと思いますが、ついついコードの使い回しで新しいプロジェクトに最新ではないAMIを埋め込みがちです。

AMI IDの調べ方は3つあります。

マネジメントコンソールからウィザードで起動する

IaCでない場合はこれが楽です。

f:id:bluepixel:20200513203911p:plain

ただし、クイックスタートに出てこないタイプのAMIを使いたい場合はサイドバーから詳細な検索をする必要があります。あまりここの使い勝手はよくないです。

f:id:bluepixel:20200513203932p:plain

クイックスタートの一番上に出てくるのは、Amazon Linuxv2で、アーキテクチャx86_64、ルートデバイスタイプがebsgp2(汎用SSD)で、ミニマルではない通常のイメージです。

EC2 APIによる検索

docs.aws.amazon.com

EC2のAPIdescribe-imageというものがあり、これで検索することができます。

aws ec2 describe-images

AWS所有のパブリックイメージに絞るオプションとかつけられます。

aws ec2 describe-images --owners self amazon

前述のタイプのAMIの最新のイメージを調べるには以下のような複雑なクエリを投げる必要があります。AMIの名前を正規表現でマッチさせて、作成日時の降順でソートして最初のもの、という感じです。

aws ec2 describe-images \
    --owners amazon \
    --filters 'Name=name,Values=amzn2-ami-hvm-2.0.????????.?-x86_64-gp2' 'Name=state,Values=available' \
    --query 'reverse(sort_by(Images, &CreationDate))[:1].ImageId' \
    --output text

ぶっちゃけめちゃめちゃ使いづらくないですか?
名前の部分arm64だとどうなるんだっけーとか結局調べないといけないし 。

ドキュメント用意されてる割にはあまり良いプラクティスではない感じがします。

パラメータストアから取得

docs.aws.amazon.com

AMIはパブリックパラメータとして定義されているので、Systems ManagerのAPI経由で参照することができます。

パラメータストアでは、パラメータをパスとして扱い、階層構造を作れるようになっているので検索はこちらの方が捗ります。

最新のAmazon Linux AMIは/aws/service/ami-amazon-linux-latest/を親として、以下のパラメータが定義されています。

  • amzn-ami-hvm-x86_64-ebs
  • amzn-ami-hvm-x86_64-gp2
  • amzn-ami-hvm-x86_64-s3
  • amzn-ami-minimal-hvm-x86_64-s3
  • amzn-ami-minimal-pv-x86_64-s3
  • amzn-ami-pv-x86_64-s3
  • amzn2-ami-hvm-arm64-gp2
  • amzn2-ami-hvm-x86_64-ebs
  • amzn2-ami-hvm-x86_64-gp2
  • amzn2-ami-minimal-hvm-arm64-ebs
  • amzn-ami-minimal-hvm-x86_64-ebs
  • amzn-ami-minimal-pv-x86_64-ebs
  • amzn-ami-pv-x86_64-ebs
  • amzn2-ami-minimal-hvm-x86_64-ebs

親を指定すると再帰的に検索されるので、全ての結果が含まれてきます。 EC2 APIを用いるよりもだいぶ使い勝手が良くなりました。

$aws ssm get-parameters-by-path --path /aws/service/ami-amazon-linux-latest | jq '.Parameters[] | {Name,Value}'
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-ebs",
  "Value": "ami-0923830d7114f09d2"
}
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-gp2",
  "Value": "ami-0318ecd6d05daa212"
}
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-s3",
  "Value": "ami-089402c666ec00be9"
}
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn-ami-minimal-hvm-x86_64-s3",
  "Value": "ami-0ceddc27dc1f7852f"
}
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn-ami-minimal-pv-x86_64-s3",
  "Value": "ami-0a14693c2d4a85d4d"
}
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn-ami-pv-x86_64-s3",
  "Value": "ami-0691fa0c29ff644ef"
}
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-arm64-gp2",
  "Value": "ami-08360a37d07f61f88"
}
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-ebs",
  "Value": "ami-06aa6ba9dc39dc071"
}
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2",
  "Value": "ami-0f310fced6141e627"
}
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn2-ami-minimal-hvm-arm64-ebs",
  "Value": "ami-07fe0b0aed2e82d18"
}
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn-ami-minimal-hvm-x86_64-ebs",
  "Value": "ami-06a0756c2d7ea1c30"
}
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn-ami-minimal-pv-x86_64-ebs",
  "Value": "ami-00699c69c98dcddd0"
}
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn-ami-pv-x86_64-ebs",
  "Value": "ami-0ca3d0d6f5f1459e8"
}
{
  "Name": "/aws/service/ami-amazon-linux-latest/amzn2-ami-minimal-hvm-x86_64-ebs",
  "Value": "ami-03494c35f936e7fd7"
}

ただ、まだ特定のAMIをクエリするにはパスを覚えておく必要があります。

/aws/service/ami-amazon-linux-latest/amzn2-ami-minimal-hvm-x86_64-ebs

amzn2-ami-minimal-hvm-x86_64-ebsの部分です。

ここは規則的で、{os}{バージョン}-ami-{ミニマルかどうか}-{仮想化タイプ}-{アーキテクチャ}-{ボリューム}-{ルートデバイス}みたいになっているのでハックできそうです。

コマンドを自作する

Goで軽く実装してみました。
cobraを使ってコマンドとして利用できるようにしました。

github.com

abc ami でパスを指定しない全てのAMIを表示します。

$ abc ami | jq '.'
[
  {
    "os": "amzn",
    "version": "1",
    "virtualization_type": "hvm",
    "arch": "x86_64",
    "storage": "ebs",
    "minimal": false,
    "id": "ami-0923830d7114f09d2",
    "arn": "arn:aws:ssm:ap-northeast-1::parameter/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-ebs"
  },
...

要はさっきの名前の部分を正規表現で分解して構造体に変換しています。
こうすることによって、「arm64を使いたい」という場合は archでクエリできるようになります。

$ abc ami -a arm64 | jq '.'
[
  {
    "os": "amzn",
    "version": "2",
    "virtualization_type": "hvm",
    "arch": "arm64",
    "storage": "gp2",
    "minimal": false,
    "id": "ami-08360a37d07f61f88",
    "arn": "arn:aws:ssm:ap-northeast-1::parameter/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-arm64-gp2"
  },
  {
    "os": "amzn",
    "version": "2",
    "virtualization_type": "hvm",
    "arch": "arm64",
    "storage": "ebs",
    "minimal": true,
    "id": "ami-07fe0b0aed2e82d18",
    "arn": "arn:aws:ssm:ap-northeast-1::parameter/aws/service/ami-amazon-linux-latest/amzn2-ami-minimal-hvm-arm64-ebs"
  }
]

こんな感じで細かく特定することが可能です。

$ abc ami -v 2 -V hvm -a arm64 -s gp2 -m false | jq '.'
[
  {
    "os": "amzn",
    "version": "2",
    "virtualization_type": "hvm",
    "arch": "arm64",
    "storage": "gp2",
    "minimal": false,
    "id": "ami-08360a37d07f61f88",
    "arn": "arn:aws:ssm:ap-northeast-1::parameter/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-arm64-gp2"
  }
]

(おまけ)そもそもAmazon Linuxってそんなに種類あるんだっけ...?

現在でも14のAMIが利用できます。

バージョン

2017年にAmazon Linux 2がリリースされ、バージョン番号のない初代Amazon Linuxはサポートが終了することになりました。
当初は2020年6月30日の予定でしたが、12月31日まで延長されたようです。
また、サポート終了後もメンテナンスサポート期間として2023年6月30日までは重大なセキュリティアップデートは行ってくれるようです。

Amazon Linux AMI のサポート期間終了に関する更新情報 | Amazon Web Services ブログ

ということで基本はAmazon Linux 2 を利用します。

仮想化タイプ

専門的な話でよくはわからないのですが、準仮想化 (PV)ハードウェア仮想マシン (HVM)という2つの種類が存在します。
(PV on HVM というものもありますが割愛します。)

ただこれも明確で、HVMを使ってください。 PVは使用できないインスタンスタイプがあり、PVタイプが使用されているのは旧世代のAMIなのでバージョン2ではHVMが基本になります。

アーキテクチャ

x86_64arm64 です。

ルートデバイスボリューム

EBSに置くEBS-backedとS3に置くinstance store-backedがあります。
instance store-backedAmazon Linux 2にないようですが使えなくなったんでしたっけ...?まあ昔からあるのはこっちなのでサポートしなくなったのかな。

Amazon EBS-Backed Linux AMI の作成 - Amazon Elastic Compute Cloud

あとEBS-backedの中でも旧世代のEBSボリュームタイプではなく汎用SSD(gp2)を用いるのが一般的です。

ミニマル

軽量なのがほしい時はコミュニティAMIでminimalというものが提供されています。
詳しくは以下の記事で。

dev.classmethod.jp

結局

2で、HVMで、gp2のやつ。
クイックスタートの一番上に出てくるやつを使っておけば間違いない。

$ abc ami -v 2 -V hvm -a x86_64 -s gp2 -m false | jq '.'
[
  {
    "os": "amzn",
    "version": "2",
    "virtualization_type": "hvm",
    "arch": "x86_64",
    "storage": "gp2",
    "minimal": false,
    "id": "ami-0f310fced6141e627",
    "arn": "arn:aws:ssm:ap-northeast-1::parameter/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"
  }
]

ap-northeast-1a は概念

f:id:bluepixel:20200512225558p:plain

アベイラビリティゾーンについて学ぶ

東京リージョンap-northeast-1 では4つの利用可能なAZ(アベイラビリティゾーン)が提供されている。

  • ap-northeast-1a
  • ap-northeast-1b
  • ap-northeast-1c
  • ap-northeast-1d

これらは実体として1つずつの独立したデータセンターを表しており、
耐障害性、安定性の確保のために物理的に分けられている。

いわゆるマルチAZ構成にして、片方のDCに隕石が直撃したときでもサービスを継続できるようにするためである。

全てのAZが使えるわけではない

アカウントで利用できるAZは以下のコマンドで確認できる。

$ aws ec2 describe-availability-zones --region ap-northeast-1 | jq -r '.AvailabilityZones[].ZoneName'
ap-northeast-1a
ap-northeast-1c
ap-northeast-1d

1bがない。
最近作ったアカウントでは1bは利用できない。
これはすでに廃止?に向かっているAZで、
新規リソースの作成ができないようになっているらしい。

1bが利用できないアカウントでは3つのAZを使ってリソースを構築していくことになる。

ap-northeast-1a

「あるAZは独立したデータセンターを表している」と最初に書きましたが、
これに関してよく勘違いされていることがあります。

ap-northeast-1aというデータセンターはありません。
あるアカウントにおける1aと、別のアカウントにおける1aが、
同じデータセンターを指しているとは限らないということです。

以下ドキュメントからの引用です。

アベイラビリティーゾーンは、リージョンコードとそれに続く文字識別子によって表されます (us-east-1a など)。 リソースがリージョンの複数のアベイラビリティーゾーンに分散するように、 アベイラビリティーゾーンは各 AWS アカウントの名前に個別にマップされます。 たとえば、ご使用の AWS アカウントのアベイラビリティーゾーン us-east-1a は 別の AWS アカウントのアベイラビリティーゾーン us-east-1a と同じ場所にはない可能性があります。

docs.aws.amazon.com

去年8月に東京リージョンで大規模な障害があった時、
1aと1dが障害を受けたとかそんなコメントがたくさん流れましたが、
これはあくまでその人のアカウントにおける1aとか1dの話であって、
「1aだけどうちは被害受けてない...」みたいなのがあったのはそういうわけです。

ちなみに公式の声明では単一のアベイラビリティゾーンとして具体的なAZは明らかにされていませんが、パートナー等各社のレポートを見るとapne1-az4だと思われます。

aws.amazon.com

閑話休題

AZ ID

じゃあ物理的なDCを特定するのって無理なの?というとそんなことはなくて、1aとか1c以外に一意にAZを特定できるAZ IDというものがあります。

先ほどのAPIZoneIdというプロパティにあたるものです。

$aws ec2 describe-availability-zones --region ap-northeast-1 | jq -r '.AvailabilityZones[] | { ZoneName, ZoneId }'
{
  "ZoneName": "ap-northeast-1a",
  "ZoneId": "apne1-az4"
}
{
  "ZoneName": "ap-northeast-1c",
  "ZoneId": "apne1-az1"
}
{
  "ZoneName": "ap-northeast-1d",
  "ZoneId": "apne1-az2"
}

上の例でいうと、あるDCに一意に割り振られているIDがapne1-az4であり、このアカウントにおいて1aにマッピングされていて、1aに作成したリソースは全てそのDCに配置されるということです。

で、これって何か役に立つの?

DCが特定できないと困ることがあります。
例えばアカウント間で共有VPCを作る場合、1aが指すDCが異なっていると適切な分散が行えません。 そういう時にこのAZ IDを使って調節を行います。

アカウント間でアベイラビリティーゾーンを調整するには、 アベイラビリティーゾーンの一意で一貫性のある識別子である AZ ID を使用する必要があります。 たとえば、use1-az1 は、us-east-1 リージョンの AZ ID で、すべての AWS アカウントで同じ場所になります。

docs.aws.amazon.com

DCの具体的な場所

基本的には非公開なのですが、WikiLeaksや協力事業会社の情報などから
ある程度の場所は推定されているようです。

xn--o9j8h1c9hb5756dt0ua226amc1a.com

以上です。

aws-sdk-goを触ってみた所感

f:id:bluepixel:20200506182434p:plain

普段はcli, Ruby, Node.jsなどでAWSと戯れていますが、今回初めてaws-sdk-goを使ってみたので所感をしたためておきます。

▼題材はこれです。

bluepixel.hatenablog.com

Rubyで書いた40行ほどのスクリプトをGo実装で焼き直してみました。

rezept/main.go at master · Blue-Pix/rezept · GitHub

ちなみにGoは本当にちょっとしか書いたことがないです。
文法は一通りさらってありますが、細かい作法はよく知りません。
なのでaws-sdk-goではなくGoそのものに対する所感を含まれているかもしれません。

パッケージ

AWSまわりはこのへん

import(
  "github.com/aws/aws-sdk-go/aws"
  "github.com/aws/aws-sdk-go/aws/awserr"
  "github.com/aws/aws-sdk-go/aws/session"
  "github.com/aws/aws-sdk-go/service/cloudformation"
)

awsはポインタを返すaws.String()や、認証のconfigとかで使う。
レスポンスもaws.StringValue()とかで扱ってますが、このヘルパーって自分でポインタにすれば別に使わなくてもいいんだよね...?

awserrはエラーコードの判定。詳細知らなくてもよければ特に必要ない。

sessionは認証で、service/cloudformationは各サービスのAPI実装。

認証

環境変数のアクセスキーやプロファイルを順に読み込んでいくのは他のSDKと変わらず。

特に何も指定しない場合はこう。

sess := session.Must(session.NewSession())

session.Must()を使えば以下のようなハンドリングを書かずに済む。

sess, err := session.NewSession()
if err != nil {
  panic(err)
}

プロファイルを使う場合はこういう感じ。

sess := session.Must(
  session.NewSessionWithOptions(
    session.Options{
      Profile: "myProfile",
    },
  ),
)

API

サービスごとにクライアントの作成

service := cloudformation.New(sess)

パラメータは、各APIの名前にInputというサフィックスをつけた構造体で渡す。ListStacksAPIであれば、ListStacksInputになる。

params := &cloudformation.ListStacksInput{
  NextToken: token,
}

実行。

resp, err := service.ListStacks(params)
if err != nil {
  panic(err)
}

レスポンスはポインタになるのでaws.StringValueとかを使って変換する。

for _, stack := range resp.StackSummaries {
  result[aws.StringValue(stack.StackId)] = aws.StringValue(stack.StackName)
}

ページングも同じで次ページのトークンを含める方式。

if resp.NextToken != nil {
  listStacks(sess, resp.NextToken, result)
}

サービス名とAPI名を一定のルールに沿って変換するだけなので特に難しくない。他の言語でAWS実装に親しんでいればなんとなくで予想して書ける。

使えないオプションがある

例えばaws cloudformation list-stacksにはmax-itemsというオプションがあるが、aws-sdk-goの方では使用できない。

list-stacks — AWS CLI 1.18.53 Command Reference

エラーハンドリング

詳細にエラーの種類を把握したい時にはawserr.Errorに変換してごにょごにょやる必要がある。

Handling Errors in the AWS SDK for Go - AWS SDK for Go

このあたりちょっと記述が冗長になってつらい。

resp, err := service.ListImports(params)
if err != nil {
  if aerr, ok := err.(awserr.Error); ok {
    if aerr.Code() == "ValidationError" && strings.Contains(aerr.Message(), "is not imported by any stack") {
      return
    }
  }
  panic(err)
}

参考資料

docs.aws.amazon.com

docs.aws.amazon.com

www.techscore.com

dev.classmethod.jp

その他Goに対する所感

  • いまはGo modulesが主流なんですか?
  • スライスはポインタであってポインタではない...?
  • デフォルト引数サクッと使いたい。Functional Option Pattern...?

SQSのイベントをトリガーに起動するLambdaの仕組みを完全に理解する🧘‍♀️

先日S3のイベント駆動でLambdaをトリガーする記事を書きましたが、今回はSQSのイベントをトリガーにします。

bluepixel.hatenablog.com

LambdaのイベントソースにSQSが指定できるようになったのは意外にだいぶ遅めで、2018年4月です。FIFOキューはさらにその1年半後になります。

aws.amazon.com

スタックの作成

AWSTemplateFormatVersion: "2010-09-09"
Parameters:
  Name:
    Description: identifier
    Type: String
    Default: sqs-lambda-event
Resources:
  Queue:
    Type: AWS::SQS::Queue
    Properties: 
      QueueName: !Ref Name
 
  EventSourceMapping:
    Type: AWS::Lambda::EventSourceMapping
    Properties: 
      Enabled: true
      EventSourceArn: !GetAtt Queue.Arn
      FunctionName: !GetAtt Lambda.Arn
      BatchSize: 1

  Lambda:
      Type: AWS::Lambda::Function
      Properties:
        FunctionName: !Ref Name
        Handler: index.lambda_handler
        Role: !GetAtt LambdaRole.Arn
        Runtime: nodejs12.x
        Code:
          ZipFile: |
            exports.lambda_handler = async (event, context) => {
              const util = require('util');
              console.log(util.inspect(event,false,null));
              return 200;
            }

  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns: 
        - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
      Policies:
        - PolicyName: sqs-access
          PolicyDocument:
            Statement:
              - Sid: 1
                Effect: Allow
                Action:
                  - sqs:DeleteMessage
                  - sqs:GetQueueAttributes
                  - sqs:ReceiveMessage
                Resource:
                  - !GetAtt Queue.Arn

Lambdaの実行ロールにはSQSを扱うための以下の権限が必要です。

  • sqs:DeleteMessage
  • sqs:GetQueueAttributes
  • sqs:ReceiveMessage

イベントの設定はLambda::EventSourceMappingで行います。 バッチサイズにはメッセージの取得件数を指定します。

動作確認

バッチで10件のメッセージをキューに送信します。

aws sqs send-message-batch --queue-url $QUEUE_URL --entries file://entries.json
[
  {
    "Id": "1",
    "MessageBody": "message: 1"
  },
  {
    "Id": "2",
    "MessageBody": "message: 2"
  },
...

BatchSizeが1なので、10回Lambdaが起動します。
ちなみに関数は同期呼び出しです。

eventの中身はこんな感じです。

{
  "Records": [
    {
      "messageId": "aa7e56f2-198b-46d2-9bdb-fd71ce34c02a",
      "receiptHandle": "AQEBJf14oNlhTcHzramlg4Zv800hpZSJu17DzO1L5B66TKlBN8Nq062rNWUljYtfB4nzKq8qxMwIZ1Bt5Zb99Ou5nHQixmo8WIf6oJTviKkqK4mKgmr+oC2YDR+FnsTHZclRXC04t6aeA/rHFizcNmF+zI/nqRj32URWkfQ8XoY+xipzAYFKQFdukfb1lOOA4f+hLdPqFm3Wq5LB0cM0E3xL3GhxEUU4jppW5thRMFQG2IVcU4Xt/UwIh5DuLvlNErTjCa2ROplPkNdjbtopHf075UH/ecg+CYyG1xgEOiwCaHAJXeco1zlLGP+dNHHDBWuPZktV8YR0jHRbD8LAfq5ku3TbSvJE8SnVz2tFZo5j9YVe5LdICWASXZl2qi1+sXjCjfmLeNyVzW/mJ70u4dXwNQ==",
      "body": "message: 1",
      "attributes": {
        "ApproximateReceiveCount": "1",
        "SentTimestamp": "1588712405385",
        "SenderId": "AIDAXTNEXWMNORT3ASWF5",
        "ApproximateFirstReceiveTimestamp": "1588712405388"
      },
      "messageAttributes": {},
      "md5OfBody": "6405aa584acecc53be5693fc1ecc83cb",
      "eventSource": "aws:sqs",
      "eventSourceARN": "arn:aws:sqs:ap-northeast-1:xxxxxxxx:sqs-lambda-event",
      "awsRegion": "ap-northeast-1"
    }
  ]
}

バッチ処理に関して

例えばバッチサイズを10にした場合。
10個のインフライトメッセージが存在する場合でも、イベントが1つになるとは限りません。

動作確認ではメッセージが2つ、6つ、2つに分割されて3回起動されました。

f:id:bluepixel:20200506062445p:plain

これはイベントマッピングの問題ではなく、単純にSQSのアーキテクチャに依る仕様です。

bluepixel.hatenablog.com

裏側が分散システムになっているので、バッチサイズの最大数を取得できる保証はありません。

また、ドキュメントにはMaximumBatchingWindowInSecondsというプロパティが存在しますが、SQSをイベントソースとする場合これは指定することができません。以下のエラーが発生します。

MaximumBatchingWindowInSeconds isn't supported for this event source type.

これはストリーム型のKinesisまたはDynamoDBのための設定値です。

実はロングポーリング

イベントとは実は名ばかりで、裏側ではロングポーリングが使用されています。上述のMaximumBatchingWindowInSecondsが使えないのもそのためで、SQSのポーリング待機時間が優先されるためです。

具体的には基本5つのプロセスが並列で20秒のロングポーリングを行なっていて、インフライトメッセージの数に合わせてスケールするようになっています。最大で1000バッチ処理できるそうです。

気になるのが料金で、このポーリングの部分はしっかり課金されます。
全くメッセージがない場合でもLambdaのイベント設定が有効になっているだけで、およそ5×(60/20)×60×24×30=648,000/monthほどのリクエストが発生することになるので注意してください。

f:id:bluepixel:20200506063702p:plain

イベント駆動なので節約できると思いきや全然そんなことないので、ワーカーを自分で構築しなくて済むくらいの感じです。

メッセージの削除

関数が正常に終了すればメッセージは自動的に削除されます。

タイムアウト

Lambda関数のタイムアウトはメッセージの保持期間より短くする必要があります。処理中にタイムアウトするのを防ぐためです。

また、キューの可視性タイムアウトは関数のタイムアウト値の6倍以上に設定することが推奨されています。

FIFOキュー

S3のイベントでは通常キューしか使えませんでしたが、こちらはFIFOキューもサポートされています。

公式ドキュメント

docs.aws.amazon.com

使用したコード

github.com

CloudFormationで使われていないExportを洗い出す

f:id:bluepixel:20200505223512p:plain

CloudFormationにおいてスタック間で値を受け渡したいときに、参照される側で出力値をエクスポートして、参照する側でFn::ImportValueで読み込む方法があります。

docs.aws.amazon.com

エクスポートされている値は各スタック詳細の出力や[エクスポート]から見ることができます。

f:id:bluepixel:20200505221611p:plain

f:id:bluepixel:20200505221623p:plain

どのスタックからインポートされているかも確認できます。

f:id:bluepixel:20200505221646p:plain

ただし、どこからも参照されていないエクスポート値を調べるのはマネジメントコンソールでは無理なのでAPIを使って洗い出すスクリプトを書きました 。

まず最初にスタックIDとスタック名のリストを作ります。 これは、後に出てくるAPIのレスポンスにスタックIDしか含まれていないので、わかりやすいスタック名を表示するためです。

def list_stacks(result = {}, next_token = nil)
  sleep 1
  resp = @client.list_stacks(:next_token => next_token)
  resp.stack_summaries.each do |stack|
    result[stack.stack_id] = stack.stack_name
  end
  list_stacks(result, resp.next_token) if resp.next_token
  result
end

スタックIDをキー、スタック名をバリューとしたHashが返ります。
この手のAWSAPIトークンベースのページングになっているので、結果がある限りループします。ループの部分は再帰で実装しています。

あとドキュメントが見つからなかったのですが、数が多いとスロットリングが発生するので1秒起きにリクエストを送っています。スタック数、エクスポート数ともに50くらいの環境では1秒の待機でいけました。

次にエクスポート値を全て取得します。

def list_exports(result = {}, next_token = nil)
  sleep 1
  resp = @client.list_exports(:next_token => next_token)
  resp.exports.each do |export|
    result[export.name] = { :value => export.value, :stack_id => export.exporting_stack_id }
  end
  list_exports(resp.next_token) if resp.next_token
  result
end

これも同様にページングです。
エクスポート名をキーにした、エクスポート値と出力しているスタックIDのHashを返します。

次にエクスポートごとにインポート状態の確認を行います。

def list_imports(export_name, result = [], next_token = nil)
  sleep 1
  resp = @client.list_imports(:export_name => export_name, :next_token => next_token)
  result << resp.imports
  list_imports(export_name, result, resp.next_token) if resp.next_token
  result
rescue Aws::CloudFormation::Errors::ValidationError => e
  return [] if e.message.include?("is not imported by any stack")
  raise e
end

戻り値は参照しているスタック名の配列です。

どこからも参照されていない場合、ValidationErrorが発生するのでハンドリングしてます。 これはこのケースに限らない共通のエラークラスなので、一応詳細なエラーメッセージも判定するようにしています。

あとは、最初に取得しておいたスタックIDとスタック名のマップを使ってよしなに変換を行い、どこからもインポートされていないエクスポートをプリントして終わりです。

if __FILE__ == $0
  @client = Aws::CloudFormation::Client.new
  stacks = list_stacks
  exports = list_exports
  puts "following exports value is not used in any stack."
  exports.keys.each do |key|
    exports[key][:importing_stacks] = list_imports(key)
    puts "#{key} (defined in #{stacks[exports[key][:stack_id]]})" if exports[key][:importing_stacks].size == 0
  end
end

思いの外面倒でした。

ソースコードはこちら github.com