見出し画像

AnsibleでAWS EC2のfstabを正しく作成する方法 Day.11

★SHIFTGroup技術ブログ(公式note)でアドベントカレンダー★
SHIFT公式ブロガーによるブログ版アドベントカレンダーで、SHIFTらしい多彩な最新記事をクリスマスまでの25日間に毎日お届けしております!
SHIFT公式アドカレ2023まとめ記事 
SHIFT公式アドカレ2022はじめます 
SHIFTGroup技術ブログTOP 

昨日の記事は、クリスマスプレゼントにいかが? 幼児向け絵本を作成するGPTsを作ってみた Day.10
でした。いかがでしたか?さて本日はこちら!

こんにちは。
SHIFTからグループ会社のシステムアイに出向している水谷です。

以前は同じくグループ会社であるRGA(リアルグローブ・オートメーティッド)に出向していたのですが、1年ちょっと前にRGAがシステムアイに吸収される形で合併したため、今はシステムアイの「RGA事業部」でインフラやコンテナ関連の仕事をしております。
システムアイのRGA事業部ではIaCでの自動化を含むクラウドインフラの構築から、CI/CDの構築を含むSRE領域でのサービスなどを行っているのですが、このアドベントカレンダーでは私がAnsibleを使ったサーバ構築の自動化で技術的に面白かった問題を取り上げたいと思います。


AWSのストレージに関するちょっと面倒な問題

AWSのEC2でm5.largeやそれ以上のインスタンスタイプを選ぶと、ストレージデバイスはnvmeタイプのSSDになります。

nvmeタイプのストレージは高速なのは良いのですが、ルートストレージ以外に2つ以上のストレージがアタッチされている場合はちょっと面倒なことがおこります。

というのも、このタイプのストレージはRHELなどLinux系OSからは /dev/nvme0n1/dev/nvme1n1などのデバイスとして見えるのですが、このデバイス名がVMの再起動や、停止→開始の際に変わってしまう可能性があるのです。

ルートボリュームのデバイス名は /dev/nvme0n1であることが保証されるのですが、それ以降のデバイス名は準備ができたもの(デバイスが起動し読み書きできる状態になったもの)から /dev/nvme1n1, /dev/nvme2n1, /dev/nvme3n1・・・の順で登録される仕様になっているためです。

この仕様で一番影響を受けるのが、(ストレージデバイスのマウント情報を保持する)fstab の作成でしょう。

一般的にfstabを使ってマウント設定を行う際、デバイス名とマウントポイント(およびファイルシステムの種別など付随する情報)を1行ごとに記述していけばよいのですが、上記のようにデバイス名(/dev/nvme1n1など)が変動してしまうため、これが使用できません。

このような場合はデバイス名の代わりに(変動しない)UUIDを使ってfstabを記述する必要があります。

となると問題は、各マウントポイントに対応するデバイスのUUIDはどのように取得すればよいか、ということになってきます。

ここではRHEL8に以下のようなストレージ構成を指定したEC2を例に、手動でfstabを作成する方法と、それを自動化する方法を書いてみたいと思います。

AWSから見えるストレージ名	ボリュームID	            サイズ	用途
dev/sda	                vol-0f4ab838a944b2203	50GB	ルートデバイス
dev/sdb	                vol-0135628c692f1db4d	10GB	スワップ
dev/sdc	                vol-0ed8e4b4cc39c1097	20GB	/var/test1へのマウント
dev/sdd	                vol-050244b3300e91afa	30GB	/var/test2へのマウント

手動で行う方法

ストレージの情報を取得するのによく使われるコマンドに lsblk があります。このコマンドはストレージの一覧を表示することができ、その際様々な情報もオプションで表示されます。

ここで、'-o +SERIAL' オプションをつけて実行すると、ストレージのシリアル番号が表示されるのですが、AWS EBSの場合はここにボリュームIDが表示されます。

