AnsibleでAWS EC2のfstabを正しく作成する方法 Day.11
こんにちは。
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編』
お楽しみに!
――――――――――――――――――――――――――――――――――
お問合せはお気軽に
SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/
SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/
SHIFTの導入事例
https://service.shiftinc.jp/case/
お役立ち資料はこちら
https://service.shiftinc.jp/resources/
SHIFTの採用情報はこちら