Ansible: vsftpd でFTPサーバを構築する

はじめに

レガシーなシステムに携わっているとデータ置き場としてFTPサーバによく出くわします。今回は最低限の設定で vsftpd を構築する手順をPlaybook化してみました。

前提

  • OS は RHEL8 を利用する
  • FTPサービスとして vsftpd を利用する

実装

段取りとしては以下のようになります。

  1. vsftpd のインストール
  2. vsftpd の起動
  3. vsftpd の設定&反映(パッシブモード)
  4. firewallFTP 通信許可

上記を踏まえて今回書いたPlaybookは以下の通り。

---
- name: Setup vsftpd
  hosts: servers
  vars:
    pasv_min_port: 10021
    pasv_max_port: 10031
  become: true
  tasks:
    - name: Install vsftpd
      ansible.builtin.dnf:
        name: vsftpd
        state: present

    - name: Start vsftpd
      ansible.builtin.systemd:
        state: started
        name: vsftpd

    - name: Add passive mode to vsftpd
      ansible.builtin.blockinfile:
        path: /etc/vsftpd/vsftpd.conf
        state: present
        block: |
          pasv_enable=Yes
          pasv_min_port={{ pasv_min_port }}
          pasv_max_port={{ pasv_max_port }}
      register: _res

    - name: Restart vsftpd
      ansible.builtin.systemd:
        state: started
        name: vsftpd
      when: _res.changed

    - name: Permit ftp service with firewall
      ansible.posix.firewalld:
        service: ftp
        state: enabled
        permanent: true
        immediate: true

    - name: Enable nf_conntrack_helper kernel module
      ansible.posix.sysctl:
        name: net.netfilter.nf_conntrack_helper
        value: '1'
        sysctl_file: "/etc/sysctl.d/10-nf_conntrack_helper.conf"
        state: present
        reload: true

今回はvsftpdのの設定項目が少ないので blockinfile モジュールで済ませています。

また、パッシブモード時のFTP通信許可設定については sysctl の設定変更( ftp サーバをパッシブモードで起動する場合にfirewall 側で必要な通信許可を行うため、 nf_conntrack_helper カーネルモジュールをロードする)で対応しています。

実行結果は以下の通り。

PLAY [Setup vsftpd] ************************************************************

TASK [Gathering Facts] *********************************************************
ok: [server.test.local]

TASK [Install vsftpd] **********************************************************
changed: [server.test.local]

TASK [Start vsftpd] ************************************************************
changed: [server.test.local]

TASK [Add passive mode to vsftpd] **********************************************
changed: [server.test.local]

TASK [Restart vsftpd] **********************************************************
changed: [server.test.local]

TASK [Permit ftp service with firewall] ****************************************
changed: [server.test.local]

TASK [Enable nf_conntrack_helper kernel module] ****************************************
changed: [server.test.local]

PLAY RECAP *********************************************************************
server.test.local      : ok=1    changed=6    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

2回目以降は changed を可能な限りなくしたい派です。再実行結果は以下の通り。

PLAY [Setup vsftpd] ************************************************************

TASK [Gathering Facts] *********************************************************
ok: [server.test.local]

TASK [Install vsftpd] **********************************************************
ok: [server.test.local]

TASK [Start vsftpd] ************************************************************
ok: [server.test.local]

TASK [Add passive mode to vsftpd] **********************************************
ok: [server.test.local]

TASK [Restart vsftpd] **********************************************************
skipping: [server.test.local]

TASK [Permit ftp service with firewall] ****************************************
ok: [server.test.local]

TASK [Enable nf_conntrack_helper kernel module] ****************************************
ok: [server.test.local]

PLAY RECAP *********************************************************************
server.test.local      : ok=6    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

ok と skip のみになりました。

まとめ

vsfptd の構築方法についてまとめました。1年に1回くらいFTPサーバ構築のPlaybook作っている気がしたので今回アウトプットでもう大丈夫なはず。

