ちょっと話題の記事

awspecでAWSリソースをテストする

2018.02.05

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、佐伯です。

AWSリソースの設定をマネジメントコンソールから目視確認するのはつらみがありますよね。ということで、今日はAWSリソースをテストするツール、awspecについて紹介したいと思います。

awspecとは

awspecはAWSのリソース構成をServerspecのようにテストできるツールです。GitHubリポジトリは下記になります。

GitHub - k1LoW/awspec: RSpec tests for your AWS resources.

ローカル環境を整える

rbenv, Bundler, direnvの話になるので、普段使ってる方は省略してください。

OSSを利用する際、バージョン管理が重要な課題になってきます。注目度が高いOSSほど頻繁にバージョンアップされ、新たな機能追加やバグ修正が行われます。また、稀にバージョンアップによって意図せぬバグが生まれ、以前のバージョンではできていたことができなくなってしまうこともあります。

そのため、既存のコードが動作するかをバージョンアップのタイミングで確認せずに安易にバージョンアップしてしまうと、ハマってしまうことがありますので、rbenvでRubyのバージョンを、Bundlerでgemのバージョンを管理できるように、ローカル環境を整えましょう。

なお、その後CIを導入する場合でも同じ仕組みを使うことができると思いますので、ちゃんと取り組みたいポイントになります。

rbenvのインストール

といいつつも、過去エントリのご紹介になります。下記エントリを参考にrbenvをインストールして下さい。rbenvは複数のRubyバージョンをディレクトリごとなどの単位で切り替えることができます。

rbenvとruby-buildで複数バージョンのrubyをインストール

様々な言語の実行環境が必要な方は下記エントリを参考にanyenvをインストールしても良いと思います。

anyenvを使って*env系をまとめて管理

Bundlerのインストール

こちらも過去エントリ(というか↑の続き)の紹介になります。Bundlerでgemのバージョン管理を行うのでインストールします。

bundlerでgemをプロジェクトごとに管理する

(必要に応じて)direnvのインストール

複数のAWSアカウントを使用している場合は、AWSアカウント毎のIAMユーザーのアクセスキー/シークレットキーを使用するのではなく、direnvを使用してAssumeRoleで一時クレデンシャルを環境変数にセットする方法がおすすめです。

[小ネタ]ディレクトリ移動した際に自動で一時クレデンシャルを取得・設定する

セットアップ

インストール手順はGitHub - k1LoW/awspec: RSpec tests for your AWS resources.に記載のとおりですが、上記のツール類を使った場合のセットアップ方法を書きます。

Rubyのインストール

awspecはRuby 2.1以上が必要です。Ruby 2.4.3までテスト済みのようなので、今回はRuby 2.4.3をインストールします。

$ rbenv install 2.4.3
$ rbenv local 2.4.3
$ rbenv versions
  system
* 2.4.3 (set by /Users/saiki.ko/example/.ruby-version)

Bundlerのインストール

私の環境にRuby 2.4.3を初めて入れたので、Bundlerもインストールしておきます。

$ gem install bundler

awspecのインストール

Gemfileを生成した後にawspecとrake(後ほど実行するawspec initでRakeタスク(Rakefile)を生成してくれるので)を追記し、bundle installを実行してawspecとrakeをインストールします。

$ bundle init
Writing new Gemfile to /Users/saiki.ko/example/Gemfile
$ echo 'gem "awspec"' >>Gemfile
$ echo 'gem "rake"' >>Gemfile
$ bundle install --path vendor/bundle
Fetching gem metadata from https://rubygems.org/....................
Resolving dependencies...
Fetching aws-partitions 1.57.0
Installing aws-partitions 1.57.0
Fetching aws-sigv4 1.0.2
Installing aws-sigv4 1.0.2
Fetching jmespath 1.3.1
Installing jmespath 1.3.1
Fetching aws-sdk-core 3.14.0
Installing aws-sdk-core 3.14.0
......(省略)......

awspecディレクトリとinitファイルの作成

