MoleculeでEC2を活用したサーバグループ単位のテストの模索

どうも、大曲です。

MoleculeでEC2を活用したサーバグループ単位のテストを模索して
最終的にMoleculeを開発環境で使う方向にまとまった話です。

きっかけ

自分たちの組織では、リリースされたプロダクションコードを定期的に棚卸しする活動を行なっています。
この活動は技術ごとのスペシャリスト制度の一環で行なっており、この制度に関しては別の機会にブログで紹介できればと思います。

Ansibleの開発が活発になる一方で、ローカルでの開発の限界を感じていました。
また、コードとしての品質担保の仕組みが確立していないため全体的に非効率だと考えたため
Moleculeの検証を行うことにしました。

課題

Moleculeで解決したい課題は以下の通りです。

1.開発時での作業の限界

コンテナ、Vagrantなどで実環境でのズレ(メタデータとか)がありコーディングで苦労することが多いです。
ローカル環境での限界があり、実際のサーバ(テスト環境)に対して直接Ansibleを実行してコーディングしてしまうことがあります。

2.品質担保のため共通の場所がない

各自の環境で実施するため同じ環境がないため、あるとしたら本番サーバやテストサーバとなります。
テストサーバもAnsibleのために作り直しをしないため、サクッとサーバ作成してコードを実行できる環境がありません。

3.品質劣化への対策がない

いつの間にか動かなくなっていたや別の改修が他にも影響していたなどが容易に発生してしまいます。
古いから怖くて実行できなくなり、結局またコードを書き直すことがよくあります。(サクッと実行する仕組みもない。)
また、サーバのロール単位で担保していてもロールが組み合わせているサーバグループ単位で見ると
ロール同士の依存で正常に動かないことあります。

4.冪等性対策がない

現状は人力でカバーしています。
コードレビューで「この書き方だと冪等性担保できないよ」などの指摘したりします。
開発とは異なるインフラ作業(サーバ追加)などで実施した時に
AnsibleのWarningなどで気づいて修正したりします。

Moleculeとは

Ansibleのロールの開発とテストをサポートするツール。
規約チェック、テスト準備、テスト実行、テスト実行後の後片付けを組み合わせが出来ます。
Ansibleの公式プロジェクトであります。
github.com

Molecule入門
Moleculeの基本的な設定などはこちらの資料が非常に分かりやすいです。

ロールごとではなく、サーバグループ毎にした理由

実際のサービスでは最終的にAnsibleを実行したサーバグループ単位で問題ないかの確認を行なっています。
またロール毎のテストを行なっていても、ロール同士が衝突する(同じ部分を修正していた)可能性があるので
人が確認を行なっているサーバグループ単位でテストを行うことにしました。
(単体テストが完璧でもE2Eでの品質担保は満たせないのと同じ)

moleculeのDriverでDockerを選択しなかった理由

MoleculeではEC2だけでなく、Dockerなども利用できます。
Dockerの方が環境の再現性や高速であるためメリットがありますが、
実際のAnsible実行時に近しい環境ではないため(ネットワークやメモリ設定など)再現性に欠けるため採用しませんでした。
オンプレのロールの場合では、オンプレのOSに近しいAWSのイメージを利用すれば良いと考えています。

利用できるDriver一覧と各種設定のコード
次のバージョンである3.0では構成が変わるようでEC2の設定は別リポジトリになっているようです。
Molecule EC2 Plugin

Moleculeでの設定の工夫

  • 必要最低限のEC2リソースの作成
  • 踏み台サーバ経由の対応
  • Dynamic Inventory 対応
  • 並列処理ができるようにユニークな値を活用
  • テストコードがシンボリックリンクで共通化できるように対応

必要最低限のEC2リソースの作成

参考にしたcreate.yml
参考にしたdestroy.yml

GitHubにあるコードでは以下のリソースが作成されます。

  • セキュリティグループ
  • キーペア
  • EC2

毎回、作成する必要はないのでEC2のみ作成する処理に変更しました。
セキュリティグループやキーペアの情報はmolecule.ymlに書いてたりします。
molecule_ymlの変数を利用すればmolecule.ymlのデータをそのまま渡せるので
動的に扱いたい場合は利用できます。

create.yml

tasks:
  - name: Create molecule instance(s)
    ec2:
      key_name: "{{ item.ec2.key_name }}" # <-- molecule.ymlのplatforms.ec2.key_nameが該当する
      ...
    with_items: "{{ molecule_yml.platforms }}"