参考情報:

2.8.6. FTP を使用するインストールソースの作成 Red Hat Enterprise Linux 8 | Red Hat Customer Portal

centos - How to configure vsftpd to work with passive mode - Server Fault

Ansible: KVMの仮想マシンを削除する

はじめに

検証環境としてサクッとVMを作ったり壊したりするのに便利なKVM、よく使っています。今回はKVM上で動作する仮想マシンを削除するためのPlaybookを作ってみます。手動で操作する場合は virsh を叩いたりしています。ただ、削除するにしても複数のサブコマンド実行が必要だったり、ホストマシンからの名前解決を/etc/hosts で簡易的に管理している場合はそちらのエントリも削除したりといった具合で手間がかかるため、そうした手間を省くのが今回のモチベーションです。

削除操作を自動化していますので試す場合はご注意ください&自己責任でお願いします。

準備

VMの操作には community.libvirt.virt モジュールを使います。

community.libvirt.virt module – Manages virtual machines supported by libvirt — Ansible Documentation

Playbookと同じディレクトリで requirements.yml を作成し、依存する collection を定義します。

requirements.yml

---
collections:
  - community.libvirt

collection をインストールします。

ansible-galaxy collection install -r requirements.yml -p collections

実装

今回作成したPlaybookがこちらです。

---
- name: Delete vms
  hosts: localhost
  gather_facts: false
  vars:
    vms:
      - hostname: 'centos8'
  tasks:
    - name: Get available vms
      community.libvirt.virt:
        command: list_vms
      register: existing_vms

    - name: Check if target vm name is existing
      ansible.builtin.assert:
        that: item.hostname in existing_vms['list_vms']
        fail_msg: "target vm '{{ item.hostname }}' is not found on kvm"
      loop: "{{ vms }}"

    - name: Destroy vms
      community.libvirt.virt:
        command: destroy
        name: "{{ item.hostname }}"
      loop: "{{ vms }}"
      ignore_errors: true

    - name: Undefine vms
      community.libvirt.virt:
        command: undefine
        name: "{{ item.hostname }}"
      loop: "{{ vms }}"

    - name: Delete qcow2 image
      ansible.builtin.file:
        path: "/var/lib/libvirt/images/{{ item.hostname }}.qcow2"
        state: absent
      loop: "{{ vms }}"

    - name: Delete hosts entry
      ansible.builtin.lineinfile:
        path: /etc/hosts
        regex: ".*{{ item.hostname }}.*"
        state: absent
      loop: "{{ vms }}"

複数のVMをまとめて削除できるよう、ドメイン名のリストを変数として定義し、各タスクで loop させています。安直に1つのPlaybookにまとめましたが、この処理をまるっとタスクファイルに切り出して include_tasks で呼び出すようにすれば、loopの定義が1箇所で済むのでもう少しスマートになりそうです。

削除の流れとしては、指定したドメインVMが存在するかを確認した上で、問題なければ destory、undefineを実行してVMを削除します。仮想マシンが停止している状態だと destory する際にエラーを返すため、 ignore_errors: true を対処しています。

また、前述の通り今回はホストマシンから仮想マシンに対する名前解決はホストマシン上の /etc/hosts で管理しているので、登録されたエントリも併せて削除しています。

実行結果

仮想マシンは以下のように停止した状態で削除してみます。

sudo virsh list --all
 Id   Name         State
-----------------------------
 -    centos8      shut off

いざ実行。

PLAY [Delete vms] ****************************************************************************************************************************************************************************

TASK [Get available vms] *********************************************************************************************************************************************************************
ok: [localhost]

TASK [Check if target vm name is existing] ***************************************************************************************************************************************************
ok: [localhost] => (item={'hostname': 'centos8'}) => {
    "ansible_loop_var": "item",
    "changed": false,
    "item": {
        "hostname": "centos8"
    },
    "msg": "All assertions passed"
}