awspecのコードはawspecディレクトリで管理したい(後ほどTerraformのコードも作成予定)ので、ディレクトリを作成し、awspec initコマンドでinitファイルを生成します。

$ mkdir awspec
$ cd awspec
$ bundle exec awspec init
 + spec/
 + spec/spec_helper.rb
 + Rakefile
 + spec/.gitignore
 + .rspec

認証情報の設定

AWS CLIの認証情報にも対応してますし、spec/secrets.ymlに設定してもよいですし、紹介したdirenvを使って環境変数にセットする形でもよいです。また、IAMの権限は基本的にはRead権限があれば大抵のテストは実行できると思います。

動かしてみる

awspecを使ってAWSリソースをテストしてみましょう。

その前に

そもそもテストするAWSリソースがないので、さくっとTerraformで作っちゃいます。terraformディレクトリを作成し、その中にTerraform Module Refistryのvpc/awsモジュールを使ったvpc.tfを作成し、VPC、サブネット、ルートテーブル、インターネットゲートウェイを作成しました。

vpc.tf

provider "aws" {
  region = "ap-northeast-1"
}

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = "my-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["ap-northeast-1a", "ap-northeast-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24"]

  enable_nat_gateway = false
  enable_vpn_gateway = false

  tags = {
    Terraform   = "true"
    Environment = "dev"
  }
}

テストコードを書く

現在のディレクトリ構成は下記のとおりです。awspec/spec配下にテストコードを作成します。

example
├── Gemfile
├── Gemfile.lock
├── awspec
│   ├── Rakefile
│   └── spec
├── terraform
│   └── vpc.tf
└── vendor
    └── bundle

基本的にはドキュメントを読みながらリソースごとにテストする項目を決め、*_spec.rbというファイル名でテストを書きます。特にリソース単位でファイルを分ける必要はないですが、私はなんとなく分けてます。

また、spec/spec_helper.rbでawspecをロードしてるので、各テストコードファイルの先頭にrequire 'spec_helper'を書きましょう。

VPC

awspec/spec/vpc_spec.rb

require 'spec_helper'

describe vpc('my-vpc') do
  it { should exist }
  it { should be_available }
  its(:cidr_block) { should eq '10.0.0.0/16' }
  its(:dhcp_options_id) { should eq 'dopt-XXXXXXXX' }
  it { should have_route_table('my-vpc-private-ap-northeast-1a') }
  it { should have_route_table('my-vpc-private-ap-northeast-1c') }
  it { should have_route_table('my-vpc-public') }
  it { should have_network_acl('acl-XXXXXXXX') }
  it { should have_tag('Name').value('my-vpc') }
  it { should have_tag('Environment').value('dev') }
end

Subnet

awspec/spec/subnet_spec.rb

require 'spec_helper'

describe subnet('my-vpc-private-ap-northeast-1a') do
  it { should exist }
  it { should be_available }
  it { should belong_to_vpc('my-vpc') }
  its(:availability_zone) { should eq 'ap-northeast-1a' }
  its(:cidr_block) { should eq '10.0.1.0/24'}
  its(:map_public_ip_on_launch) { should eq false }
  it { should have_tag('Name').value('my-vpc-private-ap-northeast-1a') }
  it { should have_tag('Environment').value('dev') }
end

describe subnet('my-vpc-private-ap-northeast-1c') do
  it { should exist }
  it { should be_available }
  it { should belong_to_vpc('my-vpc') }
  its(:availability_zone) { should eq 'ap-northeast-1c' }
  its(:cidr_block) { should eq '10.0.2.0/24'}
  its(:map_public_ip_on_launch) { should eq false }
  it { should have_tag('Name').value('my-vpc-private-ap-northeast-1c') }
  it { should have_tag('Environment').value('dev') }
end

describe subnet('my-vpc-public-ap-northeast-1a') do
  it { should exist }
  it { should be_available }
  it { should belong_to_vpc('my-vpc') }
  its(:availability_zone) { should eq 'ap-northeast-1a' }
  its(:cidr_block) { should eq '10.0.101.0/24'}
  its(:map_public_ip_on_launch) { should eq true }
  it { should have_tag('Name').value('my-vpc-public-ap-northeast-1a') }
  it { should have_tag('Environment').value('dev') }
