CloudFormationでECSのBlue/Greenデプロイができるようになったので試す
概要
これまで CodeDeploy と連携させて実現していた ECS の Blue/Green デプロイが CloudFormation にインテグレートされて、External
なデプロイメントコントローラーとして使用できるようになりました。これによって、一連のデプロイパイプラインをテンプレート上に集約して構築することができます。
プレスリリース
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
ブラウザ上ではこう見えます。
テンプレートの説明は後にして、ここから実際に新しいバージョンをデプロイしてみます。
ざっくり言うと 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
などを作り始めます。
CodeDeploy を見て、トラフィックが新しい環境に流れ始めたことを確認します。
再びブラウザからアクセスすると、画像のようにplain-text
イメージが使われていることが確認できます。
基本的な動きはこんな感じです。
マクロとフックの仕組み
テンプレートにはTransform
とHooks
のブロックがあります。
Transform
はテンプレートに対して適用できるマクロです。
通常の宣言的な記述とは違って、このマクロでテンプレートを書き換えたりします。
Hooks
はそのマクロをいつどのように適用するかを定義するブロックです。 今回の ECS の Blue/Green デプロイの場合、TaskDefinition
またはTaskSet
に対する変更がフックされます。
ECSAttributes: TaskDefinitions: - BlueTaskDefinition - GreenTaskDefinition TaskSets: - BlueTaskSet - GreenTaskSet
最初にここで気になったのがGreenTaskDefinition
という論理IDです。
これはテンプレートには存在しませんが、マクロで作るリソースの論理IDをあらかじめ指定するという感じで動作しているみたいです。 名前を Red/Black に変えても動きました。
存在しない論理IDを参照してるようでちょっと違和感ある🤔
さっきは直接スタックの更新を走らせましたが、今度は変更セットを作ってマクロがどのような動作をするのかを見てみます。
以上のように、マクロは古いタスクを削除して、新しいGreen環境のリソースを作成するようなテンプレートを生成します。
これが裏側で起こっていることです。
Green環境が安定するまでにエラーが発生した場合は、通常どおりロールバックが行われ、Blue環境が維持されます。
ルーティング
サンプルではトラフィックが一気に切り替わります。
これはTrafficRoutingConfig: Type: AllAtOnce
によるものです。
次は1分おきに20%ずつ新しい環境へ流していく戦略を取ります。
TrafficRoutingConfig: Type: TimeBasedLinear TimeBasedLinear: StepPercentage: 20 BakeTimeMins: 1
リロードするたびに変わるのが見て取れると思います。
20%はplain-text
に流れる状態です。
他に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ツールの裏側の話です。
OSSとしてバイナリの配布も始めました。
GoでCLIツールを作る手段
いくつか候補が見つかったんですが、何分 Go に入門したばかりなのでPros/Consが判断できず。
とりあえず Kuberntes や Hugo など大手プロジェクトに採用実績があり、viper での設定の注入など機能が豊富そうな cobra で雛形を作ってみることにしました。
サブコマンドで実装
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を追う必要があります。
特にIaCが当たり前のいまでは、CloudFormationでもTerraformでもAMI IDをあらかじめ変数化しておくことが多いと思いますが、ついついコードの使い回しで新しいプロジェクトに最新ではないAMIを埋め込みがちです。
AMI IDの調べ方は3つあります。
マネジメントコンソールからウィザードで起動する
IaCでない場合はこれが楽です。
ただし、クイックスタートに出てこないタイプのAMIを使いたい場合はサイドバーから詳細な検索をする必要があります。あまりここの使い勝手はよくないです。
クイックスタートの一番上に出てくるのは、Amazon Linuxのv2
で、アーキテクチャがx86_64
、ルートデバイスタイプがebs
のgp2(汎用SSD)
で、ミニマルではない通常のイメージです。
EC2 APIによる検索
EC2のAPIにdescribe-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だとどうなるんだっけーとか結局調べないといけないし 。
ドキュメント用意されてる割にはあまり良いプラクティスではない感じがします。
パラメータストアから取得
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を使ってコマンドとして利用できるようにしました。
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_64 か arm64 です。
ルートデバイスボリューム
EBSに置くEBS-backedとS3に置くinstance store-backedがあります。
instance store-backedはAmazon Linux 2にないようですが使えなくなったんでしたっけ...?まあ昔からあるのはこっちなのでサポートしなくなったのかな。
Amazon EBS-Backed Linux AMI の作成 - Amazon Elastic Compute Cloud
あとEBS-backedの中でも旧世代のEBSボリュームタイプではなく汎用SSD(gp2)を用いるのが一般的です。
ミニマル
軽量なのがほしい時はコミュニティAMIでminimalというものが提供されています。
詳しくは以下の記事で。
結局
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 は概念
アベイラビリティゾーンについて学ぶ
東京リージョン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 と同じ場所にはない可能性があります。
去年8月に東京リージョンで大規模な障害があった時、
1aと1dが障害を受けたとかそんなコメントがたくさん流れましたが、
これはあくまでその人のアカウントにおける1aとか1dの話であって、
「1aだけどうちは被害受けてない...」みたいなのがあったのはそういうわけです。
ちなみに公式の声明では単一のアベイラビリティゾーン
として具体的なAZは明らかにされていませんが、パートナー等各社のレポートを見るとapne1-az4
だと思われます。
閑話休題。
AZ ID
じゃあ物理的なDCを特定するのって無理なの?というとそんなことはなくて、1aとか1c以外に一意にAZを特定できるAZ ID
というものがあります。
先ほどのAPIでZoneId
というプロパティにあたるものです。
$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 アカウントで同じ場所になります。
DCの具体的な場所
基本的には非公開なのですが、WikiLeaksや協力事業会社の情報などから
ある程度の場所は推定されているようです。
xn--o9j8h1c9hb5756dt0ua226amc1a.com
以上です。
aws-sdk-goを触ってみた所感
普段はcli, Ruby, Node.jsなどでAWSと戯れていますが、今回初めてaws-sdk-go
を使ってみたので所感をしたためておきます。
▼題材はこれです。
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
というサフィックスをつけた構造体で渡す。ListStacks
APIであれば、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) }
参考資料
その他Goに対する所感
- いまはGo modulesが主流なんですか?
- スライスはポインタであってポインタではない...?
- デフォルト引数サクッと使いたい。Functional Option Pattern...?
SQSのイベントをトリガーに起動するLambdaの仕組みを完全に理解する🧘♀️
先日S3のイベント駆動でLambdaをトリガーする記事を書きましたが、今回はSQSのイベントをトリガーにします。
LambdaのイベントソースにSQSが指定できるようになったのは意外にだいぶ遅めで、2018年4月です。FIFOキューはさらにその1年半後になります。
スタックの作成
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回起動されました。
これはイベントマッピングの問題ではなく、単純にSQSのアーキテクチャに依る仕様です。
裏側が分散システムになっているので、バッチサイズの最大数を取得できる保証はありません。
また、ドキュメントには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ほどのリクエストが発生することになるので注意してください。
イベント駆動なので節約できると思いきや全然そんなことないので、ワーカーを自分で構築しなくて済むくらいの感じです。
メッセージの削除
関数が正常に終了すればメッセージは自動的に削除されます。
タイムアウト
Lambda関数のタイムアウトはメッセージの保持期間より短くする必要があります。処理中にタイムアウトするのを防ぐためです。
また、キューの可視性タイムアウトは関数のタイムアウト値の6倍以上に設定することが推奨されています。
FIFOキュー
S3のイベントでは通常キューしか使えませんでしたが、こちらはFIFOキューもサポートされています。
公式ドキュメント
使用したコード
CloudFormationで使われていないExportを洗い出す
CloudFormationにおいてスタック間で値を受け渡したいときに、参照される側で出力値をエクスポートして、参照する側でFn::ImportValue
で読み込む方法があります。
エクスポートされている値は各スタック詳細の出力や[エクスポート]から見ることができます。
どのスタックからインポートされているかも確認できます。
ただし、どこからも参照されていないエクスポート値を調べるのはマネジメントコンソールでは無理なので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が返ります。
この手のAWSのAPIはトークンベースのページングになっているので、結果がある限りループします。ループの部分は再帰で実装しています。
あとドキュメントが見つからなかったのですが、数が多いとスロットリングが発生するので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