$ lsblk -o +SERIAL
NAME        MAJ:MIN RM SIZE RO TYPE MOUNTPOINT SERIAL
nvme0n1     259:0    0  50G  0 disk            vol0f4ab838a944b2203
├─nvme0n1p1 259:6    0   1M  0 part
└─nvme0n1p2 259:7    0  50G  0 part /
nvme1n1     259:1    0   30G  0 disk            vol050244b3300e91afa
nvme3n1     259:2    0   10G  0 disk            vol0135628c692f1db4d
nvme2n1     259:3    0   20G  0 disk            vol0ed8e4b4cc39c1097

このボリュームIDをAWSコンソールでEC2インスタンスのストレージタブに表示されているボリュームID内から探せば、(AWS上の)ストレージデバイス名が特定できます。

例えばスワップ用デバイスのボリュームIDがAWSコンソール上から vol-0135628c692f1db4d だとわかっている場合は、上記 lsblk の実行結果の中からそれを探せば、nvme3n1が対応することがわかりますね。

※今回の例では各EBSの容量が異なるので、そこから調べることもできますが、同じ要領のEBSがアタッチされている場合は特定できないので、その方法は取らないことにします。

続いて同じくlsblkコマンドで 'lsblk /dev/nvme3n1 -no UUID'と実行すればUUIDが取れます。

$ lsblk /dev/nvme1n1 -no UUID
22b6c512-bbe8-4d5e-aff1-4ac84d95d57b

これをfstabの一番左に記述すればよいことになりますね。以下はその例です。

3af43073-3877-443b-8b0c-c84e9d06d531 / xfs defaults 1 1
3c6da472-10f5-4772-a564-ea34b68e4e86 none swap sw 0 0
3c7a7608-958d-473e-a71a-2972c02dccfe /var/test1 xfs defaults 1 2
22b6c512-bbe8-4d5e-aff1-4ac84d95d57b /var/test2 xfs defaults 1 2

Ansibleによる自動化

このような追加ストレージが少ない構成では手動で作成してもそれほど時間はかかりませんが、ストレージが多い場合や多数のサーバの設定を行う必要がある場合は自動化しないと大変です。

ということで、ここではAnsibleで自動化してみましょう。

ポイントは、OSから見たデバイス名(例 /dev/nvme1n1)とAWS側のデバイス名(例 dev/sdb)の紐づけをどのようにするか、という点になります。

その方法としてまず思いつくのが、AWS CLIを使う方法です。

このEC2インスタンスに対して 'describe-instances'を実行してみると、以下のように出力されました。