end

describe subnet('my-vpc-public-ap-northeast-1c') do
  it { should exist }
  it { should be_available }
  it { should belong_to_vpc('my-vpc') }
  its(:availability_zone) { should eq 'ap-northeast-1c' }
  its(:cidr_block) { should eq '10.0.102.0/24'}
  its(:map_public_ip_on_launch) { should eq true }
  it { should have_tag('Name').value('my-vpc-public-ap-northeast-1c') }
  it { should have_tag('Environment').value('dev') }
end

Internet Gataway

awspec/spec/internet_gateway_spec.rb

require 'spec_helper'

describe internet_gateway('my-vpc') do
  it { should exist }
  it { should be_attached_to('my-vpc') }
  it { should have_tag('Name').value('my-vpc') }
  it { should have_tag('Environment').value('dev') }
end

Route Table

awspec/spec/route_table_spec.rb

require 'spec_helper'

describe route_table('my-vpc-private-ap-northeast-1a') do
  it { should exist }
  it { should have_subnet('my-vpc-private-ap-northeast-1a') }
  it { should have_route('10.0.0.0/16').target(gateway: 'local') }
  it { should have_tag('Name').value('my-vpc-private-ap-northeast-1a') }
  it { should have_tag('Environment').value('dev') }
end

describe route_table('my-vpc-private-ap-northeast-1c') do
  it { should exist }
  it { should have_subnet('my-vpc-private-ap-northeast-1c') }
  it { should have_route('10.0.0.0/16').target(gateway: 'local') }
  it { should have_tag('Name').value('my-vpc-private-ap-northeast-1c') }
  it { should have_tag('Environment').value('dev') }
end

describe route_table('my-vpc-public') do
  it { should exist }
  it { should have_subnet('my-vpc-public-ap-northeast-1a') }
  it { should have_subnet('my-vpc-public-ap-northeast-1c') }
  it { should have_route('10.0.0.0/16').target(gateway: 'local') }
  it { should have_route('0.0.0.0/0').target(gateway: 'my-vpc') }
  it { should have_tag('Name').value('my-vpc-public') }
  it { should have_tag('Environment').value('dev') }
end

テストを実行する

awspec initでRakefileが生成されており、awspecディレクトリ配下でbundle exec rake specでテストが実行できます。

[saiki.ko@~/example/awspec]
$ bundle exec rake spec
/Users/saiki.ko/.anyenv/envs/rbenv/versions/2.4.3/bin/ruby -I/Users/saiki.ko/example/vendor/bundle/ruby/2.4.0/gems/rspec-core-3.7.1/lib:/Users/saiki.ko/example/vendor/bundle/ruby/2.4.0/gems/rspec-support-3.7.1/lib /Users/saiki.ko/example/vendor/bundle/ruby/2.4.0/gems/rspec-core-3.7.1/exe/rspec --pattern spec/\*\*\{,/\*/\*\*\}/\*_spec.rb

internet_gateway 'my-vpc'
  should exist
  should be attached to "my-vpc"
  should have tag "Name"
  should have tag "Environment"

route_table 'my-vpc-private-ap-northeast-1a'
  should exist
  should have subnet "my-vpc-private-ap-northeast-1a"
  should have route "10.0.0.0/16"
  should have tag "Name"
  should have tag "Environment"

route_table 'my-vpc-private-ap-northeast-1c'
  should exist
  should have subnet "my-vpc-private-ap-northeast-1c"
  should have route "10.0.0.0/16"
  should have tag "Name"
  should have tag "Environment"

route_table 'my-vpc-public'
  should exist
  should have subnet "my-vpc-public-ap-northeast-1a"
  should have subnet "my-vpc-public-ap-northeast-1c"
  should have route "10.0.0.0/16"
  should have route "0.0.0.0/0"
  should have tag "Name"
  should have tag "Environment"

