tk_ch’s blog

インフラエンジニアのブログ

ansible_specでAnsibleとServerspecを連携させる

サーバ構築の際に、Ansibleで設定、Serverspecで試験を実施する場合、以下のような課題が出てくる。

  • 実行対象のサーバとロールについて、AnsibleとServerspecそれぞれでファイル管理する必要がある。
    (Ansibleはインベントリファイルとプレイブック、ServerspecはRakefileや独自の設定ファイルなど。)
  • Serverspecの初期のディレクトリ構成、Rakefile、spec_helper.rbは複数サーバへの実行がしづらいため、導入する際は使い方を検討して、それなりに修正が必要になる。

ansible_specというツールを使うと、Ansibleのインベントリから対象サーバを、プレイブックから対象ロールを読み込み、ServerspecのRakeタスクを生成できるらしい。
 複数サーバ、複数ロールの実行にも対応しており、上記課題を解決できそうなので試してみる。

※Ansibleで構築した環境に試験が必要なのかという点について
Ansibleは宣言的な記述ができ、記述された状態になることを保障してくれるので、そもそも別ツールでの試験が必要なのか、と思ったりもする。
しかし、以下の理由で必要だと考えている。

  • プレイブックの記載ミスや、Ansible自体の不具合、そもそも設定内容が誤っているといった事態があり得るので、別のツールで試験することに意味はある。
  • Ansibleだけで動作確認しようとすると、プレイブックが複雑になってくる(単に設定するだけではなく、その設定で想定通りの挙動になるかの確認コマンドの実行などを盛り込んでいく必要が出てくる)。
  • 実際の開発では、「実績のあるAnsibleプレイブックで構築してるのでテストしなくて大丈夫!」で偉い人や顧客を説得できないこともあると思う。
    なんだかんだ別のテストツールでの試験エビデンスがあった方が、みんな安心。

環境

バージョン

  • DockerホストのOS:RockyLinux8.6
  • 管理対象サーバ(Serverspecの実行先)のOS:RockyLinux8.6
  • Docker:20.10.18
  • ansible:7.3.0
  • ansible-core:2.14.3
  • ansiible_spec:0.3.2
  • Serverspec:2.42.2

※Ansibleの実行環境は以下記事で構築したものを使う。 tk-ch.hatenablog.com

Ansible実行対象のサーバ

商用環境

  • test-prd-001 (192.168.10.1)
  • test-prd-002 (192.168.10.2)
  • test-prd-003 (192.168.10.3)

    ステージング環境

  • test-stg-001 (192.168.20.1)

実施内容

Ansibleのファイルを準備する

ansible_specを試すために、Ansibleのプレイブックを用意する。
今回は、以下記事で使用したものを使うことにした。
tk-ch.hatenablog.com

ディレクトリ構成は以下の通り。

ansible
|-- ansible.cfg                     # Ansibleの設定ファイル
|-- inventories                     # インベントリと変数定義の格納場所
|   |-- production                  # 商用環境用
|   |   |-- group_vars              # グループ単位の変数定義の格納場所
|   |   |   |-- all.yml             # 全グループ共通の変数定義
|   |   |   |-- test_group_a.yml    # グループごとの変数定義(ファイル名はグループ名.yml)
|   |   |   |-- test_group_ab.yml
|   |   |   `-- test_group_b.yml
|   |   |-- host_vars               # ホスト単位の変数定義の格納場所
|   |   |   |-- test-prd-001.yml    # ホストごとの変数定義(ファイル名はホスト名.yml)
|   |   |   |-- test-prd-002.yml
|   |   |   `-- test-prd-003.yml
|   |   `-- hosts                   # インベントリ(Ansibleの実行対象ホスト、グループの定義)
|   `-- staging                     # ステージング環境用
|       |-- group_vars
|       |   |-- all.yml
|       |   |-- test_group_a.yml
|       |   |-- test_group_ab.yml
|       |   `-- test_group_b.yml
|       |-- host_vars
|       |   `-- test-stg-001.yml
|       `-- hosts
|-- roles                           # タスク定義の格納場所
|   |-- role00                      # ロール単位でタスク定義をまとめている
|   |   |-- defaults
|   |   |   `-- main.yml            # ロールのデフォルト変数定義
|   |   `-- tasks
|   |       `-- main.yml            # タスクで実行したい処理をここに書く
|   |-- role01
|   |   |-- defaults
|   |   |   `-- main.yml
|   |   |-- tasks
|   |   |   `-- main.yml
|   |   |-- templates
|   |   |   `-- variable.txt.j2     # templateモジュールで使うテンプレートファイル
|   |   `-- vars
|   |       `-- main.yml            # ロール変数定義
|   `-- role02
|       |-- defaults
|       |   `-- main.yml
|       |-- tasks
|       |   `-- main.yml
|       |-- templates
|       |   `-- variable.txt.j2
|       `-- vars
|           `-- main.yml
|-- site.yml                        # マスタープレイブック

|-- test_group_a.yml                # 実行対象のグループとロールを記載したプレイブック
|-- test_group_ab.yml
`-- test_group_b.yml

Ansibleを商用環境とステージング環境に実行し、エラー無く実行されることを確認する。

# ansible-playbook -i inventories/production/hosts site.yml
# ansible-playbook -i inventories/staging/hosts site.yml

ちなみに、実行される処理は以下の2つ(詳細はこの記事を参照)。

  • chronyd、crond、sshdサービスの自動起動を有効にする。
  • /tmp/ロール名.txtに、Ansibleの各種変数定義ファイルにで定義した変数の値を出力する。

ansible_spec実行用のDockerイメージをビルドする

ansible_specはどこでも簡単に構築できるよう、Dockerコンテナを使って実行することにする。
まずはDockerfileを作成する。 Dockerfile_ansiblespec

# ベースイメージ
FROM rockylinux:8.6

# 変数定義
ARG ANSIBLESPEC_VERSION

# 必要なパッケージのインストール
RUN dnf module -y enable ruby:3.1 && \
    dnf -y install \
    gcc-c++ \
    make \
    openssh-clients \
    redhat-rpm-config \
    ruby \
    ruby-devel && \
    dnf clean all && \
    rm -rf /var/cache/dnf/*

# Serverspecのインストール
RUN gem install ansible_spec -v $ANSIBLESPEC_VERSION
RUN gem install rake
RUN gem install highline

# ロケールを日本語に設定
RUN dnf -y install glibc-locale-source glibc-langpack-en && \
    dnf clean all && \
    rm -rf /var/cache/dnf/*
RUN localedef -f UTF-8 -i ja_JP ja_JP.utf8
RUN echo 'LANG="ja_JP.UTF-8"' >  /etc/locale.conf

# タイムゾーンをJSTに設定
RUN echo 'ZONE="Asia/Tokyo"' > /etc/sysconfig/clock
RUN rm -f /etc/localtime
RUN ln -fs /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

# コンテナログイン時のカレントディレクトリを設定
WORKDIR /work/ansible

→Dockerfileの解説

  • ベースイメージがRockyLinux8.6なのは使い慣れているからで、特に意味は無い。他のOSで作っても良い。
  • ansible_specのインストール方法は、GitのREADMEを参照。
  • ansible_specをインストールすると、Serverspecも一緒にインストールされる。
  • openssh-clientsは、Serverspecから管理対象サーバへ接続するために必要なのでインストールした。
  • インストールするansible_specのバージョンを制御できるよう、ARGにて変数ANSIBLESPEC_VERSIONを定義し、ansible_specインストール時に指定している。 変数に設定する値は、後ほど「docker build」実行時に--build-argオプションで指定する。
    詳細はDockerの公式ドキュメントのここを参照。
  • dnf実行時の書き方については、RedHatの「How to build tiny container images」を参考にした。
  • ロケールタイムゾーンの設定は必須ではないが、自前のコンテナイメージ作成時はいつもやっているので入れている。

ansible_specのGitリポジトリReleasesを見ると、0.3.2が現在(2023年2月)時点の最新版の模様。 ansible_specのバージョンを0.3.2と指定し、コンテナイメージをビルドする。

インストールしたいansible_specのバージョンを環境変数に格納
# export ANSIBLESPEC_VERSION=0.3.2

Dockerイメージをビルド
# docker build --no-cache=true -f Dockerfile_ansiblespec -t ansiblespec:$ANSIBLESPEC_VERSION --build-arg ANSIBLESPEC_VERSION=$ANSIBLESPEC_VERSION .

ビルドされたイメージを確認
# docker images | grep ansiblespec | grep $ANSIBLESPEC_VERSION
ansiblespec           0.3.2     9b5927bf46bd   15 seconds ago   509MB

ビルドされたイメージを起動し、インストールされたバージョンを確認
# docker run --rm -it ansiblespec:$ANSIBLESPEC_VERSION gem list ansible_spec serverspec rake highline

*** LOCAL GEMS ***

ansible_spec (0.3.2)

*** LOCAL GEMS ***

serverspec (2.42.2)

*** LOCAL GEMS ***

rake (13.0.6)

*** LOCAL GEMS ***

highline (2.1.0)

ansiblespec-initの実行

先ほどビルドしたコンテナイメージを指定し、ansible_specを実行してみる。
まずは、ansiblespec-initを実行して必要なファイルを生成する。
ansiblespec-initはAnsibleプレイブックがあるディレクトリで実行するので、ansible_specコンテナ起動時に該当のディレクトリをマウントしている。

Ansibleのファイルが置かれたDockerホストのディレクトリを確認
# ls -l /root/ansible
合計 16
-rw-r--r-- 1 root root  37  3月  4 11:54 ansible.cfg
drwxr-xr-x 4 root root  39  3月  4 13:53 inventories
drwxr-xr-x 5 root root  48  3月  4 12:24 roles
-rw-r--r-- 1 root root  76  3月  4 11:54 site.yml
-rw-r--r-- 1 root root 132  3月  4 11:54 test_group_a.yml
-rw-r--r-- 1 root root 132  3月  4 11:54 test_group_b.yml

使用するansible_specのバージョンを指定
# export ANSIBLESPEC_VERSION=0.3.2

ansiblespecコンテナから、DockerホストのAnsible用ディレクトリがマウントできていることを確認
# docker run --rm -it -v /root/ansible:/work/ansible ansiblespec:$ANSIBLESPEC_VERSION ls -l
total 16
-rw-r--r-- 1 root root  37 Mar  4 20:54 ansible.cfg
drwxr-xr-x 4 root root  39 Mar  4 22:53 inventories
drwxr-xr-x 5 root root  48 Mar  4 21:24 roles
-rw-r--r-- 1 root root  76 Mar  4 20:54 site.yml
-rw-r--r-- 1 root root 132 Mar  4 20:54 test_group_a.yml
-rw-r--r-- 1 root root 132 Mar  4 20:54 test_group_b.yml

ansiblespec-initを実行して、ansible_specで使うファイルを生成する。
# docker run --rm -it -v /root/ansible:/work/ansible ansiblespec:$ANSIBLESPEC_VERSION ansiblespec-init
                create  spec
                create  spec/spec_helper.rb
                create  Rakefile
                create  .ansiblespec
                create  .rspec

作成されたファイルの中身は以下の通り。
Rakefile

require 'rake'
require 'rspec/core/rake_task'
require 'yaml'
require 'ansible_spec'

properties = AnsibleSpec.get_properties
# {"name"=>"Ansible-Sample-TDD", "hosts"=>["192.168.0.103","192.168.0.103"], "user"=>"root", "roles"=>["nginx", "mariadb"]}
# {"name"=>"Ansible-Sample-TDD", "hosts"=>[{"name" => "192.168.0.103:22","uri"=>"192.168.0.103","port"=>22, "private_key"=> "~/.ssh/id_rsa"}], "user"=>"root", "roles"=>["nginx", "mariadb"]}
cfg = AnsibleSpec::AnsibleCfg.new

desc "Run serverspec to all test"
task :all => "serverspec:all"

namespace :serverspec do
  properties = properties.compact.reject{|e| e["hosts"].length == 0}
  task :all => properties.map {|v| 'serverspec:' + v["name"] }
  properties.each_with_index.map do |property, index|
    property["hosts"].each do |host|
      desc "Run serverspec for #{property["name"]}"
      RSpec::Core::RakeTask.new(property["name"].to_sym) do |t|
        puts "Run serverspec for #{property["name"]} to #{host}"
        ENV['TARGET_HOSTS'] = host["hosts"]
        ENV['TARGET_HOST'] = host["uri"]
        ENV['TARGET_PORT'] = host["port"].to_s
        ENV['TARGET_GROUP_INDEX'] = index.to_s
        ENV['TARGET_PRIVATE_KEY'] = host["private_key"]
        unless host["user"].nil?
          ENV['TARGET_USER'] = host["user"]
        else
          ENV['TARGET_USER'] = property["user"]
        end
        ENV['TARGET_PASSWORD'] = host["pass"]
        ENV['TARGET_CONNECTION'] = host["connection"]

        roles = property["roles"]
        for role in property["roles"]
          for rolepath in cfg.roles_path
            deps = AnsibleSpec.load_dependencies(role, rolepath)
            if deps != []
              roles += deps
              break
            end
          end
        end
        t.pattern = '{' + cfg.roles_path.join(',') + '}/{' + roles.join(',') + '}/spec/*_spec.rb'
      end
    end
  end
end

→Ansibleのファイルから実行対象のホストとロールの情報を取得し、実行するホストとspecファイルを決定している。
spec/spec_helper.rb

require 'serverspec'
require 'net/ssh'
require 'ansible_spec'
require 'winrm'

#
# Set ansible variables to serverspec property
#
host = ENV['TARGET_HOST']
hosts = ENV["TARGET_HOSTS"]

group_idx = ENV['TARGET_GROUP_INDEX'].to_i
vars = AnsibleSpec.get_variables(host, group_idx,hosts)
ssh_config_file = AnsibleSpec.get_ssh_config_file
set_property vars

connection = ENV['TARGET_CONNECTION']

case connection
when 'ssh'
#
# OS type: UN*X
#
  set :backend, :ssh

  # Ansible use `BECOME`, But Serverspec use `SUDO`.
  if ENV['ASK_BECOME_PASSWORD']
    begin
      require 'highline/import'
    rescue LoadError
      fail "highline is not available. Try installing it."
    end
    set :sudo_password, ask("Enter become password: ") { |q| q.echo = false }
  else
    set :sudo_password, ENV['BECOME_PASSWORD']
  end

  options = Net::SSH::Config.for(host)

  options[:user] = ENV['TARGET_USER'] || options[:user]
  options[:port] = ENV['TARGET_PORT'] || options[:port]
  options[:keys] = ENV['TARGET_PRIVATE_KEY'] || options[:keys]

  if ssh_config_file
    from_config_file = Net::SSH::Config.for(host,files=[ssh_config_file])
    options.merge!(from_config_file)
  end

  set :host,        options[:host_name] || host
  set :ssh_options, options

  # Disable become (Serverspec use sudo)
  # set :disable_sudo, true


  # Set environment variables
  # set :env, :LANG => 'C', :LC_MESSAGES => 'C'

  # Set PATH
  # set :path, '/sbin:/usr/local/sbin:$PATH'
when 'winrm'
#
# OS type: Windows
#
  set :backend, :winrm
  set :os, :family => 'windows'

  user = ENV['TARGET_USER']
  port = ENV['TARGET_PORT']
  pass = ENV['TARGET_PASSWORD']

  if user.nil?
    begin
      require 'highline/import'
    rescue LoadError
      fail "highline is not available. Try installing it."
    end
    user = ask("\nEnter #{host}'s login user: ") { |q| q.echo = true }
  end
  if pass.nil?
    begin
      require 'highline/import'
    rescue LoadError
      fail "highline is not available. Try installing it."
    end
    pass = ask("\nEnter #{user}@#{host}'s login password: ") { |q| q.echo = false }
  end

  endpoint = "http://#{host}:#{port}/wsman"

  winrm = ::WinRM::WinRMWebService.new(endpoint, :ssl, :user => user, :pass => pass, :basic_auth_only => true)
  winrm.set_timeout 300 # 5 minutes max timeout for any operation
  Specinfra.configuration.winrm = winrm

when 'local'
#
# local connection
#
    set :backend, :exec
end

→Ansibleのファイルからの変数定義読み込みと、実行先ホストへのSSH接続の設定が書いてある。
.ansiblespec

---
-
  playbook: site.yml
  inventory: hosts

→ansible_specの設定ファイル。
 記載内容はこちらを参照。
.rspec

--color
--format documentation

rspec実行時の設定ファイル。

タスクを確認する

Rakefileにより作成されるRakeタスクを確認してみる。

# export ANSIBLESPEC_VERSION=0.3.2
# docker run --rm -it -v /root/ansible:/work/ansible ansiblespec:$ANSIBLESPEC_VERSION rake INVENTORY=inventories/production/hosts -T
rake all                      # Run serverspec to all test
rake serverspec:test_group_a  # Run serverspec for test_group_a
rake serverspec:test_group_b  # Run serverspec for test_group_b

→表示されたRakeタスクを確認すると、「rake all」という全対象に対するタスクと、Ansibleのインベントリに記載しているグループ「test_group_a」、「test_group_b」に対するタスクが作成されている。
「rake serverspec: 」に続く文字列はプレイブックの「name:」に指定された文字列になる。
 Rakefile内でansible_specの機能が使用されて、Ansibleのプレイブック、インベントリファイルから情報が読み込まれている模様。

注意:Ansibleのディレクトリ構成がansible_specのデフォルト設定と異なる場合

最初以下のように実行したら、エラーになった。

# export ANSIBLESPEC_VERSION=0.3.2
# docker run --rm -it -v /root/ansible:/work/ansible ansiblespec:$ANSIBLESPEC_VERSION rake -T
Error: hosts is not Found. create hosts or ./.ansiblespec  See https://github.com/volanja/ansible_spec

→デフォルトでは「.ansiblespec」の記載内容に従い、プレイブックとしてsite.yml、インベントリ ファイルとしてhostsが使用される。
 今回のディレクトリ構成だとインベントリファイルは「inventories/環境名/hosts」になるので、「Error: hosts is not Found. 」となって実行に失敗している。
 「.ansiblespec」を変更するか、ansible_specのREADMEにあるように環境変数で変更できる。
 環境変数INVENTORYにインベントリファイルの配置パスを設定して実行したところ、エラーが解消した。
 (ちなみに、プレイブックをデフォルトから変更したい場合は環境変数PLAYBOOKに設定する。)

注意:プレイブックの「name:」の値について

プレイブックの「name:」の値は、Serverspecタスクの「rake serverspec: 」に続く文字列に使われる。
そのため、プレイブックに「name:」が定義されていないと以下のように「Please insert name on playbook」というエラーになる。

# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production -T
rake aborted!
Please insert name on playbook 'site.yml'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:273:in `load_playbook'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:426:in `get_properties'
/work/ansible/Rakefile:6:in `<top (required)>'
/usr/local/share/gems/gems/rake-13.0.6/exe/rake:27:in `<top (required)>'
(See full trace by running task with --trace)

また、「name:」の値にはスペースを含めることができ、Ansible実行時には特に問題にならない。
しかし、Serverspec側で以下の「rake serverspec:test group a」のようなタスク定義になってしまい、そのままコマンドとして実行してもエラーになってしまうので、避けるべき。

タスク定義を表示
# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production -T
rake all                      # Run serverspec to all test
rake serverspec:test group a  # Run serverspec for test group a
rake serverspec:test_group_b  # Run serverspec for test_group_b

以下はエラーになる
# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production serverspec:test group a
rake aborted!
Don't know how to build task 'serverspec:test' (See the list of available tasks with `rake --tasks`)
Did you mean?  serverspec:all
/usr/local/share/gems/gems/rake-13.0.6/exe/rake:27:in `<top (required)>'
(See full trace by running task with --trace)

以下のように二重引用符で囲めばエラーにならないが、分かりづらい
# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production "serverspec:test group a"

また、「name:」の値が仮に被っていると、同じnameのプレイブックはまとめて一つのタスクにされてしまい、個別の実行が出来なくなる。
例えば、test_group_a.ymlもtest_group_b.ymlも「name: test_group」としている場合、タスク定義は「rake serverspec:test_group」にまとめられてしまう。
このタスクを実行するとtest_group_aとtest_group_b両方のグループにServerspecが実行されることになり、グループ単位での実行ができない。

# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production -T
rake all                    # Run serverspec to all test
rake serverspec:test_group  # Run serverspec for test_group

以上のことから、「name:」の値はスペースなど特殊な文字は含めず、他のプレイブックのnameと重複していない、実行内容が分かりやすいものがよい。
基本的には、本記事で使っているプレイブックのように、「name:」の値は「hosts:」の値と同じにするのが無難だと思う。

テストコードを作成する

テストを実行するため、テストコードを作成する。
テストコードの記載におけるポイントは以下の通り。

  • 書式など、記載方法は普通にServerspecを使うときと同じ。
  • テストコードの配置場所は、ansible_specのREADMEに従い、テスト対象のロールの「roles/ロール名/spec/*_spec.rb」にする。
  • ansible_specを使うと、テストコード内でAnsibleで設定している変数を使用できる。
    変数が上書きされる優先度もAnsibleと同じなので、テストコードに対応するロールと同様の値を得られることになる。
    ただし、インベントリファイルとロールのtasks/main.ymlに定義された変数、Fasts変数に対応していないので注意すること。
    ※詳細はansible_specのREADME参照。
     
    各ロールのテストコードを以下のように作成した。

    role00

    roles/role00/spec/main_spec.rb

require 'spec_helper'

puts '###### roles/role00のテスト'

property['role00_services_list'].each do |service|
  describe service("#{service}"), :if => os[:family] == 'redhat' do
    it { should be_enabled }
  end
end

→role00は「role00_services_listに定義したサービス(chronyd、crond、sshd)の自動起動を有効にする」というロール。
対応するテストとして、serviceリソースを使用して、「サービスの自動起動が有効であること」を確認する内容にする。
対象のサービスはAnsibleで以下のように定義しているので、「property['role00_services_list']」でリストを取り出して、Ansibleのタスクと同様繰り返し処理にしている。
roles/role00/defaults/main.yml

---
role00_services_list:
  - chronyd
  - crond
  - sshd

role01

roles/role01/spec/main_spec.rb

require 'spec_helper'

puts '###### roles/role01のテスト'

describe file('/tmp/role01.txt'), :if => os[:family] == 'redhat' do
  it { should be_file }
end

variable_file = <<"EOF"
test_var_role_default: #{property['test_var_role_default']}
test_var_group_all   : #{property['test_var_group_all']}
test_var_group_parent: #{property['test_var_group_parent']}
test_var_group       : #{property['test_var_group']}
test_var_host        : #{property['test_var_host']}
test_var_role        : #{property['test_var_role']}
EOF

describe file('/tmp/role01.txt'), :if => os[:family] == 'redhat' do
  its(:content) { should match variable_file }
end

→role01は「/tmp/ロール名.txtに、Ansibleの各種変数定義ファイルにで定義した変数の値を出力する」というロール。 対応するテストとして、fileリソースを使用して、「ファイルが存在していること、内容が想定通りであること」を確認する内容にする。
its(:content)マッチャ―で1行ずつ確認することもできるが、それだと各文字列の順番が正しいかまでは確認できないので、ヒアドキュメントを使ってファイルの内容全体を比較している。

注意:Ansibleのマジック変数について

今回用意したAnsibleのプレイブックでは、ロール名.txtというファイルを作成する際に、role_nameというAnsibleのマジック変数を使用している。
そのため、テストコードでもファイル名を指定する部分「describe file('/tmp/role01.txt')」は、「describe file('/tmp/' + property['role_name'] + '.txt')」としたかったが、ansible_spec実行時にrole_nameの展開はされたが値が空で上手くいかなかった。
恐らく、role_nameはansible-playbook実行時にしか値が設定されないのだと思う。
そのため、事実上ansible_specではこういったAnsibleマジック変数は使用できないと考えられる。

role02

roles/role02/spec/main_spec.rb

require 'spec_helper'

puts '###### roles/role02のテスト'

describe file('/tmp/role02.txt'), :if => os[:family] == 'redhat' do
  it { should be_file }
end

variable_file = <<"EOF"
test_var_role_default: #{property['test_var_role_default']}
test_var_group_all   : #{property['test_var_group_all']}
test_var_group_parent: #{property['test_var_group_parent']}
test_var_group       : #{property['test_var_group']}
test_var_host        : #{property['test_var_host']}
test_var_role        : #{property['test_var_role']}
EOF

describe file('/tmp/role02.txt'), :if => os[:family] == 'redhat' do
  its(:content) { should match variable_file }
end

→role02はファイル名と変数の値以外role01と同じロールなので、テストコードもファイル名以外はrole01と同じ内容。

Serverspecを実行する

商用環境のtest_group_aグループに対してテストを実行する

実行におけるポイントは以下の通り。

  • 実行先の環境面は、環境変数INVENTORYに指定するインベントリファイルの選択で制御する。
  • 環境変数INVENTORYにインベントリファイルのパスを指定する(理由は前述の補足参照)。
  • 環境変数VARS_DIRS_PATHにgroup_vars、host_varsが存在するディレクトリパスを指定する(理由は後述)。
  • グループ単位に実行したい場合はrakeコマンドの引数に「serverspec:グループ名」と指定する。全ホストに実行する場合は「all」を指定する。
  • 今回、実行先サーバへのSSHはパスワード認証なので、実行するときパスワード入力を求められるが、手動で入力する。
    ※Ansibleプレイブックにはansible_ssh_passでパスワードを設定済みだが、こちらに記載の通りansible_specでは非対応なため、読み込まれない。
# export ANSIBLESPEC_VERSION=0.3.2

# docker run --rm -it -v /root/ansible:/work/ansible ansiblespec:$ANSIBLESPEC_VERSION rake INVENTORY=inventories/production/hosts serverspec:test_group_a
Run serverspec for test_group_a to {"name"=>"192.168.10.1", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.1"}
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb
###### roles/role00のテスト
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys
root@192.168.10.1's password:
###### roles/role01のテスト

Service "chronyd"
  is expected to be enabled

Service "crond"
  is expected to be enabled

Service "sshd"
  is expected to be enabled

File "/tmp/role01.txt"
  is expected to be file

File "/tmp/role01.txt"
  content
    is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : roles/role01/defaults/main.yml\ntest_var_group_parent: roles/role01/defaults/main.yml\ntest_var_group       : roles/role01/defaults/main.yml\ntest_var_host        : roles/role01/defaults/main.yml\ntest_var_role        : roles/role01/vars/main.yml\n" (FAILED - 1)

Failures:

  1) File "/tmp/role01.txt" content is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : roles/role01/defaults/main.yml\ntest_var_group_parent: roles/role01/defaults/main.yml\ntest_var_group       : roles/role01/defaults/main.yml\ntest_var_host        : roles/role01/defaults/main.yml\ntest_var_role        : roles/role01/vars/main.yml\n"
     On host `192.168.10.1'
     Failure/Error: its(:content) { should match variable_file }
       expected "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production...entories/production/host_vars/test-prd-001.yml\ntest_var_role        : roles/role01/vars/main.yml\n" to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : roles/role01/defaults/main.yml\ntest_var_group_parent: roles/role01/defaults/main.yml\ntest_var_group       : roles/role01/defaults/main.yml\ntest_var_host        : roles/role01/defaults/main.yml\ntest_var_role        : roles/role01/vars/main.yml\n"
       Diff:
       @@ -1,7 +1,7 @@
        test_var_role_default: roles/role01/defaults/main.yml
       -test_var_group_all   : roles/role01/defaults/main.yml
       -test_var_group_parent: roles/role01/defaults/main.yml
       -test_var_group       : roles/role01/defaults/main.yml
       -test_var_host        : roles/role01/defaults/main.yml
       +test_var_group_all   : inventories/production/group_vars/all.yml
       +test_var_group_parent: inventories/production/group_vars/test_group_ab.yml
       +test_var_group       : inventories/production/group_vars/test_group_a.yml
       +test_var_host        : inventories/production/host_vars/test-prd-001.yml
        test_var_role        : roles/role01/vars/main.yml

       sudo -p 'Password: ' /bin/sh -c cat\ /tmp/role01.txt\ 2\>\ /dev/null\ \|\|\ echo\ -n
       test_var_role_default: roles/role01/defaults/main.yml
test_var_group_all   : inventories/production/group_vars/all.yml
test_var_group_parent: inventories/production/group_vars/test_group_ab.yml
test_var_group       : inventories/production/group_vars/test_group_a.yml
test_var_host        : inventories/production/host_vars/test-prd-001.yml
test_var_role        : roles/role01/vars/main.yml

     # ./roles/role01/spec/main_spec.rb:19:in `block (2 levels) in <top (required)>'

Finished in 0.3952 seconds (files took 5.37 seconds to load)
5 examples, 1 failure

Failed examples:

rspec ./roles/role01/spec/main_spec.rb:19 # File "/tmp/role01.txt" content is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : roles/role01/defaults/main.yml\ntest_var_group_parent: roles/role01/defaults/main.yml\ntest_var_group       : roles/role01/defaults/main.yml\ntest_var_host        : roles/role01/defaults/main.yml\ntest_var_role        : roles/role01/vars/main.yml\n"

/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb failed

→Failureとなった点があるので確認すると、4つの変数test_var_group_all、test_var_group_parent、test_var_group、test_var_hostの値がプレイブックで作成されたものと異なる。
この4つはinventories/{group_vars,host_vars}ディレクトリ配下で定義された値で上書きされてほしかったが、上書きされていない。
これは、ansible_specはデフォルトでは直下にあるgroup_varsディレクトリとhost_varsディレクトリの配下しか読み込まないため。
その他の場所を読み込ませるには環境変数VARS_DIRS_PATHにgroup_varsディレクトリとhost_varsディレクトリのパスを設定する必要がある。
この点を修正して再度実行してみる。

# export ANSIBLESPEC_VERSION=0.3.2

# docker run --rm -it -v /root/ansible:/work/ansible ansiblespec:$ANSIBLESPEC_VERSION rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production serverspec:test_group_a
Run serverspec for test_group_a to {"name"=>"192.168.10.1", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.1"}
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb
###### roles/role00のテスト
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys
root@192.168.10.1's password:
###### roles/role01のテスト

Service "chronyd"
  is expected to be enabled

Service "crond"
  is expected to be enabled

Service "sshd"
  is expected to be enabled

File "/tmp/role01.txt"
  is expected to be file

File "/tmp/role01.txt"
  content
    is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_a.yml\ntest_var_host        : inventories/production/group_vars/test_group_a.yml\ntest_var_role        : roles/role01/vars/main.yml\n" (FAILED - 1)

Failures:

  1) File "/tmp/role01.txt" content is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_a.yml\ntest_var_host        : inventories/production/group_vars/test_group_a.yml\ntest_var_role        : roles/role01/vars/main.yml\n"
     On host `192.168.10.1'
     Failure/Error: its(:content) { should match variable_file }
       expected "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production...entories/production/host_vars/test-prd-001.yml\ntest_var_role        : roles/role01/vars/main.yml\n" to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_a.yml\ntest_var_host        : inventories/production/group_vars/test_group_a.yml\ntest_var_role        : roles/role01/vars/main.yml\n"
       Diff:
       @@ -2,6 +2,6 @@
        test_var_group_all   : inventories/production/group_vars/all.yml
        test_var_group_parent: inventories/production/group_vars/test_group_ab.yml
        test_var_group       : inventories/production/group_vars/test_group_a.yml
       -test_var_host        : inventories/production/group_vars/test_group_a.yml
       +test_var_host        : inventories/production/host_vars/test-prd-001.yml
        test_var_role        : roles/role01/vars/main.yml

       sudo -p 'Password: ' /bin/sh -c cat\ /tmp/role01.txt\ 2\>\ /dev/null\ \|\|\ echo\ -n
       test_var_role_default: roles/role01/defaults/main.yml
test_var_group_all   : inventories/production/group_vars/all.yml
test_var_group_parent: inventories/production/group_vars/test_group_ab.yml
test_var_group       : inventories/production/group_vars/test_group_a.yml
test_var_host        : inventories/production/host_vars/test-prd-001.yml
test_var_role        : roles/role01/vars/main.yml

     # ./roles/role01/spec/main_spec.rb:19:in `block (2 levels) in <top (required)>'

Finished in 0.4168 seconds (files took 5.81 seconds to load)
5 examples, 1 failure

Failed examples:

rspec ./roles/role01/spec/main_spec.rb:19 # File "/tmp/role01.txt" content is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_a.yml\ntest_var_host        : inventories/production/group_vars/test_group_a.yml\ntest_var_role        : roles/role01/vars/main.yml\n"

/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb failed

→test_var_group_all、test_var_group_parent、test_var_groupについてはgroup_vars配下で定義された値が読み込まれるようになった。
しかし、test_var_host変数だけはまだNGのまま。
host_vars配下の値が読み込まれていないように見える。
これは、結論から言うとhost_vars配下のファイル名を以下のようにホスト名.ymlからIPアドレス.ymlに変更したら解消した。
※host_vars配下のファイルは、ファイル名がhostsファイルに定義するホスト名と一致しないと読み込まれないので、hostsファイル内の定義も併せて修正している。
<修正前>

# ls -l inventories/*/host_vars
inventories/production/host_vars:
合計 12
-rw-r--r-- 1 root root 138  3月  4 12:36 test-prd-001.yml
-rw-r--r-- 1 root root 138  3月  4 12:36 test-prd-002.yml
-rw-r--r-- 1 root root 138  3月  4 12:36 test-prd-003.yml

inventories/staging/host_vars:
合計 4
-rw-r--r-- 1 root root 132  3月  4 12:39 test-stg-001.yml