TASK [Destroy vms] ***************************************************************************************************************************************************************************
An exception occurred during task execution. To see the full traceback, use -vvv. The error was: libvirt.libvirtError: Requested operation is not valid: domain is not running
failed: [localhost] (item={'hostname': 'centos8'}) => {"ansible_loop_var": "item", "changed": false, "item": {"hostname": "centos8"}, "msg": "Requested operation is not valid: domain is not running"}
...ignoring

TASK [Undefine vms] **************************************************************************************************************************************************************************
ok: [localhost] => (item={'hostname': 'centos8'})

TASK [Delete qcow2 image] ********************************************************************************************************************************************************************
changed: [localhost] => (item={'hostname': 'centos8'})

TASK [Delete hosts entry] ********************************************************************************************************************************************************************
changed: [localhost] => (item={'hostname': 'centos8'})

PLAY RECAP ***********************************************************************************************************************************************************************************
localhost                  : ok=6    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=1

無事削除できました。

まとめ

KVM上で動作するVMを削除するPlaybookについて記載しました。

Ansible: keycloakの設定をAPI経由で行う

はじめに

近頃、認可サービスを立てるためにkeycloakを触る機会がありました。Ansibleを使ってkeycloakの設定を行う場合は専用のモジュールを利用することも可能ですが、痒いところに手の届かない感じで細かな設定に対応しきれないことがあります。そこで、 uri モジュールを利用してkeycloakのAPI経由で設定する方法が一手段として考えられます。

ただし、さすが認可機能を提供するためのソフトウェアだけあって、keycloak自体のAPI操作も認可フローを介して操作する必要があります。このため、アクセストークンの取得が必要になるわけですが、今回は簡単に実装を紹介します。

準備

Podman - Keycloak

keycloakはコミュニティから提供されるコンテナイメージを利用します。今回はpodmanを利用しますが、上記のリンクに記載されているようコマンド一発でOKです。

podman run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:20.0.3 start-dev

起動したらブラウザで http://localhost:8080/admin にアクセスし、user/password = admin/admin でログインできることを確認します。

また、GUIからトークンエンドポイントを確認しておきます。

トークンエンドポイントの確認1

トークンエンドポイントの確認2

アクセストークンを取得するのみであれば、準備はこれで完了です。

実装

Documentation - Keycloak

APIの細かい仕様はドキュメントに任せるとして、アクセストークンを取得するだけでは面白味に欠けるため、トークンを使ってレルムを作成するPlaybookを書いてみます。

---
- name: Get keycloak access token
  hosts: localhost
  vars:
    kc_auth_username: admin
    kc_auth_password: admin
    kc_token_endpoint: http://localhost:8080/realms/master/protocol/openid-connect/token
  tasks:
    - name: Get access token
      ansible.builtin.uri:
        url: "{{ kc_token_endpoint }}"
        method: POST
        body_format: "form-urlencoded"
        body:
          grant_type: "password"
          client_id: "admin-cli"
          username: "{{ kc_auth_username }}"
          password: "{{ kc_auth_password }}"
        headers:
          Content-Type: "application/x-www-form-urlencoded"
      register: _res

    - name: Set token into a variable.
      ansible.builtin.set_fact:
        _access_token: "{{ _res.json.access_token }}"

    - name: Show access token
      ansible.builtin.debug:
        msg: "{{ _res.json.access_token }}"

    - name: Create realm
      ansible.builtin.uri:
        url: "http://localhost:8080/admin/realms"
        method: POST
        body_format: json
        body:
          realm: "my_realm"
          enabled: true
        headers:
          Authorization: "Bearer {{ _access_token }}"
        status_code: [201, 409]
      register: _res
      changed_when: _res.status == 201