subnet 'my-vpc-private-ap-northeast-1a'
  should exist
  should be available
  should belong to vpc "my-vpc"
  should have tag "Name"
  should have tag "Environment"
  availability_zone
    should eq "ap-northeast-1a"
  cidr_block
    should eq "10.0.1.0/24"
  map_public_ip_on_launch
    should eq false

subnet 'my-vpc-private-ap-northeast-1c'
  should exist
  should be available
  should belong to vpc "my-vpc"
  should have tag "Name"
  should have tag "Environment"
  availability_zone
    should eq "ap-northeast-1c"
  cidr_block
    should eq "10.0.2.0/24"
  map_public_ip_on_launch
    should eq false

subnet 'my-vpc-public-ap-northeast-1a'
  should exist
  should be available
  should belong to vpc "my-vpc"
  should have tag "Name"
  should have tag "Environment"
  availability_zone
    should eq "ap-northeast-1a"
  cidr_block
    should eq "10.0.101.0/24"
  map_public_ip_on_launch
    should eq true

subnet 'my-vpc-public-ap-northeast-1c'
  should exist
  should be available
  should belong to vpc "my-vpc"
  should have tag "Name"
  should have tag "Environment"
  availability_zone
    should eq "ap-northeast-1c"
  cidr_block
    should eq "10.0.102.0/24"
  map_public_ip_on_launch
    should eq true

vpc 'my-vpc'
  should exist
  should be available
  should have route table "my-vpc-private-ap-northeast-1a"
  should have route table "my-vpc-private-ap-northeast-1c"
  should have route table "my-vpc-public"
  should have network acl "acl-XXXXXXXX"
  should have tag "Name"
  should have tag "Environment"
  cidr_block
    should eq "10.0.0.0/16"
  dhcp_options_id
    should eq "dopt-XXXXXXXX"

Finished in 2.05 seconds (files took 1.34 seconds to load)
63 examples, 0 failures

テキストじゃあんまりパッとしないので、画像で一部分を貼ります。

ちなみに、テストが失敗した時はこんな感じ。(VPCのNameタグのテストを'my-vpc'から'my-vpn'に変更して意図的にテストを失敗させてます)

ファイル単体でテストを実行したい場合は、bundle exec rspec spec/<ファイル名>で実行できます。

[saiki.ko@~/example/awspec]
$ bundle exec rspec spec/vpc_spec.rb

vpc 'my-vpc'
  should exist
  should be available
  should have route table "my-vpc-private-ap-northeast-1a"
  should have route table "my-vpc-private-ap-northeast-1c"
  should have route table "my-vpc-public"
  should have network acl "acl-XXXXXXXX"
  should have tag "Name"
  should have tag "Environment"
  cidr_block
    should eq "10.0.0.0/16"
  dhcp_options_id
    should eq "dopt-XXXXXXXX"

Finished in 0.61097 seconds (files took 1.37 seconds to load)
10 examples, 0 failures

テストコードを生成する

awspecにはテストコードを生成するgenerateサブコマンドがあります。awspec v1.3.0でテストコードを生成できるリソースは下記の通りです。