molecule.yml

platforms:
  - name: e2e amazon linux1
    ec2:
      key_name: ansible-e2e

EC2の作成と削除のみの処理となったのでIAMは以下の設定で十分です。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:AttachVolume",
                "ec2:CreateTags",
                "ec2:CreateVolume",
                "ec2:DeleteVolume",
                "ec2:DeregisterImage",
                "ec2:DescribeImageAttribute",
                "ec2:DescribeImages",
                "ec2:DescribeInstances",
                "ec2:DescribeRegions",
                "ec2:DescribeSecurityGroups",
                "ec2:DescribeSnapshots",
                "ec2:DescribeSubnets",
                "ec2:DescribeTags",
                "ec2:DescribeVolumes",
                "ec2:DetachVolume",
                "ec2:ModifyInstanceAttribute",
                "ec2:RunInstances",
                "ec2:StopInstances",
                "ec2:TerminateInstances"
            ],
            "Resource": "*"
        }
    ]
}

踏み台サーバ経由の対応

この対応をするのは踏み台サーバ経由でMoleculeを実行したいケースがあるためです。
変更する箇所は「接続時にSSH設定変更」と「Moleculeで保持される接続設定変更」と「作成直後のSSH確認」です。

接続時にSSH設定変更

Driver EC2の設定
molecule.ymlのDriverの項目にsshのオプションを設定できます。

driver:
  name: ec2
  ssh_connection_options:
  - "-o 'ProxyJump XXXX@XX.XX.XX.XX'"

Moleculeで接続設定変更

Moleculeはローカルに接続情報を保持します。
その情報を踏み台経由にするためにプライベートIPを保持するように処理を変更します。

create.yml

- name: Populate instance config dict
  set_fact:
    instance_conf_dict: {
      'instance': "{{ item.instances[0].tags.Name }}",
      'address': "{{ item.instances[0].private_ip }}", # <-- public_ipからprivate_ipに変更
      'user': "{{ ssh_user }}",
      'port': "{{ ssh_port }}",
      'identity_file': "{{ keypair_path }}",
      'instance_ids': "{{ item.instance_ids }}"
    }
  with_items: "{{ ec2_jobs.results }}"
  register: instance_config_dict
  when: server.changed | bool

作成直後のSSH確認

Ansibleのec2モジュールでインスタンスを作成後にSSH出来るまで待つ処理があります。
そこでも踏み台経由でSSHするように設定を変更します。

create.yml

- name: Wait for SSH
  wait_for:
    port: "{{ ssh_port }}"
    host: "{{ item.address }}"
    search_regex: SSH
    delay: 10
    timeout: 320
  delegate_to: "{{ ssh_bastion_ip }}"   # <-- 踏み台IP
  remote_user: "{{ ssh_bastion_user }}" # <-- 踏み台のユーザー
  with_items: "{{ lookup('file', molecule_instance_config) | \
               molecule_from_yaml }}"

Dynamic Inventory 対応

MoleculeにInventoryの設定を考慮しないと、テスト用のgroup_varsを読み込めません。
そのために「groupsとchildrenの設定追加」と「group_varsの参照設定追加」の対応を行います。

groupsとchildrenの設定追加

platformsのドキュメント
Inventoryに関して

Moleculeの設定というより、Ansibleの設定に近いです。

[tag_role_admin]
xx.xx.xx.xx

[test:children]
tag_role_admin

上記の静的ホストファイルでの設定が
Moleculeのplatformsの書き方だと以下の内容と同じ設定になります。

platforms:
  - name: e2e amazon linux1
    groups:
      - test
    children:
      - tag_role_admin

group_varsの参照設定追加

platformsでEC2のホストの情報が付与できたので、今度はgroup_varsのファイルを参照できるようにします。
こちらはAnsibleの実行での対応になるので、provisionerの項目を変更する必要あります。

Provisionerのドキュメント

provisioner:
  name: ansible
  ...
  inventory:
    links:
      group_vars: ../../group_vars

並列処理ができるようにユニークな値を活用

複数人もしくは並列処理を実行しようとするとサクッとは出来ません。
EC2モジュールのexact_countとcount_tagの設定を利用して
インスタンスが起動中なら再利用するような設定になっているためです。

この設定を逆に利用してユニーク値を生成して重複しないようにします。