トークンエンドポイントに対してPOSTリクエストのボディに認可フローの種別、認証情報などを埋め込んでアクセストークンを取得します。最後のタスクでレルムを作成していますが、Authorizationヘッダーに Bearer形式でアクセストークンを渡し、認可されればリクエストボディに記載したレルムの設定情報に基づいて新規作成します。また、想定されるステータスコードAPI仕様を確認しながら決めましょう。今回は、既にレルムが作成されている場合も考慮して 401 を追加しています。

実行結果

Playbookの実行結果は以下の通りです。

PLAY [Get keycloak access token] ***************************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] *************************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Get access token] ************************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Set token into a variable.] **************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Show access token] ***********************************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "msg": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJ4M3ZQdm1KMFNiUnhNSDhlOG05d1N5OHhnUHVld2t3d3daMUJya2ZjX3dvIn0.eyJleHAiOjE2NzQzODIzMzEsImlhdCI6MTY3NDM4MjI3MSwianRpIjoiNDYzM2Y5NzYtMDNjMS00NmJkLWJiNTYtYmRmOWRjNWYzOWViIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tYXN0ZXIiLCJzdWIiOiIwMmEyZWE1Mi0wNWM0LTRjMGYtYTFkZi1iMDBjYjBlNzY3ZmUiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJhZG1pbi1jbGkiLCJzZXNzaW9uX3N0YXRlIjoiNTI0NjhmNTAtODQ5My00YTA5LThmMzUtYjM4NjFjOTYxZTUzIiwiYWNyIjoiMSIsInNjb3BlIjoicHJvZmlsZSBlbWFpbCIsInNpZCI6IjUyNDY4ZjUwLTg0OTMtNGEwOS04ZjM1LWIzODYxYzk2MWU1MyIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoiYWRtaW4ifQ.I4FEXun0sh1K9d06TUkuT4Js6-wM92Swu_nu3eYtmujMU0rb1G9F55HWV0pWImiP2dIXDuv461i2fb8zsz3TxTgFHNKENoCDamQEEij9RK59Wybr3RjgknmekYCZIMbULjribOk4xthWJzFyJAzdIESlfmLvKTilPgeorKNdE_NymbKNAdXvrtPpAJtV6xImGmGnDjTZoy_-u67Ih839YSDJEBfhSxrZilNsKNLTHK3UNBtbmSuy4-Kiw8vcOxNXgvAZ8tbcrl4Q3ajPrVh8i2BGoUc9R9hmL7p2Nm6flCPQcpLSn-FlQMmOoUn8dNaoz3cwk1iyK2uiASux7bZZSw"
}

TASK [Create realm] ****************************************************************************************************************************************************************************************************************************************
changed: [localhost]

PLAY RECAP *************************************************************************************************************************************************************************************************************************************************
localhost                  : ok=5    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0 

実行後にGUIへログインし、レルムが作成されたことが確認できます。

keycloakonAPI操作によってレルムが作成

まとめ

keycloakの設定をansibleの uri モジュールを利用してAPI経由で実現する方法をまとめました。実利用を考えると実行時間が長い場合はアクセストークンがExpireするため、再度取得するなどのケアが必要になります。昨今はOAuth/OIDCといった認可が必須のAPIサービスも多いですので、今回の実装例は生かせるケースがまたきっとあるかも?

Ansible: リストへ動的に要素を追加する

はじめに

Playbookを書いていると、情報取得のキーとなるリストを変数として定義し、各キーで取得できたデータを変換・加工した後に再度それらのリスト作る、なんていう場面がよくあると思います。

今回は、APIから情報を取得するケースでこれらのPlaybookを考えてみます。

実装

基本方針としては繰り返しとなる処理部分を共通化して別タスクとして切り出します。共通処理部分では

  • データ取得
  • (必要に応じて)データ変換・加工
  • リストへの追加

という流れでリストへ要素を追加します。このため、共通化処理の呼び出し元では要素を追加するリスト変数の初期化を忘れず行います。まずは Playbookです。

site_dynamic_appending_elem.yml

---
- name: Append data to a list dynamically
  hosts: localhost
  vars:
    api_endpoint: "https://fakerestapi.azurewebsites.net/api/v1"
    book_ids: [1, 2, 3, 4]
  tasks:
    - name: Initialize variable
      ansible.builtin.set_fact:
        _book_titles: []

    - name: Fetch book title list
      ansible.builtin.include_tasks: inner_loop.yml
      loop: "{{ book_ids }}"
      loop_control:
        loop_var: _book_id

    - name: Show list generated dynamcally
      ansible.builtin.debug:
        var: _book_titles

次にPlaybook から呼び出されるタスクファイルです。

inner_loop.yml

---
- name: Append data to a list dynamically
  hosts: localhost
  vars:
    api_endpoint: "https://fakerestapi.azurewebsites.net/api/v1"
    book_ids: [1, 2, 3, 4]
  tasks:
    - name: Initialize variable
      ansible.builtin.set_fact:
        _book_titles: []

    - name: Fetch book title list
      ansible.builtin.include_tasks: inner_loop.yml
      loop: "{{ book_ids }}"
      loop_control:
        loop_var: _book_id

    - name: Show list generated dynamcally
      ansible.builtin.debug:
        var: _book_titles

実行結果

PLAY [Append data to a list dynamically] *******************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] *************************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Initialize variable] *********************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Fetch book title list] *******************************************************************************************************************************************************************************************************************************
included: /Users/k-boo/t/ansible/loop/inner_loop.yml for localhost => (item=1)
included: /Users/k-boo/t/ansible/loop/inner_loop.yml for localhost => (item=2)
included: /Users/k-boo/t/ansible/loop/inner_loop.yml for localhost => (item=3)
included: /Users/k-boo/t/ansible/loop/inner_loop.yml for localhost => (item=4)

TASK [Fetch Book info] *************************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Show title in  API response] *************************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "msg": "Book 1"
}

TASK [Append the book title to list] ***********************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Fetch Book info] *************************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Show title in  API response] *************************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "msg": "Book 2"
}

TASK [Append the book title to list] ***********************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Fetch Book info] *************************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Show title in  API response] *************************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "msg": "Book 3"
}

TASK [Append the book title to list] ***********************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Fetch Book info] *************************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Show title in  API response] *************************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "msg": "Book 4"
}

TASK [Append the book title to list] ***********************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Show list generated dynamcally] **********************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "_book_titles": [
        "Book 1",
        "Book 2",
        "Book 3",
        "Book 4"
    ]
}

PLAY RECAP *************************************************************************************************************************************************************************************************************************************************
localhost                  : ok=19   changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0  

まとめ

今回はリストへ動的に要素を追加する実装方法について書きました。この実装は共通処理部分で、外部リソースにアクセスしたり、複雑なデータ操作が必要となるような場合に適していると思われます。

Ansible: 2つのリストの組み合わせを処理する

はじめに

Playbookを書いていると多次元のデータをループさせて処理させる場合がよくあります。

以下のようなデータ構造を考えてみます。

people:
  - taro
  - jiro
  - saburo
sports:
  - soccer
  - baseball
  - bascketball

上記の2つのリストを使って、 <who> likes <sport> のような文字列の組み合わせをそれぞれ出力したいとします。

多重ループ

loop を利用して多重ループを構成する場合は内側のループを構成するタスクを別ファイルに分けて、外側のループから include_task を使って呼び出します。

site_doubleloop.yml

---
- name: Process data with double loop
  hosts: localhost
  vars:
    people:
      - taro
      - jiro
      - saburo
    sports:
      - soccer
      - baseball
      - bascketball
  tasks:
    - name: Outer loop call inner loop
      include_tasks: "inner_loop.yml"
      loop: "{{ people }}"
      loop_control:
        loop_var: person

inner_loop.yml

---
- name: Output
  debug:
    msg: "{{ person }} likes {{ sport }}"
  loop: "{{ sports }}"
  loop_control:
    loop_var: sport