# cat inventories/production/hosts
[test_group_a]
test-prd-001 ansible_host=192.168.10.1
test-prd-002 ansible_host=192.168.10.2

[test_group_b]
test-prd-003 ansible_host=192.168.10.3

<修正後>

# ls -l inventories/*/host_vars
inventories/production/host_vars:
合計 12
-rw-r--r-- 1 root root 138  3月  4 12:36 192.168.10.3.yml
-rw-r--r-- 1 root root 138  3月  4 12:36 192.168.10.1.yml
-rw-r--r-- 1 root root 138  3月  4 12:36 192.168.10.2.yml

inventories/staging/host_vars:
合計 4
-rw-r--r-- 1 root root 132  3月  4 12:39 192.168.20.1.yml

# cat inventories/production/hosts
[test_group_a]
#test-prd-001
192.168.10.1
#test-prd-002
192.168.10.2

[test_group_b]
#test-prd-003
192.168.10.3

再度試してみる。

# docker run --rm -it -v /root/ansible:/work/ansible ansiblespec:$ANSIBLESPEC_VERSION rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production serverspec:test_group_a
Run serverspec for test_group_a to {"name"=>"192.168.10.1", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.1"}
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb
###### roles/role00のテスト
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys
root@192.168.10.1's password:
###### roles/role01のテスト

Service "chronyd"
  is expected to be enabled

Service "crond"
  is expected to be enabled

Service "sshd"
  is expected to be enabled

File "/tmp/role01.txt"
  is expected to be file

File "/tmp/role01.txt"
  content
    is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_a.yml\ntest_var_host        : inventories/production/host_vars/test-prd-001.yml\ntest_var_role        : roles/role01/vars/main.yml\n"

Finished in 0.39271 seconds (files took 6.47 seconds to load)
5 examples, 0 failures

Run serverspec for test_group_a to {"name"=>"192.168.10.2", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.2"}
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb
###### roles/role00のテスト
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys
root@192.168.10.2's password:
###### roles/role01のテスト

Service "chronyd"
  is expected to be enabled

Service "crond"
  is expected to be enabled

Service "sshd"
  is expected to be enabled

File "/tmp/role01.txt"
  is expected to be file

File "/tmp/role01.txt"
  content
    is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_a.yml\ntest_var_host        : inventories/production/host_vars/test-prd-002.yml\ntest_var_role        : roles/role01/vars/main.yml\n"

Finished in 0.4198 seconds (files took 5.39 seconds to load)
5 examples, 0 failures

→全てのテストをパスした。

注意:host_vars/ホスト名.ymlが読み込まれない

host_vars/ホスト名.ymlで上手くいかず、host_vars/IPアドレス.ymlだと上手くいかなかったのは、インベントリファイルで以下のようにホスト名を登録してIPをansible_hostに設定する方式にしていた(登録された名前と実際のSSH時の宛先が異なることになる)からだと考えている。
inventories/production/hosts

[test_group_a]
test-prd-001 ansible_host=192.168.10.1
test-prd-002 ansible_host=192.168.10.2

[test_group_b]
test-prd-003 ansible_host=192.168.10.3

→恐らく、ansible_specでは「sshする際に使用する実行先(今回はIPアドレス)の名前.yml」が読み込まれるのだと思う。
 実際、IPではなくホスト名指定でSSHできるように環境を変更し、インベントリファイルにもホスト名だけ記載すれば、ホスト名.ymlのファイルを読み込んでくれた。
 

商用環境の全ホストに対してテストを実行する

テストが通るようになったので、商用環境の全ホストに対して改めてテストを実行してみる。

# docker run --rm -it -v /root/ansible:/work/ansible ansiblespec:$ANSIBLESPEC_VERSION rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production all
Run serverspec for test_group_a to {"name"=>"192.168.10.1", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.1"}
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb
###### roles/role00のテスト
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys
root@192.168.10.1's password:
###### roles/role01のテスト

Service "chronyd"
  is expected to be enabled

Service "crond"
  is expected to be enabled

Service "sshd"
  is expected to be enabled

File "/tmp/role01.txt"
  is expected to be file

File "/tmp/role01.txt"
  content
    is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_a.yml\ntest_var_host        : inventories/production/host_vars/test-prd-001.yml\ntest_var_role        : roles/role01/vars/main.yml\n"

Finished in 0.40632 seconds (files took 5.39 seconds to load)
5 examples, 0 failures

Run serverspec for test_group_a to {"name"=>"192.168.10.2", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.2"}
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb
###### roles/role00のテスト
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys
root@192.168.10.2's password:
###### roles/role01のテスト

Service "chronyd"
  is expected to be enabled

Service "crond"
  is expected to be enabled

Service "sshd"
  is expected to be enabled

File "/tmp/role01.txt"
  is expected to be file

File "/tmp/role01.txt"
  content
    is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_a.yml\ntest_var_host        : inventories/production/host_vars/test-prd-002.yml\ntest_var_role        : roles/role01/vars/main.yml\n"

Finished in 0.44318 seconds (files took 5.66 seconds to load)
5 examples, 0 failures

Run serverspec for test_group_b to {"name"=>"192.168.10.3", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.3"}
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role02\}/spec/\*_spec.rb
###### roles/role00のテスト
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys
root@192.168.10.3's password:
###### roles/role02のテスト

Service "chronyd"
  is expected to be enabled

Service "crond"
  is expected to be enabled

Service "sshd"
  is expected to be enabled

File "/tmp/role02.txt"
  is expected to be file

File "/tmp/role02.txt"
  content
    is expected to match "test_var_role_default: roles/role02/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_b.yml\ntest_var_host        : inventories/production/host_vars/test-prd-003.yml\ntest_var_role        : roles/role02/vars/main.yml\n"

Finished in 1.13 seconds (files took 9.97 seconds to load)
5 examples, 0 failures

→問題なく実行され、テストもすべてパスした。
 以下のことが分かる。

  • 192.168.10.1、192.168.10.2に対してはrole00とrole01のテスト、192.168.10.3に対してはrole00とrole02のテストというように、Ansibleと同じホスト、ロールの組み合わせでテストが実行できている。
  • 「File "/tmp/ロール名.txt"」のテストをパスできているため、同じ変数がAnsibleのファイルの複数個所に定義されていても、ansible_specがちゃんとAnsibleと同じ優先順で値を上書きしていることが分かる。

ステージング環境の全ホストに対してテストを実行する

次に、ステージング環境の全ホストに対してテストを実行する。
環境変数INVENTORY、VARS_DIRS_PATHをステージング環境のものに変更して実行すればよい。

# docker run --rm -it -v /root/ansible:/work/ansible ansiblespec:$ANSIBLESPEC_VERSION rake INVENTORY=inventories/staging/hosts VARS_DIRS_PATH=inventories/staging all
Run serverspec for test_group_a to {"name"=>"192.168.20.1", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.20.1"}
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb
###### roles/role00のテスト
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys
root@192.168.20.1's password:
###### roles/role01のテスト

Service "chronyd"
  is expected to be enabled

Service "crond"
  is expected to be enabled

Service "sshd"
  is expected to be enabled

File "/tmp/role01.txt"
  is expected to be file

File "/tmp/role01.txt"
  content
    is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/staging/group_vars/all.yml\ntest_var_group_parent: inventories/staging/group_vars/test_group_ab.yml\ntest_var_group       : inventories/staging/group_vars/test_group_a.yml\ntest_var_host        : inventories/staging/host_vars/test-stg-001.yml\ntest_var_role        : roles/role01/vars/main.yml\n"

Finished in 0.27998 seconds (files took 4.84 seconds to load)
5 examples, 0 failures

環境変数INVENTORY、VARS_DIRS_PATHを変更することで、テスト実行先の環境面と読み込まれる変数定義を切り替えることが出来た。

注意:親グループをhostsに指定した際の挙動が、Ansibleとansible_specで異なる

先ほどまで使用していたsite.ymlでは、test_group_a.yml、test_group_b.ymlでhostsとしてtest_group_aグループ、test_group_bグループを指定している。
この2グループはインベントリで以下のようにtest_group_abグループの子として設定しているため、group_vars配下のtest_group_a.yml、test_group_b.ymlに加え、test_group_ab.ymlの変数定義も読み込まれる。
これは、Ansibleでもansible_specでも同様の挙動になっている。
inventories/production/hosts

# cat inventories/production/hosts
[test_group_a]
#test-prd-001
192.168.10.1
#test-prd-002
192.168.10.2

[test_group_b]
#test-prd-003
192.168.10.3

[test_group_ab:children]
test_group_a
test_group_b

しかし、以下のプレイブックのようにhostsに親グループを指定したときは、Ansibleとansible_specで挙動が異なる。
test_group_ab.yml

---
- name: test_group_ab
  hosts: test_group_ab
  roles:
    - role: role00
      tags: role00
    - role: role01
      tags: role01

試しに、上記プレイブックを使ってAnsibleとServerspecを実行してみる。
まずAnsibleを実行する

# ansible-playbook -i inventories/production/hosts test_group_ab.yml

次に、Serverspecを実行する。

# docker run --rm -it -v /root/ansible:/work/ansible ansiblespec:$ANSIBLESPEC_VERSION rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production PLAYBOOK=test_group_ab.yml -T
rake all                       # Run serverspec to all test
rake serverspec:test_group_ab  # Run serverspec for test_group_ab

# docker run --rm -it -v /root/ansible:/work/ansible ansiblespec:$ANSIBLESPEC_VERSION rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production PLAYBOOK=test_group_ab.yml serverspec:test_group_ab
Run serverspec for test_group_ab to {"name"=>"192.168.10.1", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.1"}
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb
###### roles/role00のテスト
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys
root@192.168.10.1's password:
###### roles/role01のテスト

Service "chronyd"
  is expected to be enabled

Service "crond"
  is expected to be enabled

Service "sshd"
  is expected to be enabled

File "/tmp/role01.txt"
  is expected to be file

File "/tmp/role01.txt"
  content
    is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_ab.yml\ntest_var_host        : inventories/production/host_vars/test-prd-001.yml\ntest_var_role        : roles/role01/vars/main.yml\n" (FAILED - 1)

Failures:

  1) File "/tmp/role01.txt" content is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_ab.yml\ntest_var_host        : inventories/production/host_vars/test-prd-001.yml\ntest_var_role        : roles/role01/vars/main.yml\n"
     On host `192.168.10.1'
     Failure/Error: its(:content) { should match variable_file }
       expected "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production...entories/production/host_vars/test-prd-001.yml\ntest_var_role        : roles/role01/vars/main.yml\n" to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_ab.yml\ntest_var_host        : inventories/production/host_vars/test-prd-001.yml\ntest_var_role        : roles/role01/vars/main.yml\n"
       Diff:
       @@ -1,7 +1,7 @@
        test_var_role_default: roles/role01/defaults/main.yml
        test_var_group_all   : inventories/production/group_vars/all.yml
        test_var_group_parent: inventories/production/group_vars/test_group_ab.yml
       -test_var_group       : inventories/production/group_vars/test_group_ab.yml
       +test_var_group       : inventories/production/group_vars/test_group_a.yml
        test_var_host        : inventories/production/host_vars/test-prd-001.yml
        test_var_role        : roles/role01/vars/main.yml

       sudo -p 'Password: ' /bin/sh -c cat\ /tmp/role01.txt\ 2\>\ /dev/null\ \|\|\ echo\ -n
       test_var_role_default: roles/role01/defaults/main.yml
test_var_group_all   : inventories/production/group_vars/all.yml
test_var_group_parent: inventories/production/group_vars/test_group_ab.yml
test_var_group       : inventories/production/group_vars/test_group_a.yml
test_var_host        : inventories/production/host_vars/test-prd-001.yml
test_var_role        : roles/role01/vars/main.yml

     # ./roles/role01/spec/main_spec.rb:19:in `block (2 levels) in <top (required)>'

Finished in 0.40189 seconds (files took 5.97 seconds to load)
5 examples, 1 failure

Failed examples:

rspec ./roles/role01/spec/main_spec.rb:19 # File "/tmp/role01.txt" content is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_ab.yml\ntest_var_host        : inventories/production/host_vars/test-prd-001.yml\ntest_var_role        : roles/role01/vars/main.yml\n"

/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb failed

→Ansibleでは変数test_var_groupの値がtest_group_a.yml、test_group_b.ymlに定義された値で上書きされている。
しかし、Serverspec(ansible_spec)では変数test_var_groupの値がtest_group_ab.ymlに定義された値のままで、test_group_a.yml、test_group_b.ymlが読み込まれていないことが分かる。
整理すると以下のようになる。

  • hostsに子グループを指定した場合
    • ansible:group_vars配下の子グループ.ymlに加え、親グループ.ymlも読み込まれる。
    • ansible_spec:group_vars配下の子グループ.ymlに加え、親グループ.ymlも読み込まれる。
  • hostsに親グループを指定した場合
    • ansible:group_vars配下の親グループ.ymlに加え、子グループ.ymlも読み込まれる。
    • ansible_spec:group_vars配下の親グループ.ymlだけが読み込まれ、子グループ.ymlは読み込まれない。

上記の挙動であるため、ansible_specを使う場合は、プレイブックのhostsには親グループではなく子グループを指定した方がよい。
親グループを指定してしまうと、挙動のずれによってServerspecでテストできなくなる。

aliasの設定

ansible_spec実行時に毎回「docker run ~」と入力するのは手間なので、エイリアスを使って簡単に実行できるようにする。
Dockerホストの「~/.bashrc」に以下の2行を追記する。

export ANSIBLESPEC_VERSION=0.3.2
alias rake='docker run --rm -it -v /root/ansible:/work/ansible ansiblespec:$ANSIBLESPEC_VERSION rake'

試してみる。

変更を反映
# source ~/.bashrc

エイリアスを指定してansoble_specを実行する。
# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production -T
rake all                      # Run serverspec to all test
rake serverspec:test_group_a  # Run serverspec for test_group_a
rake serverspec:test_group_b  # Run serverspec for test_group_b

→「rake ~」と実行するだけでDockerコンテナを使ってansible_specを実行できるようになった。
別バージョンのansible_specを使いたい場合は、該当バージョンのansible_specがインストールされたDockerイメージを用意し、環境変数「ANSIBLESPEC_VERSION」に指定する値を変えることで使用するバージョンを制御できる。

ansible_specのカスタマイズ

実行してみていくつか気になった点があるので、設定を変更して解消する。
全てansible_specというよりServerspecの話なので、Serverspec単体で使っている場合も同様の対処になる。

SSHの認証パスワードを自動入力するよう設定する

ansibleは、実行対象サーバにはパスワード認証ではなく鍵認証を使って接続できるよう設定しておくことを推奨しており、ansible_specもそれに倣っている。 そのため、SSHがパスワード認証になっているサーバにServerspecを実行した場合は、パスワードを手動入力する必要がある。
しかし、パスワード認証を使いたいケースもあると思うため、パスワードを自動入力するようにしてみる。
SSHがパスワード認証のサーバに対しServerspecを実行する際の設定は、以下に記載がある。

→spec_helper.rbを修正して、環境変数「LOGIN_PASSWORD」にSSHパスワードを格納してServerspecを実行すればパスワードの手動入力が不要になる。

上記ドキュメント通りに、spec/spec_helper.rbに以下2点を追記する(追記場所は後述)。

require 'highline/import'
if ENV['ASK_LOGIN_PASSWORD']
  options[:password] = ask("\nEnter login password: ") { |q| q.echo = false }
else
  options[:password] = ENV['LOGIN_PASSWORD']
end

以下のように実行すると、SSHの認証パスワードの入力を求められずにServerspecを実行できる。

# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production LOGIN_PASSWORD="SSHの認証パスワード" all

※ちなみに、環境変数LOGIN_PASSWORDを使わずに、以下のようにパスワードを直接spec_helper.rbに書いても同様の挙動になる。
環境変数に設定するのが面倒ならこの方法もありだが、パスワードをファイルに平文で記載するのはセキュリティ的には良くないので注意すること。

require 'highline/import'
LOGIN_PASSWORD="SSHの認証パスワード"  ## 変更点
if ENV['ASK_LOGIN_PASSWORD']
  options[:password] = ask("\nEnter login password: ") { |q| q.echo = false }
else
  options[:password] = LOGIN_PASSWORD ## 変更点
end

テストが失敗しても処理が終了しないようにする

デフォルトの挙動だと、テスト項目がFAILEDになるとそこでテストが終了し、その後のテスト項目、ホストへのテスト実行はされない。
FAILEDになってもとりあえず全テストを終わらせて、後でまとめて結果を見たいので、テストが失敗しても処理が終了しないようにする。
こちらを参考にさせていただいて、Rakefileに以下処理を追記した(追記場所は後述)。

        # 環境変数IGNORE_FAILが1の場合、途中でfailしてもテストを続ける
        if ENV['IGNORE_FAIL'] == "1"
          t.fail_on_error = false
        end

これで、以下のように環境変数IGNORE_FAILに1を設定してServerspecを実行すると、テストが失敗しても処理が継続される。

# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production LOGIN_PASSWORD="SSHの認証パスワード" IGNORE_FAIL=1 all

出力を見やすくする

どのロールに対応するテストか分かるように、specファイルに以下のようにputsを書いている。
roles/role00/spec/main_spec.rb

require 'spec_helper'

puts '###### roles/role00のテスト'

property['role00_services_list'].each do |service|
  describe service("#{service}"), :if => os[:family] == 'redhat' do
    it { should be_enabled }
  end
end

roles/role01/spec/main_spec.rb

require 'spec_helper'

puts '###### roles/role01のテスト'

describe file('/tmp/role01.txt'), :if => os[:family] == 'redhat' do
  it { should be_file }
end

variable_file = <<"EOF"
test_var_role_default: #{property['test_var_role_default']}
test_var_group_all   : #{property['test_var_group_all']}
test_var_group_parent: #{property['test_var_group_parent']}
test_var_group       : #{property['test_var_group']}
test_var_host        : #{property['test_var_host']}
test_var_role        : #{property['test_var_role']}
EOF

describe file('/tmp/role01.txt'), :if => os[:family] == 'redhat' do
  its(:content) { should match variable_file }
end

これは、以下のような表示になることを期待して書いた。

###### roles/role00のテスト
(roles/role00のテスト結果)
###### roles/role01のテスト
(roles/role01のテスト結果)

しかし、実際には以下のようになり、どこがrole00とrole01の結果の境界なのか分からなくなっている。

###### roles/role00のテスト
###### roles/role01のテスト
(roles/role00のテスト結果)
(roles/role01のテスト結果)

これは恐らく、Rakefileのホストごとのfor分の中で、以下のようにスペックファイルが一気に読み込まれ、同時実行されているためだと思う。
Rakefileの一部

t.pattern = '{' + cfg.roles_path.join(',') + '}/{' + roles.join(',') + '}/spec/*_spec.rb'

これを修正するのは影響が大きそうなので、テストコードを以下のように変更することで、ロールとテスト内容を表示し、テストコードとテスト結果を紐づけ易くした。

  • 全体をdescribeでグループ化し、説明を「Role "ロール名"」とする。
  • テスト単位でdescribeでグループ化し、説明を「Test "テストの説明"」とする。

roles/role00/spec/main_spec.rb

require 'spec_helper'

describe 'Role "roles/role00"' do

  describe 'Test "check_service_enabled"' do
    property['role00_services_list'].each do |service|
      describe service("#{service}"), :if => os[:family] == 'redhat' do
        it { should be_enabled }
      end
    end
  end

end

roles/role01/spec/main_spec.rb

require 'spec_helper'

describe 'Role "roles/role01"' do

  describe 'Test "variable_file_exist"' do
    describe file('/tmp/role01.txt'), :if => os[:family] == 'redhat' do
      it { should be_file }
    end
  end

  describe 'Test "variable_file_contents"' do
    variable_file = <<"EOF"
test_var_role_default: #{property['test_var_role_default']}
test_var_group_all   : #{property['test_var_group_all']}
test_var_group_parent: #{property['test_var_group_parent']}
test_var_group       : #{property['test_var_group']}
test_var_host        : #{property['test_var_host']}
test_var_role        : #{property['test_var_role']}
EOF

    describe file('/tmp/role01.txt'), :if => os[:family] == 'redhat' do
      its(:content) { should match variable_file }
    end
  end

end

また現状の出力だと、実行対象ホスト間の境界や、ホストに実行される対象ロールが分かりづらいので、Rakefileに以下を追記する(追記場所は後述)。
Rakefileの一部

        # 実行ホスト名、実行ロールを表示する
        puts "#####################################################################"
        puts "  Target host : #{host["name"]}"
        puts "  Target role : {#{cfg.roles_path.join(',')}}/{#{roles.join(',')}}"
        puts "##################################################3##################"

上記変更後のServerspecの出力は以下のようになる。

# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production LOGIN_PASSWORD="SSHの認証パスワード" IGNORE_FAIL=1 all
Run serverspec for test_group_a to {"name"=>"192.168.10.1", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.1"}
#####################################################################
  Target host : 192.168.10.1
  Target role : {roles}/{role00,role01}
##################################################3##################
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys

Role "roles/role00"
  Test "check_service_enabled"
    Service "chronyd"
      is expected to be enabled
    Service "crond"
      is expected to be enabled
    Service "sshd"
      is expected to be enabled

Role "roles/role01"
  Test "variable_file_exist"
    File "/tmp/role01.txt"
      is expected to be file
  Test "variable_file_contents"
    File "/tmp/role01.txt"
      content
        is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_a.yml\ntest_var_host        : inventories/production/host_vars/test-prd-001.yml\ntest_var_role        : roles/role01/vars/main.yml\n"

Finished in 0.41207 seconds (files took 3.38 seconds to load)
5 examples, 0 failures

Run serverspec for test_group_a to {"name"=>"192.168.10.2", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.2"}
#####################################################################
  Target host : 192.168.10.2
  Target role : {roles}/{role00,role01}
##################################################3##################
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys

Role "roles/role00"
  Test "check_service_enabled"
    Service "chronyd"
      is expected to be enabled
    Service "crond"
      is expected to be enabled
    Service "sshd"
      is expected to be enabled

Role "roles/role01"
  Test "variable_file_exist"
    File "/tmp/role01.txt"
      is expected to be file
  Test "variable_file_contents"
    File "/tmp/role01.txt"
      content
        is expected to match "test_var_role_default: roles/role01/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_a.yml\ntest_var_host        : inventories/production/host_vars/test-prd-002.yml\ntest_var_role        : roles/role01/vars/main.yml\n"

Finished in 0.43605 seconds (files took 3.28 seconds to load)
5 examples, 0 failures

Run serverspec for test_group_b to {"name"=>"192.168.10.3", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.3"}
#####################################################################
  Target host : 192.168.10.3
  Target role : {roles}/{role00,role02}
##################################################3##################
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role02\}/spec/\*_spec.rb
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys

Role "roles/role00"
  Test "check_service_enabled"
    Service "chronyd"
      is expected to be enabled
    Service "crond"
      is expected to be enabled
    Service "sshd"
      is expected to be enabled

Role "roles/role02"
  Test "variable_file_exist"
    File "/tmp/role02.txt"
      is expected to be file
  Test "variable_file_contents"
    File "/tmp/role02.txt"
      content
        is expected to match "test_var_role_default: roles/role02/defaults/main.yml\ntest_var_group_all   : inventories/production/group_vars/all.yml\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\ntest_var_group       : inventories/production/group_vars/test_group_b.yml\ntest_var_host        : inventories/production/host_vars/test-prd-003.yml\ntest_var_role        : roles/role02/vars/main.yml\n"

Finished in 1.19 seconds (files took 7.31 seconds to load)
5 examples, 0 failures

→出力が変わり、ホスト、ロール、テスト項目を探しやすくなったと思う。

テスト結果をファイル出力する

ここまで、テスト結果はコンソールに表示されたものを確認してきたが、実際の開発ではエビデンスが必要になる。
そのため、テスト結果をファイル出力する方法をいくつか試す。

方法1:普通にリダイレクトする

rakeの実行結果をファイルにリダイレクトする。
コンソールに表示されていた結果をそのまま保存するだけでいいならこれで十分。
ただ、対象のホストやテスト項目が大量だと、見づらいという問題はある。

# mkdir -p audit/documentation

# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production LOGIN_PASSWORD="SSHの認証パスワード" IGNORE_FAIL=1 all > ./audit/documentation/$(date +%Y%m%d-%H%M%S)-production-all.log

# ls -l ./audit/documentation/
合計 8
-rw-r--r-- 1 root root 5042  3月  7 05:26 20230307-052630-production-all.log
方法2:ホスト単位でファイル出力する

こちらを参考にさせていただいて、Rakefileを以下のように変更する。
Rakefileに以下を追記する(追記場所は後述)。
これで、環境変数LOG_DIRを指定すると、指定先に実行結果がファイル出力される。

        # 実行結果をファイル出力する
        if ENV['LOG_DIR']
          t.rspec_opts = "--format documentation -o #{ENV['LOG_DIR']}/#{ENV['TARGET_HOST']}.log"
        end

LOG_DIRを指定して実行する。
rakeコマンドの実行結果以外は変わらずコンソールに出力される。

# mkdir -p audit/documentation

# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production LOGIN_PASSWORD="SSHの認証パスワード" IGNORE_FAIL=1 LOG_DIR=audit/documentation/ all
Run serverspec for test_group_a to {"name"=>"192.168.10.1", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.1"}
#####################################################################
  Target host : 192.168.10.1
  Target role : {roles}/{role00,role01}
##################################################3##################
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb --format documentation -o audit/documentation//$(date +%Y%m%d-%H%M%S)-192.168.10.1.log
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys
Run serverspec for test_group_a to {"name"=>"192.168.10.2", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.2"}
#####################################################################
  Target host : 192.168.10.2
  Target role : {roles}/{role00,role01}
##################################################3##################
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role01\}/spec/\*_spec.rb --format documentation -o audit/documentation//$(date +%Y%m%d-%H%M%S)-192.168.10.2.log
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys
Run serverspec for test_group_b to {"name"=>"192.168.10.3", "port"=>22, "connection"=>"ssh", "uri"=>"192.168.10.3"}
#####################################################################
  Target host : 192.168.10.3
  Target role : {roles}/{role00,role02}
##################################################3##################
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern \{roles\}/\{role00,role02\}/spec/\*_spec.rb --format documentation -o audit/documentation//$(date +%Y%m%d-%H%M%S)-192.168.10.3.log
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys

ログを確認すると、ホスト単位で出力されている。

# ls -l audit/documentation/
合計 12
-rw-r--r-- 1 root root 879  3月  7 06:12 192.168.10.1.log
-rw-r--r-- 1 root root 882  3月  7 06:11 192.168.10.2.log
-rw-r--r-- 1 root root 882  3月  7 06:12 192.168.10.3.log

ホスト単位でどれくらいfailしたのか、以下のように見られるので、比較的結果が確認しやすそう。

# grep ".*examples,.*failures" audit/documentation/*.log
audit/documentation/192.168.10.1.log:5 examples, 0 failures
audit/documentation/192.168.10.2.log:5 examples, 0 failures
audit/documentation/192.168.10.3.log:5 examples, 0 failures

ちなみに、formatを変えればJSON形式でも出力できる。
Rakefileを以下のように変更する。

        # 実行結果をファイル出力する
        if ENV['LOG_DIR']
          t.rspec_opts = "--format json -o #{ENV['LOG_DIR']}/#{ENV['TARGET_HOST']}.json"
        end

実行する。

# mkdir -p audit/json

# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production LOGIN_PASSWORD="SSHの認証パスワード" IGNORE_FAIL=1 LOG_DIR=audit/json/ all
(出力は省略)

# ls -l audit/json/
合計 12
-rw-r--r-- 1 root root 2560  3月  7 11:29 20230307-202931-192.168.10.1.json
-rw-r--r-- 1 root root 2560  3月  7 11:29 20230307-202933-192.168.10.2.json
-rw-r--r-- 1 root root 2559  3月  7 11:29 20230307-202936-192.168.10.3.json

中を見ると、JSON形式になっている。
プログラムに読み込ませて処理を行う場合などに使えそう。

# cat audit/json/20230307-202931-192.168.10.1.json | jq '.'
{
  "version": "3.12.1",
  "examples": [
    {
      "id": "./roles/role00/spec/main_spec.rb[1:1:1:1]",
      "description": "is expected to be enabled",
      "full_description": "Role \"roles/role00\" Test \"check_service_enabled\" Service \"chronyd\" is expected to be enabled",
      "status": "passed",
      "file_path": "./roles/role00/spec/main_spec.rb",
      "line_number": 8,
      "run_time": 0.090414959,
      "pending_message": null
    },
    {
      "id": "./roles/role00/spec/main_spec.rb[1:1:2:1]",
      "description": "is expected to be enabled",
      "full_description": "Role \"roles/role00\" Test \"check_service_enabled\" Service \"crond\" is expected to be enabled",
      "status": "passed",
      "file_path": "./roles/role00/spec/main_spec.rb",
      "line_number": 8,
      "run_time": 0.086973199,
      "pending_message": null
    },
    {
      "id": "./roles/role00/spec/main_spec.rb[1:1:3:1]",
      "description": "is expected to be enabled",
      "full_description": "Role \"roles/role00\" Test \"check_service_enabled\" Service \"sshd\" is expected to be enabled",
      "status": "passed",
      "file_path": "./roles/role00/spec/main_spec.rb",
      "line_number": 8,
      "run_time": 0.092661662,
      "pending_message": null
    },
    {
      "id": "./roles/role01/spec/main_spec.rb[1:1:1:1]",
      "description": "is expected to be file",
      "full_description": "Role \"roles/role01\" Test \"variable_file_exist\" File \"/tmp/role01.txt\" is expected to be file",
      "status": "passed",
      "file_path": "./roles/role01/spec/main_spec.rb",
      "line_number": 7,
      "run_time": 0.07561173,
      "pending_message": null
    },
    {
      "id": "./roles/role01/spec/main_spec.rb[1:2:1:1:1]",
      "description": "is expected to match \"test_var_role_default: roles/role01/defaults/main.yml\\ntest_var_group_all   : inventories/production/group_vars/all.yml\\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\\ntest_var_group       : inventories/production/group_vars/test_group_a.yml\\ntest_var_host        : inventories/production/host_vars/test-prd-001.yml\\ntest_var_role        : roles/role01/vars/main.yml\\n\"",
      "full_description": "Role \"roles/role01\" Test \"variable_file_contents\" File \"/tmp/role01.txt\" content is expected to match \"test_var_role_default: roles/role01/defaults/main.yml\\ntest_var_group_all   : inventories/production/group_vars/all.yml\\ntest_var_group_parent: inventories/production/group_vars/test_group_ab.yml\\ntest_var_group       : inventories/production/group_vars/test_group_a.yml\\ntest_var_host        : inventories/production/host_vars/test-prd-001.yml\\ntest_var_role        : roles/role01/vars/main.yml\\n\"",
      "status": "passed",
      "file_path": "./roles/role01/spec/main_spec.rb",
      "line_number": 22,
      "run_time": 0.076268357,
      "pending_message": null
    }
  ],
  "summary": {
    "duration": 0.424786489,
    "example_count": 5,
    "failure_count": 0,
    "pending_count": 0,
    "errors_outside_of_examples_count": 0
  },
  "summary_line": "5 examples, 0 failures"
}
方法3:formatterを使ってCSV形式で出力する

rspec-coreのドキュメントを見ると、rspecの「--formatオプション」に指定できるのはdefault、documentation、json、html。
これら以外の形式にしたい場合は、Custom Formattersを定義して使用することになる。
試しに、こちらを参考にCSV出力するCustom Formattersを作成する。
serverspec_audit_formatter.rb

require 'rspec/core/formatters/documentation_formatter'
require 'specinfra'
require 'serverspec/version'
require 'serverspec/type/base'
require 'serverspec/type/command'
require 'fileutils'
require 'csv'

class ServerspecAuditFormatter < RSpec::Core::Formatters::DocumentationFormatter
  RSpec::Core::Formatters.register self, :example_group_started,
                                   :example_passed, :example_pending, :example_failed

  def initialize(output)
    super
    @seq = 0
  end

  def example_group_started(notification)
    @seq = 0 if @group_level == 0
    super
  end

  def example_passed(notification)
    save_evidence(notification.example)
    super
  end

  def example_pending(notification)
    save_evidence(notification.example)
    super
  end

  def example_failed(notification)
    save_evidence(notification.example, notification.exception)
    super
  end

  # CSVファイルへの出力
  def save_evidence(example, exception=nil)
    #test = example.metadata
    host  = ENV['TARGET_HOST']
    result_status  = example.metadata[:execution_result].status
    location  = example.metadata[:location].to_s
    full_description  = example.metadata[:full_description]

    csv_data = [host, result_status, location, full_description]

    output_file = ENV['LOG_DIR'] + '/' + ENV['LOG_FILE']
    CSV.open(output_file, "a") do |csv|
      csv << csv_data
    end
  end
end

Rakefileに以下を追記する(追記場所は後述)。
これで、環境変数LOG_DIRとLOG_FILEを指定すると、「LOG_DIR/LOG_FILE」にCSV形式のファイルが出力されるようになる。

        if ENV['LOG_DIR']
          t.rspec_opts = "--format ServerspecAuditFormatter --require ./formatters/serverspec_audit_formatter.rb"
        end

環境変数LOG_DIRとLOG_FILEを指定して実行する。

# mkdir -p audit/csv

# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production LOGIN_PASSWORD="SSHの認証パスワード" IGNORE_FAIL=1 LOG_DIR=audit/csv/ LOG_FILE=$(date +%Y%m%d-%H%M%S)-production-all.log all
(コンソールにはdocumentation形式で結果が出力されるが省略)

# ls -l audit/csv/
合計 4
-rw-r--r-- 1 root root 3333  3月  7 15:14 20230307-151401-production-all.log

中身が想定通りCSV形式になっている。

# head -n 3 audit/csv/20230307-151401-production-all.log
192.168.10.1,passed,./roles/role00/spec/main_spec.rb:8,"Role ""roles/role00"" Test ""check_service_enabled"" Service ""chronyd"" is expected to be enabled"
192.168.10.1,passed,./roles/role00/spec/main_spec.rb:8,"Role ""roles/role00"" Test ""check_service_enabled"" Service ""crond"" is expected to be enabled"
192.168.10.1,passed,./roles/role00/spec/main_spec.rb:8,"Role ""roles/role00"" Test ""check_service_enabled"" Service ""sshd"" is expected to be enabled"

メッセージ「Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys」を抑制する(未解決)

Serverspecの実行結果の中で、ホストに対してSSHする箇所で毎回以下メッセージが表示されている。
邪魔なので出力されないようにしたいのだが、方法が分からなかったので一旦放置している。

/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user, keys

最終的なディレクトリ構成・ファイル

最終的なディレクトリ構成・ファイルは以下の通り。
ここに置いてある。

ディレクトリ構成

ansible
|-- .ansiblespec                        # ansiblespec-initで作成される
|-- .rspec                              # ansiblespec-initで作成される
|-- Rakefile                            # ansiblespec-initで作成される
|-- ansible.cfg
|-- audit                               # Serverspecの結果ファイル出力先
|   |-- csv
|   |-- documentation
|   `-- json
|-- formatters                          # ServerspecのCustom Formatters置き場
|   `-- serverspec_audit_formatter.rb
|-- inventories
|   |-- production
|   |   |-- group_vars
|   |   |   |-- all.yml
|   |   |   |-- test_group_a.yml
|   |   |   |-- test_group_ab.yml
|   |   |   `-- test_group_b.yml
|   |   |-- host_vars
|   |   |   |-- 192.168.10.1.yml
|   |   |   |-- 192.168.10.2.yml
|   |   |   `-- 192.168.10.3.yml
|   |   `-- hosts
|   `-- staging
|       |-- group_vars
|       |   |-- all.yml
|       |   |-- test_group_a.yml
|       |   |-- test_group_ab.yml
|       |   `-- test_group_b.yml
|       |-- host_vars
|       |   `-- 192.168.20.1.yml
|       `-- hosts
|-- roles
|   |-- role00
|   |   |-- defaults
|   |   |   `-- main.yml
|   |   |-- spec                        # specファイル置き場
|   |   |   `-- main_spec.rb
|   |   `-- tasks
|   |       `-- main.yml
|   |-- role01
|   |   |-- defaults
|   |   |   `-- main.yml
|   |   |-- spec
|   |   |   `-- main_spec.rb
|   |   |-- tasks
|   |   |   `-- main.yml
|   |   |-- templates
|   |   |   `-- variable.txt.j2
|   |   `-- vars
|   |       `-- main.yml
|   `-- role02
|       |-- defaults
|       |   `-- main.yml
|       |-- spec
|       |   `-- main_spec.rb
|       |-- tasks
|       |   `-- main.yml
|       |-- templates
|       |   `-- variable.txt.j2
|       `-- vars
|           `-- main.yml
|-- site.yml
|-- spec                                # ansiblespec-initで作成される
|   `-- spec_helper.rb                  # ansiblespec-initで作成される
|-- test_group_a.yml
|-- test_group_ab.yml
`-- test_group_b.yml

ファイル

ここにはRakefiletとspec_helper.rbのみ記載する。
他のファイルはここに格納しているもの参照。
Rakefile

require 'rake'
require 'rspec/core/rake_task'
require 'yaml'
require 'ansible_spec'

properties = AnsibleSpec.get_properties
# {"name"=>"Ansible-Sample-TDD", "hosts"=>["192.168.0.103","192.168.0.103"], "user"=>"root", "roles"=>["nginx", "mariadb"]}
# {"name"=>"Ansible-Sample-TDD", "hosts"=>[{"name" => "192.168.0.103:22","uri"=>"192.168.0.103","port"=>22, "private_key"=> "~/.ssh/id_rsa"}], "user"=>"root", "roles"=>["nginx", "mariadb"]}
cfg = AnsibleSpec::AnsibleCfg.new

desc "Run serverspec to all test"
task :all => "serverspec:all"

namespace :serverspec do
  properties = properties.compact.reject{|e| e["hosts"].length == 0}
  task :all => properties.map {|v| 'serverspec:' + v["name"] }
  properties.each_with_index.map do |property, index|
    property["hosts"].each do |host|
      desc "Run serverspec for #{property["name"]}"
      RSpec::Core::RakeTask.new(property["name"].to_sym) do |t|
        puts "Run serverspec for #{property["name"]} to #{host}"
        ENV['TARGET_HOSTS'] = host["hosts"]
        ENV['TARGET_HOST'] = host["uri"]
        ENV['TARGET_PORT'] = host["port"].to_s
        ENV['TARGET_GROUP_INDEX'] = index.to_s
        ENV['TARGET_PRIVATE_KEY'] = host["private_key"]
        unless host["user"].nil?
          ENV['TARGET_USER'] = host["user"]
        else
          ENV['TARGET_USER'] = property["user"]
        end
        ENV['TARGET_PASSWORD'] = host["pass"]
        ENV['TARGET_CONNECTION'] = host["connection"]

        roles = property["roles"]
        for role in property["roles"]
          for rolepath in cfg.roles_path
            deps = AnsibleSpec.load_dependencies(role, rolepath)
            if deps != []
              roles += deps
              break
            end
          end
        end
        # 実行ホスト名、実行ロールを表示する
        puts "#####################################################################"
        puts "  Target host : #{host["name"]}"
        puts "  Target role : {#{cfg.roles_path.join(',')}}/{#{roles.join(',')}}"
        puts "##################################################3##################"
        t.pattern = '{' + cfg.roles_path.join(',') + '}/{' + roles.join(',') + '}/spec/*_spec.rb'
        # 実行結果をファイル出力する。使いたいフォーマットのコメントアウトを外すこと
        if ENV['LOG_DIR']
          # Documentation
          t.rspec_opts = "--format documentation -o #{ENV['LOG_DIR']}/$(date +%Y%m%d-%H%M%S)-#{ENV['TARGET_HOST']}.log"
          # JSON
          #t.rspec_opts = "--format json -o #{ENV['LOG_DIR']}/$(date +%Y%m%d-%H%M%S)-#{ENV['TARGET_HOST']}.json"
          # CSV
          #t.rspec_opts = "--format ServerspecAuditFormatter --require ./formatters/serverspec_audit_formatter.rb"
        end
        # 環境変数IGNORE_FAILが1の場合、途中でfailしてもテストを続ける
        if ENV['IGNORE_FAIL'] == "1"
          t.fail_on_error = false
        end
      end
    end
  end
end

spec/spec_helper.rb

require 'serverspec'
require 'net/ssh'
require 'ansible_spec'
require 'winrm'
require 'highline/import'  # SSHのパスワード認証を使うため追加

#
# Set ansible variables to serverspec property
#
host = ENV['TARGET_HOST']
hosts = ENV["TARGET_HOSTS"]

group_idx = ENV['TARGET_GROUP_INDEX'].to_i
vars = AnsibleSpec.get_variables(host, group_idx,hosts)
ssh_config_file = AnsibleSpec.get_ssh_config_file
set_property vars

connection = ENV['TARGET_CONNECTION']

case connection
when 'ssh'
#
# OS type: UN*X
#
  set :backend, :ssh

  # Ansible use `BECOME`, But Serverspec use `SUDO`.
  if ENV['ASK_BECOME_PASSWORD']
    begin
      require 'highline/import'
    rescue LoadError
      fail "highline is not available. Try installing it."
    end
    set :sudo_password, ask("Enter become password: ") { |q| q.echo = false }
  else
    set :sudo_password, ENV['BECOME_PASSWORD']
  end

  options = Net::SSH::Config.for(host)

  options[:user] = ENV['TARGET_USER'] || options[:user]
  options[:port] = ENV['TARGET_PORT'] || options[:port]
  options[:keys] = ENV['TARGET_PRIVATE_KEY'] || options[:keys]

  if ssh_config_file
    from_config_file = Net::SSH::Config.for(host,files=[ssh_config_file])
    options.merge!(from_config_file)
  end

# SSHのパスワード認証を使うため追加
if ENV['ASK_LOGIN_PASSWORD']
  options[:password] = ask("\nEnter login password: ") { |q| q.echo = false }
else
  options[:password] = ENV['LOGIN_PASSWORD']
end

  set :host,        options[:host_name] || host
  set :ssh_options, options

  # Disable become (Serverspec use sudo)
  # set :disable_sudo, true


  # Set environment variables
  # set :env, :LANG => 'C', :LC_MESSAGES => 'C'

  # Set PATH
  # set :path, '/sbin:/usr/local/sbin:$PATH'
when 'winrm'
#
# OS type: Windows
#
  set :backend, :winrm
  set :os, :family => 'windows'

  user = ENV['TARGET_USER']
  port = ENV['TARGET_PORT']
  pass = ENV['TARGET_PASSWORD']

  if user.nil?
    begin
      require 'highline/import'
    rescue LoadError
      fail "highline is not available. Try installing it."
    end
    user = ask("\nEnter #{host}'s login user: ") { |q| q.echo = true }
  end
  if pass.nil?
    begin
      require 'highline/import'
    rescue LoadError
      fail "highline is not available. Try installing it."
    end
    pass = ask("\nEnter #{user}@#{host}'s login password: ") { |q| q.echo = false }
  end

  endpoint = "http://#{host}:#{port}/wsman"

  winrm = ::WinRM::WinRMWebService.new(endpoint, :ssl, :user => user, :pass => pass, :basic_auth_only => true)
  winrm.set_timeout 300 # 5 minutes max timeout for any operation
  Specinfra.configuration.winrm = winrm

when 'local'
#
# local connection
#
    set :backend, :exec
end

補足

注意:インベントリファイルでグループのネスト(親子グループ)を使用している場合は記載順に気を付ける

Ansibleで、以下のようにグループのネストをしている場合があると思う。
inventories/production/hosts

[test_group_ab:children]
test_group_a
test_group_b

[test_group_a]
#test-prd-001
192.168.10.1
#test-prd-002
192.168.10.2

[test_group_b]
#test-prd-003
192.168.10.3

test_group_ab.yml

---
- name: test_group_ab
  hosts: test_group_ab
  roles:
    - role: role00
      tags: role00
    - role: role01
      tags: role01

この記載でAnsibleは問題なく実行できる。
しかし、Serverspecを実行すると、以下のように「TypeError: no implicit conversion of nil into Array」というエラーになる。

# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production PLAYBOOK=test_group_ab.yml -T
rake aborted!
TypeError: no implicit conversion of nil into Array
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:112:in `+'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:112:in `block in get_parent'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:111:in `each'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:111:in `get_parent'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:86:in `block in load_targets'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:80:in `each'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:80:in `load_targets'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:425:in `get_properties'
/work/ansible/Rakefile:6:in `<top (required)>'
/usr/local/share/gems/gems/rake-13.0.6/exe/rake:27:in `<top (required)>'
(See full trace by running task with --trace)

→エラーメッセージを見ると、ansible_specがAnsibleのから親子グループのあたりの情報を取得する部分で失敗しているように見える。
このエラーは、親グループの定義が子グループの後になるよう、インベントリファイルの記載順を以下のように修正したら解消した。
inventories/production/hosts

[test_group_a]
#test-prd-001
192.168.10.1
#test-prd-002
192.168.10.2

[test_group_b]
#test-prd-003
192.168.10.3

[test_group_ab:children]
test_group_a
test_group_b

Serverspecが正常に実行できるようになる。

# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/product
ion PLAYBOOK=test_group_ab.yml -T
rake all                       # Run serverspec to all test
rake serverspec:test_group_ab  # Run serverspec for test_group_ab

親グループの定義を子グループより後に記載しないと、ansible_specが動作しないことが分かった。
Ansibleだけを使っているときは意識していない点だと思うので、気を付ける。

注意:ansible.cfgファイルのコメントに日本語を使用するとエラーになる

ansible.cfgで以下のように日本語のコメントを入れている場合、Ansibleは問題なく実行できる。
ansible.cfg

[defaults]
# 鍵のチェックを無効
host_key_checking = False

test_group_ab.yml

---
- name: test_group_ab
  hosts: test_group_ab
  roles:
    - role: role00
      tags: role00
    - role: role01
      tags: role01

しかし、Serverspecを実行すると、以下のように「ArgumentError: invalid byte sequence in US-ASCII」というエラーになる。

# rake INVENTORY=inventories/production/hosts VARS_DIRS_PATH=inventories/production -T
rake aborted!
ArgumentError: invalid byte sequence in US-ASCII
/usr/local/share/gems/gems/inifile-3.0.0/lib/inifile.rb:522:in `==='
/usr/local/share/gems/gems/inifile-3.0.0/lib/inifile.rb:522:in `block in parse'
/usr/local/share/gems/gems/inifile-3.0.0/lib/inifile.rb:515:in `each_line'
/usr/local/share/gems/gems/inifile-3.0.0/lib/inifile.rb:515:in `parse'
/usr/local/share/gems/gems/inifile-3.0.0/lib/inifile.rb:400:in `parse'
/usr/local/share/gems/gems/inifile-3.0.0/lib/inifile.rb:128:in `block in read'
/usr/local/share/gems/gems/inifile-3.0.0/lib/inifile.rb:128:in `open'
/usr/local/share/gems/gems/inifile-3.0.0/lib/inifile.rb:128:in `read'
/usr/local/share/gems/gems/inifile-3.0.0/lib/inifile.rb:80:in `initialize'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:608:in `new'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:608:in `block in load_ansible_cfg'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:607:in `each'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:607:in `load_ansible_cfg'
/usr/local/share/gems/gems/ansible_spec-0.3.2/lib/ansible_spec/load_ansible.rb:585:in `initialize'
/work/ansible/Rakefile:9:in `new'
/work/ansible/Rakefile:9:in `<top (required)>'
/usr/local/share/gems/gems/rake-13.0.6/exe/rake:27:in `<top (required)>'
(See full trace by running task with --trace)

→ansible_specがansible.cfgを読み込む際に、日本語(たぶんUS-ASCIIに無い文字全て)が含まれているとエラーになる模様。
日本語が書かれている場合は消しておくこと。
(ちなみにRakefile、spec_helper.rb、.rspec、.ansiblespecやAnsibleのyamlには日本語コメントを入れても特にエラーにならなかった。)

まとめ

  • ansible_specを導入することで、Ansibleのホスト、ロール、変数情報をServerspecに連携することができ、情報の2重管理の手間がなくなり、ディレクトリ構成も分かりやすいものに出来た。
  • ansible_specの使用にあたり、以下に気を付けること。
    • Ansibleのディレクトリ構成に合わせて、INVENTORY、PLAYBOOK、VARS_DIRS_PATHといったansible_specの環境変数を適切に設定する。
    • プレイブックの「name:」の値は必ず設定し、値は「hosts:」の値と同じにする。
      少なくとも、スペースなど特殊な文字や、他のプレイブックのnameと重複する値は避ける。
    • Ansibleのマジック変数は使用できない可能性が高い。
    • Ansibleとansible_specで変数の読み込まれ方を一致させるため、hash_behaviourは同じ設定にする。
    • ホスト変数(host_vars配下のyaml)は、ansible_specでは最終的にServerspecがSSH接続する際に指定する文字列(環境によりFQDNだったりIPアドレスだったりする)に一致する名前のyamlしか読み込まれないので、インベントリファイルでansible_host変数を使っている場合は注意する。
    • インベントリファイルでグループのネスト(親子グループ)を使用している場合は、親グループの定義を子グループより後に記載しないとエラーになるので、記載順に注意する。
    • ansible.cfgに日本語などUS-ASCIIに無い文字が含まれるとエラーになるため、コメント記載時などは注意する。

参考文献

Ansibleのベストプラクティスなディレクトリ構成を試す

Ansibleを使用する際に、プレイブックを運用し易く使い勝手のいいものにするためには、ディレクトリ構成が重要。
Ansible公式ドキュメントのSample Ansible setupにベストプラクティスなディレクトリ構成が載っているので、これを試してみる。
本記事で使ったプレイブックはここにある。

環境

Ansibleバージョン

  • ansible:7.3.0
  • ansible-core:2.14.3

※Ansibleの実行環境は以下記事で構築したものを使う。 tk-ch.hatenablog.com

Ansible実行対象のサーバ

商用環境
  • test-prd-001 (192.168.10.1)
  • test-prd-002 (192.168.10.2)
  • test-prd-003 (192.168.10.3)
    ステージング環境
  • test-stg-001 (192.168.20.1)

実施内容

ディレクトリ構成

Ansible公式ドキュメントには、「Sample directory layout」と「Alternative directory layout」の2パターンが載っている。
今回は、環境面(商用環境、ステージング環境など)の差分が大きくても柔軟に対応しやすい後者のパターンを参考にして、以下のような構成とした。

ansible
|-- ansible.cfg                     # Ansibleの設定ファイル
|-- inventories                     # インベントリと変数定義の格納場所
|   |-- production                  # 商用環境用
|   |   |-- group_vars              # グループ単位の変数定義の格納場所
|   |   |   |-- all.yml             # 全グループ共通の変数定義
|   |   |   |-- test_group_a.yml    # グループごとの変数定義(ファイル名はグループ名.yml)
|       |   |-- test_group_ab.yml
|   |   |   `-- test_group_b.yml
|   |   |-- host_vars               # ホスト単位の変数定義の格納場所
|   |   |   |-- test-prd-001.yml    # ホストごとの変数定義(ファイル名はホスト名.yml)
|   |   |   |-- test-prd-002.yml
|   |   |   `-- test-prd-003.yml
|   |   `-- hosts                   # インベントリ(Ansibleの実行対象ホスト、グループの定義)
|   `-- staging                     # ステージング環境用
|       |-- group_vars
|       |   |-- all.yml
|       |   |-- test_group_a.yml
|       |   |-- test_group_ab.yml
|       |   `-- test_group_b.yml
|       |-- host_vars
|       |   `-- test-stg-001.yml
|       `-- hosts
|-- roles                           # タスク定義の格納場所
|   |-- role00                      # ロール単位でタスク定義をまとめている
|   |   |-- defaults
|   |   |   `-- main.yml            # ロールのデフォルト変数定義
|   |   `-- tasks
|   |       `-- main.yml            # タスクで実行したい処理をここに書く
|   |-- role01
|   |   |-- defaults
|   |   |   `-- main.yml
|   |   |-- tasks
|   |   |   `-- main.yml
|   |   |-- templates
|   |   |   `-- variable.txt.j2     # templateモジュールで使うテンプレートファイル
|   |   `-- vars
|   |       `-- main.yml            # ロール変数定義
|   `-- role02
|       |-- defaults
|       |   `-- main.yml
|       |-- tasks
|       |   `-- main.yml
|       |-- templates
|       |   `-- variable.txt.j2
|       `-- vars
|           `-- main.yml
|-- site.yml                        # マスタープレイブック

|-- test_group_a.yml                # 実行対象のグループとロールを記載したプレイブック
`-- test_group_b.yml
構成のポイント
  • タスクをロール単位に分割し、変数やインベントリと分離することで、タスクの再利用性を高めている。
  • 変数とインベントリは環境面ごとにディレクトリを分けることで、環境面同士の差分が大きい場合(サーバの構成や台数が違う、必要な変数定義が異なるなど)でも、柔軟に対応できるようになっている。
  • 小さい実行粒度でまとめたプレイブック(例だとtest_group_a.ymlとtest_group_b.yml)をそれらをまとめて読み込むマスタープレイブック(site.yaml)を用意している。
    これにより、公式ドキュメントのこちらに記載の通り、細かい単位でプレイブックを実行しやすくなる。
    ※ansible-playbook実行時に--limitオプションで実行するロールを指定することもできるが、より明示的で、ロール単位より広い粒度での実行もしやすいという利点がある。
  • Ansible では変数は様々な箇所に定義可能だが、この構成では以下のファイルに定義する。
    ちなみにこれらのファイルに同じ変数が存在する場合、優先度に従い上書きされる(優先度は下に行くほど高い。変数の優先順の詳細はこちらを参照。)
    • roles/ロール名/defaults/main.yml:ロールのデフォルト変数の定義場所。値が環境面やホストによらない変数を定義する。
    • inventories/環境名/group_vars/all.yml:グループ変数の定義場所。ある環境内で、全グループ共通の値になる変数を定義する。
    • inventories/環境名/group_vars/グループ名.yml:グループ変数の定義場所。グループごとに値が異なる変数を定義する。
      グループ間に親子関係がある場合は、親グループ.ymlに定義された変数が子グループに継承される。
      親と子で同じ変数が定義されている場合は、子グループ名.ymlに定義された値の方が変数の優先度が高い。
    • inventories/環境名/hosts_vars/ホスト名.yml:ホスト変数の定義場所。ホストごとに値が異なる変数を定義する。
    • roles/ロール名/vars/main.yml:ロール変数の定義場所。値が環境面やホストによらず、かつグループ変数やホスト変数で上書きされたくない変数を定義する。

各ファイルの内容

上記のディレクトリ構成を試すにあたり、以下が気になっているので確認したい。

  • 商用環境とステージング環境で、同じロールを使いまわせること、結果が環境面ごとの変数に応じて変わること。
  • プレイブックに記載した内容に応じて、適切なホストに適切なロールが実行されること。
  • ロールごと、グループごと、ホストごとに定義した変数が、想定通り読み込まれること。

→上記が確認できるように、以下のような内容でファイルを作成した。

  • グループを2つ(test_group_a、test_group_b)作成し、「test_group_aにはrole00とrole01を実行し、test_group_bにはrole00とrole02を実行する」という違いをつける。
  • 親子グループの挙動も確認したいので、test_group_a、test_group_bを子とする親グループ「test_group_ab」も定義する。
  • group_varsディレクトリ配下のファイルに全グループ共通のグループ変数「test_var_group_all」 、グループごとに値が異なるグループ変数「test_var_group」、グループをまとめた親グループの変数「test_var_group_parent」 を定義する。
  • host_varsディレクトリ配下のファイルに、ホストごとに値が異なるホスト変数「test_var_host 」を定義する。
  • グループ変数とホスト変数の値は、商用環境とステージング環境でも異なる値にする。
  • ロールごとに値が異なるロールのデフォルト変数「test_var_role_default」、ロール変数「test_var_role」を定義する。
  • role02、role03は上記変数をテキストに出力するタスクにして、想定通り変数が読み込まれたことを確認できるようにする。
  • 上記変数は、上書きの優先度が想定通りであることを確認するために、想定される優先度が自分より高いファイルに定義された変数も、併せて定義しておく。
ロール
role00

処理内容は何でもよかったが、タスクの繰り返し実行を書いてみたかったので、複数のサービスの自動起動を有効化する内容にした。
roles/role00/tasks/main.yml

---
- name: enable and start services
  systemd:
    daemon_reload: yes
    enabled: yes
    state: started
    name: "{{ item }}"
  with_items: "{{  role00_services_list  }}"

対象になるサービスのリストを変数として定義している。
roles/role00/defaults/main.yml

---
role00_services_list:
  - chronyd
  - crond
  - sshd
role01

今回定義した変数が書き込まれるテキストファイルを、templateモジュールで作成する内容。
roles/role01/tasks/main.yml

---
- name: "create {{ role_name }} variable test file"
  template:
    src: variable.txt.j2
    dest: "/tmp/{{ role_name }}.txt"

ロールのデフォルト変数の定義。
「test_var_role_default」以外は他の箇所に定義された値に上書きされる想定。
roles/role01/defaults/main.yml

---
test_var_role_default: "roles/role01/defaults/main.yml"
test_var_group_all: "roles/role01/defaults/main.yml"
test_var_group_parent: "roles/role01/defaults/main.yml"
test_var_group: "roles/role01/defaults/main.yml"
test_var_host: "roles/role01/defaults/main.yml"
test_var_role: "roles/role01/defaults/main.yml"

ロール変数。
roles/role01/vars/main.yml

---
test_var_role: "roles/role01/vars/main.yml"

変数を書き込むテキストファイルのテンプレート。
roles/role01/templates/variable.txt.j2

test_var_role_default: {{ test_var_role_default }}
test_var_group_all   : {{ test_var_group_all }}
test_var_group_parent: {{ test_var_group_parent }}
test_var_group       : {{ test_var_group }}
test_var_host        : {{ test_var_host }}
test_var_role        : {{ test_var_role }}
role02

role01と同様の処理で、以下の変数の値のみ変更した内容。
roles/role02/defaults/main.yml

---
test_var_role_default: "roles/role02/defaults/main.yml"
test_var_group_all: "roles/role02/defaults/main.yml"
test_var_group_parent: "roles/role02/defaults/main.yml"
test_var_group: "roles/role02/defaults/main.yml"
test_var_host: "roles/role02/defaults/main.yml"
test_var_role: "roles/role02/defaults/main.yml"

roles/role02/vars/main.yml

---
test_var_role: "roles/role02/vars/main.yml"
環境面ごとのインベントリ・変数定義
商用環境(production)

実行対象のホスト名を記載している。
※ホスト名指定でSSHできない環境なので、ここで紹介されているようにマジック変数である「ansible_host」にIPを設定している。
 IP自体をホスト名として記載しても接続はできるが、ホスト名とIPが両方記載されている方が分かりやすいと思うので、この書き方にした。 inventories/production/hosts

[test_group_a]
test-prd-001 ansible_host=192.168.10.1
test-prd-002 ansible_host=192.168.10.2

[test_group_b]
test-prd-003 ansible_host=192.168.10.3

[test_group_ab:children]
test_group_a
test_group_b

全グループ共通の値になるグループ変数の定義。
「test_var_group_all」以外は他の箇所に定義された値に上書きされる想定。
Ansibleは、実行対象のサーバに対し鍵認証でSSHすることを推奨しているが、今回使っているサーバはパスワード認証であるため、「ansible_ssh_user」、「ansible_ssh_pass」を設定している。
不要な場合は削除すること。
inventories/production/group_vars/all.yml

---
ansible_ssh_user: root
ansible_ssh_pass: password
test_var_group_all: "inventories/production/group_vars/all.yml"
test_var_group_parent: "inventories/production/group_vars/all.yml"
test_var_group: "inventories/production/group_vars/all.yml"
test_var_host: "inventories/production/group_vars/all.yml"
test_var_role: "inventories/production/group_vars/all.yml"

グループ名.ymlという名前のファイルに、グループごとに値が異なるグループ変数を定義する。
まずは親グループ。
「test_var_group_parent」以外は他の箇所に定義された値に上書きされる想定。
inventories/production/group_vars/test_group_ab.yml

---
test_var_group_parent: "inventories/production/group_vars/test_group_ab.yml"
test_var_group: "inventories/production/group_vars/test_group_ab.yml"
test_var_host: "inventories/production/group_vars/test_group_ab.yml"
test_var_role: "inventories/production/group_vars/test_group_ab.yml"

次に子グループ。
「test_var_group」以外は他の箇所に定義された値に上書きされる想定。
inventories/production/group_vars/test_group_a.yml

---
test_var_group: "inventories/production/group_vars/test_group_a.yml"
test_var_host: "inventories/production/group_vars/test_group_a.yml"
test_var_role: "inventories/production/group_vars/test_group_a.yml"

inventories/production/group_vars/test_group_b.yml

---
test_var_group: "inventories/production/group_vars/test_group_b.yml"
test_var_host: "inventories/production/group_vars/test_group_b.yml"
test_var_role: "inventories/production/group_vars/test_group_b.yml"

ホスト名.ymlという名前のファイルに、ホストごとに値が異なるグループ変数を定義する。 「test_var_role」は「roles/ロール名/defaults/main.yml」に定義された値に上書きされる想定。
inventories/production/host_vars/test-prd-001.yml

---
test_var_host: "inventories/production/host_vars/test-prd-001.yml"
test_var_role: "inventories/production/host_vars/test-prd-001.yml"

inventories/production/host_vars/test-prd-002.yml

---
test_var_host: "inventories/production/host_vars/test-prd-002.yml"
test_var_role: "inventories/production/host_vars/test-prd-002.yml"

inventories/production/host_vars/test-prd-003.yml

---
test_var_host: "inventories/production/host_vars/test-prd-003.yml"
test_var_role: "inventories/production/host_vars/test-prd-003.yml"
ステージング環境(staging)

商用環境とほぼ同様の構成。
実行対象サーバが少ないのと、変数に設定された値が商用環境と異なる。
inventories/staging/hosts

[test_group_a]
test-stg-001 ansible_host=192.168.20.1

[test_group_b]

[test_group_ab:children]
test_group_a
test_group_b

inventories/staging/group_vars/all.yml

---
ansible_ssh_user: root
ansible_ssh_pass: password
test_var_group_all: "inventories/staging/group_vars/all.yml"
test_var_group_parent: "inventories/staging/group_vars/all.yml"
test_var_group: "inventories/staging/group_vars/all.yml"
test_var_host: "inventories/staging/group_vars/all.yml"
test_var_role: "inventories/staging/group_vars/all.yml"

inventories/staging/group_vars/test_group_ab.yml

---
test_var_group_parent: "inventories/staging/group_vars/test_group_ab.yml"
test_var_group: "inventories/staging/group_vars/test_group_ab.yml"
test_var_host: "inventories/staging/group_vars/test_group_ab.yml"
test_var_role: "inventories/staging/group_vars/test_group_ab.yml"

inventories/staging/group_vars/test_group_a.yml

---
test_var_group: "inventories/staging/group_vars/test_group_a.yml"
test_var_host: "inventories/staging/group_vars/test_group_a.yml"
test_var_role: "inventories/staging/group_vars/test_group_a.yml"

inventories/staging/group_vars/test_group_b.yml

---
test_var_group: "inventories/staging/group_vars/test_group_b.yml"
test_var_host: "inventories/staging/group_vars/test_group_b.yml"
test_var_role: "inventories/staging/group_vars/test_group_b.yml"

inventories/staging/host_vars/test-stg-001.yml

---
test_var_host: "inventories/staging/host_vars/test-stg-001.yml"
test_var_role: "inventories/staging/host_vars/test-stg-001.yml"
プレイブック

test_group_aグループにrole00とrole01を実行するという内容。
ロールにタグをつけておくと、一部のロールだけ実行することができ便利なので、こちらを参考タグを設定した。
test_group_a.yml

---
- name: test_group_a
  hosts: test_group_a
  roles:
    - role: role00
      tags: role00
    - role: role01
      tags: role01

こちらはtest_group_bグループにrole00とrole02を実行するという内容。
test_group_b.yml

---
- name: test_group_b
  hosts: test_group_b
  roles:
    - role: role00
      tags: role00
    - role: role02
      tags: role02

マスタープレイブック。
上記2つのプレイブックをimportする。
site.yml

- import_playbook: test_group_a.yml
- import_playbook: test_group_b.yml

親グループをプレイブックのhostsに指定した場合の挙動についても確認したいため、親グループtest_group_abにrole00とrole01を実行するという内容のプレイブックも用意する。
test_group_ab.yml

---
- name: test_group_ab
  hosts: test_group_ab
  roles:
    - role: role00
      tags: role00
    - role: role01
      tags: role01
Ansibleの設定ファイル

ansible.cfgに変数を設定することで、Ansibleの挙動を制御できる。
今回は、ホストへの初回のSSH接続の際に出る警告を無視するために「host_key_checking = False」を設定した。
他の設定値はAnsible Configuration Settingsを参照。
ansible.cfg

[defaults]
host_key_checking = False

実行してみる

まず、商用環境に対して実行する。
実行対象の環境の制御は、以下のようにansible-playbook実行時に-iオプションで指定するインベントリファイルを使い分けることで実現する。

$ ansible-playbook -i inventories/production/hosts site.yml

PLAY [test_group_a] ******************************************************************************************

TASK [Gathering Facts] ***************************************************************************************
ok: [test-prd-001]
ok: [test-prd-002]

TASK [role00 : enable and start services] ********************************************************************
ok: [test-prd-001] => (item=chronyd)
ok: [test-prd-002] => (item=chronyd)
ok: [test-prd-001] => (item=crond)
ok: [test-prd-002] => (item=crond)
ok: [test-prd-001] => (item=sshd)
ok: [test-prd-002] => (item=sshd)

TASK [role01 : create role01 variable test file] *************************************************************
changed: [test-prd-001]
changed: [test-prd-002]

PLAY [test_group_b] ******************************************************************************************

TASK [Gathering Facts] ***************************************************************************************
ok: [test-prd-003]

TASK [role00 : enable and start services] ********************************************************************
ok: [test-prd-003] => (item=chronyd)
ok: [test-prd-003] => (item=crond)
ok: [test-prd-003] => (item=sshd)

TASK [role02 : create role02 variable test file] *************************************************************
changed: [test-prd-003]

PLAY RECAP ***************************************************************************************************
test-prd-001               : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
test-prd-002               : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
test-prd-003               : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

→プレイブックに記載した通り、test_group_aにはrole00とrole01、test_group_bにはrole00とrole02が実行された。  
role01、role02で作成されたファイルは以下の通り。
test-prd-001の/tmp/role01.txt

test_var_role_default: roles/role01/defaults/main.yml
test_var_group_all   : inventories/production/group_vars/all.yml
test_var_group_parent: inventories/production/group_vars/test_group_ab.yml
test_var_group       : inventories/production/group_vars/test_group_a.yml
test_var_host        : inventories/production/host_vars/test-prd-001.yml
test_var_role        : roles/role01/vars/main.yml

test-prd-002の/tmp/role01.txt

test_var_role_default: roles/role01/defaults/main.yml
test_var_group_all   : inventories/production/group_vars/all.yml
test_var_group_parent: inventories/production/group_vars/test_group_ab.yml
test_var_group       : inventories/production/group_vars/test_group_a.yml
test_var_host        : inventories/production/host_vars/test-prd-002.yml
test_var_role        : roles/role01/vars/main.yml

test-prd-003の/tmp/role02.txt

test_var_role_default: roles/role02/defaults/main.yml
test_var_group_all   : inventories/production/group_vars/all.yml
test_var_group_parent: inventories/production/group_vars/test_group_ab.yml
test_var_group       : inventories/production/group_vars/test_group_b.yml
test_var_host        : inventories/production/host_vars/test-prd-003.yml
test_var_role        : roles/role02/vars/main.yml

→内容を比較すると、想定通り以下の挙動となっていることが分かる。

  • all_varsは全て同じ値。
    「roles/ロール名/defaults/main.yml」で定義された値が、「inventories/production/group_vars/all.yml」で定義された値で上書きされている。
  • test_var_group_parentの値はインベントリで定義された親グループから継承されている。
    「roles/ロール名/defaults/main.yml」で定義された値が、「inventories/production/group_vars/親グループ名.yml」で定義された値で上書きされている。
  • group_varsはグループごとに異なる値。
    「roles/ロール名/defaults/main.yml」、「inventories/production/group_vars/all.yml」で定義された値が、「inventories/production/group_vars/グループ名.yml」で定義された値で上書きされている。
  • host_varsはホストごとに異なる値。
    「roles/ロール名/defaults/main.yml」、「inventories/production/group_vars/all.yml」、「inventories/production/group_vars/グループ名.yml」で定義された値が、「inventories/production/group_vars/ホスト名.yml」で定義された値で上書きされている。
  • role_varsはロールごとに異なる値。
    「roles/ロール名/defaults/main.yml」、「inventories/production/group_vars/all.yml」、「inventories/production/group_vars/グループ名.yml」、「inventories/production/group_vars/ホスト名.yml」で定義された値が、「roles/ロール名/vars/main.yml」で定義された値で上書きされている。

次に、ステージング環境に対して実行する。

$ ansible-playbook -i inventories/staging/hosts site.yml

PLAY [test_group_a] ******************************************************************************************

TASK [Gathering Facts] ***************************************************************************************
ok: [test-stg-001]

TASK [role00 : enable and start services] ********************************************************************
ok: [test-stg-001] => (item=chronyd)
ok: [test-stg-001] => (item=crond)
ok: [test-stg-001] => (item=sshd)

TASK [role01 : create role01 variable test file] *************************************************************
changed: [test-stg-001]

PLAY [test_group_b] ******************************************************************************************
skipping: no hosts matched

PLAY RECAP ***************************************************************************************************
test-stg-001               : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

→ansible-paybook実行時に指定するインベントリファイルを変更することで、実行先が切り替わったことが分かる。
 
role01で作成されたファイルは以下の通り。
test-stg-001の/tmp/role01.txt

test_var_role_default: roles/role01/defaults/main.yml
test_var_group_all   : inventories/staging/group_vars/all.yml
test_var_group_parent: inventories/staging/group_vars/test_group_ab.yml
test_var_group       : inventories/staging/group_vars/test_group_a.yml
test_var_host        : inventories/staging/host_vars/test-stg-001.yml
test_var_role        : roles/role01/vars/main.yml

→商用環境のtest-prd-001の/tmp/role01.txtと比較すると、想定通り値がproductionからstagingに変わっていることが分かる。

プレイブックのhostsに親グループを指定した場合

プレイブックのhostsに子グループを指定して実行した場合に、group_vars/子グループ名.ymlとgroup_vars/親グループ名.ymlのどちらで定義された変数も読み込まれていることは確認できた。
プレイブックのhostsに親グループを指定して実行した場合の挙動も確認しておく。
親グループをhostsに指定したプレイブックを実行する。

# ansible-playbook -i inventories/production/hosts test_group_ab.yml

PLAY [test_group_ab] ********************************************************************************************

TASK [Gathering Facts] ******************************************************************************************
ok: [192.168.10.1]
ok: [192.168.10.2]
ok: [192.168.10.3]

TASK [role00 : enable and start services] ***********************************************************************
ok: [192.168.10.1] => (item=chronyd)
ok: [192.168.10.2] => (item=chronyd)
ok: [192.168.10.3] => (item=chronyd)
ok: [192.168.10.1] => (item=crond)
ok: [192.168.10.2] => (item=crond)
ok: [192.168.10.1] => (item=sshd)
ok: [192.168.10.2] => (item=sshd)
ok: [192.168.10.3] => (item=crond)
ok: [192.168.10.3] => (item=sshd)

TASK [role01 : create role01 variable test file] ****************************************************************
ok: [192.168.10.2]
ok: [192.168.10.1]
changed: [192.168.10.3]

PLAY RECAP ******************************************************************************************************
192.168.10.3               : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.10.1               : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.10.2               : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

role01で作成されたファイルの内容は以下の通り。
test-prd-001の/tmp/role01.txt

test_var_role_default: roles/role01/defaults/main.yml
test_var_group_all   : inventories/production/group_vars/all.yml
test_var_group_parent: inventories/production/group_vars/test_group_ab.yml
test_var_group       : inventories/production/group_vars/test_group_a.yml
test_var_host        : inventories/production/host_vars/test-prd-001.yml
test_var_role        : roles/role01/vars/main.yml

test-prd-002の/tmp/role01.txt

test_var_role_default: roles/role01/defaults/main.yml
test_var_group_all   : inventories/production/group_vars/all.yml
test_var_group_parent: inventories/production/group_vars/test_group_ab.yml
test_var_group       : inventories/production/group_vars/test_group_a.yml
test_var_host        : inventories/production/host_vars/test-prd-002.yml
test_var_role        : roles/role01/vars/main.yml

test-prd-003の/tmp/role01.txt

test_var_role_default: roles/role01/defaults/main.yml
test_var_group_all   : inventories/production/group_vars/all.yml
test_var_group_parent: inventories/production/group_vars/test_group_ab.yml
test_var_group       : inventories/production/group_vars/test_group_b.yml
test_var_host        : inventories/production/host_vars/test-prd-003.yml
test_var_role        : roles/role01/vars/main.yml

→hostsに子グループを指定したときと同様の内容となっている。
このことから、プレイブックのhostsに親子どちらのグループを記載しても、group_vars/子グループ名.ymlとgroup_vars/親グループ名.ymlの両方が読み込まれていることが分かる。

タグを使って指定したロールだけを実行する

おまけとして、タグを使った一部ロールの実行も試してみる。
ansible-playbook実行時に、「--tags タグ名」オプションを付けることでタグが一致するロールのみが実行される。

$ ansible-playbook -i inventories/production/hosts --tags role00 site.yml

PLAY [test_group_a] *********************************************************************************************

TASK [Gathering Facts] ******************************************************************************************
ok: [test-prd-001]
ok: [test-prd-002]

TASK [role00 : enable and start services] ***********************************************************************
ok: [test-prd-002] => (item=chronyd)
ok: [test-prd-001] => (item=chronyd)
ok: [test-prd-001] => (item=crond)
ok: [test-prd-002] => (item=crond)
ok: [test-prd-002] => (item=sshd)
ok: [test-prd-001] => (item=sshd)

PLAY [test_group_b] *********************************************************************************************

TASK [Gathering Facts] ******************************************************************************************
ok: [test-prd-003]

TASK [role00 : enable and start services] ***********************************************************************
ok: [test-prd-003] => (item=chronyd)
ok: [test-prd-003] => (item=crond)
ok: [test-prd-003] => (item=sshd)

PLAY RECAP ******************************************************************************************************
test-prd-001               : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
test-prd-002               : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
test-prd-003               : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

→想定通り、role00だけが実行された。

まとめ

  • ベストプラクティスを意識したディレクトリ構成にすることで、環境差分を吸収してロールの再利用性を高めることができた。
  • 各種変数の定義場所、上書き優先度を確認できた。

参考文献

Serverspecを複数のサーバに対して実行する

以下の記事で、Serverspecの実行コンテナを作成した。 tk-ch.hatenablog.com

今回、複数のサーバに対してServerspecでテストを実行したい。
しかし、初期状態のServerspecの設定だと、以下の問題がある。

  • specディレクトリ配下に、1サーバ毎にディレクトリを用意し、各ディレクトリ内に実行するテストを記載したspecファイルを配置しておく必要がある。
    同じ内容のテストを複数のサーバに実行する場合、同じファイルが格納されたディレクトリが複数存在することになり、作成や維持が面倒。

→Serverspecの設定、ディレクトリ構成を変更してこれら問題を解消してみる。
ちなみに本記事で使ったプレイブックはここにある。

環境

  • DockerホストのOS:RockyLinux8.6
  • 管理対象サーバ(Serverspecの実行先)のOS:RockyLinux8.6
  • Serverspec:2.42.2

実施内容

以下を参考に、ディレクトリ構成とファイルの中身を変更した。

変更点は以下の通り。

  • specファイルのディレクトリをサーバ単位からロール(共通、DBサーバ、WEBサーバなど)単位に変更。
  • 実行対象のサーバとロールについては「hosts.yml」というファイルに記載して、Rakefileをこのファイルを読み込んで処理するように変更。
  • サーバやロールごとに値が異なるテスト内容については、変数に置き換えることでspecファイルに記載するテストコードは共通化する。
    変数はpropertyという名前の変数を使って参照する。
  • サーバごとに異なる変数は「hosts.yml」、ロールごとに異なる変数は「properties.yml」というファイルに記載する。
    spec_helper.rbに、これらファイルを読み込んで変数として使えるように設定する処理を追加。

変更前のディレクトリ構成と

serverspec-initコマンドで作成したままのディレクトリ構成。
以下のようになっている。

# tree -a --charset=c /root/serverspec
/root/serverspec
|-- .rspec
|-- Rakefile
`-- spec
    |-- 192.168.10.123
    |   `-- sample_spec.rb
    `-- spec_helper.rb

2 directories, 4 files

変更後のディレクトリ構成とファイル

以下のように変更した。

# tree -a --charset=c /root/serverspec
/root/serverspec
|-- .rspec
|-- Rakefile
|-- hosts.yml
|-- properties.yml
`-- spec
    |-- base
    |   |-- dns_spec.rb
    |   `-- sshd_spec.rb
    |-- db
    |   `-- postgres_spec.rb
    `-- spec_helper.rb

3 directories, 8 files

各ファイルの中身は以下の通り。 Rakefile

require 'rake'
require 'rspec/core/rake_task'
require 'yaml' # YAMLファイルを処理するために必要

# 実行対象が記載されたYAMLファイルを読み込む
hosts = YAML.load_file('hosts.yml')

desc "Run serverspec to all hosts (=spec:all)"
task :spec    => 'spec:all'

namespace :spec do
  desc "Run serverspec to all hosts (=spec)"
  task :all => hosts.keys.map {|host| 'spec:' + host }
  hosts.keys.each do |host|
    desc "Run serverspec to #{host}"
    RSpec::Core::RakeTask.new(host.to_sym) do |t|
      ENV['TARGET_HOST'] = host
      role = hosts[host][:roles].join(',')
      puts "#####################################################"
      puts "  Target host : #{ENV['TARGET_HOST']}"
      puts "  Role        : #{role}"
      puts "#####################################################"
      t.pattern = 'spec/{' + hosts[host][:roles].join(',') + '}/*_spec.rb'
    end
  end
end

spec_helper.rb

require 'serverspec'
require 'net/ssh'
require 'highline/import'
require 'yaml'  # YAMLファイルを処理するために必要

# サーバごとの変数が記載されたYAMLファイルを読み込む
host_properties = YAML.load_file('hosts.yml')
# ロールごとの変数が記載されたYAMLファイルを読み込む
common_properties = YAML.load_file('properties.yml')

set :backend, :ssh

if ENV['ASK_SUDO_PASSWORD']
  begin
    require 'highline/import'
  rescue LoadError
    fail "highline is not available. Try installing it."
  end
  set :sudo_password, ask("Enter sudo password: ") { |q| q.echo = false }
else
  set :sudo_password, ENV['SUDO_PASSWORD']
end

host = ENV['TARGET_HOST']

# 対象サーバの変数と、実行されるロールの変数をマージして、Property情報に格納
properties = host_properties[host]
properties[:roles].each do |r|
  properties = common_properties[r].merge(host_properties[host]) if common_properties[r]
end
set_property properties

options = Net::SSH::Config.for(host)

options[:user] ||= Etc.getlogin

if ENV['ASK_LOGIN_PASSWORD']
  options[:password] = ask("\nEnter login password: ") { |q| q.echo = false }
else
  options[:password] = ENV['LOGIN_PASSWORD']
end

set :host,        options[:host_name] || host
set :ssh_options, options

hosts.yml

192.168.10.123:
  :roles:
    - base
192.168.10.124:
  :roles:
    - base
    - db
  :db_user: root

properties.yml

base:
  :dns_server: 10.0.0.2

sshd_spec.rb

require 'spec_helper'

describe service('sshd'), :if => os[:family] == 'redhat' do
  it { should be_running }
end

dns_spec.rb

require 'spec_helper'

describe file('/etc/resolv.conf') do
  its(:content) { should match /^nameserver #{property[:dns_server]}/ }
end

postgres_spec.rb

require 'spec_helper'

describe user("#{property[:db_user]}") do
  it { should exist }
end

実行してみる

まず、定義されたタスクを表示する。
全サーバを対象でも、サーバ単位でも実行できることが分かる。

# rake -T
rake spec               # Run serverspec to all hosts (=spec:all)
rake spec:192.168.10.123  # Run serverspec to 192.168.10.123
rake spec:192.168.10.124  # Run serverspec to 192.168.10.124
rake spec:all           # Run serverspec to all hosts (=spec)

全サーバを対象にServerspecを実行する。

# LOGIN_PASSWORD=実行対象サーバへSSHする際の認証パスワード

# rake spec LOGIN_PASSWORD=$LOGIN_PASSWORD
#####################################################
  Target host : 192.168.10.123
  Role        : base
#####################################################
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern spec/\{base\}/\*_spec.rb
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user

File "/etc/resolv.conf"
  content
    is expected to match /^nameserver 10.0.0.2/

Service "sshd"
  is expected to be running

Finished in 0.16813 seconds (files took 2.38 seconds to load)
2 examples, 0 failures

#####################################################
  Target host : 192.168.10.124
  Role        : base,db
#####################################################
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern spec/\{base,db\}/\*_spec.rb
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user

File "/etc/resolv.conf"
  content
    is expected to match /^nameserver 10.0.0.2/

Service "sshd"
  is expected to be running

User "root"
  is expected to exist

Finished in 0.28116 seconds (files took 2.42 seconds to load)
3 examples, 0 failures

→以下のことが確認できる。

  • host.ymlに定義した通り、192.168.10.123にはbaseロール、192.168.10.124にはbaseロールとdbロールのテストが実行されている。
  • hosts.ymlとproperties.ymlに定義した変数(DNSサーバのIPと、DB接続用ユーザ)がテスト実行の際に展開、使用されている。

まとめ

  • 複数サーバを対象に、管理しやすい形でServerspecを実行できた。
  • RakeFileやspec_helper.rbを工夫することで、柔軟にServerspecを使用することができる。
    Serverspecというより、Rubyの知識が必要。
  • Serverspecの対象サーバとロールをAnsibleのファイルから読み込めるansible_specというツールがあるらしい。
    次はこれを試してみたい。

参考文献

Serverspecの実行環境をDockerコンテナにする(Serverspec2.42.2、RockyLinux8、SSHパスワード認証)

Serverspecを使う際に、以下のような環境面での悩みが出てくるケースがある。

  • 複数の構成管理サーバにServerspecをインストールするのが手間。
  • Serverspecの実行環境にはRubyが必要だが、Serverspecのために特定バージョンのRubyをインストールして環境が汚れるのが嫌。
  • 複数のバージョンのServerspecを同じサーバで実行させたい場合、共存させるのが面倒(というか共存できない?)。

→これらは、Serverspecの実行環境をDockerコンテナにすれば解決できる。
 という訳で、Serverspec実行用のDockerコンテナを用意してみる。

環境

  • DockerホストのOS:RockyLinux8.6
  • 管理対象サーバ(Serverspecの実行先)のOS:RockyLinux8.6
  • Docker:20.10.18
  • Serverspec:2.42.2

実施内容

使用するServerspecのバージョンを決める

ServerspecのGitリポジトリReleasesを見ると、2.42.2が現在(2023年2月)時点の最新版の模様。
また、Ruby3.1以上が必要と記載されている。
Serverspecのコンテナイメージをビルドする前に、実際に使用するコンテナイメージにRuby3.1以上とServerspec2.42.2がインストールできるか試してみる。

RockyLinux8.6のコンテナを起動。

# docker run --rm -it rockylinux:8.6 /bin/bash

Rubyをインストールしてみる。

インストールできるRubyのバージョンを確認する。
デフォルトでRuby 2.5がインストールされ、最新のバージョンとしては3.1を選択できることが分かる。
# dnf module list ruby
Last metadata expiration check: 0:01:00 ago on Fri Feb 17 04:15:04 2023.
Rocky Linux 8 - AppStream
Name          Stream           Profiles           Summary
ruby          2.5 [d]          common [d]         An interpreter of object-oriented scripting language
ruby          2.6              common [d]         An interpreter of object-oriented scripting language
ruby          2.7              common [d]         An interpreter of object-oriented scripting language
ruby          3.0              common [d]         An interpreter of object-oriented scripting language
ruby          3.1              common [d]         An interpreter of object-oriented scripting language

Ruby3.1を有効化する。
# dnf module -y enable ruby:3.1
# dnf module list ruby
Last metadata expiration check: 0:09:18 ago on Fri Feb 17 04:15:04 2023.
Rocky Linux 8 - AppStream
Name          Stream           Profiles           Summary
ruby          2.5 [d]          common [d]         An interpreter of object-oriented scripting language
ruby          2.6              common [d]         An interpreter of object-oriented scripting language
ruby          2.7              common [d]         An interpreter of object-oriented scripting language
ruby          3.0              common [d]         An interpreter of object-oriented scripting language
ruby          3.1 [e]          common [d]         An interpreter of object-oriented scripting language

Hint: [d]efault, [e]nabled, [x]disabled, [i]nstalled

Ruby3.1をインストールする
# dnf install -y ruby

バージョン確認
# ruby --version
ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [x86_64-linux]

Serverspec2.42.2をインストールしてみる。

インストールできるServerspecのバージョンを確認する。最新バージョンが2.42.2であることが確認できる。
# gem list serverspec -rea

*** REMOTE GEMS ***

serverspec (2.42.2, 2.42.1, 2.42.0, 2.41.8, 2.41.7, 2.41.6, 2.41.5, 2.41.4, 2.41.3, 2.41.2, 2.41.1, 2.41.0, 2.40.0, 2.39.2, 2.39.1, 2.39.0, 2.38.1, 2.38.0, 2.37.2, 2.37.1, 2.37.0, 2.36.1, 2.36.0, 2.35.0, 2.34.0, 2.33.0, 2.32.0, 2.31.1, 2.31.0, 2.30.1, 2.30.0, 2.29.2, 2.29.1, 2.29.0, 2.28.0, 2.27.0, 2.26.0, 2.25.0, 2.24.3, 2.24.2, 2.24.1, 2.24.0, 2.23.1, 2.23.0, 2.22.0, 2.21.1, 2.21.0, 2.20.0, 2.19.0, 2.18.0, 2.17.1, 2.17.0, 2.16.0, 2.15.0, 2.14.1, 2.14.0, 2.13.0, 2.12.0, 2.11.0, 2.10.2, 2.10.1, 2.10.0, 2.9.1, 2.9.0, 2.8.2, 2.8.1, 2.8.0, 2.7.2, 2.7.1, 2.7.0, 2.6.0, 2.5.0, 2.4.0, 2.3.1, 2.3.0, 2.2.0, 2.1.0, 2.0.1, 2.0.0, 1.16.0, 1.15.0, 1.14.0, 1.13.0, 1.12.0, 1.11.0, 1.10.0, 1.9.1, 1.9.0, 1.8.0, 1.7.1, 1.7.0, 1.6.0, 1.5.0, 1.4.2, 1.4.1, 1.4.0, 1.3.0, 1.2.0, 1.1.0, 1.0.0, 0.16.0, 0.15.5, 0.15.4, 0.15.3, 0.15.2, 0.15.1, 0.15.0, 0.14.4, 0.14.3, 0.14.2, 0.14.1, 0.14.0, 0.13.7, 0.13.6, 0.13.5, 0.13.4, 0.13.3, 0.13.2, 0.13.1, 0.13.0, 0.12.0, 0.11.5, 0.11.4, 0.11.3, 0.11.2, 0.11.1, 0.11.0, 0.10.13, 0.10.12, 0.10.11, 0.10.10, 0.10.9, 0.10.8, 0.10.7, 0.10.6, 0.10.5, 0.10.4, 0.10.3, 0.10.2, 0.10.1, 0.10.0, 0.9.8, 0.9.7, 0.9.6, 0.9.5, 0.9.4, 0.9.3, 0.9.2, 0.9.1, 0.9.0, 0.8.1, 0.8.0, 0.7.13, 0.7.12, 0.7.11, 0.7.10, 0.7.9, 0.7.8, 0.7.7, 0.7.6, 0.7.5, 0.7.4, 0.7.3, 0.7.2, 0.7.1, 0.7.0, 0.6.30, 0.6.29, 0.6.28, 0.6.27, 0.6.26, 0.6.25, 0.6.24, 0.6.23, 0.6.22, 0.6.21, 0.6.20, 0.6.19, 0.6.18, 0.6.17, 0.6.16, 0.6.15, 0.6.13, 0.6.12, 0.6.11, 0.6.10, 0.6.9, 0.6.8, 0.6.7, 0.6.6, 0.6.5, 0.6.4, 0.6.3, 0.6.2, 0.6.1, 0.6.0, 0.5.8, 0.5.7, 0.5.6, 0.5.5, 0.5.4, 0.5.3, 0.5.2, 0.5.1, 0.5.0, 0.4.14, 0.4.13, 0.4.12, 0.4.11, 0.4.10, 0.4.9, 0.4.8, 0.4.7, 0.4.6, 0.4.5, 0.4.4, 0.4.3, 0.4.2, 0.4.1, 0.4.0, 0.3.2, 0.3.1, 0.3.0, 0.2.28, 0.2.27, 0.2.26, 0.2.25, 0.2.24, 0.2.23, 0.2.22, 0.2.21, 0.2.20, 0.2.19, 0.2.18, 0.2.17, 0.2.16, 0.2.15, 0.2.14, 0.2.13, 0.2.12, 0.2.11, 0.2.10, 0.2.9, 0.2.8, 0.2.7, 0.2.6, 0.2.5, 0.2.4, 0.2.3, 0.2.2, 0.2.1, 0.1.7, 0.1.6, 0.1.5, 0.1.4, 0.1.3, 0.1.2, 0.1.1, 0.1.0, 0.0.19, 0.0.18, 0.0.17, 0.0.16, 0.0.15, 0.0.14, 0.0.13, 0.0.12, 0.0.11, 0.0.10, 0.0.9, 0.0.8, 0.0.7, 0.0.6, 0.0.5, 0.0.4, 0.0.3, 0.0.2, 0.0.1)

Serverspec2.42.2をインストール
# gem install serverspec -v 2.42.2

バージョン確認
# gem list serverspec

*** LOCAL GEMS ***

serverspec (2.42.2)

Serverspec実行用のDockerイメージをビルドする

Dockerfileを作成する。 Dockerfile_serverspec

# ベースイメージ
FROM rockylinux:8.6

# 変数定義
ARG SERVERSPEC_VERSION

# 必要なパッケージのインストール
RUN dnf module -y enable ruby:3.1 && \
    dnf -y install ruby \
    openssh-clients && \
    dnf clean all && \
    rm -rf /var/cache/dnf/*

# Serverspecのインストール
RUN gem install serverspec -v $SERVERSPEC_VERSION
RUN gem install rake
RUN gem install highline

# ロケールを日本語に設定
RUN dnf -y install glibc-locale-source glibc-langpack-en && \
    dnf clean all && \
    rm -rf /var/cache/dnf/*
RUN localedef -f UTF-8 -i ja_JP ja_JP.utf8
RUN echo 'LANG="ja_JP.UTF-8"' >  /etc/locale.conf

# タイムゾーンをJSTに設定
RUN echo 'ZONE="Asia/Tokyo"' > /etc/sysconfig/clock
RUN rm -f /etc/localtime
RUN ln -fs /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

# コンテナログイン時のカレントディレクトリを設定
WORKDIR /work/serverspec

→Dockerfileの解説

  • ベースイメージがRockyLinux8.6なのは使い慣れているからで、特に意味は無い。他のOSで作っても良い。
  • Serverspecのインストール方法は、Serverspec公式ドキュメントを参照。
  • openssh-clientsは、Serverspecから管理対象サーバへ接続するために必要なのでインストールした。
  • インストールするServerspecのバージョンを制御できるよう、ARGにて変数SERVERSPEC_VERSIONを定義し、Serverspecインストール時に指定している。 変数に設定する値は、後ほど「docker build」実行時に--build-argオプションで指定する。
    詳細はDockerの公式ドキュメントのここを参照。
  • dnf実行時の書き方については、RedHatの「How to build tiny container images」を参考にした。
  • ロケールタイムゾーンの設定は必須ではないが、自前のコンテナイメージ作成時はいつもやっているので入れている。

Serverspecバージョンを2.42.2と指定し、コンテナイメージをビルドする。

インストールしたいServerspecのバージョンを環境変数に格納
# export SERVERSPEC_VERSION=2.42.2

Dockerイメージをビルド
# docker build --no-cache=true -f Dockerfile_serverspec -t serverspec:$SERVERSPEC_VERSION --build-arg SERVERSPEC_VERSION=$SERVERSPEC_VERSION .

ビルドされたイメージを確認
# docker images | grep serverspec | grep $SERVERSPEC_VERSION
serverspec            2.42.2    ca0aa9b20af6   3 seconds ago        297MB

ビルドされたイメージを起動し、想定通りのServerspecバージョンがインストールされたことを確認
# docker run --rm -it serverspec:$SERVERSPEC_VERSION gem list serverspec

*** LOCAL GEMS ***

serverspec (2.42.2)

ビルドしたイメージを使ってServerspecを実行する

先ほどビルドしたコンテナイメージを指定し、Serverspecを実行してみる。
まず、必要なファイルをserverspec-initコマンドで生成する。
注意点として、コンテナ内のファイルはコンテナ終了時に削除されてしまうため、Serverspec用のディレクトリはDockerホストをマウントする。
今回は、DockerfileでWORKDIRとして指定したコンテナ内のディレクトリ(/work/serverspec)に、Dockerホストのserverspec用ファイル置き場として作成した/root/serverspecをマウントする。

使用するServerspecのバージョンを指定
# export SERVERSPEC_VERSION=2.42.2

serverspec-initを実行して、Serverspecで使うファイルを生成する。
# docker run --rm -it -v /root/.ssh/:/root/.ssh/ -v /root/serverspec:/work/serverspec serverspec:$SERVERSPEC_VERSION serverspec-init

Select OS type:

  1) UN*X
  2) Windows

Select number: 1

Select a backend type:

  1) SSH
  2) Exec (local)

Select number: 1

Vagrant instance y/n: n
Input target host name: 192.168.10.123
 + spec/
 + spec/192.168.10.123/
 + spec/192.168.10.123/sample_spec.rb
/usr/local/share/gems/gems/serverspec-2.42.2/lib/serverspec/setup.rb:155: warning: Passing safe_level with the 2nd argument of ERB.new is deprecated. Do not use it, and specify other arguments as keyword arguments.
/usr/local/share/gems/gems/serverspec-2.42.2/lib/serverspec/setup.rb:155: warning: Passing trim_mode with the 3rd argument of ERB.new is deprecated. Use keyword argument like ERB.new(str, trim_mode: ...) instead.
 + spec/spec_helper.rb
 + Rakefile

作成されたファイル、ディレクトリをコンテナ内から確認する
# docker run --rm -it -v /root/.ssh/:/root/.ssh/ -v /root/serverspec:/work/serverspec serverspec:$SERVERSPEC_VERSION ls -l
total 4
-rw-r--r-- 1 root root 685  2月 18 22:39 Rakefile
drwxr-xr-x 3 root root  48  2月 18 22:39 spec

Dockerホスト側からも参照できる。ディレクトリ構成は以下のようになっている。
# tree -a --charset=c /root/serverspec
/root/serverspec
|-- .rspec
|-- Rakefile
`-- spec
    |-- 192.168.10.123
    |   `-- sample_spec.rb
    `-- spec_helper.rb

2 directories, 4 files

→DockerホストのServerspec用ディレクトリにコンテナ内で作成したServerspec用ファイルが残っていることが確認できる。

ちなみに、作成された.rspecRakefile、spec_helper.rbの中身は以下の通り。 .rspec

--color
--format documentation

Rakefile

require 'rake'
require 'rspec/core/rake_task'

task :spec    => 'spec:all'
task :default => :spec

namespace :spec do
  targets = []
  Dir.glob('./spec/*').each do |dir|
    next unless File.directory?(dir)
    target = File.basename(dir)
    target = "_#{target}" if target == "default"
    targets << target
  end

  task :all     => targets
  task :default => :all

  targets.each do |target|
    original_target = target == "_default" ? target[1..-1] : target
    desc "Run serverspec tests to #{original_target}"
    RSpec::Core::RakeTask.new(target.to_sym) do |t|
      ENV['TARGET_HOST'] = original_target
      t.pattern = "spec/#{original_target}/*_spec.rb"
    end
  end
end

spec_helper.rb

require 'serverspec'
require 'net/ssh'

set :backend, :ssh

if ENV['ASK_SUDO_PASSWORD']
  begin
    require 'highline/import'
  rescue LoadError
    fail "highline is not available. Try installing it."
  end
  set :sudo_password, ask("Enter sudo password: ") { |q| q.echo = false }
else
  set :sudo_password, ENV['SUDO_PASSWORD']
end

host = ENV['TARGET_HOST']

options = Net::SSH::Config.for(host)

options[:user] ||= Etc.getlogin

set :host,        options[:host_name] || host
set :ssh_options, options

# Disable sudo
# set :disable_sudo, true


# Set environment variables
# set :env, :LANG => 'C', :LC_MESSAGES => 'C'

# Set PATH
# set :path, '/sbin:/usr/local/sbin:$PATH'

Serverspecでテストを実行する前に、テスト内容をパスできるものに変更する。
sshdサービスが動いていることを確認する内容にした。

sample_spec.rb

require 'spec_helper'

describe service('sshd'), :if => os[:family] == 'redhat' do
  it { should be_running }
end

serverspecを実行する。
今回対象としているサーバはSSHにパスワード認証が必要なので、途中でパスワードを入力する。

# export SERVERSPEC_VERSION=2.42.2
# docker run --rm -it -v /root/.ssh/:/root/.ssh/ -v /root/serverspec:/work/serverspec serverspec:$SERVERSPEC_VERSION  rake spec
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern spec/192.168.10.123/\*_spec.rb
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user
root@192.168.10.123's password:

Service "sshd"
  is expected to be running

Finished in 0.06606 seconds (files took 5.44 seconds to load)
1 example, 0 failures

→Serverspecが実行され、テストをパスした。

SSHの認証パスワードを自動入力するよう設定する

SSHする際にパスワード認証が必要なサーバに対しServerspecを実行する際の設定は、以下に記載がある。

→spec_helper.rbを修正して、環境変数「LOGIN_PASSWORD」にSSHパスワードを格納してServerspecを実行すればパスワードの手動入力が不要になる模様。

上記ドキュメント通りに、spec_helper.rbを修正する。 spec_helper.rb

require 'serverspec'
require 'net/ssh'
require 'highline/import' ##追加

set :backend, :ssh

if ENV['ASK_SUDO_PASSWORD']
  begin
    require 'highline/import'
  rescue LoadError
    fail "highline is not available. Try installing it."
  end
  set :sudo_password, ask("Enter sudo password: ") { |q| q.echo = false }
else
  set :sudo_password, ENV['SUDO_PASSWORD']
end

host = ENV['TARGET_HOST']

options = Net::SSH::Config.for(host)

options[:user] ||= Etc.getlogin

 ##ここから
if ENV['ASK_LOGIN_PASSWORD']
  options[:password] = ask("\nEnter login password: ") { |q| q.echo = false }
else
  options[:password] = ENV['LOGIN_PASSWORD']
end
 ##ここまで追加

set :host,        options[:host_name] || host
set :ssh_options, options

# Disable sudo
# set :disable_sudo, true


# Set environment variables
# set :env, :LANG => 'C', :LC_MESSAGES => 'C'

# Set PATH
# set :path, '/sbin:/usr/local/sbin:$PATH'

試してみる。

# export SERVERSPEC_VERSION=2.42.2
# export LOGIN_PASSWORD=SSHの認証パスワード

# docker run --rm -it -v /root/.ssh/:/root/.ssh/ -v /root/serverspec:/work/serverspec serverspec:$SERVERSPEC_VERSION rake spec LOGIN_PASSWORD=$LOGIN_PASSWORD
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern spec/192.168.10.123/\*_spec.rb
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user

Service "sshd"
  is expected to be running

Finished in 0.06473 seconds (files took 2.39 seconds to load)
1 example, 0 failures

SSHの認証パスワードの入力を求められずにServerspecを実行できた。

※ちなみに、以下のようにパスワードをspec_helper.rbに書いても同様の動作になる。
環境変数に設定するのが面倒ならこの方法もありだが、パスワードをファイルに平文で記載するのはセキュリティ的には良くないので注意すること。 spec_helper.rb

require 'serverspec'
require 'net/ssh'
require 'highline/import'

LOGIN_PASSWORD="SSHの認証パスワード"  ## 変更点

set :backend, :ssh

if ENV['ASK_SUDO_PASSWORD']
  begin
    require 'highline/import'
  rescue LoadError
    fail "highline is not available. Try installing it."
  end
  set :sudo_password, ask("Enter sudo password: ") { |q| q.echo = false }
else
  set :sudo_password, ENV['SUDO_PASSWORD']
end

host = ENV['TARGET_HOST']

options = Net::SSH::Config.for(host)

options[:user] ||= Etc.getlogin

if ENV['ASK_LOGIN_PASSWORD']
  options[:password] = ask("\nEnter login password: ") { |q| q.echo = false }
else
  options[:password] = LOGIN_PASSWORD ## 変更点
end

set :host,        options[:host_name] || host
set :ssh_options, options

# Disable sudo
# set :disable_sudo, true


# Set environment variables
# set :env, :LANG => 'C', :LC_MESSAGES => 'C'

# Set PATH
# set :path, '/sbin:/usr/local/sbin:$PATH'

aliasの設定

Serverspec実行時に毎回「docker run ~」と入力するのは手間なので、エイリアスを使って簡単に実行できるようにする。
Dockerホストの「~/.bashrc」に以下の2行を追記する。

export SERVERSPEC_VERSION=2.42.2
alias rake='docker run --rm -it -v /root/.ssh/:/root/.ssh/ -v /root/serverspec:/work/serverspec serverspec:$SERVERSPEC_VERSION rake'

試してみる。

変更を反映
# source ~/.bashrc

エイリアスを指定してServerspecを実行する。
# export LOGIN_PASSWORD=SSHの認証パスワード
# rake spec LOGIN_PASSWORD=$LOGIN_PASSWORD
/usr/bin/ruby -I/usr/local/share/gems/gems/rspec-support-3.12.0/lib:/usr/local/share/gems/gems/rspec-core-3.12.1/lib /usr/local/share/gems/gems/rspec-core-3.12.1/exe/rspec --pattern spec/192.168.10.123/\*_spec.rb
/usr/local/share/gems/gems/specinfra-2.85.0/lib/specinfra/backend/ssh.rb:82:in `create_ssh': Passing nil, or [nil] to Net::SSH.start is deprecated for keys: user
root@192.168.10.123's password:

Service "sshd"
  is expected to be running

Finished in 0.06606 seconds (files took 5.44 seconds to load)
1 example, 0 failures

→「rake spec」と実行するだけでDockerコンテナを使ってServerspecを実行できるようになった。
別バージョンのServerspecも実行したい場合は、該当バージョンのServerspecがインストールされたDockerイメージを用意し、環境変数「SERVERSPEC_VERSION」の値を変えることで実行するバージョンを制御できる。

まとめ

  • Serverspecの実行環境をDockerコンテナにすることができた。Dockerさえインストールされていれば、簡単にServerspecの実行環境を整備できる。

参考文献

AWS MediaConvertで作成したHLS形式動画とAES鍵へのアクセスを、CloudFront署名付きCookieでユーザグループ単位に制限する

以下記事で、AWS MediaConvertで作成したHLS形式動画へのアクセスを、CloudFront署名付きCookieでアクセス制限してみた。
今回はその続き。
HLS形式動画を暗号化して、よりセキュアにしてみたい。

tk-ch.hatenablog.com

○上記記事で実現したこと

  • AWSにアップロードした動画ファイルをHLS形式(HTTP Live Streaming)で配信する。
  • 運用コストを抑える&拡張を容易にするため、HLS形式への変換や、HLS動画の配信基盤はサーバーレスでやりたい(EC2の管理したくない)。
  • 特定のユーザ(何らかの認証に通ったユーザ)のみが動画を視聴できるようにする。
  • ユーザの権限(所属グループ)に応じて視聴できる動画を制御する(グループ001所属ユーザは、グループ001所属のユーザがアップロードした動画しか視聴できない、といったイメージ)。

○今回追加で実現したいこと

  • HLS形式の動画を、AES鍵で暗号化する。AES鍵は動画ごとに異なるものにする(鍵が流出した場合にすべての動画が復号化できてしまうことを避けるため)。
  • HLS形式動画同様、特定のユーザ(何らかの認証に通ったユーザ)のみがAES鍵にアクセスできるようにする。
  • さらに、ユーザの権限(所属グループ)に応じて取得できるAES鍵を制御する(グループ001所属ユーザは、グループ001所属のユーザがアップロードした動画に紐づくAES鍵しか取得できない、といったイメージ)。 →仮に動画が悪意のある第三者に取得されても、AES鍵がなければ再生することができないため、よりセキュアになる。

※より強力なコンテンツ保護手段として、「Digital Rights Management(デジタル著作権管理、DRM)」があるが、コストも手間もかかりそうなので、より手軽なHLS+AESを試してみることにした。
コンテンツ保護についての概要は「動画配信におけるコンテンツ保護の重要性とそれを実現する仕組みを自分なりにまとめてみた」が参考になる。

環境

  • Terraform:1.2.5

構成

構成図

今回構築する環境は以下の通り。

構成図
構成図

説明

  • 大まかな構成は前回の記事と同じ。
  • MediaConvertにジョブ登録するLambdaを修正して、ジョブ定義にAES鍵での暗号化を有効化する設定を含める。
  • 暗号化に必要なAES鍵は動画ごとに変えたいので、ジョブ登録するLambda内で、都度AES鍵を生成してその情報をジョブ定義に含めることにした。
  • Lambda内で生成した鍵は、AES鍵用S3バケットを用意してアップロードすることにした(HLS形式動画と鍵が同じバケットに存在すると、そのバケット不正アクセスされた場合に復号できてしまうため)。
    AES鍵は認証済みユーザからはアクセスできる必要があるので、HLS形式動画と同様、CloudFrontで公開して署名付きCookieでアクセスを制限する。
  • HLS動画用S3バケットとAES鍵格納用S3バケットで、公開に使用するCloudfrontディストリビューションは共用することにした(詳細はこの後の補足参照)。
    Cloudfrontディストリビューションに、以下のようにパスに応じてオリジンとなるS3バケットを振り分ける設定(マルチオリジン)をする。
    • パスがkey/*の場合→AES鍵用S3にアクセス
    • パスがvideo/*の場合→HLS動画用S3にアクセス
  • AES鍵へのアクセスをユーザの所属グループ単位で制御する要件は、HLS形式動画と同様、CloudFront署名付きCookieのカスタムポリシーで実現する。
    動作確認の都合上、同じCookieでAES鍵にもHLS動画にもアクセスできるようにする。
    ※AES鍵とHLS動画で異なるCookieが必要な設定にもできるが、動作確認時に2種類のCookieを設定する方法がうまくいかなかったのでやめた。

補足:CloudFrontの構成について

今回は、HLS動画用S3バケットとAES鍵格納用S3バケットでCloudFrontのディストリビューションを共用することにしたが、当初は以下のように分ける構成を検討していた。

[構成図没案]
構成図没案

→カスタムドメインを使わずにCloudFront がディストリビューションに割り当てたドメイン名をそのまま使っている場合、「*.cloudfront.net」といったドメイン指定でCookieを利用することができない。
今回は使えるカスタムドメインがなく、Cookieも2つのCloudFrontで共用したかったので、この案は没にした。
※「*.cloudfront.net」をCookieのDomainに指定できない件は、このページに以下の記載がある。

CloudFront がディストリビューションに割り当てたドメイン名 (d111111abcdef8.cloudfront.net など) を指定することはできますが、*.cloudfront.net をドメイン名として指定することはできません。

商用環境で使う場合にはカスタムドメインがあると思うので、以下のようにCloudFrontディストリビューションを分けても問題ない見込み(未検証)。

[構成図カスタムドメイン使用時]
構成図カスタムドメイン使用時

実施内容

Terraformでのリソース変更・追加

前回の記事で使用したTerraformコードを変更する。

変更・追加するリソース

Terraformで変更・追加するリソースの概要は以下の通り。

  • AES鍵用S3バケットを追加。
  • CloudFrontに、key/*へのアクセスの場合AES鍵用S3バケットをオリジンとして使用し、かつ署名付きCookieがないとアクセスできないよう設定。
  • MediaConvertジョブ登録用Lambda関数からAES鍵用S3バケットにAES鍵をアップロードするため、Lambda実行ロールのIAMポリシーにAES鍵用S3バケットへのPUT権限を追加。

Terraformコード

Terraformコードは以下の通り。
「### 変更箇所ここから」と「### 変更箇所ここまで」の間が前記事のコードからの変更箇所。

local.tf

# ローカル変数定義
locals {

  name_prefix = "hlstest"
  common_tags = {
    Terraform = "True" #Terraformで作成したリソースだとわかるようにタグを付ける。付けなくても処理には影響ない。
  }

}

main.tf

# オリジナル動画用S3バケット
module "s3_bucket_source" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "3.3.0"

  bucket        = "${local.name_prefix}-s3-source"
  force_destroy = false

  # デフォルトの暗号化。原則S3マスターキーでの暗号化を有効にする。
  server_side_encryption_configuration = {
    rule = {
      apply_server_side_encryption_by_default = {
        sse_algorithm = "AES256"
      }
    }
  }

  # ブロックパブリックアクセス。原則全てtrueにする。
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-s3-source"
  })
}

# オリジナル動画用S3バケットにmp4がアップロードされた際のS3イベント通知
module "s3_notifications_hls" {
  source  = "terraform-aws-modules/s3-bucket/aws//modules/notification"
  version = "3.3.0"

  bucket = module.s3_bucket_source.s3_bucket_id

  # Common error - Error putting S3 notification configuration: InvalidArgument: Configuration is ambiguously defined. Cannot have overlapping suffixes in two rules if the prefixes are overlapping for the same event type.

  sns_notifications = {
    video_uploaded = {
      topic_arn     = aws_sns_topic.video_upload.arn
      events        = ["s3:ObjectCreated:*"]
      filter_prefix = "video/"
      filter_suffix = ".mp4"
    }
  }

  create_sns_policy = true
}

# S3イベント通知の送信先SNSトピック
resource "aws_sns_topic" "video_upload" {
  name = "${local.name_prefix}-sns-upload"

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-sns-upload"
  })
}

# S3イベント通知の送信先SQSキュー
resource "aws_sqs_queue" "video_upload" {
  name = "${local.name_prefix}-sqs-upload"

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-sqs-upload"
  })
}

# S3イベント通知の送信先SQSキューのポリシー設定
resource "aws_sqs_queue_policy" "sqs_video_upload_policy" {
  queue_url = aws_sqs_queue.video_upload.id
  policy    = data.aws_iam_policy_document.sqs_video_upload_policy.json
}

# AWSアカウントIDを取得
data "aws_caller_identity" "now" {}

# S3イベント通知の送信先SQSキューのポリシー内容
data "aws_iam_policy_document" "sqs_video_upload_policy" {
  statement {
    sid = "__owner_statement"
    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::${data.aws_caller_identity.now.account_id}:root"]
    }
    effect    = "Allow"
    actions   = ["SQS:*"]
    resources = ["${aws_sqs_queue.video_upload.arn}"]
  }
  statement {
    sid = "Allow-SNS-SendMessage"
    principals {
      type        = "Service"
      identifiers = ["sns.amazonaws.com"]
    }
    effect    = "Allow"
    actions   = ["sqs:SendMessage"]
    resources = ["${aws_sqs_queue.video_upload.arn}"]
    condition {
      test     = "ArnEquals"
      variable = "aws:SourceArn"
      values   = ["${aws_sns_topic.video_upload.arn}"]
    }
  }
}

# SNSトピックからSQSキューへのサブスクライブ設定
resource "aws_sns_topic_subscription" "sqs_request_mosaic" {
  topic_arn = aws_sns_topic.video_upload.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.video_upload.arn
}

# HLS動画用S3バケット
module "s3_bucket_hls" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "3.3.0"

  bucket        = "${local.name_prefix}-s3-hls"
  force_destroy = false

  attach_policy = true
  policy        = data.aws_iam_policy_document.s3_bucket_hls_policy.json

  # デフォルトの暗号化。原則S3マスターキーでの暗号化を有効にする。
  server_side_encryption_configuration = {
    rule = {
      apply_server_side_encryption_by_default = {
        sse_algorithm = "AES256"
      }
    }
  }

  # ブロックパブリックアクセス。原則全てtrueにする。
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

  # CORS設定
  cors_rule = [
    {
      allowed_methods = ["GET"]
      allowed_origins = ["*"]
      allowed_headers = ["*"]
      expose_headers  = []
      max_age_seconds = 3000
    }
  ]

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-s3-hls"
  })
}

# HLS動画用S3バケットのバケットポリシー内容
data "aws_iam_policy_document" "s3_bucket_hls_policy" {
  statement {
    principals {
      type        = "AWS"
      identifiers = module.cloudfront.cloudfront_origin_access_identity_iam_arns
    }
    actions = [
      "s3:GetObject",
    ]
    resources = [
      "${module.s3_bucket_hls.s3_bucket_arn}/*",
    ]
  }
}

# HLS配信用CloudFront
module "cloudfront" {
  source  = "terraform-aws-modules/cloudfront/aws"
  version = "2.8.0"

  enabled             = true
  is_ipv6_enabled     = true
  price_class         = "PriceClass_All"
  retain_on_delete    = false # terraform destroy時に削除でなく無効にするか
  wait_for_deployment = false
  default_root_object = "index.html"

  create_origin_access_identity = true
  origin_access_identities = {
    s3_bucket_one = "access-identity-${module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name}"
  }

  origin = {
    "${module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name}" = {
      domain_name = module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name
      s3_origin_config = {
        origin_access_identity = "s3_bucket_one" # key in `origin_access_identities`
      }
    }
### 変更箇所ここから
    "${module.s3_bucket_aeskey.s3_bucket_bucket_regional_domain_name}" = {
      domain_name = module.s3_bucket_aeskey.s3_bucket_bucket_regional_domain_name
      s3_origin_config = {
        origin_access_identity = "s3_bucket_one" # key in `origin_access_identities`
      }
    }
### 変更箇所ここまで
  }

  default_cache_behavior = {
    target_origin_id       = module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    use_forwarded_values   = false # https://github.com/hashicorp/terraform-provider-aws/issues/19041
    cache_policy_id        = data.aws_cloudfront_cache_policy.managed_caching_disabled.id
  }

  ordered_cache_behavior = [
    {
      path_pattern           = "video/*"
      target_origin_id       = module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name
      viewer_protocol_policy = "redirect-to-https"
      allowed_methods        = ["GET", "HEAD"]
      cached_methods         = ["GET", "HEAD"]
      compress               = true
      trusted_key_groups     = [aws_cloudfront_key_group.key_group_hls.id]
      use_forwarded_values   = false # https://github.com/hashicorp/terraform-provider-aws/issues/19041
      cache_policy_id        = data.aws_cloudfront_cache_policy.managed_caching_disabled.id
### 変更箇所ここから
    },
    {
      path_pattern           = "key/*"
      target_origin_id       = module.s3_bucket_aeskey.s3_bucket_bucket_regional_domain_name
      viewer_protocol_policy = "redirect-to-https"
      allowed_methods        = ["GET", "HEAD"]
      cached_methods         = ["GET", "HEAD"]
      compress               = true
      trusted_key_groups     = [aws_cloudfront_key_group.key_group_hls.id]
      use_forwarded_values   = false # https://github.com/hashicorp/terraform-provider-aws/issues/19041
      cache_policy_id        = data.aws_cloudfront_cache_policy.managed_caching_disabled.id
    }
### 変更箇所ここまで
  ]

  tags = local.common_tags
}

# CloudFrontのキャッシュポリシー
data "aws_cloudfront_cache_policy" "managed_caching_disabled" {
  name = "Managed-CachingDisabled"
}

# 公開鍵とキーグループ
resource "aws_cloudfront_public_key" "key_hls" {
  name        = "${local.name_prefix}-cloudfront-public-key"
  comment     = "HLS配信用パブリックキー"
  encoded_key = file("cloudfront_key/public_key.pem")
}
resource "aws_cloudfront_key_group" "key_group_hls" {
  name    = "${local.name_prefix}-cloudfront-key-group"
  comment = "HLS配信用キーグループ"
  items   = [aws_cloudfront_public_key.key_hls.id]
}

# MediaConvert用IAMポリシー
resource "aws_iam_policy" "mediaconvert" {
  name   = "${local.name_prefix}-iampol-mediaconvert"
  policy = data.aws_iam_policy_document.mediaconvert_policy.json

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-iampol-mediaconvert"
  })
}

# MediaConvert用IAMポリシーの内容
data "aws_iam_policy_document" "mediaconvert_policy" {
  statement {
    sid     = "AllowGetSourceVideo"
    effect  = "Allow"
    actions = ["s3:GetObject"]
    resources = [
      "${module.s3_bucket_source.s3_bucket_arn}",
      "${module.s3_bucket_source.s3_bucket_arn}/*"
    ]
  }
  statement {
    sid     = "AllowPutHLSVideo"
    effect  = "Allow"
    actions = ["s3:PutObject"]
    resources = [
      "${module.s3_bucket_hls.s3_bucket_arn}",
      "${module.s3_bucket_hls.s3_bucket_arn}/*"
    ]
  }
}

# MediaConvert用IAMロール
resource "aws_iam_role" "mediaconvert" {
  name                = "${local.name_prefix}-role-mediaconvert"
  managed_policy_arns = [aws_iam_policy.mediaconvert.arn]

  assume_role_policy = data.aws_iam_policy_document.mediaconvert_assume_role_policy.json

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-role-mediaconvert"
  })
}

# MediaConvert用IAMロールの信頼ポリシー内容
data "aws_iam_policy_document" "mediaconvert_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["mediaconvert.amazonaws.com"]
    }
  }
}

# MediaConvertジョブ登録Lambda用IAMポリシー
resource "aws_iam_policy" "lambda_submit_mcjob" {
  name   = "${local.name_prefix}-iampol-lambda_submit_mcjob"
  policy = data.aws_iam_policy_document.lambda_submit_mcjob_policy.json

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-iampol-lambda_submit_mcjob"
  })
}

# MediaConvertジョブ登録Lambda用IAMポリシー内容
data "aws_iam_policy_document" "lambda_submit_mcjob_policy" {
  statement {
    sid     = "AllowPassRole"
    effect  = "Allow"
    actions = ["iam:PassRole"]
    resources = [
      "${aws_iam_role.mediaconvert.arn}"
    ]
  }
  statement {
    sid     = "AllowCreateMCJob"
    effect  = "Allow"
    actions = ["mediaconvert:CreateJob"]
    resources = [
      "arn:aws:mediaconvert:ap-northeast-1:${data.aws_caller_identity.now.account_id}:*"
    ]
  }
### 変更箇所ここから
  statement {
    sid    = "AllowPutAesKey"
    effect = "Allow"
    actions = [
      "s3:PutObject"
    ]
    resources = ["${module.s3_bucket_aeskey.s3_bucket_arn}/*"]
  }
### 変更箇所ここまで
}

# MediaConvertジョブ登録Lambda用IAMロール
resource "aws_iam_role" "lambda_submit_mcjob" {
  name = "${local.name_prefix}-role-lambda_submit_mcjob"
  managed_policy_arns = [
    aws_iam_policy.lambda_submit_mcjob.arn,
    data.aws_iam_policy.AWSLambdaSQSQueueExecutionRole.arn
  ]

  assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-role-lambda_submit_mcjob"
  })
}

# SQSトリガーのLambda用AWS管理ポリシー取得
data "aws_iam_policy" "AWSLambdaSQSQueueExecutionRole" {
  arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole"
}

# MediaConvertジョブ登録Lambda用IAMロールの信頼ポリシー
data "aws_iam_policy_document" "lambda_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

### 変更箇所ここから
# AES鍵用S3バケット
module "s3_bucket_aeskey" {
  source = "../../../modules/terraform-aws-s3-bucket-3.3.0"

  bucket        = "${local.name_prefix}-s3-aeskey"
  force_destroy = false

  attach_policy = true
  policy        = data.aws_iam_policy_document.s3_bucket_aeskey_policy.json

  # デフォルトの暗号化。原則S3マスターキーでの暗号化を有効にする。
  server_side_encryption_configuration = {
    rule = {
      apply_server_side_encryption_by_default = {
        sse_algorithm = "AES256"
      }
    }
  }

  # ブロックパブリックアクセス。原則全てtrueにする。
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

  # CORS設定
  cors_rule = [
    {
      allowed_methods = ["GET"]
      allowed_origins = ["*"]
      allowed_headers = ["*"]
      expose_headers  = []
      max_age_seconds = 3000
    }
  ]

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-s3-aeskey"
  })
}

# HLS配信用S3バケットのバケットポリシー内容
data "aws_iam_policy_document" "s3_bucket_aeskey_policy" {
  statement {
    principals {
      type        = "AWS"
      identifiers = module.cloudfront.cloudfront_origin_access_identity_iam_arns
    }
    actions = [
      "s3:GetObject",
    ]
    resources = [
      "${module.s3_bucket_aeskey.s3_bucket_arn}/*",
    ]
  }
}
### 変更箇所ここまで

MediaConvertジョブ登録用Lambda関数の修正

マネジメントコンソールで、Lambda関数を修正する。
AES鍵を生成する処理のコードはこちらを参考にさせていただいた。

処理概要

修正後のLambda関数内で実行する処理の概要は以下。

  • SQSから受信したメッセージからS3イベントを取り出す。
  • アップロードされたmp4ファイルのパスから、HLS動画用S3バケットのどのパスに出力するか決定する。
    (オリジナル動画用S3バケットの「video/<グループ名>/<mp4ファイルID>.mp4」としてアップロードされたmp4動画の出力先は、HLS動画用S3バケットの「video/<グループ名>/hls_<mp4ファイルID>/」配下になる。)
  • (追加した処理)HLS形式動画の暗号化に必要なAES鍵と定数初期化ベクトルを生成する。
  • (追加した処理)AES鍵の格納先URLを決定する。
    (オリジナル動画用S3バケットの「video/<グループ名>/<mp4ファイルID>.mp4」としてアップロードされたmp4動画に対応するAES鍵の格納先は、AES鍵用S3バケットの「key/<グループ名>/hls_<mp4ファイルID>/<mp4ファイルID>.key」になる。)
  • (変更した処理)事前に作成したMediaConvertのテンプレートを元に、mp4とHLS出力先のパス、AES鍵の値とURL、定数初期化ベクトルを指定して、MediaConvertジョブを登録する。
  • (追加した処理)AES鍵をAES鍵用S3バケットにアップロードする。

関数作成時の注意点

  • 関数の実行ロールはTerraformで作成した「hlstest-role-lambda_submit_mcjob」を指定する。
  • トリガーにはTerraformで作成したSQSキュー「hlstest-sqs-upload」を指定する。
  • 環境変数に以下を設定する。
    • JOB_TEMPLATE_NAME:MediaConvertのジョブテンプレート名。今回はTerraformで作成した「hlstest-mcjob-template」。
    • MEDIACONVERT_ENDPOINT:MediaConvertのAPI エンドポイント。マネジメントコンソールのMediaConvertのページで、「アカウント」を選択すると表示される。
    • MEDIACONVERT_ROLE:MediaConvert実行用のIAMロールのARN。今回はTerraformで作成した「hlstest-role-mediaconvert」のARN。
    • OUTPUT_S3_NAME:HLS形式動画の出力先S3バケットの名前。今回はTerraformで作成した「hlstest-s3-hls」。
    • AESKEY_S3_NAME:AES鍵用S3バケットの名前。今回はTerraformで作成した「hlstest-s3-aeskey」。

関数コード

import os
import urllib.parse
import boto3
import json
import secrets
import codecs
import re

# MediaConvertのエンドポイントURL
MEDIACONVERT_ENDPOINT = os.environ['MEDIACONVERT_ENDPOINT']

# MediaConvert用のロールのARN
MEDIACONVERT_ROLE = os.environ['MEDIACONVERT_ROLE']

# 前回作成したジョブテンプレートの名前
JOB_TEMPLATE_NAME = os.environ['JOB_TEMPLATE_NAME']

# 変換後ファイルの出力先S3のバケット名
OUTPUT_S3_NAME = os.environ['OUTPUT_S3_NAME']

# AES鍵の格納先S3のバケット名
AESKEY_S3_NAME = os.environ['AESKEY_S3_NAME']

# AES鍵の格納先S3を公開しているCloudFrontドメイン名
AESKEY_CLOUDFRONT_DOMAIN = os.environ['AESKEY_CLOUDFRONT_DOMAIN']

s3Client = boto3.client('s3')

def lambda_handler(event, context):
    print(f'boto3 version: {boto3.__version__}')
    
    for record in event['Records']:
        message_str = json.loads(record["body"])['Message']
        message = json.loads(message_str)
    
        bucket = message['Records'][0]['s3']['bucket']['name']
        key = urllib.parse.unquote_plus(message['Records'][0]['s3']['object']['key'], encoding='utf-8')

        # 16バイトのランダムなデータを16進数文字列として生成(MediaConvertの「静的キーの値」として使用する)
        aes_key_value = secrets.token_hex(16)
        # 16バイトのランダムなバイナリデータに変換(MediaConvertの「静的キー」の中身になる)
        aes_key_binary = codecs.decode(aes_key_value, 'hex_codec')
        # 16バイトのランダムなデータを16進数文字列として生成(MediaConvertの「定数初期化ベクトル」として使用する)
        iv = secrets.token_hex(16)
    
        # 静的キーをAES鍵格納用S3にアップロード
        basename_video = os.path.basename(key).split('.')[0]
        dirname_key = re.sub('^video', 'key', os.path.dirname(key))
        aes_key = dirname_key + '/' + basename_video + '/' + basename_video + '.key'
        print(s3Client.put_object(
            Bucket = AESKEY_S3_NAME,
            Key = aes_key,
            Body = aes_key_binary
        ))
    
        settings = make_settings(bucket, key, aes_key_value, iv, aes_key)
        user_metadata = {
            'JobCreatedBy': 'videoConvertSample',
        }
    
        client = boto3.client('mediaconvert', endpoint_url = MEDIACONVERT_ENDPOINT)
        result = client.create_job(
            Role = MEDIACONVERT_ROLE,
            JobTemplate = JOB_TEMPLATE_NAME,
            Settings=settings,
            UserMetadata=user_metadata,
        )

def make_settings(bucket, key, aes_key_value, iv, aes_key):
    basename = os.path.basename(key).split('.')[0]
    dirname = os.path.dirname(key)

    return \
    {
        "Inputs": [
            {
                "FileInput": f"s3://{bucket}/{key}",
            }
        ],
        "OutputGroups": [
            {
                "Name": "Apple HLS",
                "OutputGroupSettings": {
                    "Type": "HLS_GROUP_SETTINGS",
                    "HlsGroupSettings": {
                        "Destination": f"s3://{OUTPUT_S3_NAME}/{dirname}/hls_{basename}/",
                        "Encryption": {
                            "EncryptionMethod": "AES128",
                            "ConstantInitializationVector": iv,
                            "StaticKeyProvider": {
                                "StaticKeyValue": aes_key_value,
                                "Url": 'https://' + AESKEY_CLOUDFRONT_DOMAIN + '/' + aes_key
                            },
                            "Type": "STATIC_KEY"
                        }
                    },
                },
            },
        ],
    }

mp4動画をAES鍵で暗号化されたHLS形式に変換する

001グループ用のHLS形式動画を作成

前回同様、適当に用意した「test.mp4」を、オリジナル動画用S3バケットの「video/001/」にアップロードする。
実行されたジョブはMediaConvertのページの「ジョブ」から確認でき、該当するジョブの「ジョブの詳細」→「出力グループ」→「Apple HLS」と進むと「DRM 暗号化」という項目と暗号化に利用したAES鍵の情報を参照できる。

HLS動画用S3バケットを確認すると、以下のように「video/001/hls_test」配下にHLS形式のインデックスファイルとセグメントファイルが出力されている。

グループ001用のHLS形式ファイル
グループ001用のHLS形式ファイル

作成されたインデックスファイルの中身は以下。
「test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr.m3u8」に、「#EXT-X-KEY:METHOD」というHLS形式動画の復号に使うAES鍵の情報が含まれていることが確認できる。
※<CloudFrontのドメイン名>には実際にはドメイン名が入る。

test.m3u8

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1011712,AVERAGE-BANDWIDTH=980138,CODECS="avc1.64001e,mp4a.40.5",RESOLUTION=640x360,FRAME-RATE=30.000
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr.m3u8

test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr.m3u8

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-KEY:METHOD=AES-128,URI="https://<CloudFrontのドメイン名>/key/001/hls_test/test.key",IV=0xB7293945FA8669A6B29B3484F15560F7
#EXTINF:3,
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr_00001.ts
#EXTINF:3,
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr_00002.ts
#EXTINF:1,
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr_00003.ts
#EXT-X-ENDLIST

また、AES鍵用S3バケットを確認すると、以下のように「key/001/hls_test」配下にAES鍵が配置されている。

AES鍵ファイル(001)
AES鍵ファイル(001)

002グループ用のHLS形式動画を作成

「test.mp4」を、オリジナル動画用S3バケットの「video/002/」にアップロードする。 先ほどと同様にMediaConvertジョブが実行され、HLS動画とAES鍵がそれぞれのS3バケットに出力される。

curlコマンドで接続確認する

curlコマンドで、HLS形式動画とAES鍵にアクセス署名付きCookieを持つユーザのみがアクセス出来ることを確認する。
記載している手順はインターネットに接続できる環境で実行すること。
また、<CloudFrontのドメイン名>にはTerraformで作成したCloudFrontディストリビューションドメイン名を入れる。

署名付きCookie無しではアクセスできないことを確認

CloudFrontドメイン名を設定
$ CF_DOMAIN=<CloudFrontのドメイン名>

グループ001用のHLS形式ファイル(インデックスファイル)へアクセスする
$ curl https://${CF_DOMAIN}/video/001/hls_test/test.m3u8
<?xml version="1.0" encoding="UTF-8"?><Error><Code>MissingKey</Code><Message>Missing Key-Pair-Id query parameter or cookie value</Message></Error>

$ curl https://${CF_DOMAIN}/key/001/hls_test/test.key
<?xml version="1.0" encoding="UTF-8"?><Error><Code>MissingKey</Code><Message>Missing Key-Pair-Id query parameter or cookie value</Message></Error>

→CloudFrontにて「video/*」と「key/*」に署名付きCookieの設定をしているため、Cookieの無い状態でこれらのパスにアクセスすると「Missing Key-Pair-Id query parameter or cookie value」というエラーになる。

署名付きCookieを使うとアクセスできることを確認

次に、署名付きCookieを使えばアクセスできることを確認する。

署名付きCookie生成用のカスタムポリシーを用意する

カスタムポリシーのJSONファイルを作成する。
グループ001とグループ002でアクセスできるパスを変えたいので、それぞれのJSONファイルを作成する。
 

  • Resourceにはアクセスを許可するURLを記載する。今回は同じCookieでAES鍵にもHLS動画にもアクセスできるようにしたいので、「video/グループ名/*」にも「key/グループ名/*」も許可対象になるよう、「*/グループ名/*」と指定する。
    <CloudFrontのドメイン名>はCloudFrontのドメイン名に書き換える。
  • DateLessThanはURL の有効期限切れ日時。Unix 時間形式 (秒単位) および協定世界時 (UTC) で指定する。
    今回は適当に未来日時を「$ date -d "2023-11-11 08:38:25" +%s」を実行してエポックタイムに変換したものを記載している。

policy_aeskey_001.json

{
    "Statement": [
        {
            "Resource": "https://<CloudFrontのドメイン名>/*/001/*",
            "Condition": {
                "DateLessThan": {
                    "AWS:EpochTime": 1699691905
                }
            }
        }
    ]
}

policy_aeskey_002.json

{
    "Statement": [
        {
            "Resource": "https://<CloudFrontのドメイン名>/*/002/*",
            "Condition": {
                "DateLessThan": {
                    "AWS:EpochTime": 1699691905
                }
            }
        }
    ]
}

署名付きCookieを作成して、アクセスできるか確認する

以下の作業は秘密鍵とカスタムポリシーJSONファイルがあるディレクトリで行う。 秘密鍵前回作成したものを使っている。

$ ls -l
total 16
-rw-rw-r-- 1 ec2-user ec2-user  266 Nov 15 03:29 policy_aeskey_001.json
-rw-rw-r-- 1 ec2-user ec2-user  266 Nov 15 03:29 policy_aeskey_002.json
-rw-rw-r-- 1 ec2-user ec2-user 1679 Nov 17 05:56 private_key.pem
-rw-rw-r-- 1 ec2-user ec2-user  451 Nov 17 05:57 public_key.pem

まずグループ001のユーザを想定して署名付きCookieを作成する。
以下の手順を実行する。
手順中の<CloudFrontのドメイン名>と<公開鍵のID>は環境に応じて記載する。

CloudFrontドメイン名を環境変数に格納
$ CF_DOMAIN=<CloudFrontのドメイン名>

公開鍵のIDを環境変数に格納
$ CF_KEYPAIR_ID=<公開鍵のID>

ポリシーファイルのパスを環境変数に格納
$ CF_POLICY_FILE=policy_aeskey_001.json

秘密鍵のパスを環境変数に格納
$ CF_PRIVATE_KEY=private_key.pem

カスタムポリシーのJSONファイルをbase64エンコードして環境変数に格納
$ CF_POLICY=$(cat ${CF_POLICY_FILE} | tr -d "\n" | tr -d " \t\n\r" | openssl base64 -A | tr -- '+=/' '-_~')

カスタムポリシーのJSONファイルに秘密鍵で署名し、base64エンコードして環境変数に格納
$ CF_SIGNATURE=$(cat ${CF_POLICY_FILE} | tr -d "\n" | tr -d " \t\n\r" | openssl sha1 -sign ${CF_PRIVATE_KEY} | openssl base64 -A | tr -- '+=/' '-_~')

Cookieをセットして接続すると、グループ001用のパスにあるHLS形式ファイル(インデックスファイル)にアクセスできる
$ curl https://${CF_DOMAIN}/video/001/hls_test/test.m3u8 -b "CloudFront-Policy=${CF_POLICY}; CloudFront-Signature=${CF_SIGNATURE}; CloudFront-Key-Pair-Id=${CF_KEYPAIR_ID}"
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1011712,AVERAGE-BANDWIDTH=980138,CODECS="avc1.64001e,mp4a.40.5",RESOLUTION=640x360,FRAME-RATE=30.000
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr.m3u8

Cookieをセットして接続すると、グループ001用のパスにあるAES鍵にアクセスできる(鍵はバイナリファイルなので、中身をちゃんと表示することはできない)
$ curl https://${CF_DOMAIN}/key/001/hls_test/test.key -b "CloudFront-Policy=${CF_POLICY}; CloudFront-Signature=${CF_SIGNATURE}; CloudFront-Key-Pair-Id=${CF_KEYPAIR_ID}"
P[01;32m

Cookieをセットして接続しても、グループ002用のパスにあるHLS形式ファイル、AES鍵にはアクセスできない。
$ curl https://${CF_DOMAIN}/video/002/hls_test/test.m3u8 -b "CloudFront-Policy=${CF_POLICY}; CloudFront-Signature=${CF_SIGNATURE}; CloudFront-Key-Pair-Id=${CF_KEYPAIR_ID}"                           
<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>Access denied</Message></Error>

$ curl https://${CF_DOMAIN}/key/002t/test.key -b "CloudFront-Policy=${CF_POLICY}; CloudFront-Signature=${CF_SIGNATURE}; CloudFront-Key-Pair-Id=${CF_KEYPAIR_ID}"                                                                                    
<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>Access denied</Message></Error>

後ほどブラウザで確認する際に使うので、以下の値は書き留めておく
$ echo ${CF_POLICY}
$ echo ${CF_SIGNATURE}

次に、グループ002のユーザを想定して署名付きCookieを作成する。
ポリシーJSONファイルだけグループ002用のものに変えて、先ほどと同じ手順を実行すると、002のHLS形式ファイル、AES鍵にのみ接続できることが確認できる。

→カスタムポリシーを使い分けることで、所属グループ(権限)ごとにアクセスできるHLS形式ファイル、AES鍵を制御することができた。

ブラウザで接続確認する

ブラウザからアクセスして、実際に視聴できるか確認する。

Cookieをセットして接続する

前回同様、何らかの方法でブラウザにCookieをセットして接続確認してみる。

curlコマンドでの実行時に作成した、グループ001用署名付きCookieをブラウザにセットして「http://<CloudFrontのドメイン名>」に接続する。
以下のように、グループ001の動画のみ再生でき、グループ002用動画は「The media could not be loaded, either because the server or network failed or because the format is not supported.」となり視聴できない。

グループ001用Cookieを設定して接続した場合
グループ001用Cookieを設定して接続した場合

また、念のため本当にAES鍵で復号して再生しているか確認してみる。
AES鍵用S3バケットにあるAES鍵を削除してブラウザを更新すると、以下のように先ほどまで再生できていた映像がずっと読み込みのグルグル状態になって再生できなくなった。

AES鍵を削除して接続した場合
AES鍵を削除して接続した場合

→想定通りの制限および暗号化が出来ていることを、ブラウザからの接続で確認できた。

まとめ

  • MediaConvertでのHLS形式動画作成時にAES鍵による暗号化ができ、動画コンテンツ保護が行えることを確認できた。

参考文献

AWS MediaConvertで作成したHLS形式動画へのアクセスを、CloudFront署名付きCookieでユーザグループ単位に制限する

AWSで以下を実現したい。

  • AWSにアップロードした動画ファイルをHLS形式(HTTP Live Streaming)で配信する。
  • 運用コストを抑える&拡張を容易にするため、HLS形式への変換や、HLS動画の配信基盤はサーバーレスでやりたい(EC2の管理したくない)。
  • 特定のユーザ(何らかの認証に通ったユーザ)のみが動画を視聴できるようにする。
  • ユーザの権限(所属グループ)に応じて視聴できる動画を制御する(グループ001所属ユーザは、グループ001所属のユーザがアップロードした動画しか視聴できない、といったイメージ)。

→HLS形式に変換するのはMediaConvert、配信はCloudFront+S3で手軽にできそう。
視聴できる動画のグループ単位の制限は、CloudFrontの署名付きCookieを使うことで実現出来そうなので、試してみる。

環境

  • Terraform:1.2.5

構成

構成図

今回構築する環境は以下の通り。

構成図
構成図

説明

  • HLS形式への変換は、MediaConvertで行う。
  • S3イベントとLambdaを使い、オリジナル映像用S3バケットに動画がアップロードされると、自動的にMediaConvertジョブが登録・実行される仕組み
    S3イベントは直接Lambdaに送信してもよいが、耐障害性や機能拡張性(アップロード通知の追加や、動画アップロードを契機とした別処理を追加するなど)を考慮して、SNS、SQSを介している。
  • ユーザはグループ001、グループ002のいずれかに属していることとする。
  • ユーザはオリジナル映像用S3バケットの所属グループに対応するパス(「video/001/」or「video/002/」)配下にのみファイルアップロードできる。
    →この制限はIAMロールやS3バケットポリシー等で実現できるが、今回は本題ではないので割愛する。
  • ユーザはHLS動画用S3バケットの所属グループに対応するパス(「video/001/」or「video/002/」)に存在するHLS動画のみ視聴できるようにする。
    →CloudFrontの署名付きCookieとカスタムポリシーを使用して、Cookieに応じて特定のパス配下の動画のみアクセスできるように制限することにした。
    署名付きURLではなく署名付きCookieを使用する理由は後述。
  • ユーザの認証や、ユーザの所属グループに応じた認証情報やCookieをユーザに返す仕組みは、別途構築する想定。
    Cognito、API Gateway、Lambdaなどでこちらもサーバーレスに実現できる見込み。
    →本記事では、動作確認のためCookieの作成をコマンドで行う。

補足:CloudFrontのアクセス制限方法について

CloudFrontでコンテンツにアクセスできるユーザーを制限する機能としては、署名付き URL署名付き Cookie がある。
どちらを使うべきかについて、「署名付き URL と署名付き Cookie の選択」に以下の記載がある。

次のような場合は、署名付き URL を使用します。

・個別のファイル (アプリケーションのインストールダウンロード) へのアクセスを制限する場合。

・ユーザーが Cookie をサポートしていないクライアント (カスタム HTTP クライアントなど) を使用している場合。

 

次のような場合は、署名付き Cookie を使用します。

・複数の制限されたファイル (HLS 形式の動画のすべてのファイルやウェブサイトの購読者の領域にあるすべてのファイルなど) へのアクセスを提供する場合。

・現在の URL を変更したくない場合。

HLS形式の動画は、複数のファイル(インデックスファイルとセグメントファイル)で構成されるので、署名付きCookieが適している。
また、今回はグループごとのパスの配下にHLS形式の動画を格納し、その全てにアクセス出来るようにしたいため、「ウェブサイトの購読者の領域にあるすべてのファイル」に該当し、そういった意味でも署名付きCookieが適している。
そのため、今回は署名付きCookieを選択した。

実施内容

CloudFrontの署名付きCookie用キーペアの作成

署名付きCookie用のキーペアを作成する。
作成方法はこちらを参考にした。

キーペア作成

RSA キーペアを生成する
$ openssl genrsa -out private_key.pem 2048

公開鍵と秘密鍵の両方が含まれているため、公開鍵を抽出する
$ openssl rsa -pubout -in private_key.pem -out public_key.pem

確認
$ ls -l
total 8
-rw-rw-r-- 1 ec2-user ec2-user 1679 Nov 17 05:56 private_key.pem
-rw-rw-r-- 1 ec2-user ec2-user  451 Nov 17 05:57 public_key.pem

Terraformでのリソース作成

MediaConvertジョブ登録用Lambda関数と、MediaConvertジョブテンプレート以外のリソースをTerraformで作成する。

作成するリソース

Terraformで作成するリソースの概要は以下の通り。

  • オリジナル動画用S3バケット。mp4ファイルがアップロードされたら、SNSトピックにS3イベント通知を行う。
  • S3イベント通知を受け取るSNSトピック、SQSキュー。
  • HLS動画用S3バケット。CloudFront経由でのみアクセスできるバケットポリシーを設定。CORSも設定する。
  • CloudFrontの署名付きCookie用の公開鍵を登録し、キーグループを作成。
  • HLS動画用S3バケットをオリジンとするCloudFrontディストリビューション
    上記キーグループを設定し、「video/*」には署名付きCookieが無しではアクセス出来ないようにする。
    CloudFrontにキャッシュが残っていると動作確認時に挙動が分かりづらくなるかもしれないので、キャッシュポリシーはManaged-CachingDisabledを指定しキャッシュを無効にしている。
  • MediaConvertの実行用のIAMポリシー、ロール。
    権限については「IAM での IAM ロールの作成」を参考に、オリジナル動画用S3バケットへのGETと、HLS動画用S3バケットへのPUTを許可する。
    ※上記ドキュメントのポリシー例ではAPI Gatewayへのアクセスも許可しているが、これはこちらによると「SPEKE によるデジタル著作権管理、ニールセン非線形透かし」といった機能を使用する場合にのみ必要らしい。 詳細は調べていないが、通常は使用する必要は無さそうなので、許可していない。
  • MediaConvertジョブ登録用Lambda関数の実行ロール。
    権限については「AWS Elemental MediaConvert コンソールを使用するために必要なアクセス許可」を参考に、Lambdaからのジョブ登録に必要な権限に絞った。
     

Terraformコード

Terraformコードは以下の通り。
(先ほど作成した公開鍵は、tfファイルの配置場所にある「cloudfront_key」というディレクトリに格納している想定。)
以下のTerraform Moduleを使用している。

local.tf

# ローカル変数定義
locals {

  name_prefix = "hlstest"
  common_tags = {
    Terraform = "True" #Terraformで作成したリソースだとわかるようにタグを付ける。付けなくても処理には影響ない。
  }

}

main.tf

# オリジナル動画用S3バケット
module "s3_bucket_source" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "3.3.0"

  bucket        = "${local.name_prefix}-s3-source"
  force_destroy = false

  # デフォルトの暗号化。原則S3マスターキーでの暗号化を有効にする。
  server_side_encryption_configuration = {
    rule = {
      apply_server_side_encryption_by_default = {
        sse_algorithm = "AES256"
      }
    }
  }

  # ブロックパブリックアクセス。原則全てtrueにする。
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-s3-source"
  })
}

# オリジナル動画用S3バケットにmp4がアップロードされた際のS3イベント通知
module "s3_notifications_hls" {
  source  = "terraform-aws-modules/s3-bucket/aws//modules/notification"
  version = "3.3.0"

  bucket = module.s3_bucket_source.s3_bucket_id

  # Common error - Error putting S3 notification configuration: InvalidArgument: Configuration is ambiguously defined. Cannot have overlapping suffixes in two rules if the prefixes are overlapping for the same event type.

  sns_notifications = {
    video_uploaded = {
      topic_arn     = aws_sns_topic.video_upload.arn
      events        = ["s3:ObjectCreated:*"]
      filter_prefix = "video/"
      filter_suffix = ".mp4"
    }
  }

  create_sns_policy = true
}

# S3イベント通知の送信先SNSトピック
resource "aws_sns_topic" "video_upload" {
  name = "${local.name_prefix}-sns-upload"

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-sns-upload"
  })
}

# S3イベント通知の送信先SQSキュー
resource "aws_sqs_queue" "video_upload" {
  name = "${local.name_prefix}-sqs-upload"

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-sqs-upload"
  })
}

# S3イベント通知の送信先SQSキューのポリシー設定
resource "aws_sqs_queue_policy" "sqs_video_upload_policy" {
  queue_url = aws_sqs_queue.video_upload.id
  policy    = data.aws_iam_policy_document.sqs_video_upload_policy.json
}

# AWSアカウントIDを取得
data "aws_caller_identity" "now" {}

# S3イベント通知の送信先SQSキューのポリシー内容
data "aws_iam_policy_document" "sqs_video_upload_policy" {
  statement {
    sid = "__owner_statement"
    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::${data.aws_caller_identity.now.account_id}:root"]
    }
    effect    = "Allow"
    actions   = ["SQS:*"]
    resources = ["${aws_sqs_queue.video_upload.arn}"]
  }
  statement {
    sid = "Allow-SNS-SendMessage"
    principals {
      type        = "Service"
      identifiers = ["sns.amazonaws.com"]
    }
    effect    = "Allow"
    actions   = ["sqs:SendMessage"]
    resources = ["${aws_sqs_queue.video_upload.arn}"]
    condition {
      test     = "ArnEquals"
      variable = "aws:SourceArn"
      values   = ["${aws_sns_topic.video_upload.arn}"]
    }
  }
}

# SNSトピックからSQSキューへのサブスクライブ設定
resource "aws_sns_topic_subscription" "sqs_request_mosaic" {
  topic_arn = aws_sns_topic.video_upload.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.video_upload.arn
}

# HLS動画用S3バケット
module "s3_bucket_hls" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "3.3.0"

  bucket        = "${local.name_prefix}-s3-hls"
  force_destroy = false

  attach_policy = true
  policy        = data.aws_iam_policy_document.s3_bucket_hls_policy.json

  # デフォルトの暗号化。原則S3マスターキーでの暗号化を有効にする。
  server_side_encryption_configuration = {
    rule = {
      apply_server_side_encryption_by_default = {
        sse_algorithm = "AES256"
      }
    }
  }

  # ブロックパブリックアクセス。原則全てtrueにする。
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

  # CORS設定
  cors_rule = [
    {
      allowed_methods = ["GET"]
      allowed_origins = ["*"]
      allowed_headers = ["*"]
      expose_headers  = []
      max_age_seconds = 3000
    }
  ]

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-s3-hls"
  })
}

# HLS動画用S3バケットのバケットポリシー内容
data "aws_iam_policy_document" "s3_bucket_hls_policy" {
  statement {
    principals {
      type        = "AWS"
      identifiers = module.cloudfront.cloudfront_origin_access_identity_iam_arns
    }
    actions = [
      "s3:GetObject",
    ]
    resources = [
      "${module.s3_bucket_hls.s3_bucket_arn}/*",
    ]
  }
}

# HLS配信用CloudFront
module "cloudfront" {
  source  = "terraform-aws-modules/cloudfront/aws"
  version = "2.8.0"

  enabled             = true
  is_ipv6_enabled     = true
  price_class         = "PriceClass_All"
  retain_on_delete    = false # terraform destroy時に削除でなく無効にするか
  wait_for_deployment = false
  default_root_object = "index.html"

  create_origin_access_identity = true
  origin_access_identities = {
    s3_bucket_one = "access-identity-${module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name}"
  }

  origin = {
    "${module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name}" = {
      domain_name = module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name
      s3_origin_config = {
        origin_access_identity = "s3_bucket_one" # key in `origin_access_identities`
      }
    }
  }

  default_cache_behavior = {
    target_origin_id       = module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    use_forwarded_values   = false # https://github.com/hashicorp/terraform-provider-aws/issues/19041
    cache_policy_id        = data.aws_cloudfront_cache_policy.managed_caching_disabled.id
  }

  ordered_cache_behavior = [
    {
      path_pattern           = "video/*"
      target_origin_id       = module.s3_bucket_hls.s3_bucket_bucket_regional_domain_name
      viewer_protocol_policy = "redirect-to-https"
      allowed_methods        = ["GET", "HEAD"]
      cached_methods         = ["GET", "HEAD"]
      compress               = true
      trusted_key_groups     = [aws_cloudfront_key_group.key_group_hls.id]
      use_forwarded_values   = false # https://github.com/hashicorp/terraform-provider-aws/issues/19041
      cache_policy_id        = data.aws_cloudfront_cache_policy.managed_caching_disabled.id
    }
  ]

  tags = local.common_tags
}

# CloudFrontのキャッシュポリシー
data "aws_cloudfront_cache_policy" "managed_caching_disabled" {
  name = "Managed-CachingDisabled"
}

# 公開鍵とキーグループ
resource "aws_cloudfront_public_key" "key_hls" {
  name        = "${local.name_prefix}-cloudfront-public-key"
  comment     = "HLS配信用パブリックキー"
  encoded_key = file("cloudfront_key/public_key.pem")
}
resource "aws_cloudfront_key_group" "key_group_hls" {
  name    = "${local.name_prefix}-cloudfront-key-group"
  comment = "HLS配信用キーグループ"
  items   = [aws_cloudfront_public_key.key_hls.id]
}

# MediaConvert用IAMポリシー
resource "aws_iam_policy" "mediaconvert" {
  name   = "${local.name_prefix}-iampol-mediaconvert"
  policy = data.aws_iam_policy_document.mediaconvert_policy.json

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-iampol-mediaconvert"
  })
}

# MediaConvert用IAMポリシーの内容
data "aws_iam_policy_document" "mediaconvert_policy" {
  statement {
    sid     = "AllowGetSourceVideo"
    effect  = "Allow"
    actions = ["s3:GetObject"]
    resources = [
      "${module.s3_bucket_source.s3_bucket_arn}",
      "${module.s3_bucket_source.s3_bucket_arn}/*"
    ]
  }
  statement {
    sid     = "AllowPutHLSVideo"
    effect  = "Allow"
    actions = ["s3:PutObject"]
    resources = [
      "${module.s3_bucket_hls.s3_bucket_arn}",
      "${module.s3_bucket_hls.s3_bucket_arn}/*"
    ]
  }
}

# MediaConvert用IAMロール
resource "aws_iam_role" "mediaconvert" {
  name                = "${local.name_prefix}-role-mediaconvert"
  managed_policy_arns = [aws_iam_policy.mediaconvert.arn]

  assume_role_policy = data.aws_iam_policy_document.mediaconvert_assume_role_policy.json

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-role-mediaconvert"
  })
}

# MediaConvert用IAMロールの信頼ポリシー内容
data "aws_iam_policy_document" "mediaconvert_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["mediaconvert.amazonaws.com"]
    }
  }
}

# MediaConvertジョブ登録Lambda用IAMポリシー
resource "aws_iam_policy" "lambda_submit_mcjob" {
  name   = "${local.name_prefix}-iampol-lambda_submit_mcjob"
  policy = data.aws_iam_policy_document.lambda_submit_mcjob_policy.json

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-iampol-lambda_submit_mcjob"
  })
}

# MediaConvertジョブ登録Lambda用IAMポリシー内容
data "aws_iam_policy_document" "lambda_submit_mcjob_policy" {
  statement {
    sid     = "AllowPassRole"
    effect  = "Allow"
    actions = ["iam:PassRole"]
    resources = [
      "${aws_iam_role.mediaconvert.arn}"
    ]
  }
  statement {
    sid     = "AllowCreateMCJob"
    effect  = "Allow"
    actions = ["mediaconvert:CreateJob"]
    resources = [
      "arn:aws:mediaconvert:ap-northeast-1:${data.aws_caller_identity.now.account_id}:*"
    ]
  }
}

# MediaConvertジョブ登録Lambda用IAMロール
resource "aws_iam_role" "lambda_submit_mcjob" {
  name = "${local.name_prefix}-role-lambda_submit_mcjob"
  managed_policy_arns = [
    aws_iam_policy.lambda_submit_mcjob.arn,
    data.aws_iam_policy.AWSLambdaSQSQueueExecutionRole.arn
  ]

  assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_policy.json

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-role-lambda_submit_mcjob"
  })
}

# SQSトリガーのLambda用AWS管理ポリシー取得
data "aws_iam_policy" "AWSLambdaSQSQueueExecutionRole" {
  arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole"
}

# MediaConvertジョブ登録Lambda用IAMロールの信頼ポリシー
data "aws_iam_policy_document" "lambda_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

MediaConvertのジョブテンプレートの作成

MediaConvertにはジョブテンプレートを保存する機能がある。
テンプレートを使えば毎回細かい設定値を指定しなくてよくなるので、活用する。
マネジメントコンソールのMediaConvertのページで、「ジョブテンプレート」→「テンプレートをインポート」と進み、以下のJSONをインポートして「hlstest-mcjob-template」というジョブテンプレートを作成した。
JSONの内容は、こちらのチュートリアルで作成されるジョブ内容を参考に、以下の点を変更して作成した。

  • Output動画が様々なサイズが作成されるところを、640×360の1種類だけに変更
  • Output動画の出力先S3バケットを空欄に変更(ジョブ登録時にLambdaで指定するため)

hlstest-mcjob-template.json

{
    "Queue": "Default",
    "Role": "",
    "Settings": {
      "OutputGroups": [
        {
          "Name": "Apple HLS",
          "Outputs": [
            {
              "ContainerSettings": {
                "Container": "M3U8",
                "M3u8Settings": {
                  "AudioFramesPerPes": 4,
                  "PcrControl": "PCR_EVERY_PES_PACKET",
                  "PmtPid": 480,
                  "PrivateMetadataPid": 503,
                  "ProgramNumber": 1,
                  "PatInterval": 0,
                  "PmtInterval": 0,
                  "VideoPid": 481,
                  "AudioPids": [
                    482,
                    483,
                    484,
                    485,
                    486,
                    487,
                    488,
                    489,
                    490,
                    491,
                    492,
                    493,
                    494,
                    495,
                    496,
                    497,
                    498
                  ]
                }
              },
              "VideoDescription": {
                "Width": 640,
                "ScalingBehavior": "DEFAULT",
                "Height": 360,
                "TimecodeInsertion": "DISABLED",
                "AntiAlias": "ENABLED",
                "Sharpness": 100,
                "CodecSettings": {
                  "Codec": "H_264",
                  "H264Settings": {
                    "InterlaceMode": "PROGRESSIVE",
                    "ParNumerator": 1,
                    "NumberReferenceFrames": 3,
                    "Syntax": "DEFAULT",
                    "GopClosedCadence": 1,
                    "HrdBufferInitialFillPercentage": 90,
                    "GopSize": 3,
                    "Slices": 1,
                    "GopBReference": "ENABLED",
                    "HrdBufferSize": 3750000,
                    "MaxBitrate": 1500000,
                    "SlowPal": "DISABLED",
                    "ParDenominator": 1,
                    "SpatialAdaptiveQuantization": "ENABLED",
                    "TemporalAdaptiveQuantization": "ENABLED",
                    "FlickerAdaptiveQuantization": "ENABLED",
                    "EntropyEncoding": "CABAC",
                    "RateControlMode": "QVBR",
                    "QvbrSettings": {
                      "QvbrQualityLevel": 7
                    },
                    "CodecProfile": "HIGH",
                    "Telecine": "NONE",
                    "MinIInterval": 0,
                    "AdaptiveQuantization": "MEDIUM",
                    "CodecLevel": "AUTO",
                    "FieldEncoding": "PAFF",
                    "SceneChangeDetect": "ENABLED",
                    "QualityTuningLevel": "SINGLE_PASS_HQ",
                    "UnregisteredSeiTimecode": "DISABLED",
                    "GopSizeUnits": "SECONDS",
                    "ParControl": "SPECIFIED",
                    "NumberBFramesBetweenReferenceFrames": 5,
                    "RepeatPps": "DISABLED",
                    "DynamicSubGop": "ADAPTIVE"
                  }
                },
                "AfdSignaling": "NONE",
                "DropFrameTimecode": "ENABLED",
                "RespondToAfd": "NONE",
                "ColorMetadata": "INSERT"
              },
              "AudioDescriptions": [
                {
                  "AudioTypeControl": "FOLLOW_INPUT",
                  "AudioSourceName": "Audio Selector 1",
                  "CodecSettings": {
                    "Codec": "AAC",
                    "AacSettings": {
                      "AudioDescriptionBroadcasterMix": "NORMAL",
                      "Bitrate": 64000,
                      "RateControlMode": "CBR",
                      "CodecProfile": "HEV1",
                      "CodingMode": "CODING_MODE_2_0",
                      "RawFormat": "NONE",
                      "SampleRate": 48000,
                      "Specification": "MPEG4"
                    }
                  },
                  "LanguageCodeControl": "FOLLOW_INPUT",
                  "AudioType": 0
                }
              ],
              "NameModifier": "_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr"
            }
          ],
          "OutputGroupSettings": {
            "Type": "HLS_GROUP_SETTINGS",
            "HlsGroupSettings": {
              "ManifestDurationFormat": "INTEGER",
              "SegmentLength": 3,
              "TimedMetadataId3Period": 10,
              "CaptionLanguageSetting": "OMIT",
              "TimedMetadataId3Frame": "PRIV",
              "CodecSpecification": "RFC_4281",
              "OutputSelection": "MANIFESTS_AND_SEGMENTS",
              "ProgramDateTimePeriod": 600,
              "MinSegmentLength": 0,
              "DirectoryStructure": "SINGLE_DIRECTORY",
              "ProgramDateTime": "EXCLUDE",
              "SegmentControl": "SEGMENTED_FILES",
              "ManifestCompression": "NONE",
              "ClientCache": "ENABLED",
              "StreamInfResolution": "INCLUDE"
            }
          }
        }
      ],
      "AdAvailOffset": 0,
      "Inputs": [
        {
          "AudioSelectors": {
            "Audio Selector 1": {
              "Offset": 0,
              "DefaultSelection": "DEFAULT",
              "ProgramSelection": 1
            }
          },
          "VideoSelector": {
            "ColorSpace": "FOLLOW",
            "Rotate": "DEGREE_0",
            "AlphaBehavior": "DISCARD"
          },
          "FilterEnable": "AUTO",
          "PsiControl": "USE_PSI",
          "FilterStrength": 0,
          "DeblockFilter": "DISABLED",
          "DenoiseFilter": "DISABLED",
          "TimecodeSource": "ZEROBASED",
          "FileInput": "s3://sourcebucket/assets01/test.mp4"
        }
      ]
    },
    "AccelerationSettings": {
      "Mode": "PREFERRED"
    },
    "StatusUpdateInterval": "SECONDS_60"
  }

※詳細な設定値については確認していないが、チュートリアルによるとこの設定値で以下の動画フォーマットをインプットとしてHLS形式に変換できる模様。

  • .MOV
  • .mpeg
  • .mp4
  • .mkv
  • .m4v
  • .M4V
  • .mov
  • .M2TS
  • .MKV
  • .MP4
  • .MPEG
  • .WEBM
  • .WMV
  • .mpg
  • .M3U8
  • .h264
  • .MPG
  • .webm
  • .mxf
  • .m3u8
  • .H264
  • .wmv
  • .m2ts
  • .MXF

MediaConvertジョブ登録用Lambda関数の作成

マネジメントコンソールで、Lambda関数を作成する。
関数のコードはこちらを参考にさせていただいた。

処理概要

Lambda関数内で実行する処理の概要は以下。

  • SQSから受信したメッセージからS3イベントを取り出す。
  • アップロードされたmp4ファイルのパスから、HLS動画用S3バケットのどのパスに出力するか決定する。
    (オリジナル動画用S3バケットの「video/<グループ名>/<mp4ファイルID>.mp4」としてアップロードされたmp4動画の出力先は、HLS動画用S3バケットの「video/<グループ名>/hls_<mp4ファイルID>/」配下になる。)
  • 先ほど作成したMediaConvertのテンプレートを元に、mp4とHLS出力先のパスを指定して、MediaConvertジョブを登録する。

関数作成時の注意点

  • 関数の実行ロールはTerraformで作成した「hlstest-role-lambda_submit_mcjob」を指定する。
  • トリガーにはTerraformで作成したSQSキュー「hlstest-sqs-upload」を指定する。
  • 環境変数に以下を設定する。
    • JOB_TEMPLATE_NAME:MediaConvertのジョブテンプレート名。今回はTerraformで作成した「hlstest-mcjob-template」。
    • MEDIACONVERT_ENDPOINT:MediaConvertのAPI エンドポイント。マネジメントコンソールのMediaConvertのページで、「アカウント」を選択すると表示される。
    • MEDIACONVERT_ROLE:MediaConvert実行用のIAMロールのARN。今回はTerraformで作成した「hlstest-role-mediaconvert」のARN。
    • OUTPUT_S3_NAME:HLS形式動画の出力先S3バケットの名前。今回はTerraformで作成した「hlstest-s3-hls」。

関数コード

import os
import urllib.parse
import boto3
import json

# MediaConvertのエンドポイントURL
MEDIACONVERT_ENDPOINT = os.environ['MEDIACONVERT_ENDPOINT']

# MediaConvert用のロールのARN
MEDIACONVERT_ROLE = os.environ['MEDIACONVERT_ROLE']

# MediaConvertジョブテンプレートの名前
JOB_TEMPLATE_NAME = os.environ['JOB_TEMPLATE_NAME']

# 変換後ファイルの出力先S3のバケット名
OUTPUT_S3_NAME = os.environ['OUTPUT_S3_NAME']

def lambda_handler(event, context):
    print(f'boto3 version: {boto3.__version__}')
    
    for record in event['Records']:
        message_str = json.loads(record["body"])['Message']
        message = json.loads(message_str)
    
        bucket = message['Records'][0]['s3']['bucket']['name']
        key = urllib.parse.unquote_plus(message['Records'][0]['s3']['object']['key'], encoding='utf-8')
    
        settings = make_settings(bucket, key)
        user_metadata = {
            'JobCreatedBy': 'videoConvertSample',
        }
    
        client = boto3.client('mediaconvert', endpoint_url = MEDIACONVERT_ENDPOINT)
        result = client.create_job(
            Role = MEDIACONVERT_ROLE,
            JobTemplate = JOB_TEMPLATE_NAME,
            Settings=settings,
            UserMetadata=user_metadata,
        )

def make_settings(bucket, key):
    basename = os.path.basename(key).split('.')[0]
    dirname = os.path.dirname(key)

    return \
    {
        "Inputs": [
            {
                "FileInput": f"s3://{bucket}/{key}",
            }
        ],
        "OutputGroups": [
            {
                "Name": "Apple HLS",
                "OutputGroupSettings": {
                    "Type": "HLS_GROUP_SETTINGS",
                    "HlsGroupSettings": {
                        "Destination": f"s3://{OUTPUT_S3_NAME}/{dirname}/hls_{basename}/",
                    },
                },
            },
        ],
    }

補足:SNS、SQSを介さない場合の関数コード

今回はオリジナル動画用S3バケットのS3イベント通知を、SNS、SQSを介してLambda関数に渡している。
S3イベント通知を直接Lamba関数に渡す構成にする場合のコードは以下。
イベント内容を読み取る部分のみ異なる。

import os
import urllib.parse
import boto3

# MediaConvertのエンドポイントURL
MEDIACONVERT_ENDPOINT = os.environ['MEDIACONVERT_ENDPOINT']

# MediaConvert用のロールのARN
MEDIACONVERT_ROLE = os.environ['MEDIACONVERT_ROLE']

# MediaConvertジョブテンプレートの名前
JOB_TEMPLATE_NAME = os.environ['JOB_TEMPLATE_NAME']

# 変換後ファイルの出力先S3のバケット名
OUTPUT_S3_NAME = os.environ['OUTPUT_S3_NAME']

def lambda_handler(event, context):
    print(f'boto3 version: {boto3.__version__}')
    
    bucket = event['Records'][0]['s3']['bucket']['name']
    key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'], encoding='utf-8')

    settings = make_settings(bucket, key)
    user_metadata = {
        'JobCreatedBy': 'videoConvertSample',
    }

    client = boto3.client('mediaconvert', endpoint_url = MEDIACONVERT_ENDPOINT)
    result = client.create_job(
        Role = MEDIACONVERT_ROLE,
        JobTemplate = JOB_TEMPLATE_NAME,
        Settings=settings,
        UserMetadata=user_metadata,
    )

def make_settings(bucket, key):
    basename = os.path.basename(key).split('.')[0]
    dirname = os.path.dirname(key)

    return \
    {
        "Inputs": [
            {
                "FileInput": f"s3://{bucket}/{key}",
            }
        ],
        "OutputGroups": [
            {
                "Name": "Apple HLS",
                "OutputGroupSettings": {
                    "Type": "HLS_GROUP_SETTINGS",
                    "HlsGroupSettings": {
                        "Destination": f"s3://{OUTPUT_S3_NAME}/{dirname}/hls_{basename}/",
                    },
                },
            },
        ],
    }

mp4動画をHLS形式に変換する

動画の準備

適当なmp4動画ファイルを「test.mp4」というファイル名で用意する。
自分はPexelsというフリー素材サイトのこちらの動画をダウンロードして使った。
動画の詳細は以下の通り。

使用した動画のプロパティ
使用した動画のプロパティ

001グループ用のHLS形式動画を作成

「test.mp4」を、オリジナル動画用S3バケットの「video/001/」にアップロードする。
MediaConvertのページの「ジョブ」を確認すると、以下のようにHLS形式への変換ジョブが登録され、PROGRESSING(進行中)にステータスになっていることが確認できる。

MediaConvertジョブ(PROGRESSING)
MediaConvertジョブ(PROGRESSING)

少し待って画面を更新すると、以下のようにジョブのステータスがCOMPLETEになる。

MediaConvertジョブ(COMPLETE)
MediaConvertジョブ(COMPLETE)

ジョブIDをクリックすると、以下のようにジョブの詳細が確認できる。
6秒の動画を5秒でトランスコードしている。

MediaConvertジョブ詳細
MediaConvertジョブ詳細

HLS動画用S3バケットを確認すると、以下のように「video/001/hls_test」配下にHLS形式のインデックスファイルとセグメントファイルが出力されている。

グループ001用のHLS形式ファイル
グループ001用のHLS形式ファイル

作成されたインデックスファイルの中身は以下。

test.m3u8

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1011690,AVERAGE-BANDWIDTH=980106,CODECS="avc1.64001e,mp4a.40.5",RESOLUTION=640x360,FRAME-RATE=30.000
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr.m3u8

test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr.m3u8

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:3,
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr_00001.ts
#EXTINF:3,
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr_00002.ts
#EXTINF:1,
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr_00003.ts
#EXT-X-ENDLIST

002グループ用のHLS形式動画を作成

「test.mp4」を、オリジナル動画用S3バケットの「video/002/」にアップロードする。 先ほどと同様にMediaConvertジョブが実行され、HLS動画用S3バケットの「video/002/hls_test」配下にHLS形式のインデックスファイルとセグメントファイルが出力される。

グループ002用のHLS形式ファイル
グループ002用のHLS形式ファイル

curlコマンドで接続確認する

curlコマンドで、HLS形式のファイルにアクセス署名付きCookieを持つユーザのみがアクセス出来ることを確認する。
記載している手順はインターネットに接続できる環境で実行すること。
また、<CloudFrontのドメイン名>にはTerraformで作成したCloudFrontディストリビューションドメイン名を入れる。
※本例では、アクセス対象はインデックスファイル(test.m3u8)にしているが、セグメントファイル(*.ts)についても同様の挙動となる。

署名付きCookie無しではアクセスできないことを確認

CloudFrontドメイン名を設定
$ CF_DOMAIN=<CloudFrontのドメイン名>

グループ001用のHLS形式ファイル(インデックスファイル)へアクセスする
$ curl https://${CF_DOMAIN}/video/001/hls_test/test.m3u8
<?xml version="1.0" encoding="UTF-8"?><Error><Code>MissingKey</Code><Message>Missing Key-Pair-Id query parameter or cookie value</Message></Error>

グループ002用のHLS形式ファイル(インデックスファイル)へアクセスする
$ curl https://${CF_DOMAIN}/video/002/hls_test/test.m3u8
<?xml version="1.0" encoding="UTF-8"?><Error><Code>MissingKey</Code><Message>Missing Key-Pair-Id query parameter or cookie value</Message></Error>

→CloudFrontにて「video/*」に署名付きCookieの設定をしているため、Cookieの無い状態でこれらのパスにアクセスすると「Missing Key-Pair-Id query parameter or cookie value」というエラーになる。

署名付きCookieを使うとアクセスできることを確認

次に、署名付きCookieを使えばアクセスできることを確認する。

署名付きCookieのポリシーについて

署名付きCookieを作成する場合、Cookieの有効期間やパスなど署名付きCookieでの制限内容を指定するJSON形式のポリシーステートメントを用意する必要がある。
ポリシーには既定ポリシーとカスタムポリシーがあり、違いは「署名付き Cookie の既定ポリシーとカスタムポリシーの選択」に記載されている通り。
→ポリシーはグループごとに固定したいので、対象のパス指定にはワイルドカードで「video/001/*」のように指定したい。
既定ポリシーではワイルドカードを使った指定ができないので、カスタムポリシーを使う。

署名付きCookie生成用のカスタムポリシーを用意する

カスタムポリシーのJSONファイルを作成する。
グループ001とグループ002でアクセスできるパスを変えたいので、それぞれのJSONファイルを作成する。
 
カスタムポリシーを使用する署名付き Cookie の設定」を参照し、以下の内容で作成した。

  • Resourceにはアクセスを許可するURLを記載する。今回は、ここにグループごとのパスを記載した。
    <CloudFrontのドメイン名>はCloudFrontのドメイン名に書き換える。
  • DateLessThanはURL の有効期限切れ日時。Unix 時間形式 (秒単位) および協定世界時 (UTC) で指定する。 今回は適当に未来日時を「$ date -d "2023-11-11 08:38:25" +%s」を実行してエポックタイムに変換したものを記載している。

policy_001.json

{
    "Statement": [
        {
            "Resource": "https://<CloudFrontのドメイン名>/video/001/*",
            "Condition": {
                "DateLessThan": {
                    "AWS:EpochTime": 1699691905
                }
            }
        }
    ]
}

policy_002.json

{
    "Statement": [
        {
            "Resource": "https://<CloudFrontのドメイン名>/video/002/*",
            "Condition": {
                "DateLessThan": {
                    "AWS:EpochTime": 1699691905
                }
            }
        }
    ]
}

署名付きCookieを作成して、アクセスできるか確認する

カスタムポリシーを使用する署名付き Cookie の設定」によると、カスタムポリシーを使用して署名付きCookieを設定する場合、以下の3つの名前と値のペアが必要となる。

以下の作業は秘密鍵とカスタムポリシーJSONファイルがあるディレクトリで行う。

$ ls -l
total 16
-rw-rw-r-- 1 ec2-user ec2-user  266 Nov 15 03:29 policy_001.json
-rw-rw-r-- 1 ec2-user ec2-user  266 Nov 15 03:29 policy_002.json
-rw-rw-r-- 1 ec2-user ec2-user 1679 Nov 17 05:56 private_key.pem
-rw-rw-r-- 1 ec2-user ec2-user  451 Nov 17 05:57 public_key.pem

まずグループ001のユーザを想定して署名付きCookieを作成する。
以下の手順を実行する。
手順中の<CloudFrontのドメイン名>と<公開鍵のID>は環境に応じて記載する。

CloudFrontドメイン名を環境変数に格納
$ CF_DOMAIN=<CloudFrontのドメイン名>

公開鍵のIDを環境変数に格納
$ CF_KEYPAIR_ID=<公開鍵のID>

ポリシーファイルのパスを環境変数に格納
$ CF_POLICY_FILE=policy_001.json

秘密鍵のパスを環境変数に格納
$ CF_PRIVATE_KEY=private_key.pem

カスタムポリシーのJSONファイルをbase64エンコードして環境変数に格納
$ CF_POLICY=$(cat ${CF_POLICY_FILE} | tr -d "\n" | tr -d " \t\n\r" | openssl base64 -A | tr -- '+=/' '-_~')

カスタムポリシーのJSONファイルに秘密鍵で署名し、base64エンコードして環境変数に格納
$ CF_SIGNATURE=$(cat ${CF_POLICY_FILE} | tr -d "\n" | tr -d " \t\n\r" | openssl sha1 -sign ${CF_PRIVATE_KEY} | openssl base64 -A | tr -- '+=/' '-_~')

Cookieをセットして接続すると、グループ001用のパスにあるHLS形式ファイル(インデックスファイル)にアクセスできる
$ curl https://${CF_DOMAIN}/video/001/hls_test/test.m3u8 -b "CloudFront-Policy=${CF_POLICY}; CloudFront-Signature=${CF_SIGNATURE}; CloudFront-Key-Pair-Id=${CF_KEYPAIR_ID}"
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1011690,AVERAGE-BANDWIDTH=980106,CODECS="avc1.64001e,mp4a.40.5",RESOLUTION=640x360,FRAME-RATE=30.000
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr.m3u8

Cookieをセットして接続しても、グループ002用のパスにあるHLS形式ファイル(インデックスファイル)にはアクセスできない。
$ curl https://${CF_DOMAIN}/video/002/hls_test/test.m3u8 -b "CloudFront-Policy=${CF_POLICY}; CloudFront-Signature=${CF_SIGNATURE}; CloudFront-Key-Pair-Id=${CF_KEYPAIR_ID}"                           
<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>Access denied</Message></Error>

後ほどブラウザで確認する際に使うので、以下の値は書き留めておく
$ echo ${CF_POLICY}
$ echo ${CF_SIGNATURE}

次に、グループ002のユーザを想定して署名付きCookieを作成する。
ポリシーJSONファイルだけグループ002用のものに変えて、先ほどと同じ手順を実行する。

CloudFrontドメイン名を環境変数に格納
$ CF_DOMAIN=<CloudFrontのドメイン名>

公開鍵のIDを環境変数に格納
$ CF_KEYPAIR_ID=<公開鍵のID>

ポリシーファイルのパスを環境変数に格納
$ CF_POLICY_FILE=policy_001.json

秘密鍵のパスを環境変数に格納
$ CF_PRIVATE_KEY=private_key.pem

カスタムポリシーのJSONファイルをbase64エンコードして環境変数に格納
$ CF_POLICY=$(cat ${CF_POLICY_FILE} | tr -d "\n" | tr -d " \t\n\r" | openssl base64 -A | tr -- '+=/' '-_~')

カスタムポリシーのJSONファイルに秘密鍵で署名し、base64エンコードして環境変数に格納
$ CF_SIGNATURE=$(cat ${CF_POLICY_FILE} | tr -d "\n" | tr -d " \t\n\r" | openssl sha1 -sign ${CF_PRIVATE_KEY} | openssl base64 -A | tr -- '+=/' '-_~')

Cookieをセットして接続しても、グループ001用のパスにあるHLS形式ファイル(インデックスファイル)にはアクセスできない。
$ curl https://${CF_DOMAIN}/video/001/hls_test/test.m3u8 -b "CloudFront-Policy=${CF_POLICY}; CloudFront-Signature=${CF_SIGNATURE}; CloudFront-Key-Pair-Id=${CF_KEYPAIR_ID}"
<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code><Message>Access denied</Message></Error>

Cookieをセットして接続すると、グループ002用のパスにあるHLS形式ファイル(インデックスファイル)にアクセスできる
$ curl https://${CF_DOMAIN}/video/002/hls_test/test.m3u8 -b "CloudFront-Policy=${CF_POLICY}; CloudFront-Signature=${CF_SIGNATURE}; CloudFront-Key-Pair-Id=${CF_KEYPAIR_ID}"   
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=1011690,AVERAGE-BANDWIDTH=980106,CODECS="avc1.64001e,mp4a.40.5",RESOLUTION=640x360,FRAME-RATE=30.000
test_Ott_Hls_Ts_Avc_Aac_16x9_640x360p_1.5Mbps_qvbr.m3u8                        

後ほどブラウザで確認する際に使うので、以下の値は書き留めておく
$ echo ${CF_POLICY}
$ echo ${CF_SIGNATURE}

→カスタムポリシーを使い分けることで、所属グループ(権限)ごとにアクセスできるパスを制御することができた。

ブラウザで接続確認する

ブラウザからアクセスして、実際に視聴できるか確認する。

視聴確認ページの作成

HLS形式の動画を視聴するためのページが必要なので、Video.jsvideojs-http-streamingを使った視聴確認用ページを作成する。

以下をHLS動画用S3バケットの「index.html」にアップロードする。 <CloudFrontのドメイン名>にはTerraformで作成したCloudFrontディストリビューションドメイン名を入れる。
※HTMLコードはこちらを参考にさせていただいた。
index.html

<html>
  <head>
    <title>Video.js HLS TEST</title>
    <link href="https://vjs.zencdn.net/7.15.4/video-js.css" rel="stylesheet">
  </head>
  <body>
    <video-js id=video001 width=640 height=360
              class="vjs-default-skin" controls>
      <source
         src="https://<CloudFrontのドメイン名>/video/001/hls_test/test.m3u8"
         type="application/x-mpegURL">
    </video-js>
    <script src="https://vjs.zencdn.net/7.15.4/video.js"></script>
    <script>
      var player = videojs('video001');
    </script>
    <br>
    <video-js id=video002 width=640 height=360
              class="vjs-default-skin" controls>
      <source
         src="https://<CloudFrontのドメイン名>/video/002/hls_test/test.m3u8"
         type="application/x-mpegURL">
    </video-js>
    <script src="https://vjs.zencdn.net/7.15.4/video.js"></script>
    <script>
      var player = videojs('video002');
    </script>
  </body>
</html>

ブラウザで「http://<CloudFrontのドメイン名>」に接続してみる。 Cookieを設定していないので、以下のようにグループ001用動画、グループ002用動画どちらも「The media could not be loaded, either because the server or network failed or because the format is not supported.」となり視聴できない。

Cookieなしで接続した場合
Cookieなしで接続した場合

Cookieをセットして接続する

何らかの方法でブラウザにCookieをセットして接続確認してみる。
自分は、ChromeEditThisCookieという拡張機能を使った。
※参考: EditThisCookieでクッキーが追加できない

curlコマンドでの実行時に作成した、グループ001用署名付きCookieをブラウザにセットして「http://<CloudFrontのドメイン名>」に接続する。
以下のように、グループ001の動画のみ再生でき、グループ002用動画は「The media could not be loaded, either because the server or network failed or because the format is not supported.」となり視聴できない。

グループ001用Cookieを設定して接続した場合
グループ001用Cookieを設定して接続した場合

→想定通りの制限が出来ていることを、ブラウザからの接続でも確認できた。

補足:EditThisCookieを使わずにHTMLにCookieを埋め込みたい場合

EditThisCookieを使うのでは無く、HTMLにCookieを埋め込んでもいい。
その場合は、HLS動画用S3バケットの「index.html」を以下のものに差し替える。
<CloudFrontのドメイン名>、<公開鍵のID>、<先ほど環境変数「CF_POLICY」に入れた値>、<先ほど環境変数「CF_SIGNATURE」に入れた値>は環境と試したいグループに応じて記載する。

index.html

<html>
  <head>
    <title>Video.js HLS TEST</title>
    <link href="https://vjs.zencdn.net/7.15.4/video-js.css" rel="stylesheet">
  </head>
  <body>
    <p id="msg">Cookie List</p>
    <script>
      document.cookie = "CloudFront-Policy=<先ほど環境変数「CF_POLICY」に入れた値>; domain=<CloudFrontのドメイン名>; path=/; secure; max-age=3600";
      document.cookie = "CloudFront-Signature=<先ほど環境変数「CF_SIGNATURE」に入れた値>; domain=<CloudFrontのドメイン名>; path=/; secure; max-age=3600";
      document.cookie = "CloudFront-Key-Pair-Id=<公開鍵のID>; domain=<CloudFrontのドメイン名>; path=/; secure; max-age=3600";
      let e = document.getElementById('msg');
      e.insertAdjacentHTML('afterend', '<p>' + document.cookie + '</p>');
    </script>
    <video-js id=video001 width=640 height=360
              class="vjs-default-skin" controls>
      <source
         src="https://<CloudFrontのドメイン名>/video/001/hls_test/test.m3u8"
         type="application/x-mpegURL">
    </video-js>
    <script src="https://vjs.zencdn.net/7.15.4/video.js"></script>
    <script>
      var player = videojs('video001');
    </script>
    <br>
    <video-js id=video002 width=640 height=360
              class="vjs-default-skin" controls>
      <source
         src="https://<CloudFrontのドメイン名>/video/002/hls_test/test.m3u8"
         type="application/x-mpegURL">
    </video-js>
    <script src="https://vjs.zencdn.net/7.15.4/video.js"></script>
    <script>
      var player = videojs('video002');
    </script>
  </body>
</html>

まとめ

  • MediaConvertによって、HLS形式への変換が簡単に行えることを確認できた。
  • CloudFrontの署名付きCookie作成時のカスタムポリシーを工夫することで、「所属グループ用パスのみアクセス可」といった細かいアクセス制御が可能なことが確認できた。

おまけ:商用環境導入に向けた検討ポイント

今回の構成を商用環境に導入する場合に検討が必要そうなポイント、気になる点などを記載しておく。

  • MediaConvertのコスト見積もり
    →MediaConvertの料金体系はこちら参照。
    基本的に出力する動画の長さによって変動し、時間あたりの料金はコーデック、解像度等によって異なる。
    本記事の処理例だと、以下の条件となるため1分当たり料金は「0.0085USD」となる。

    • 階層:プロフェッショナル階層の機能を使っていないため、「ベーシック」
    • 解像度:640x360pに変換しており、 720未満なので「SD」
    • FPS:入力映像から変えない設定(Follow source)なの入力映像の「30」
  • MediaConvertの同時処理数
    MediaConvertのサービスクォータを確認すると、東京リージョンの「すべてのオンデマンドキューにおける同時ジョブ (ベースライン)」は「20」となっている。
    21個以上の動画を同時に変換させたい場合は、上限緩和申請をする必要がある。

  • MediaConvertの監視・リトライ処理
    →Lambda関数からMediaConvertにジョブ登録した後で、ジョブが何らかの理由で失敗した場合、ジョブ登録自体は上手くいっているのでLambda側で失敗を補足して通知したり、リトライすることはできない。
    その場合はEventBridgeでMediaConvertジョブ失敗のイベントパターンを登録することで、通知やリトライを出来そう(未検証)。
    こちらがイベントパターンの参考になりそう。

  • HLS形式動画の暗号化 →MediaConvertでの変換時に、コンテンツ保護対策としてHLS形式動画をAES鍵で暗号化することができるらしい。
    別途試してみる予定。
    続きの記事でやってみた。

  • アクセス元環境でCookieが使えない場合や、もっと細かい単位で制御したい場合(グループではなくユーザごとにアクセスできる映像が異なる場合等)はどうするか
    →「ユーザー毎に視聴の可否が異なる動画ファイルをCloudFrontで配信する方法の考察」にあるように、Lambda Edgeを使う方法で実現出来そう。

参考文献

Knative(v1.7)を触ってみる(インストール・チュートリアル)

Kubernetes上でサーバーレスを実行するためのOSSである、Knativeを触ったときのメモ。 GKEのStandardクラスタにインストールし、チュートリアルをやった。

環境

Knativeとは

Knativeは、Kubernetesを拡張して、サーバーレスアプリケーションのデプロイと管理のプロセスを簡素化するOSS
GCPのCloudRunの基盤にも使用されているらしい。
公式ドキュメントのConceptによると、KnativeはServingとEventingという2つのコンポーネントで構成されている。

  • Serving:Kubernetes上でサーバレスコンテナを簡単に実行できるようにする機能。ネットワーク、オートスケーリング(ゼロスケールも可)、リビジョンのトラッキングなどの詳細を管理する。
  • Eventing:アプリケーションでイベント駆動型アーキテクチャを使用できるようにする API のコレクション。これらの API を使用して、イベント プロデューサーから、イベントを受信するシンクと呼ばれるイベント コンシューマーにイベントをルーティングするコンポーネントを作成できる。

それぞれの概要は以下スライドを参照(バージョンは少し古いので注意)。

speakerdeck.com

speakerdeck.com

インストール

インストール方法

Installing Knativeによると、各ベンダのマネジメントKnativeを除くと、以下3つのインストール方法がある。

  • Knative Quickstart pluginというツールを使って、minikubeかkindでKnativeがインストールされたkubernetesクラスタを手軽に構築できる。開発・検証目的に使うもので、本番環境では非推奨。
  • YAMLファイルを使ってインストールする。本番環境へ対応可能。
  • Knative Operatorを使ってインストールする。本番環境へ対応可能。

→今回はGKEにインストールするので、Kubernetesクラスタから作成するKnative Quickstart pluginは適さない。
 また、Operaterを使うとインストール時作成されたリソースの内容が分かりづらくなりそうなので、今回は2つ目のYAMLファイルを使うインストールで進める。

Knative CLIのインストール

Knativeのインストール前に、こちらに従い、Knativeのリソースを簡単に操作できるようになるKnative CLIをインストールしておく。

バイナリをダウンロード
$ wget https://github.com/knative/client/releases/download/knative-v1.7.1/kn-linux-amd64

バイナリをPATHが通るディレクトリに移動、リネームする
$ sudo mv kn-linux-amd64 /usr/local/bin/kn

実行権限を付与する
$ sudo chmod +x /usr/local/bin/kn

Knative CLIのバージョン確認
$ kn version
Version:      v1.7.1
Build Date:   2022-10-11 10:18:30
Git Revision: e2f6caf3
Supported APIs:
* Serving
  - serving.knative.dev/v1 (knative-serving v1.7.0)
* Eventing
  - sources.knative.dev/v1 (knative-eventing v1.7.1)
  - eventing.knative.dev/v1 (knative-eventing v1.7.1)

Knative Servingのインストール

Installing Knative Serving using YAML files」を参考に、Servingをインストールする。

Knative Serving コンポーネントをインストールする

以下を実行する。

カスタムリソースのYAMLファイルをダウンロードする
$ wget https://github.com/knative/serving/releases/download/knative-v1.7.2/serving-crds.yaml

カスタムリソースを作成
$ kubectl apply -f serving-crds.yaml
customresourcedefinition.apiextensions.k8s.io/certificates.networking.internal.knative.dev created
customresourcedefinition.apiextensions.k8s.io/configurations.serving.knative.dev created
customresourcedefinition.apiextensions.k8s.io/clusterdomainclaims.networking.internal.knative.dev created
customresourcedefinition.apiextensions.k8s.io/domainmappings.serving.knative.dev created
customresourcedefinition.apiextensions.k8s.io/ingresses.networking.internal.knative.dev created
customresourcedefinition.apiextensions.k8s.io/metrics.autoscaling.internal.knative.dev created
customresourcedefinition.apiextensions.k8s.io/podautoscalers.autoscaling.internal.knative.dev created
customresourcedefinition.apiextensions.k8s.io/revisions.serving.knative.dev created
customresourcedefinition.apiextensions.k8s.io/routes.serving.knative.dev created
customresourcedefinition.apiextensions.k8s.io/serverlessservices.networking.internal.knative.dev created
customresourcedefinition.apiextensions.k8s.io/services.serving.knative.dev created
customresourcedefinition.apiextensions.k8s.io/images.caching.internal.knative.dev created

ServingのコアコンポーネントのYAMLファイルをダウンロードする
$ wget https://github.com/knative/serving/releases/download/knative-v1.7.2/serving-core.yaml

Servingのコアコンポーネントを作成
$ kubectl apply -f serving-core.yaml
namespace/knative-serving created
clusterrole.rbac.authorization.k8s.io/knative-serving-aggregated-addressable-resolver created
clusterrole.rbac.authorization.k8s.io/knative-serving-addressable-resolver created
clusterrole.rbac.authorization.k8s.io/knative-serving-namespaced-admin created
clusterrole.rbac.authorization.k8s.io/knative-serving-namespaced-edit created
clusterrole.rbac.authorization.k8s.io/knative-serving-namespaced-view created
clusterrole.rbac.authorization.k8s.io/knative-serving-core created
clusterrole.rbac.authorization.k8s.io/knative-serving-podspecable-binding created
serviceaccount/controller created
clusterrole.rbac.authorization.k8s.io/knative-serving-admin created
clusterrolebinding.rbac.authorization.k8s.io/knative-serving-controller-admin created
clusterrolebinding.rbac.authorization.k8s.io/knative-serving-controller-addressable-resolver created
customresourcedefinition.apiextensions.k8s.io/images.caching.internal.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/certificates.networking.internal.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/configurations.serving.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/clusterdomainclaims.networking.internal.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/domainmappings.serving.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/ingresses.networking.internal.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/metrics.autoscaling.internal.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/podautoscalers.autoscaling.internal.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/revisions.serving.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/routes.serving.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/serverlessservices.networking.internal.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/services.serving.knative.dev unchanged
secret/serving-certs-ctrl-ca created
secret/knative-serving-certs created
image.caching.internal.knative.dev/queue-proxy created
configmap/config-autoscaler created
configmap/config-defaults created
configmap/config-deployment created
configmap/config-domain created
configmap/config-features created
configmap/config-gc created
configmap/config-leader-election created
configmap/config-logging created
configmap/config-network created
configmap/config-observability created
configmap/config-tracing created
Warning: autoscaling/v2beta2 HorizontalPodAutoscaler is deprecated in v1.23+, unavailable in v1.26+; use autoscaling/v2 HorizontalPodAutoscaler
horizontalpodautoscaler.autoscaling/activator created
poddisruptionbudget.policy/activator-pdb created
deployment.apps/activator created
service/activator-service created
deployment.apps/autoscaler created
service/autoscaler created
deployment.apps/controller created
service/controller created
deployment.apps/domain-mapping created
deployment.apps/domainmapping-webhook created
service/domainmapping-webhook created
horizontalpodautoscaler.autoscaling/webhook created
poddisruptionbudget.policy/webhook-pdb created
deployment.apps/webhook created
service/webhook created
validatingwebhookconfiguration.admissionregistration.k8s.io/config.webhook.serving.knative.dev created
mutatingwebhookconfiguration.admissionregistration.k8s.io/webhook.serving.knative.dev created
mutatingwebhookconfiguration.admissionregistration.k8s.io/webhook.domainmapping.serving.knative.dev created
secret/domainmapping-webhook-certs created
validatingwebhookconfiguration.admissionregistration.k8s.io/validation.webhook.domainmapping.serving.knative.dev created
validatingwebhookconfiguration.admissionregistration.k8s.io/validation.webhook.serving.knative.dev created
secret/webhook-certs created

※以下のようにRole関連の設定に失敗する場合は、kubectlを実行しているユーザに割り当てられている権限が不足している。
 「Kubernetes Engine 管理者」ロールを割り当てるなど、必要な権限を付与して再実行する。

Error from server (Forbidden): error when creating "serving-core.yaml": clusterroles.rbac.authorization.k8s.io is forbidden: User "117530911732356708839" cannot create resource "clusterroles" in API group "rbac.authorization.k8s.io" at the cluster scope: requires one of ["container.clusterRoles.create"] permission(s).`
Error from server (Forbidden): error when creating "serving-core.yaml": clusterrolebindings.rbac.authorization.k8s.io is forbidden: User "117530911732356708839" cannot create resource "clusterrolebindings" in API group "rbac.authorization.k8s.io" at the cluster scope: requires one of ["container.clusterRoleBindings.create"] permission(s).

ネットワーク層をインストールする

ネットワーク層の選択肢はKourier、Istio、Contourがあるが、「Choose this if you are not sure」となっているKourierを使うことにする。

KourierコントローラーのYAMLファイルをダウンロード
$ wget https://github.com/knative/net-kourier/releases/download/knative-v1.7.0/kourier.yaml

このまま使うとKourierの外部IPアドレスがインターネットに公開されるので、ソースIP制限の設定を入れておく。
$ cp -pr kourier.yaml kourier_restrictip.yaml
$ vim kourier_restrictip.yaml
(略)
apiVersion: v1
kind: Service
metadata:
  name: kourier
  namespace: kourier-system
  labels:
    networking.knative.dev/ingress-provider: kourier
    app.kubernetes.io/component: net-kourier
    app.kubernetes.io/version: "1.7.0"
    app.kubernetes.io/name: knative-serving
spec:
  ports:
    - name: http2
      port: 80
      protocol: TCP
      targetPort: 8080
    - name: https
      port: 443
      protocol: TCP
      targetPort: 8443
  selector:
    app: 3scale-kourier-gateway
  type: LoadBalancer
  loadBalancerSourceRanges:                 #追加
  - {接続を許可するグローバルIP}/32    #追加
(略)

Knative Courier コントローラーを展開
$ kubectl apply -f kourier_restrictip.yaml
namespace/kourier-system created
configmap/kourier-bootstrap created
configmap/config-kourier created
serviceaccount/net-kourier created
clusterrole.rbac.authorization.k8s.io/net-kourier created
clusterrolebinding.rbac.authorization.k8s.io/net-kourier created
deployment.apps/net-kourier-controller created
service/net-kourier-controller created
deployment.apps/3scale-kourier-gateway created
service/kourier created
service/kourier-internal created

デフォルトで Kourier を使用するようKnative Servingに設定 
$ kubectl patch configmap/config-network \
  --namespace knative-serving \
  --type merge \
  --patch '{"data":{"ingress-class":"kourier.ingress.networking.knative.dev"}}'

configmap/config-network patched

Kourierの外部IPアドレスを取得
$ kubectl --namespace kourier-system get service kourier
NAME      TYPE           CLUSTER-IP    EXTERNAL-IP     PORT(S)                      AGE
kourier   LoadBalancer   10.96.13.13   34.145.106.59   80:31786/TCP,443:30679/TCP   2m6s

GKEクラスタに設定されているFirewallルール名を確認
$ gcloud compute firewall-rules list --format="value(name)" | grep "^k8s-fw-"
k8s-fw-a9dc27ac31fb24e4c9d8abcb85332173

IP制限設定がFirewallに反映されていることを確認
$ gcloud compute firewall-rules describe k8s-fw-a9dc27ac31fb24e4c9d8abcb85332173 --format="value(sourceRanges)"
{接続を許可するグローバルIP}/32

インストールされたことを確認する

以下のように確認する。

ServingコンポーネントのPodがRunningとなっていることを確認
$ kubectl get pods -n knative-serving
NAME                                      READY   STATUS    RESTARTS   AGE
activator-76b68c57-8kzk7                  1/1     Running   0          10m
autoscaler-6786666674-sqp6v               1/1     Running   0          10m
controller-dc889b65b-jshdk                1/1     Running   0          10m
domain-mapping-7579b59cd8-bm4vh           1/1     Running   0          10m
domainmapping-webhook-6c89c965bd-vggpj    1/1     Running   0          10m
net-kourier-controller-77776d79dc-hglgf   1/1     Running   0          3m16s
webhook-85ccb78947-sgvkw                  1/1     Running   0          10m

KourierコントローラーのPodがRunningとなっていることを確認
$ kubectl get pods -n kourier-system
NAME                                      READY   STATUS    RESTARTS   AGE
3scale-kourier-gateway-77665f8968-zhwl4   1/1     Running   0          14m

DNSの構成

Knativeが作成するエンドポイントにDNS名で接続できるように、デフォルトDNSサフィックスとしてsslip.ioを使う設定をする。

マジックDNS設定ジョブのYAMLファイルをダウンロード
$ wget https://github.com/knative/serving/releases/download/knative-v1.7.2/serving-default-domain.yaml

マジックDNS設定ジョブを作成
$ kubectl apply -f serving-default-domain.yaml
job.batch/default-domain created
service/default-domain-service created

Knative Eventingのインストール

Installing Knative Eventing using YAML files」を参考に、Eventingをインストールする。

Knative Eventing コンポーネントをインストールする

以下を実行する。

カスタムリソースのYAMLファイルをダウンロードする
$ wget https://github.com/knative/eventing/releases/download/knative-v1.7.3/eventing-crds.yaml

カスタムリソースを作成
$ kubectl apply -f eventing-crds.yaml
customresourcedefinition.apiextensions.k8s.io/apiserversources.sources.knative.dev created
customresourcedefinition.apiextensions.k8s.io/brokers.eventing.knative.dev created
customresourcedefinition.apiextensions.k8s.io/channels.messaging.knative.dev created
customresourcedefinition.apiextensions.k8s.io/containersources.sources.knative.dev created
customresourcedefinition.apiextensions.k8s.io/eventtypes.eventing.knative.dev created
customresourcedefinition.apiextensions.k8s.io/parallels.flows.knative.dev created
customresourcedefinition.apiextensions.k8s.io/pingsources.sources.knative.dev created
customresourcedefinition.apiextensions.k8s.io/sequences.flows.knative.dev created
customresourcedefinition.apiextensions.k8s.io/sinkbindings.sources.knative.dev created
customresourcedefinition.apiextensions.k8s.io/subscriptions.messaging.knative.dev created
customresourcedefinition.apiextensions.k8s.io/triggers.eventing.knative.dev created

EventingのコアコンポーネントのYAMLファイルをダウンロードする
$ wget https://github.com/knative/eventing/releases/download/knative-v1.7.3/eventing-core.yaml

Eventingのコアコンポーネントを作成
$ kubectl apply -f eventing-core.yaml
namespace/knative-eventing created
serviceaccount/eventing-controller created
clusterrolebinding.rbac.authorization.k8s.io/eventing-controller created
clusterrolebinding.rbac.authorization.k8s.io/eventing-controller-resolver created
clusterrolebinding.rbac.authorization.k8s.io/eventing-controller-source-observer created
clusterrolebinding.rbac.authorization.k8s.io/eventing-controller-sources-controller created
clusterrolebinding.rbac.authorization.k8s.io/eventing-controller-manipulator created
serviceaccount/pingsource-mt-adapter created
clusterrolebinding.rbac.authorization.k8s.io/knative-eventing-pingsource-mt-adapter created
serviceaccount/eventing-webhook created
clusterrolebinding.rbac.authorization.k8s.io/eventing-webhook created
rolebinding.rbac.authorization.k8s.io/eventing-webhook created
clusterrolebinding.rbac.authorization.k8s.io/eventing-webhook-resolver created
clusterrolebinding.rbac.authorization.k8s.io/eventing-webhook-podspecable-binding created
configmap/config-br-default-channel created
configmap/config-br-defaults created
configmap/default-ch-webhook created
configmap/config-ping-defaults created
configmap/config-features created
configmap/config-kreference-mapping created
configmap/config-leader-election created
configmap/config-logging created
configmap/config-observability created
configmap/config-sugar created
configmap/config-tracing created
deployment.apps/eventing-controller created
deployment.apps/pingsource-mt-adapter created
Warning: autoscaling/v2beta2 HorizontalPodAutoscaler is deprecated in v1.23+, unavailable in v1.26+; use autoscaling/v2 HorizontalPodAutoscaler
horizontalpodautoscaler.autoscaling/eventing-webhook created
poddisruptionbudget.policy/eventing-webhook created
deployment.apps/eventing-webhook created
service/eventing-webhook created
customresourcedefinition.apiextensions.k8s.io/apiserversources.sources.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/brokers.eventing.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/channels.messaging.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/containersources.sources.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/eventtypes.eventing.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/parallels.flows.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/pingsources.sources.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/sequences.flows.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/sinkbindings.sources.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/subscriptions.messaging.knative.dev unchanged
customresourcedefinition.apiextensions.k8s.io/triggers.eventing.knative.dev unchanged
clusterrole.rbac.authorization.k8s.io/addressable-resolver created
clusterrole.rbac.authorization.k8s.io/service-addressable-resolver created
clusterrole.rbac.authorization.k8s.io/serving-addressable-resolver created
clusterrole.rbac.authorization.k8s.io/channel-addressable-resolver created
clusterrole.rbac.authorization.k8s.io/broker-addressable-resolver created
clusterrole.rbac.authorization.k8s.io/flows-addressable-resolver created
clusterrole.rbac.authorization.k8s.io/eventing-broker-filter created
clusterrole.rbac.authorization.k8s.io/eventing-broker-ingress created
clusterrole.rbac.authorization.k8s.io/eventing-config-reader created
clusterrole.rbac.authorization.k8s.io/channelable-manipulator created
clusterrole.rbac.authorization.k8s.io/meta-channelable-manipulator created
clusterrole.rbac.authorization.k8s.io/knative-eventing-namespaced-admin created
clusterrole.rbac.authorization.k8s.io/knative-messaging-namespaced-admin created
clusterrole.rbac.authorization.k8s.io/knative-flows-namespaced-admin created
clusterrole.rbac.authorization.k8s.io/knative-sources-namespaced-admin created
clusterrole.rbac.authorization.k8s.io/knative-bindings-namespaced-admin created
clusterrole.rbac.authorization.k8s.io/knative-eventing-namespaced-edit created
clusterrole.rbac.authorization.k8s.io/knative-eventing-namespaced-view created
clusterrole.rbac.authorization.k8s.io/knative-eventing-controller created
clusterrole.rbac.authorization.k8s.io/knative-eventing-pingsource-mt-adapter created
clusterrole.rbac.authorization.k8s.io/podspecable-binding created
clusterrole.rbac.authorization.k8s.io/builtin-podspecable-binding created
clusterrole.rbac.authorization.k8s.io/source-observer created
clusterrole.rbac.authorization.k8s.io/eventing-sources-source-observer created
clusterrole.rbac.authorization.k8s.io/knative-eventing-sources-controller created
clusterrole.rbac.authorization.k8s.io/knative-eventing-webhook created
role.rbac.authorization.k8s.io/knative-eventing-webhook created
validatingwebhookconfiguration.admissionregistration.k8s.io/config.webhook.eventing.knative.dev created
mutatingwebhookconfiguration.admissionregistration.k8s.io/webhook.eventing.knative.dev created
validatingwebhookconfiguration.admissionregistration.k8s.io/validation.webhook.eventing.knative.dev created
secret/eventing-webhook-certs created
mutatingwebhookconfiguration.admissionregistration.k8s.io/sinkbindings.webhook.sources.knative.dev created

インストールされたことを確認する

以下のように確認する。

EventingコンポーネントのPodがRunningとなっていることを確認
$ kubectl get pods -n knative-eventing
NAME                                   READY   STATUS    RESTARTS   AGE
eventing-controller-56847b8877-6tgvp   1/1     Running   0          95s
eventing-webhook-5477db5854-glhg8      1/1     Running   0          95s

デフォルトのChannelレイヤーをインストール

Channelレイヤー(実装)は以下の3つがあるが、とりあえず一番シンプルなInMemory Channelをインストールする。

  • Apache Kafka Channel
  • InMemory Channel ※本番環境では非推奨
  • NATS Channel
InMemoryChannel実装のYAMLファイルをダウンロードする
$ wget https://github.com/knative/eventing/releases/download/knative-v1.7.3/in-memory-channel.yaml

InMemoryChannel実装をインストールする
$ kubectl apply -f in-memory-channel.yaml
serviceaccount/imc-controller created
clusterrolebinding.rbac.authorization.k8s.io/imc-controller created
rolebinding.rbac.authorization.k8s.io/imc-controller created
clusterrolebinding.rbac.authorization.k8s.io/imc-controller-resolver created
serviceaccount/imc-dispatcher created
clusterrolebinding.rbac.authorization.k8s.io/imc-dispatcher created
configmap/config-imc-event-dispatcher created
configmap/config-observability unchanged
configmap/config-tracing unchanged
deployment.apps/imc-controller created
service/inmemorychannel-webhook created
service/imc-dispatcher created
deployment.apps/imc-dispatcher created
customresourcedefinition.apiextensions.k8s.io/inmemorychannels.messaging.knative.dev created
clusterrole.rbac.authorization.k8s.io/imc-addressable-resolver created
clusterrole.rbac.authorization.k8s.io/imc-channelable-manipulator created
clusterrole.rbac.authorization.k8s.io/imc-controller created
clusterrole.rbac.authorization.k8s.io/imc-dispatcher created
role.rbac.authorization.k8s.io/knative-inmemorychannel-webhook created
mutatingwebhookconfiguration.admissionregistration.k8s.io/inmemorychannel.eventing.knative.dev created
validatingwebhookconfiguration.admissionregistration.k8s.io/validation.inmemorychannel.eventing.knative.dev created
secret/inmemorychannel-webhook-certs created

作成されたPod、Serviceを確認
$ kubectl -n knative-eventing get pod | grep imc
imc-controller-74cd59685d-44964        1/1     Running   0          3m56s
imc-dispatcher-848587f767-xkpmw        1/1     Running   0          3m56s
$ kubectl -n knative-eventing get svc | grep imc
imc-dispatcher            ClusterIP   10.96.1.73    <none>        80/TCP,9090/TCP             4m

こちらによると、デフォルトで使用されるチャンネルはdefault-ch-webhook Configmapに設定されているので、確認しておく。

確認すると、デフォルトはInMemoryChannelになっている
$ kubectl get -n knative-eventing cm default-ch-webhook -o yaml
apiVersion: v1
data:
  default-ch-config: |
    clusterDefault:
      apiVersion: messaging.knative.dev/v1
      kind: InMemoryChannel
    namespaceDefaults:
      some-namespace:
        apiVersion: messaging.knative.dev/v1
        kind: InMemoryChannel
(略)

こちらを参考に、channelを作成してみる

yamlの作成
$ cat <<EOF > example-channel.yaml
apiVersion: messaging.knative.dev/v1
kind: Channel
metadata:
  name: example-channel
EOF

$ kubectl apply -f example-channel.yaml
channel.messaging.knative.dev/example-channel created

channelが作成されていることを確認(READYがTrueになっていればOK)
$ kubectl get channels
NAME              URL                                                           AGE   READY   REASON
example-channel   http://example-channel-kn-channel.default.svc.cluster.local   29s   True

channelの種類ごとに確認することもできる。
デフォルト設定だったInMemoryChannelとして作成されていることがわかる
$ kubectl get inmemorychannels
NAME              URL                                                           AGE   READY   REASON
example-channel   http://example-channel-kn-channel.default.svc.cluster.local   36s   True

knコマンドでも確認する
$ kn channel list
NAME              TYPE              URL                                                           AGE   READY   REASON
example-channel   InMemoryChannel   http://example-channel-kn-channel.default.svc.cluster.local   59s   True

一旦channelを消しておく
$ kubectl delete -f example-channel.yaml
channel.messaging.knative.dev "example-channel" deleted

Brokerレイヤーをインストール

Brokerにも以下の複数の実装がある。
とりあえずインストールが一番簡単なMT-Channel-based Brokerをインストールする。
MT-Channel-based Brokerは内部的にchannelを使用することで、インストールを簡単にしているらしい。

  • Apache Kafka Broker
  • MT-Channel-based Broker
  • RabbitMQ Broker
MT-Channel-based BrokerのYAMLファイルをダウンロードする
$ wget https://github.com/knative/eventing/releases/download/knative-v1.7.3/mt-channel-broker.yaml

MT-Channel-based Brokerをデプロイする
$ kubectl apply -f mt-channel-broker.yaml
clusterrole.rbac.authorization.k8s.io/knative-eventing-mt-channel-broker-controller created
clusterrole.rbac.authorization.k8s.io/knative-eventing-mt-broker-filter created
serviceaccount/mt-broker-filter created
clusterrole.rbac.authorization.k8s.io/knative-eventing-mt-broker-ingress created
serviceaccount/mt-broker-ingress created
clusterrolebinding.rbac.authorization.k8s.io/eventing-mt-channel-broker-controller created
clusterrolebinding.rbac.authorization.k8s.io/knative-eventing-mt-broker-filter created
clusterrolebinding.rbac.authorization.k8s.io/knative-eventing-mt-broker-ingress created
deployment.apps/mt-broker-filter created
service/broker-filter created
deployment.apps/mt-broker-ingress created
service/broker-ingress created
deployment.apps/mt-broker-controller created
Warning: autoscaling/v2beta2 HorizontalPodAutoscaler is deprecated in v1.23+, unavailable in v1.26+; use autoscaling/v2 HorizontalPodAutoscaler
horizontalpodautoscaler.autoscaling/broker-ingress-hpa created
horizontalpodautoscaler.autoscaling/broker-filter-hpa created

作成されたPod、Serviceを確認
$ kubectl -n knative-eventing get pod | grep broker
mt-broker-controller-58b8c559f6-nfr5b   1/1     Running   0          17s
mt-broker-filter-6f897c95d-g24p9        1/1     Running   0          17s
mt-broker-ingress-d69b8bcb9-7m9nb       1/1     Running   0          17s
$ kubectl -n knative-eventing get svc | grep broker
broker-filter             ClusterIP   10.96.5.155    <none>        80/TCP,9092/TCP             28s
broker-ingress            ClusterIP   10.96.14.159   <none>        80/TCP,9092/TCP             28s

こちらによると、デフォルトで使用されるブローカーはconfig-br-defaults Configmapに設定されているので確認する。

確認すると、デフォルトはMT-Channel-based Brokerになっている
$ kubectl get -n knative-eventing cm config-br-defaults -o yaml
apiVersion: v1
data:
  default-br-config: |
    clusterDefault:
      brokerClass: MTChannelBasedBroker
      apiVersion: v1
      kind: ConfigMap
      name: config-br-default-channel
      namespace: knative-eventing
      delivery:
        retry: 10
        backoffPolicy: exponential
        backoffDelay: PT0.2S
(略)

Brokerの作成を試す https://knative.dev/docs/eventing/brokers/create-mtbroker/

こちらを参考に、channelを作成してみる

yamlを作成
$ cat <<EOF > example-broker.yaml
apiVersion: eventing.knative.dev/v1
kind: Broker
metadata:
 name: example-broker
EOF

brokerを作成
$ kubectl apply -f example-broker.yaml
broker.eventing.knative.dev/example-broker created

brokerを確認
$ kubectl get broker
NAME             URL                                                                               AGE   READY   REASON
example-broker   http://broker-ingress.knative-eventing.svc.cluster.local/default/example-broker   20s   True

knコマンドでも確認
$ kn broker list
NAME             URL                                                                               AGE   CONDITIONS   READY   REASON
example-broker   http://broker-ingress.knative-eventing.svc.cluster.local/default/example-broker   32s   6 OK / 6     True

brokerを削除する
$ kubectl delete -f example-broker.yaml
broker.eventing.knative.dev "example-broker" deleted

Sugar Controllerのインストール

Eventingの拡張機能のひとつであるSugar Controllerをインストールしてみる。
namespaceに特定のラベルを付けると、brokerを自動作成してくれるというもの。
公式ドキュメントの手順ではYAMLファイルを使ってインストールすると記載されているが、以下のようにYAMLファイルが存在しない。

Sugar ControllerのYAMLファイルをダウンロードしようとしたが、エラー
$ wget https://github.com/knative/eventing/releases/download/knative-v1.7.3/eventing-sugar-controller.yaml
--2022-10-17 13:01:24--  https://github.com/knative/eventing/releases/download/knative-v1.7.3/eventing-sugar-controller.yaml
github.com (github.com) をDNSに問いあわせています... 192.30.255.113
github.com (github.com)|192.30.255.113|:443 に接続しています... 接続しました。
HTTP による接続要求を送信しました、応答を待っています... 404 Not Found
2022-10-17 13:01:24 エラー 404: Not Found。

こちらにはconfig-sugar Configmapを変更してSugar Controllerを有効化すると書かれている。

最初の状態
$ kubectl get -n knative-eventing cm config-sugar -o yaml
apiVersion: v1
data:
  _example: |
    ################################
    #                              #
    #    EXAMPLE CONFIGURATION     #
    #                              #
    ################################
    # This block is not actually functional configuration,
    # but serves to illustrate the available configuration
    # options and document them in a way that is accessible
    # to users that `kubectl edit` this config map.
    #
    # These sample configuration options may be copied out of
    # this example block and unindented to be in the data block
    # to actually change the configuration.

    # namespace-selector specifies a LabelSelector which
    # determines which namespaces the Sugar Controller should operate upon
    # Use an empty value to disable the feature (this is the default):
    namespace-selector: ""

    # Use an empty object as a string to enable for all namespaces
    namespace-selector: "{}"

    # trigger-selector specifies a LabelSelector which
    # determines which triggers the Sugar Controller should operate upon
    # Use an empty value to disable the feature (this is the default):
    trigger-selector: ""

    # Use an empty object as string to enable for all triggers
    trigger-selector: "{}"
(略)

編集して有効化
$ kubectl edit -n knative-eventing cm config-sugar
configmap/config-sugar edited

編集後
$ kubectl get -n knative-eventing cm config-sugar -o yaml
apiVersion: v1
data:
  namespace-selector: |
    matchExpressions:
    - key: "eventing.knative.dev/injection"
      operator: "In"
      values: ["enabled"]
  trigger-selector: |
    matchExpressions:
    - key: "eventing.knative.dev/injection"
      operator: "In"
      values: ["enabled"]

ブローカーの自動作成を試してみる

default namespaceにbrokerはない
$ kubectl get broker
No resources found in default namespace.

default namespaceのラベルを確認
$ kubectl get namespaces default --show-labels
NAME      STATUS   AGE     LABELS
default   Active   6h47m   kubernetes.io/metadata.name=default

default namespaceにラベルを付ける
$ kubectl label namespace default eventing.knative.dev/injection=enabled
namespace/default labeled


default namespaceのラベルを確認
$ kubectl get namespaces default --show-labels
NAME      STATUS   AGE     LABELS
default   Active   6h48m   eventing.knative.dev/injection=enabled,kubernetes.io/metadata.name=default

ブローカーが自動作成されたことを確認
$ kubectl get broker
NAME      URL                                                                        AGE    READY   REASON
default   http://broker-ingress.knative-eventing.svc.cluster.local/default/default   109s   True

knコマンドでも確認
$ kn broker list
NAME      URL                                                                        AGE   CONDITIONS   READY   REASON
default   http://broker-ingress.knative-eventing.svc.cluster.local/default/default   96s   6 OK / 6     True

Sugar Controllerによってbrokerが自動作成されたことを確認できた。
ちなみに、自動作成されたbrokerは「eventing.knative.dev/injection=enabled」ラベルがついている間は、削除してもすぐに再作成される。

brokerを削除
$ kubectl delete broker default
broker.eventing.knative.dev "default" deleted

すぐに再作成されている
$ kubectl get broker
NAME      URL                                                                        AGE   READY   REASON
default   http://broker-ingress.knative-eventing.svc.cluster.local/default/default   5s    True

そのため、brokerを削除したい場合は、「eventing.knative.dev/injection=enabled」ラベルを消してから削除する。

ラベルの削除
$ kubectl label namespace default eventing.knative.dev/injection-
namespace/default unlabeled

「eventing.knative.dev/injection=enabled」ラベルが消えたことを確認
$ kubectl get namespaces default --show-labels
NAME      STATUS   AGE   LABELS
default   Active   46h   kubernetes.io/metadata.name=default

ラベルが消えてもbrokerは残る
$ kubectl get broker
NAME      URL                                                                        AGE     READY   REASON
default   http://broker-ingress.knative-eventing.svc.cluster.local/default/default   2m39s   True

brokerを削除
$ kubectl delete broker default
broker.eventing.knative.dev "default" deleted

再作成されていないことを確認
$ kubectl get broker
No resources found in default namespace.

チュートリアルをやってみる

動作確認を兼ねて、Knative公式ドキュメントのチュートリアルをやってみる。

Serving

Servingのチュートリアルを実施する。

Knative Serviceを作成

Deploying a Knative Serviceを実施する。

Knative Serviceのyamlを作成
$ cat <<EOF > hello.yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: hello
spec:
  template:
    spec:
      containers:
        - image: gcr.io/knative-samples/helloworld-go
          ports:
            - containerPort: 8080
          env:
            - name: TARGET
              value: "World"
EOF

Knative Serviceを作成
$ kubectl apply -f hello.yaml
service.serving.knative.dev/hello created

$ kubectl get ksvc
NAME    URL                                           LATESTCREATED   LATESTREADY   READY   REASON
hello   http://hello.default.34.145.106.59.sslip.io   hello-00001     hello-00001   True

オートスケーリング

Autoscalingを実施する。

作成されたKnativeServiceのURLを確認する
$ kubectl get ksvc
NAME    URL                                           LATESTCREATED   LATESTREADY   READY   REASON
hello   http://hello.default.34.145.106.59.sslip.io   hello-00001     hello-00001   True

この時点ではPodは存在しない。
$ kubectl get pod -l serving.knative.dev/service=hello
No resources found in default namespace.

KnativeServiceへ接続すると、レスポンスが返ってくる
$ curl http://hello.default.34.145.106.59.sslip.io
Hello World!

Podを確認すると、作成されている。
暫く待つとPodが削除される。
$ kubectl get pod -l serving.knative.dev/service=hello -w
NAME                                      READY   STATUS    RESTARTS   AGE
hello-00001-deployment-868c87995d-5brhr   2/2     Running   0          15s
hello-00001-deployment-868c87995d-5brhr   2/2     Terminating   0          62s
hello-00001-deployment-868c87995d-5brhr   1/2     Terminating   0          90s
hello-00001-deployment-868c87995d-5brhr   0/2     Terminating   0          93s

以下のコマンドを実行した状態で、別コンソールで「curl http://hello.default.34.145.106.59.sslip.io」を実行すると、Podが無い状態から作成されるところを確認できる
$ kubectl get pod -l serving.knative.dev/service=hello -w
NAME                                      READY   STATUS    RESTARTS   AGE
hello-00001-deployment-868c87995d-7bcxv   0/2     Pending   0          0s
hello-00001-deployment-868c87995d-7bcxv   0/2     Pending   0          0s
hello-00001-deployment-868c87995d-7bcxv   0/2     ContainerCreating   0          0s
hello-00001-deployment-868c87995d-7bcxv   1/2     Running             0          1s
hello-00001-deployment-868c87995d-7bcxv   2/2     Running             0          1s

→Servingによって、Podのゼロスケールができている。
 

補足:NodePortで接続する場合

Knative ServiceのエンドポイントになっているServiceは、kourier namespaceに存在するkourier。

$ kubectl get svc -n kourier-system
NAME               TYPE           CLUSTER-IP    EXTERNAL-IP     PORT(S)                      AGE
kourier            LoadBalancer   10.96.13.13   34.145.106.59   80:31786/TCP,443:30679/TCP   4h57m
kourier-internal   ClusterIP      10.96.7.171   <none>          80/TCP,443/TCP               4h57m

今回kourier Serviceは上記のようにLoadBalancerタイプとなっているが、LoadBalancerタイプのServiceは、PORT(S)列のポートでNodePortタイプでも接続が出来る。
ただ、そのままアクセスしても以下のように404エラーとなる。

$ curl -I http://${K8sクラスタのワーカーノードのIP}:31484
HTTP/1.1 404 Not Found
date: Mon, 14 Nov 2022 11:32:07 GMT
server: envoy
transfer-encoding: chunked

RedHatのkourierに関するブログ記事に以下の記載がある。

Hosts, paths, and headers: These elements are matched against the same elements included in incoming requests. When there's a match, we know that the request should be proxied to the Knative service associated with the ingress object.

→Knative ServiceがデプロイされたときにServingにより作成されるKnative Ingressに、ホスト、パスといった情報が含まれる。
kourierはこれら情報にマッチするアクセスを該当するKnative サービスにルーティングしている模様。
そのため、NodePortで接続する際には、以下のようにホストヘッダにホスト名を設定する必要がある。

hello Knative Serviceに対応するKnative Ingressの情報を参照し、ホスト名を確認する
$ kubectl get  ingresses.networking.internal.knative.dev hello -o jsonpath='{.spec.rules[?(@.visibility=="ExternalIP")].hosts}'
["hello.default.34.145.106.59.sslip.io"]

確認したホスト名をホストヘッダに含めてNodePortにアクセスすると、接続できる。
$ curl -HHost:hello.default.34.145.106.59.sslip.io http://${K8sクラスタのワーカーノードのIP}:31484
Hello Knative!

※NodePortとGKE Ingressを組み合わせて使いたい場合はこちらのブログが参考になりそう(実際には試していないので未確認)。

トラフィック分割

Traffic splittingを実施する。

hello.yamlを修正
$ cat <<EOF > hello.yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: hello
spec:
  template:
    spec:
      containers:
        - image: gcr.io/knative-samples/helloworld-go
          ports:
            - containerPort: 8080
          env:
            - name: TARGET
              value: "Knative"
EOF

デプロイする
$ kubectl apply -f hello.yaml
service.serving.knative.dev/hello configured

Knative ServiceのLATESTCREATED、LATESTREADYがhello-00002に変わっている。
URLは変化なし。
$ kubectl get ksvc
NAME    URL                                           LATESTCREATED   LATESTREADY   READY   REASON
hello   http://hello.default.34.145.106.59.sslip.io   hello-00002     hello-00002   True

KnativeServiceへ接続すると、レスポンスの文字列が変わっている
$ curl http://hello.default.34.145.106.59.sslip.io
Hello Knative!

リビジョンを確認する
$ kubectl get revisions
NAME          CONFIG NAME   K8S SERVICE NAME   GENERATION   READY   REASON   ACTUAL REPLICAS   DESIRED REPLICAS
hello-00001   hello                            1            True             0                 0
hello-00002   hello                            2            True             1                 1

Knative CLIで確認すると、トラフィックの100%がhello-00002リビジョンに転送される状態になっている
$ kn revisions list
NAME          SERVICE   TRAFFIC   TAGS   GENERATION   AGE   CONDITIONS   READY   REASON
hello-00002   hello     100%             2            13m   3 OK / 4     True
hello-00001   hello                      1            43m   3 OK / 4     True

新しいリビジョンを作成すると、Knative はデフォルトで 100% のトラフィックを最新リビジョンに転送する。
各リビジョンが受信するトラフィックの量を指定することで、このデフォルトの動作を変更できるらしい。
リビジョンのトラフィック割合を変更してみる。

hello.yamlを修正
$ cat <<EOF > hello.yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: hello
spec:
  template:
    spec:
      containers:
        - image: gcr.io/knative-samples/helloworld-go
          ports:
            - containerPort: 8080
          env:
            - name: TARGET
              value: "Knative"
  traffic:
  - latestRevision: true
    percent: 50
  - latestRevision: false
    percent: 50
    revisionName: hello-00001
EOF

デプロイする
$ kubectl apply -f hello.yaml
service.serving.knative.dev/hello configured

yamlの記載通り、トラフィックが50%ずつhello-00001リビジョンとhello-00002リビジョンに分割されている
$ kn revisions list
NAME          SERVICE   TRAFFIC   TAGS   GENERATION   AGE   CONDITIONS   READY   REASON
hello-00002   hello     50%              2            20m   3 OK / 4     True
hello-00001   hello     50%              1            50m   3 OK / 4     True

Knative Serviceに複数回アクセスすると、トラフィックが50%ずつhello-00001リビジョンとhello-00002リビジョンに分割されていることが確認できる
$ curl http://hello.default.34.145.106.59.sslip.io
Hello World!
$ curl http://hello.default.34.145.106.59.sslip.io
Hello Knative!
$ curl http://hello.default.34.145.106.59.sslip.io
Hello Knative!
$ curl http://hello.default.34.145.106.59.sslip.io
Hello World!

片付け

$ kubectl delete -f hello.yaml
service.serving.knative.dev "hello" deleted

Eventing

Eventingのチュートリアルを実施する。

Brokerの確認

Sources, Brokers, and Triggersを実施する。
公式ドキュメントのチュートリアルは「kn quickstart」でインストールしたことを前提にしている。
この場合、example-brokerというbrokerが存在する。
今回はKnativeをYAMLファイルでインストールしたため、example-brokerは存在しない。
そのため、sugar controllerの機能でbrokerを作成する。

default namespaceにラベルを付与する
$ kubectl label namespace default eventing.knative.dev/injection=enabled
namespace/default labeled

default namespaceにbrokerが自動作成される
$ kubectl get broker
NAME      URL                                                                        AGE   READY   REASON
default   http://broker-ingress.knative-eventing.svc.cluster.local/default/default   5s    True

Brokerにイベントを送信する

Using a Knative Service as a sourceを実施する。
Knative EventingはCloudEventsという仕様に準拠してイベント情報をやり取りする。
CloudEvents Playerという、CloudEvents仕様でイベント情報を送信するアプリケーションを、イベントの送信元として作成する。

CloudEvents playerのyamlを作成
$ cat <<EOF > cloudevents-player.yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: cloudevents-player
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/min-scale: "1"
    spec:
      containers:
        - image: ruromero/cloudevents-player:latest
          env:
            - name: BROKER_URL
              value: http://broker-ingress.knative-eventing.svc.cluster.local/default/default
EOF

作成する
$ kubectl apply -f cloudevents-player.yaml
service.serving.knative.dev/cloudevents-player created

確認
$ kubectl get ksvc
NAME                 URL                                                          LATESTCREATED              LATESTREADY                READY   REASON
cloudevents-player   http://cloudevents-player.default.34.145.106.59.sslip.io   cloudevents-player-00001   cloudevents-player-00001

KnativeServiceのURL(http://cloudevents-player.default.34.145.106.59.sslip.io)にアクセスすると、以下が表示される。

CloudEvents Player
CloudEvents Player

以下のように入力して「SEND EVENT」をクリックする

イベント情報を入力
イベント情報を入力

すると、以下のように送信したイベントが表示される。
(今はBrokerの先に受け取り手がいないため、送信のみが表示される。)

送信したイベントが表示される
送信したイベントが表示される

ちなみに、CloudEvents Playerにcurlすることでもイベントを送信できる。

$ curl -i http://cloudevents-player.default.34.145.106.59.sslip.io \
    -H "Content-Type: application/json" \
    -H "Ce-Id: 123456789" \
    -H "Ce-Specversion: 1.0" \
    -H "Ce-Type: some-type" \
    -H "Ce-Source: command-line" \
    -d '{"msg":"Hello CloudEvents!"}'

HTTP/1.1 202 Accepted
content-length: 0
date: Tue, 18 Oct 2022 12:46:57 GMT
x-envoy-upstream-service-time: 5
server: envoy

Brokerからイベントを受信する

Using Triggers and sinksを実施する。

yamlを作成
$ cat <<EOF > ce-trigger.yaml
apiVersion: eventing.knative.dev/v1
kind: Trigger
metadata:
  name: cloudevents-trigger
  annotations:
    knative-eventing-injection: enabled
spec:
  broker: default
  subscriber:
    ref:
      apiVersion: serving.knative.dev/v1
      kind: Service
      name: cloudevents-player
EOF

作成する
$ kubectl apply -f ce-trigger.yaml
trigger.eventing.knative.dev/cloudevents-trigger created

確認
$ kubectl get trigger
NAME                  BROKER    SUBSCRIBER_URI                                        AGE   READY   REASON
cloudevents-trigger   default   http://cloudevents-player.default.svc.cluster.local   81s   True

CloudEvents Playerからまたイベントを送信してみると、今度は2行表示される。
1行目はBrokerに送信されたイベントで、2行目はBrokerから受信した内容の模様。

brokerから受信したイベントも表示される
brokerから受信したイベントも表示される

Triggerでイベントのフィルタリングができるので、それも試してみる。
TriggerYAMLのfilter設定は公式ドキュメントのこちら参照。

Triggerを削除
$ kubectl delete -f ce-trigger.yaml
trigger.eventing.knative.dev "cloudevents-trigger" deleted

フィルタリング条件を追加したTriggerのyamlを作成
$ cat <<EOF > ce-trigger-filter.yaml
apiVersion: eventing.knative.dev/v1
kind: Trigger
metadata:
  name: cloudevents-trigger
  annotations:
    knative-eventing-injection: enabled
spec:
  broker: default
  filter:
    attributes:
      type: test-type
  subscriber:
    ref:
      apiVersion: serving.knative.dev/v1
      kind: Service
      name: cloudevents-player
EOF

作成
$ kubectl apply -f ce-trigger-filter.yaml
trigger.eventing.knative.dev/cloudevents-trigger created

確認
$ kubectl get trigger
NAME                  BROKER    SUBSCRIBER_URI                                        AGE   READY   REASON
cloudevents-trigger   default   http://cloudevents-player.default.svc.cluster.local   11s   True

この状態だと、EventTypeが「test-type」であるイベント以外はbrokerから受信されない。
EventTypeを「test-type」と入力して送信すると、Brokerからの受信含めイベントが2行表示される。

EventTypeを「test-type」とした場合
EventTypeを「test-type」とした場合
EventTypeを「some-type」と入力して送信すると、Brokerからの受信はされず、イベントは1行だけ表示される。

EventTypeを「some-type」とした場合
EventTypeを「some-type」とした場合

片付け

$ kubectl delete -f ce-trigger-filter.yaml
trigger.eventing.knative.dev "cloudevents-trigger" deleted
$ kubectl delete -f cloudevents-player.yaml
service.serving.knative.dev "cloudevents-player" deleted

おまけ

Stern

ゼロスケールするKnativeServiceのPodのログを追いたい時、「kubectl logs」だとPodが再作成されるたびにPod名を確認して再実行する必要がある。
sternというツールを使えば、指定した文字列にマッチするPodのログを出力し続けてくれるので便利。
インストール時のメモは以下。

$ wget https://github.com/wercker/stern/releases/download/1.11.0/stern_linux_amd64
$ sudo mv stern_linux_amd64 /usr/local/bin/stern
$ sudo chmod +x /usr/local/bin/stern

$ stern --version
stern version 1.11.0

使い方例:「hello」というKnativeServiceであれば、以下のように実行する。
Podが削除と再作成を繰り返しても、ログを出力し続けてくれる。

$ stern hello

Eventingのログレベル設定

デフォルトだとログレベルはinfo以上だが、イベント送受信時のBrokerやChannelのログは、debugレベルでないと出力されない。
調査などでdebugレベルに変更したい場合は、以下のようにconfig-loggin ConfigMapを修正すればよい。

編集前
$ kubectl -n knative-eventing get configmaps config-logging -o yaml
apiVersion: v1
data:
  loglevel.controller: info
  loglevel.webhook: info
  zap-logger-config: |
    {
      "level": "info",
      "development": false,
      "outputPaths": ["stdout"],
      "errorOutputPaths": ["stderr"],
      "encoding": "json",
      "encoderConfig": {
        "timeKey": "ts",
        "levelKey": "level",
        "nameKey": "logger",
        "callerKey": "caller",
        "messageKey": "msg",
        "stacktraceKey": "stacktrace",
        "lineEnding": "",
        "levelEncoder": "",
        "timeEncoder": "iso8601",
        "durationEncoder": "",
        "callerEncoder": ""
      }
    }
(略)

編集
$ kubectl -n knative-eventing edit configmaps config-logging
configmap/config-logging edited

編集後
$ kubectl -n knative-eventing get configmaps config-logging -o yaml
apiVersion: v1
data:
  loglevel.controller: debug
  loglevel.webhook: debug
  zap-logger-config: |
    {
      "level": "debug",
      "development": false,
      "outputPaths": ["stdout"],
      "errorOutputPaths": ["stderr"],
      "encoding": "json",
      "encoderConfig": {
        "timeKey": "ts",
        "levelKey": "level",
        "nameKey": "logger",
        "callerKey": "caller",
        "messageKey": "msg",
        "stacktraceKey": "stacktrace",
        "lineEnding": "",
        "levelEncoder": "",
        "timeEncoder": "iso8601",
        "durationEncoder": "",
        "callerEncoder": ""
      }
    }
(略)

まとめ

  • GKE(v1.25.1)でKnative(v1.7)が正常に動作することを確認した。
  • Servingを使うことで、Knative Serviceとして簡単にPodをクラスタ外公開できる。また、Podのゼロスケールを行える。
  • Eventingを使うことで、brokerを介してイベント情報をやり取りできた。