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つ(詳細はこの記事を参照)。
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を実行する際の設定は、以下に記載がある。
- Serverspec公式ドキュメントのTipsの「How to use SSH password login」
- 上記Tipsの元記事「パスワード認証でログインするサーバに対して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
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
実施内容
以下を参考に、ディレクトリ構成とファイルの中身を変更した。
- 公式ドキュメントのTipsの「How to share Serverspec tests among hosts」と「How to use host specific properties」
- Serverspecの効果的活用に向けたTips
- 【Serverspec】【yaml】変数を使用してテストコードを使いまわす
変更点は以下の通り。
- 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用ファイルが残っていることが確認できる。
ちなみに、作成された.rspec、Rakefile、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を実行する際の設定は、以下に記載がある。
- 公式ドキュメントのTipsの「How to use SSH password login」
- 上記Tipsの元記事「パスワード認証でログインするサーバに対して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形式動画を暗号化して、よりセキュアにしてみたい。
○上記記事で実現したこと
- 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形式のインデックスファイルとセグメントファイルが出力されている。
作成されたインデックスファイルの中身は以下。
「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鍵が配置されている。
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.」となり視聴できない。
また、念のため本当にAES鍵で復号して再生しているか確認してみる。
AES鍵用S3バケットにある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(進行中)にステータスになっていることが確認できる。
少し待って画面を更新すると、以下のようにジョブのステータスがCOMPLETEになる。
ジョブIDをクリックすると、以下のようにジョブの詳細が確認できる。
6秒の動画を5秒でトランスコードしている。
HLS動画用S3バケットを確認すると、以下のように「video/001/hls_test」配下に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形式のインデックスファイルとセグメントファイルが出力される。
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つの名前と値のペアが必要となる。
- CloudFront-Policy:カスタムポリシーのJSONファイルをbase64エンコードしたもの。
- CloudFront-Signature:カスタムポリシーのJSONファイルに秘密鍵で署名し、base64エンコードしたもの。
コマンドでの作成方法は「Linux コマンドおよび OpenSSL を使用した Base64 エンコードおよび暗号化」を参照。 - CloudFront-Key-Pair-Id:CloudFront パブリックキー(公開鍵)の ID。
マネジメントコンソールCloudFrontのページで、「キー管理」→「パブリックキー」を進めば表示される。
以下の作業は秘密鍵とカスタムポリシー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.jsのvideojs-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をセットして接続確認してみる。
自分は、ChromeのEditThisCookieという拡張機能を使った。
※参考: 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.」となり視聴できない。
→想定通りの制限が出来ていることを、ブラウザからの接続でも確認できた。
補足: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を使う方法で実現出来そう。
参考文献
- サーバーレスに動画をスケーラブルにオンデマンド配信 !
- Boto3 Docs MediaConvert
- ユーザー毎に視聴の可否が異なる動画ファイルをCloudFrontで配信する方法の考察
- AWS MediaConvert と CloudFront 署名付きCookie で HLS+AES動画配信システムを作る
- EditThisCookieでクッキーが追加できない
- S3, Lambda, MediaConvert を連携してストリーミング配信用動画ファイルへの自動変換フローを作る
- MediaConvert+Lambda+S3イベントで全自動変換フローの構築
- 署名付き Cookie を使用して HLS コンテンツを取得してみた
- Video.jsのvideojs-http-streaming(VHS)を使ってHLS形式のストリーミング配信を再生する最低限度の設定
Knative(v1.7)を触ってみる(インストール・チュートリアル)
Kubernetes上でサーバーレスを実行するためのOSSである、Knativeを触ったときのメモ。 GKEのStandardクラスタにインストールし、チュートリアルをやった。
環境
- Knative:v1.7
- GKE:v1.25.1-gke.500 ※GKEクラスタの作成については以下記事を参照。 tk-ch.hatenablog.com
Knativeとは
Knativeは、Kubernetesを拡張して、サーバーレスアプリケーションのデプロイと管理のプロセスを簡素化するOSS。
GCPのCloudRunの基盤にも使用されているらしい。
公式ドキュメントのConceptによると、KnativeはServingとEventingという2つのコンポーネントで構成されている。
- Serving:Kubernetes上でサーバレスコンテナを簡単に実行できるようにする機能。ネットワーク、オートスケーリング(ゼロスケールも可)、リビジョンのトラッキングなどの詳細を管理する。
- Eventing:アプリケーションでイベント駆動型アーキテクチャを使用できるようにする API のコレクション。これらの API を使用して、イベント プロデューサーから、イベントを受信するシンクと呼ばれるイベント コンシューマーにイベントをルーティングするコンポーネントを作成できる。
それぞれの概要は以下スライドを参照(バージョンは少し古いので注意)。
インストール
インストール方法
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)にアクセスすると、以下が表示される。
以下のように入力して「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から受信した内容の模様。
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を「some-type」と入力して送信すると、Brokerからの受信はされず、イベントは1行だけ表示される。
片付け
$ 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を介してイベント情報をやり取りできた。