TASK [Output] *******************************************************
ok: [localhost] => (item=soccer) => {
    "msg": "taro likes soccer"
}
ok: [localhost] => (item=baseball) => {
    "msg": "taro likes baseball"
}
ok: [localhost] => (item=bascketball) => {
    "msg": "taro likes bascketball"
}

TASK [Output] *******************************************************
ok: [localhost] => (item=soccer) => {
    "msg": "jiro likes soccer"
}
ok: [localhost] => (item=baseball) => {
    "msg": "jiro likes baseball"
}
ok: [localhost] => (item=bascketball) => {
    "msg": "jiro likes bascketball"
}

TASK [Output] *******************************************************
ok: [localhost] => (item=soccer) => {
    "msg": "saburo likes soccer"
}
ok: [localhost] => (item=baseball) => {
    "msg": "saburo likes baseball"
}
ok: [localhost] => (item=bascketball) => {
    "msg": "saburo likes bascketball"
}

filter を使ってデータを合成する

リストの直積を生成する product フィルタを利用します。前の方法と違い、タスクファイルを分ける必要がないためロジックがシンプルになります。

site_singleloop_with_product_filter.yml

- name: Process 2 lists data with product filter
  hosts: localhost
  vars:
    people:
      - taro
      - jiro
      - saburo
    sports:
      - soccer
      - baseball
      - bascketball
  tasks:
    - name: Generate product of the lists
      ansible.builtin.set_fact:
        _pdct: "{{ people | product(sports) }}"

    - name: Output them
      ansible.builtin.debug:
        var: _pdct

    - name: Generate Strings with the product
      debug:
        msg: "{{ item[0] }} likes {{ item[1] }}"
      loop: "{{ _pdct }}"
PLAY [Process 2 lists data with product filter] ************************************************************************************************************************************************************************************************************

TASK [Gathering Facts] *************************************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Generate product of the lists] ***********************************************************************************************************************************************************************************************************************
ok: [localhost]

TASK [Output them] *****************************************************************************************************************************************************************************************************************************************
ok: [localhost] => {
    "_pdct": [
        [
            "taro",
            "soccer"
        ],
        [
            "taro",
            "baseball"
        ],
        [
            "taro",
            "bascketball"
        ],
        [
            "jiro",
            "soccer"
        ],
        [
            "jiro",
            "baseball"
        ],
        [
            "jiro",
            "bascketball"
        ],
        [
            "saburo",
            "soccer"
        ],
        [
            "saburo",
            "baseball"
        ],
        [
            "saburo",
            "bascketball"
        ]
    ]
}

TASK [Generate Strings with the product] *******************************************************************************************************************************************************************************************************************
ok: [localhost] => (item=['taro', 'soccer']) => {
    "msg": "taro likes soccer"
}
ok: [localhost] => (item=['taro', 'baseball']) => {
    "msg": "taro likes baseball"
}
ok: [localhost] => (item=['taro', 'bascketball']) => {
    "msg": "taro likes bascketball"
}
ok: [localhost] => (item=['jiro', 'soccer']) => {
    "msg": "jiro likes soccer"
}
ok: [localhost] => (item=['jiro', 'baseball']) => {
    "msg": "jiro likes baseball"
}
ok: [localhost] => (item=['jiro', 'bascketball']) => {
    "msg": "jiro likes bascketball"
}
ok: [localhost] => (item=['saburo', 'soccer']) => {
    "msg": "saburo likes soccer"
}
ok: [localhost] => (item=['saburo', 'baseball']) => {
    "msg": "saburo likes baseball"
}
ok: [localhost] => (item=['saburo', 'bascketball']) => {
    "msg": "saburo likes bascketball"
}

PLAY RECAP *************************************************************************************************************************************************************************************************************************************************
localhost                  : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

# おわりに

2つのリストに対して要素の組み合わせを処理する方法をまとめました。