$ bundle exec awspec generate --help
Commands:
  awspec generate acm                                              # Generate acm spec
  awspec generate alb [vpc_id]                                     # Generate alb spec from VPC ID (or VPC "Name" tag)
  awspec generate cloudwatch_alarm                                 # Generate cloudwatch_alarm spec
  awspec generate cloudwatch_event                                 # Generate cloudwatch_event spec
  awspec generate cloudwatch_logs                                  # Generate cloudwatch_logs spec
  awspec generate directconnect                                    # Generate directconnect spec
  awspec generate ebs                                              # Generate attached ebs spec
  awspec generate ec2 [vpc_id]                                     # Generate ec2 spec from VPC ID (or VPC "Name" tag)
  awspec generate efs                                              # Generate efs spec
  awspec generate eip                                              # Generate eip spec
  awspec generate elasticsearch                                    # Generate elasticsearch spec
  awspec generate elb [vpc_id]                                     # Generate elb spec from VPC ID (or VPC "Name" tag)
  awspec generate help [COMMAND]                                   # Describe subcommands or one specific subcommand
  awspec generate iam_group                                        # Generate iam_group spec
  awspec generate iam_policy                                       # Generate attached iam_policy spec
  awspec generate iam_role                                         # Generate iam_role spec
  awspec generate iam_user                                         # Generate iam_user spec
  awspec generate internet_gateway [vpc_id]                        # Generate internet_gateway spec from VPC ID (or VPC "Name" tag)
  awspec generate kms                                              # Generate kms spec
  awspec generate lambda                                           # Generate lambda spec
  awspec generate nat_gateway [vpc_id]                             # Generate nat_gateway spec from VPC ID (or VPC "Name" tag)
  awspec generate network_acl [vpc_id]                             # Generate network_acl spec from VPC ID (or VPC "Name" tag)
  awspec generate network_interface [vpc_id]                       # Generate network_interface spec from VPC ID (or VPC "Name" tag)
  awspec generate nlb [vpc_id]                                     # Generate nlb spec from VPC ID (or VPC "Name" tag)
  awspec generate rds [vpc_id]                                     # Generate rds spec from VPC ID (or VPC "Name" tag)
  awspec generate rds_db_cluster_parameter_group [paramater_name]  # Generate rds_db_cluster_parameter_group spec from paramater name.
  awspec generate rds_db_parameter_group [paramater_name]          # Generate rds_db_parameter_group spec from paramater name.
  awspec generate route53_hosted_zone [example.com.]               # Generate route53_hosted_zone spec from Domain name
  awspec generate route_table [vpc_id]                             # Generate route_table spec from VPC ID (or VPC "Name" tag)
  awspec generate s3_bucket [bucket_name]                          # Generate s3_bucket spec from S3 bucket name. if NO args, Generate all.
  awspec generate security_group [vpc_id]                          # Generate security_group spec from VPC ID (or VPC "Name" tag)
  awspec generate subnet [vpc_id]                                  # Generate subnet spec from VPC ID (or VPC "Name" tag)
  awspec generate vpc [vpc_id]                                     # Generate vpc spec from VPC ID (or VPC "Name" tag)

例えば、今回作成したVPC(Name:my-vpc)のテストは以下のコマンドで生成できます。生成されたテストコードは標準出力に出力されるので、リダイレクトなりして*_spec.rbファイルに書きます。なお、生成したテストコードを使用する際、require 'spec_helper'を書くのを忘れがちになるので注意しましょー。

$ bundle exec awspec generate vpc my-vpc
describe vpc('my-vpc') do
  it { should exist }
  it { should be_available }
  its(:vpc_id) { should eq 'vpc-XXXXXXXX' }
  its(:cidr_block) { should eq '10.0.0.0/16' }
  it { should have_route_table('rtb-XXXXXXXX') }
  it { should have_route_table('my-vpc-private-ap-northeast-1a') }
  it { should have_route_table('my-vpc-private-ap-northeast-1c') }
  it { should have_route_table('my-vpc-public') }
  it { should have_network_acl('acl-XXXXXXXX') }
end

全てのリソースに対応しているわけではない

AWSは新しいサービスや機能がガンガンリリースされているので、全てのAWSリソースに対応しているわけではありません。「これテストしたいけど対応してないなー」なんて思ったら、コントリビュートチャンスです。

まとめ

簡単にawspecの使い方を紹介させて頂きました。CloudFormationやTerraformなどの構成管理ツールでAWS環境を構築しているのだから、テストは不要といった意見もありますが、運用していく中であるべき姿に保たれているか?をチェックすることは大事だと思います。GitリポジトリとCodeBuildやCircleCIなどのサービスを連動させて自動テストしたり、定期的に自動テストしたりできるといい感じになるので、以下エントリも参考にして頂ければと思います。

CodeCommit/CodePipeline/CodeBuildで自動awspecしてくれる環境を作ってみました