tasks:
  - name: Create molecule instance(s)
    ec2:
      ..
      exact_count: 1
      count_tag:
        Name: "{{ item.name }}" # <-- サーバのタグをグルーピング対象にしている。別のタグでも対応可能。

Molecule実行前に環境変数を設定してユニーク値を生成します。

export UNIQUE_HASH=${CI_JOB_ID:-`date +%s | sha256sum | base64 | head -c 10`}

${}は環境変数を展開するための書き方です。

molecule.yml

platforms:
  - name: molecule test ${UNIQUE_HASH}
  # ↑ UNIQUE_HASHはdateコマンドを使ったハッシュのどちらか

f:id:AdwaysEngineerBlog:20200219205106p:plain

テストコードがシンボリックリンクで共通化できるように対応

テストはgossを使います。
サーバグループの粒度で行なっているため、テストコードを共通化する部分が出てきます。
この場合はシンボリックリンクで解決します。

シンボリックリンクにすると、リンクエラーの場合に気づけないので
テスト実行時にリンクエラーが無いか確認する処理を入れました。

tests
|- (サーバグループ名)
|  |- test_user.yml (シンボリックリンク)
|- test_user.yml (実ファイル)

参考にしたverify.yml

verify.yml

- name: Find error synbolic link
  command: find . -type l ! -exec test -e {} \; -print
  args:
    chdir: "{{ lookup('env', 'MOLECULE_VERIFIER_TEST_DIRECTORY') }}/{{ lookup('env', 'MOLECULE_SCENARIO_NAME') }}"
  delegate_to: localhost
  register: find_error_symbolic_link
  failed_when: "find_error_symbolic_link.stdout != ''"

フォルダ構成

Ansibleのコードも含め以下のようになりました。

ansible
|- group_vars (環境ごとの変数)
|  |- production.yml
|  |- staging.yml
|  |- test.yml
|
|- roles (各役割のフォルダ)
|  |- nginx (ロール名)
|     |- handlers (notifyで使うときの、処理置き場)
|       |- main.yml
|     |- tasks (サーバで行う処理を記述するところ)
|       |- main.yml
|     |- files (そのままテンプレートを置く部分)
|       |- my.cnf
|     |- templates (テンプレート系の拡張子は、j2で行う)
|       |- my.cnf.j2
|
|- molecule
|  |- default (Moleculeの共通設定)
|  | |- create.yml (各シナリオ共通のec2作成)
|  | |- destory.yml (各シナリオ共通のec2削除)
|  | |- verify.yml (各シナリオ共通のgoss実行)
|  | |- molecule_template.yml (設定のテンプレート)
|  |- admin(サーバグループ名=Moleculeでのシナリオ名となる)
|    |- molecule.yml (ロールのe2eテスト設定)
|
|- tests (gossのテストコード test_xxx.ymlの形式)
|  |- admin (サーバグループ名)
|    |- test_nginx.yml (テストファイル)
|    |- test_user.yml (tests/test_user.ymlへのシンボリックリンク)
|  |- test_user.yml (他のサーバグループでも共通のテストファイル)
|
|- admin.yml (インストールとセットアップ両方入りのプレイブック)
|- .ansible-lint (ansible-lintの設定ファイル)
|- .yamllint (yaml-lintの設定ファイル)

まとめ

Moleculeを利用することで、課題の解決への道筋が見えてきました。
サーバグループの単体テストとして利用するのもありですが、
開発環境での活用だけでもメリットがありそうとなりました。

  • 1.開発時での作業の限界
    • ある程度はローカル(Docker,Vagrant)で開発して、最終確認をMoleculeを使う。
  • 2.品質担保のため共通の場所がない
    • MoleculeでEC2を作成、気になる部分があるならSSHすればおk。
  • 3.品質劣化への対策がない
    • スケジューラーで定期実行。まだ実装していない。そもそもテストコードをどんな立ち位置であるべきか議論しないといけない。
  • 4.冪等性対策がない
    • Molecule idempotenceの活用。これは非常に期待できる。

f:id:AdwaysEngineerBlog:20200219205119p:plain

今後

解決への道筋は見えたのですが、まだ解決には至っていません。
まだチームの文化として根付いていないからです。

チームメンバーがMoleculeを活用して、
Ansibleのコードをリファクタリングしたり開発するまでには至っておらず
今後の普及活動が必要なので力を入れていきたいと考えております。