$ aws ec2 describe-instances --instance-ids i-054fccf6baac0d713
{
    "Reservations": [
        {
            "Groups": [],
            "Instances": [
                {
                    "AmiLaunchIndex": 0,
                    "ImageId": "ami-0c66ebb049bbef209",
                    "InstanceId": "i-054fccf6baac0d713",
                    "InstanceType": "m5.large",
                    ...
                    "BlockDeviceMappings": [
                        {
                            "DeviceName": "/dev/sda1",
                            "Ebs": {
                                "AttachTime": "2023-10-12T08:21:34.000Z",
                                "DeleteOnTermination": true,
                                "Status": "attached",
                                "VolumeId": "vol-01e5f4c643ca9463b"
                            }
                        },
                        {
                            "DeviceName": "/dev/sdb",
                            "Ebs": {
                                "AttachTime": "2023-10-12T08:21:34.000Z",
                                "DeleteOnTermination": true,
                                "Status": "attached",
                                "VolumeId": "vol-0e8e408d47483b041"
                            }
                        },
                        {
                            "DeviceName": "/dev/sdc",
                            "Ebs": {
                                "AttachTime": "2023-10-12T08:21:34.000Z",
                                "DeleteOnTermination": true,
                                "Status": "attached",
                                "VolumeId": "vol-07dc6b8f8c3d8f79f"
                            }
                        },
                        {
                            "DeviceName": "/dev/sdd",
                            "Ebs": {
                                "AttachTime": "2023-10-12T08:21:34.000Z",
                                "DeleteOnTermination": true,
                                "Status": "attached",
                                "VolumeId": "vol-03a1894b9e39bfa78"
                            }
                        }
                    ],
                    ...

BlockDeviceMappingsの1要素目を見ると、"DeviceName": "/dev/sdb"と"VolumeId": "vol-01e5f4c643ca9463b"が書かれており、これと 'lsblk -o +SERIAL' の結果を突き合せればマッピングができますね。

Ansibleで自動化するには、このコマンドを 'local_action' あるいは 'delegete_to' を使って(対象ホストではなく)Ansibleサーバ側で実行すればOKです。・・・が、ちょっと手間がかかります。

まず、Ansible Automation Platformで実行する場合、Playbookの実行はExecution Environment(EE)と呼ばれるコンテナ上で行われるので、ここにAWS CLIをインストールしておかなければいけません(その場でインストールしてもよいが時間がかかるしインターネット接続が必要)。

次にAWSのAccess KeyやSecret Access Keyの管理をどうするか考えなければいけなく、さらにAWS CLIが出力したJsonデータをうまくパースして欲しい情報を取り出すことも必要となります。

ちょっと大変ですね。より簡単で良い方法はないでしょうか?

救世主 Nvme-CLI

で、いろいろ調べたところ、nvmeデバイスの情報収集、設定変更、ファームウェアアップデートに使用できるツール nvme-cli がこの用途に使えることがわかりました。

このツールをインストールすればnvmeデバイスの各種情報が見れまして、さらにコマンドラインオプションに 'id-ctrl'を指定するとnvmeコントローラの情報も見ることもできます。で、この際、'-v' オプションも併せてつけることでコントローラのベンダー固有バイナリデータ領域も見ることができるようになっています。

実はこのベンダー固有バイナリデータの先頭がAWS側からみたデバイス名(例えば/dev/sdbの「sdb」)なのです。

実際に実行してみると、以下のように表示されます。

$ sudo nvme id-ctrl /dev/nvme3n1 -v
NVME Identify Controller:
vid       : 0x1d0f
ssvid     : 0x1d0f
sn        : vol0135628c692f1db4d
mn        : Amazon Elastic Block Store
...
vs[]:
0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
0000: 73 64 62 20 20 20 20 20 20 20 20 20 20 20 20 20 "sdb............."
0010: 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 "................"

ということで、このコマンドの結果からgrepとcutで文字列を切り出せば、デバイス名である「sdb」が取り出せます。

$ sudo nvme id-ctrl /dev/nvme3n1 -v | grep 0000: | cut -c 56-58
sdb

ただし、ここに一つ落とし穴があって、ストレージボリュームを別途作成してアタッチした場合などは、ここが'sdb'ではなく、'/dev/sdb'のように'/dev/'が付きますので注意が必要です。

とはいえ、これでOS側から見たデバイス名(/nvme●n1)とAWS側のデバイス名(/dev/sd●)が紐づいたので、あとは、lsblkコマンドの結果をうまく対応付ければ、UUIDを使ったfstabの生成ができることになります。

Playbook化

ではPlaybookを作っていきたいと思います。

まず前提となるストレージ情報は、以下のような storage_info 変数が host_vars 等で設定されるとします(ファイルシステムの種別、オプションなどをそれぞれパラメーター化することもできますが、ここでは簡単な実装とします)。

storage_info:
  - device: /dev/sda1
    mountparam: '/ xfs dfault s 1 1'
  - device: /dev/sdb
    mountparam: 'none swap sw 0 0'
  - device: /dev/sdc
    mountparam: '/var/test1 xfs defaults 1 2'
  - device: /dev/sdd
    mountparam: '/var/test2 xfs defaults 1 2'

つまり、AWS側でのデバイス名と、マウント先およびファイルシステムなど、fstabで各行の後ろの方に書く内容をペアにしたリストですね。

続いてRoleの内容ですが、まずはnvmeコマンドを実行する部分の実装です。

    - name: nvmeコマンドを実行してAWS側のデバイス名を取得
      ansible.builtin.shell: "nvme id-ctrl /dev/nvme{{ item }} -v | grep 0000: | cut -c 56-64"
      changed_when: false
      become: true
      register: nvme_results
      loop: "{{ range(0, (storage_info | length)) | list }}"

    - name: AWS側ストレージ名とOS側デバイス名のマップを作成
      ansible.builtin.set_fact:
        device_storage_map: "{{ (device_storage_map | default({})) | combine({ ('/dev/' + ((item.stdout | replace('.', '')) | split('/'))[-1]) : ('/dev/nvme' + (ansible_loop.index0 | string) + 'n1') }) }}"
      loop: "{{ nvme_results.results }}"
      loop_control:
        extended: true

最初のタスクで /dev/nvme0n1 から最後のストレージデバイス(この場合は /dev/nvme3n1) までに対して nvmeコマンドを実行しています。なお、最後の cut はちょっと広めにして、'sdb'のような短いデバイス名であっても'/dev/sdb'のような形式であっても取りこぼさないようにしています。

続いて2つ目のタスクですが、nvmeコマンドの結果から、AWS側ストレージ名をキーにし、OS側のデバイス名をバリューとするマップを作成しています。ちょっと複雑なコードになっていますが、そこはAnsibleなので仕方がないところですw

続いてルート以外のファイルシステムを作成します(fstypeパラメーターは、スワップデバイスには 'swap'、それ以外は 'xfs' になるようにしています)。

    - name: ファイルシステムの作成
      community.general.filesystem:
        dev: "{{ device_storage_map[item.device] }}"
        fstype: "{{ 'swap' if 'swap' in item.mountparam else 'xfs' }}"
        resizefs: "{{ true if 'swap' not in item.mountparam else omit }}"
      when: ansible_loop.index0 != 0
      loop: "{{ storage_info }}"
      loop_control:
        extended: true
      become: true

今度はOS側デバイス名とUUIDの対応マップを作成します。

    - name: lsblkコマンドを実行してデバイスUUIDを取得
      ansible.builtin.shell: "lsblk /dev/nvme{{ item }}n1 -no UUID | grep '-'"
      register: disk_uuid
      changed_when: false
      loop: "{{ range(0, (storage_info | length)) | list }}"

    - name: デバイス名とUUIのマップを作成
      ansible.builtin.set_fact:
        device_uuid_map: "{{ (device_uuid_map | default({})) | combine({ ('/dev/nvme' + (item | string) + 'n1') : disk_uuid.results[item].stdout }) }}"
      loop: "{{ range(0, (storage_info | length)) | list }}"

1つ目のタスクで grep '-' としているのは、無駄な空行が表示される場合があるため、それを削除しています(ちょっと雑な実装ですが)。

あとはfstabのバックアップをとって、削除してからlineinfileで1行ずつUUIDとマウント情報を追加していきます。

    - name: fstabのバックアップを行う
      ansible.builtin.copy:
        src: /etc/fstab
        dest: "/etc/fstab_backup"
        remote_src: true
      failed_when: false

    - name: fstabを削除する
      ansible.builtin.file:
        path: /etc/fstab
        state: absent
      become: true

    - name: fstabを生成する
      ansible.builtin.lineinfile:
        path: /etc/fstab
        line: "UUID={{ device_uuid_map[device_storage_map[item.device]] }} {{ item.mountparam }}"
        create: true
      loop: "{{ storage_info }}"
      become: true

このタスクで生成されたfstabはこんな感じです。

[ec2-user@ip-172-31-34-247 ~]$ cat /etc/fstab
UUID=d5e8391e-0710-480d-b6ce-10d4adc095b4 / xfs defaults 1 2
UUID=fadde420-cc44-4f41-84c6-8abc2a8df97a none swap sw 0 0
UUID=eb64a6f3-98cc-443e-9fe9-edf3c3978c2d /var/test1 xfs defaults 1 2
UUID=56e4948a-9529-4128-a719-e83228c5e282 /var/test2 xfs defaults 1 2

期待通りですね。

これでマシンを再起動すれば、nvmeデバイスのデバイス名が変動したとしても期待通りにストレージがマウントされることになります。

[ec2-user@ip-172-31-34-247 ~]$ lsblk
NAME        MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
nvme0n1     259:0    0  50G  0 disk
├─nvme0n1p1 259:1    0   1M  0 part
└─nvme0n1p2 259:2    0  50G  0 part /
nvme2n1     259:3    0  20G  0 disk /var/test1
nvme3n1     259:4    0  30G  0 disk /var/test2
nvme1n1     259:5    0  10G  0 disk [SWAP]

※もし試される場合は、再起動する前にfstabの内容が正しいか確認していただければと思います。間違っているとマシンが起動しなくなる可能性がありますのでお気を付けください。

最後にPlaybook全体のコードを書いておきます(念のためマシンを再起動するタスクは記述しておりませんので必要に応じて追加してください)。なお、これはあくまで参考用のもので、動作を保証するものではありませんのでご了承ください。

- name: RHELのfstabを生成する
  hosts: rhel
  tasks:
    - name: nvme-cli をインストールする
      ansible.builtin.yum:
        name: nvme-cli
        state: present
      become: true

    - name: nvmeコマンドを実行してAWS側のデバイス名を取得
      ansible.builtin.shell: "nvme id-ctrl /dev/nvme{{ item }} -v | grep 0000: | cut -c 56-64"
      changed_when: false
      become: true
      register: nvme_results
      loop: "{{ range(0, (storage_info | length)) | list }}"

    - name: AWS側ストレージ名とOS側デバイス名のマップを作成
      ansible.builtin.set_fact:
        device_storage_map: "{{ (device_storage_map | default({})) | combine({ ('/dev/' + ((item.stdout | replace('.', '')) | split('/'))[-1]) : ('/dev/nvme' + (ansible_loop.index0 | string) + 'n1') }) }}"
      loop: "{{ nvme_results.results }}"
      loop_control:
        extended: true

    - name: ファイルシステムの作成
      community.general.filesystem:
        dev: "{{ device_storage_map[item.device] }}"
        fstype: "{{ 'swap' if 'swap' in item.mountparam else 'xfs' }}"
        resizefs: "{{ true if 'swap' not in item.mountparam else omit }}"
      when: ansible_loop.index0 != 0
      loop: "{{ storage_info }}"
      loop_control:
        extended: true
      become: true

    - name: lsblkコマンドを実行してデバイスUUIDを取得
      ansible.builtin.shell: "lsblk /dev/nvme{{ item }}n1 -no UUID | grep '-'"
      register: disk_uuid
      changed_when: false
      loop: "{{ range(0, (storage_info | length)) | list }}"

    - name: デバイス名とUUIのマップを作成
      ansible.builtin.set_fact:
        device_uuid_map: "{{ (device_uuid_map | default({})) | combine({ ('/dev/nvme' + (item | string) + 'n1') : disk_uuid.results[item].stdout }) }}"
      loop: "{{ range(0, (storage_info | length)) | list }}"

    - name: fstabのバックアップを行う
      ansible.builtin.copy:
        src: /etc/fstab
        dest: "/etc/fstab_backup"
        remote_src: true
      failed_when: false

    - name: fstabを削除する
      ansible.builtin.file:
        path: /etc/fstab
        state: absent
      become: true

    - name: fstabを生成する
      ansible.builtin.lineinfile:
        path: /etc/fstab
        line: "UUID={{ device_uuid_map[device_storage_map[item.device]] }} {{ item.mountparam }}"
        create: true
      loop: "{{ storage_info }}"
      become: true

★★SHIFT公式ブログでアドベントカレンダー★★
明日の記事は、
JUnit XMLによる自動テストレポート-Playwright + Gitlab編
お楽しみに!

――――――――――――――――――――――――――――――――――

執筆者プロフィール:水谷 裕一
大手外資系IT企業で15年間テストエンジニアとして、多数のプロジェクトでテストの自動化作業を経験。その後画像処理系ベンチャーを経てSHIFTに自動化エンジニアとして入社。
SHIFTでは、テストの自動化案件を2件こなした後、株式会社リアルグローブ・オートメーティッド(RGA)に出向。RGAがシステムアイに吸収合併後は、システムアイのRGA事業部で、プレーイングマネジャーとしてAnsibleやOpenshiftに関する案件も担当。また、Ansibleの社内教育や、外部セミナー講師も行っている。

お問合せはお気軽に

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら