AWS ParallelCluster インスタンスストアを自動マウントする仕組みを調べてみた

コンピュートノードのユーザーデータによりマウント処理が走ると覚えておけば AWS ParallelCluster を普通に利用する分には困らないです。
2022.05.15

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

AWS ParallelCluster でインスタンスストアを持つ特定のインスタンスタイプをコンピュートノードで利用するとインスタンスストアを自動的にマウントされた状態で起動します。通常の EC2 インスタンスで利用すると自動マウントはされません。ParallelCluster はどの様にマウント処理を実装しているのか調べてみました。

事前調査

AWS ParallelCluster 3.1.3 と i4i インスタンスタイプの組み合わせを例にインスタンスストアのマウント設定を確認します。

i4i インスタンスタイプはストレージ最適化インスタンスだけにインスタンスストアが搭載されたインスタンスタイプです。インスタンスタイプに応じてインスタンスストアのストレージ容量が異なるため一度確認しておきます。

$ aws ec2 describe-instance-types \
        --filters "Name=instance-type,Values=i4i*" "Name=instance-storage-supported,Values=true" \
        --query "sort_by(InstanceTypes, &InstanceStorageInfo.TotalSizeInGB)[].[InstanceType, InstanceStorageInfo.TotalSizeInGB]" \
        --region us-east-2 \
        --output table

インスタンスストアの容量は 468GB 〜 30TB までとだいぶ幅がありますね。

実行結果

---------------------------
|  DescribeInstanceTypes  |
+---------------+---------+
|  i4i.large    |  468    |
|  i4i.xlarge   |  937    |
|  i4i.2xlarge  |  1875   |
|  i4i.4xlarge  |  3750   |
|  i4i.8xlarge  |  7500   |
|  i4i.16xlarge |  15000  |
|  i4i.32xlarge |  30000  |
+---------------+---------+

i4i インスタンスタイプを通常の EC2 と、ParallelCluster コンピュートノードで起動した場合の違いを確認します。

EC2 インスタンスを確認

i4i.large インスタンスを普通に起動させました。OS は Ubuntu 20.04 です。インスタンスストアは自動的にマウントされないため、ストレージを確認できません。

$ df -h
Filesystem       Size  Used Avail Use% Mounted on
/dev/root        7.6G  1.5G  6.2G  20% /
tmpfs            7.7G     0  7.7G   0% /dev/shm
tmpfs            3.1G  836K  3.1G   1% /run
tmpfs            5.0M     0  5.0M   0% /run/lock
/dev/nvme0n1p15  105M  5.3M  100M   5% /boot/efi
tmpfs            1.6G  4.0K  1.6G   1% /run/user/1000

lsblkコマンドでブロックデバイスの一覧を確認すると435.9Gと容量からインスタンスストアらしきデバイスを確認できます。

$ lsblk
NAME         MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
loop0          7:0    0  26.6M  1 loop /snap/amazon-ssm-agent/5163
loop1          7:1    0  55.5M  1 loop /snap/core18/2344
loop2          7:2    0  61.9M  1 loop /snap/core20/1405
loop3          7:3    0  79.9M  1 loop /snap/lxd/22923
loop4          7:4    0  43.6M  1 loop /snap/snapd/15177
nvme0n1      259:0    0     8G  0 disk
├─nvme0n1p1  259:1    0   7.9G  0 part /
├─nvme0n1p14 259:2    0     4M  0 part
└─nvme0n1p15 259:3    0   106M  0 part /boot/efi
nvme1n1      259:4    0 435.9G  0 disk

nvme-cliコマンドで確認するとAmazon EC2 NVMe Instance Storageの文字列を確認できました。

$ sudo apt update -y
$ sudo apt install nvme-cli -y

$ sudo nvme id-ctrl -v /dev/nvme1n1
NVME Identify Controller:
vid       : 0x1d0f
ssvid     : 0
sn        : AWS43B53DFA47575ECBF
mn        : Amazon EC2 NVMe Instance Storage
--- snip ---

インスタンスストアをマウントする方法はファイルシステム作成して/etc/fstabに書いておくなど方法はいくつか考えられます。

EC2 インスタンスにインスタンスストアボリュームを追加する - Amazon Elastic Compute Cloud

というわけで、インスタンスストアをデバイスとして認識しているがマウントはされていない状態です。

ParallelCluster コンピュートノードで起動

i4i.8xlargeが起動するクラスターは以下のリンクで紹介しているコンフィグから作成した環境で確認します。

コンピュートノードが起動した時点でインスタンスストアは/scratchディレクトリにマウントされています。

$ df -h
Filesystem                             Size  Used Avail Use% Mounted on
/dev/root                               34G   18G   17G  51% /
devtmpfs                               124G     0  124G   0% /dev
tmpfs                                  124G     0  124G   0% /dev/shm
tmpfs                                   25G  1.1M   25G   1% /run
tmpfs                                  5.0M     0  5.0M   0% /run/lock
tmpfs                                  124G     0  124G   0% /sys/fs/cgroup
/dev/loop0                              27M   27M     0 100% /snap/amazon-ssm-agent/5163
/dev/loop2                              62M   62M     0 100% /snap/core20/1405
/dev/loop1                              56M   56M     0 100% /snap/core18/2344
/dev/loop3                              68M   68M     0 100% /snap/lxd/22753
/dev/loop4                              44M   44M     0 100% /snap/snapd/15177
/dev/loop5                              45M   45M     0 100% /snap/snapd/15534
10.0.1.12:/home                         34G   17G   18G  49% /home
10.0.1.12:/opt/parallelcluster/shared   34G   17G   18G  49% /opt/parallelcluster/shared
/dev/loop6                              62M   62M     0 100% /snap/core20/1434
/dev/mapper/vg.01-lv_ephemeral         6.8T   89M  6.5T   1% /scratch
10.0.1.12:/opt/intel                    34G   17G   18G  49% /opt/intel
10.0.1.12:/opt/slurm                    34G   17G   18G  49% /opt/slurm
tmpfs                                   25G   40K   25G   1% /run/user/1000

ブロックデバイスは2つ確認できます。

$ lsblk
NAME                 MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
loop0                  7:0    0 26.7M  1 loop /snap/amazon-ssm-agent/5163
loop1                  7:1    0 55.5M  1 loop /snap/core18/2344
loop2                  7:2    0 61.9M  1 loop /snap/core20/1405
loop3                  7:3    0 67.8M  1 loop /snap/lxd/22753
loop4                  7:4    0 43.6M  1 loop /snap/snapd/15177
loop5                  7:5    0 44.7M  1 loop /snap/snapd/15534
loop6                  7:6    0 55.5M  1 loop /snap/core18/2409
loop7                  7:7    0 61.9M  1 loop /snap/core20/1434
loop8                  7:8    0 25.1M  1 loop /snap/amazon-ssm-agent/5656
nvme0n1              259:0    0   35G  0 disk
└─nvme0n1p1          259:2    0   35G  0 part /
nvme2n1              259:1    0  3.4T  0 disk
└─vg.01-lv_ephemeral 253:0    0  6.8T  0 lvm  /scratch
nvme1n1              259:3    0  3.4T  0 disk
└─vg.01-lv_ephemeral 253:0    0  6.8T  0 lvm  /scratch

i4i.8xlargeのローカルストレージはなぜ2つあるのかと言うと

aws ec2 describe-instance-types \
    --filters "Name=instance-type,Values=i4i.8xlarge" \
    --query "InstanceTypes[].InstanceStorageInfo" \
    --region us-east-2

3750GBのストレージが2つで、7.5TBのインスタンスストアを提供されています。

実行結果

-------------------------------------------------------
|                DescribeInstanceTypes                |
+-------------------+---------------+-----------------+
| EncryptionSupport |  NvmeSupport  |  TotalSizeInGB  |
+-------------------+---------------+-----------------+
|  required         |  required     |  7500           |
+-------------------+---------------+-----------------+
||                       Disks                       ||
|+--------------+---------------------+--------------+|
||     Count    |      SizeInGB       |    Type      ||
|+--------------+---------------------+--------------+|
||  2           |  3750               |  ssd         ||
|+--------------+---------------------+--------------+|

i4i.largeのブロックデバイスは1つでしたが、大きなタイプだと個数を増やして大きなストレージ容量を提供しています。 たとえばi4i.32xlargeは 3750GB * 8個で 30TB のインスタンスストアを提供しています。

というわけで、インスタンスストアはマウントされた状態で起動することを確認できました。

ここまで確認できたこと

  • 通常の EC2 インスタンスを起動するとインスタンスストアは自動マウントされない
  • ParallelCluster のコンピュートノードで起動するとインスタンスは自動マウントされる

次は ParallelCluster のコンピュートノードで起動時、どやってインスタンスストアをマウントしたのかを確認します。

コンピュートノードのマウント処理を追う

やっと本題のどうやって ParallelCluster はインスタンスストアをマウントしているのか確認します。

コンピュートノード起動時のログ確認

/var/log/cloud-init-output.logからインスタンスストアをマウント時のログを確認できました。

ローカルストレージが2つあるため論理ボリュームグループ(LVM)を作成してからext4ファイルシステムを作成しています。その後/scratchにマウントしています。

/var/log/cloud-init-output.log 抜粋

--- snip ---
  * service[setup-ephemeral] action enable[2022-05-14T09:56:02+00:00] INFO: Processing service[setup-ephemeral] action enable (aws-parallelcluster-config::base line 30)
[2022-05-14T09:56:02+00:00] INFO: service[setup-ephemeral] enabled
    - enable service service[setup-ephemeral]
  * execute[Setup of ephemeral drivers] action run[2022-05-14T09:56:02+00:00] INFO: Processing execute[Setup of ephemeral dri
vers] action run (aws-parallelcluster-config::base line 36)

    [execute] ParallelCluster - This instance type has (2) device(s) for instance store: (/dev/nvme2n1 /dev/nvme1n1)
              ParallelCluster - LVM (/dev/vg.01/lv_ephemeral) does not exist
              ParallelCluster - Creating LVM (/dev/vg.01/lv_ephemeral)
                Physical volume "/dev/nvme2n1" successfully created.
                Physical volume "/dev/nvme1n1" successfully created.
                Volume group "vg.01" successfully created
                Wiping atari signature on /dev/vg.01/lv_ephemeral.
                Logical volume "lv_ephemeral" created.
              ParallelCluster - LVM (/dev/vg.01/lv_ephemeral) created successfully
              ParallelCluster - Found LVM (/dev/vg.01/lv_ephemeral) in state (a)
              ParallelCluster - Found LVM (/dev/vg.01/lv_ephemeral) FS type ()
              ParallelCluster - Formatting LVM (/dev/vg.01/lv_ephemeral) with FS type (ext4)
              mke2fs 1.45.5 (07-Jan-2020)
              Discarding device blocks: done
              Creating filesystem with 1831053312 4k blocks and 228884480 inodes
              Filesystem UUID: d5240c5a-f705-4841-ba1d-f6684b3b1672
              Superblock backups stored on blocks:
                32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632, 2654208,
                4096000, 7962624, 11239424, 20480000, 23887872, 71663616, 78675968,
                102400000, 214990848, 512000000, 550731776, 644972544

              Allocating group tables: done
              Writing inode tables: done
              Creating journal (262144 blocks): done
              Writing superblocks and filesystem accounting information: done

              ParallelCluster - LVM (/dev/vg.01/lv_ephemeral) formatted successfully
              ParallelCluster - LVM (/dev/vg.01/lv_ephemeral) not mounted, mounting on (/scratch)
              mount: /dev/mapper/vg.01-lv_ephemeral mounted on /scratch.
              ParallelCluster - LVM (/dev/vg.01/lv_ephemeral) mounted successfully
[2022-05-14T09:56:15+00:00] INFO: execute[Setup of ephemeral drivers] ran successfully
    - execute /usr/local/sbin/setup-ephemeral-drives.sh
--- snip ---

確認したログについて

コンピュートノードを調査したいときは/var/log/cloud-init-output.logを確認すれば概ねあたりつけられます。

コンピュートノードが起動した場合、まず /var/log/cloud-init-output.log を確認します。ヘッドノードの /var/log/chef-client.log ログと同様のセットアップログが含まれているはずです。セットアップ中に発生するほとんどのエラーは、/var/log/cloud-init-output.log ログにエラーメッセージが表示されます。

AWS ParallelCluster のトラブルシューティング - AWS ParallelCluster

setup-ephmeral.service を調べる

抜粋したcloud-init-output.logの冒頭setup-ephemeralとはなにかを探しました。

/var/log/cloud-init-output.log 該当箇所

[2022-05-14T09:56:02+00:00] INFO: service[setup-ephemeral] enabled

Chef の cookbook がコンピュートノードの以下のパスに保存されており、その中の receipe に該当の設定がありました。

パス: /etc/chef/cookbooks/aws-parallelcluster-config/recipes/base.rb

base.rb 抜粋

--- snip ---
service "setup-ephemeral" do
  supports restart: false
  action :enable
end

# Execution timeout 3600 seconds
unless virtualized?
  execute "Setup of ephemeral drivers" do
    user "root"
    command "/usr/local/sbin/setup-ephemeral-drives.sh"
  end
end
--- snip ---

aws-parallelcluster-cookbookは、ParallelCluster のソースコードとは別のリポジトリ管理されています。

setup-ephemeral.serviceenableにすると何が行われるのかコンピュートノードのサービスを確認しました。内容は/usr/local/sbin/setup-ephemeral-drives.shを実行するサービスでした。

/etc/systemd/system/setup-ephemeral.service

[Unit]
Description=Setup ephemeral drives service
After=network.target

[Service]
ExecStart=/usr/local/sbin/setup-ephemeral-drives.sh

[Install]
WantedBy=multi-user.target
/etc/systemd/system/setup-ephemeral.service

setup-ephemeral-drives.sh を調べる

/usr/local/sbin/setup-ephemeral-drives.shの実行内容はcloud-init-output.logで確認できたメッセージの生成元でした。インスタンスストアのマウント処理を行っているのはsetup-ephemeral-drives.shであることがわかりました。

スクリプト全文は長いため折りたたんであります。

折りたたみ
#!/bin/bash

# Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the
# License. A copy of the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "LICENSE.txt" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
# OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions and
# limitations under the License.

. /etc/parallelcluster/cfnconfig

LVM_VG_NAME="vg.01"
LVM_NAME="lv_ephemeral"
LVM_PATH="/dev/${LVM_VG_NAME}/${LVM_NAME}"
LVM_ACTIVE_STATE="a"
FS_TYPE="ext4"
MOUNT_OPTIONS="noatime,nodiratime"
# cfn_ephemeral_dir is set in the environment by cfnconfig sourcing
INPUT_MOUNTPOINT="${cfn_ephemeral_dir}"

function log {
  SCRIPT=$(basename "$0")
  MESSAGE="$1"
  echo "ParallelCluster - ${MESSAGE}"
}

function error_exit {
  log "[ERROR] $1"
  exit 1
}

function exit_noop {
  log "[INFO] $1"
  exit 0
}

function parameter_check {
  if [[ -z "${INPUT_MOUNTPOINT}" ]]; then
    exit_noop "Mount point not specified"
  fi
}

function set_imds_token {
  if [[ -z "${IMDS_TOKEN}" ]];then
    IMDS_TOKEN=$(curl --retry 3 --retry-delay 0 --fail -s -f -X PUT -H "X-aws-ec2-metadata-token-ttl-seconds: 900" http://169.254.169.254/latest/api/token)
    if [[ "$?" -gt 0 ]] || [[ -z "${IMDS_TOKEN}" ]]; then
      error_exit "Could not get IMDSv2 token. Instance Metadata might have been disabled or this is not an EC2 instance"
    fi
  fi
}

function get_metadata {
    QUERY=$1
    local IMDS_OUTPUT
    IMDS_OUTPUT=$(curl --retry 3 --retry-delay 0 --fail -s -q -H "X-aws-ec2-metadata-token:${IMDS_TOKEN}" -f "http://169.254.169.254/latest/${QUERY}")
    echo -n "${IMDS_OUTPUT}"
}

function print_block_device_mapping {
  echo 'block-device-mapping: '
  DEVICE_MAPPING_LIST=$(get_metadata meta-data/block-device-mapping/)
  if [[ -n "${DEVICE_MAPPING_LIST}" ]]; then
    for DEVICE_MAPPING in ${DEVICE_MAPPING_LIST}; do
      echo -e '\t' "${DEVICE_MAPPING}: $(get_metadata meta-data/block-device-mapping/"${DEVICE_MAPPING}")"
    done
  else
    echo "NOT AVAILABLE"
  fi
}

function check_instance_store {
  if ls /dev/nvme* >& /dev/null; then
    IS_NVME=1
    MAPPINGS=$(realpath --relative-to=/dev/ -P /dev/disk/by-id/nvme*Instance_Storage* | grep -v "*Instance_Storage*" | uniq)
  else
    IS_NVME=0
    set_imds_token
    MAPPINGS=$(print_block_device_mapping | grep ephemeral | awk '{print $2}' | sed 's/sd/xvd/')
  fi

  NUM_DEVICES=0
  for MAPPING in ${MAPPINGS}; do
    umount "/dev/${MAPPING}" &>/dev/null
    STAT_COMMAND="stat -t /dev/${MAPPING}"
    if ${STAT_COMMAND} &>/dev/null; then
      DEVICES+=("/dev/${MAPPING}")
      NUM_DEVICES=$((NUM_DEVICES + 1))
    fi
  done

  if [[ "${NUM_DEVICES}" -gt 0 ]]; then
    log "This instance type has (${NUM_DEVICES}) device(s) for instance store: (${DEVICES[*]})"
  else
    exit_noop "This instance type doesn't have instance store"
  fi

  if [[ "${IS_NVME}" -eq 0 ]]; then
    log "This instance store may suffer first-write penalty unless initialized: please have a look at https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/disk-performance.html"
    # Initialization can take long time, even hours
    # for DEVICE in "${DEVICES[@]}"; do
    #  dd if=/dev/zero of="${DEVICE}" bs=1M
    # done
  fi
}

function create_lvm {
  log "Creating LVM (${LVM_PATH})"
  pvcreate -y "${DEVICES[@]}"
  vgcreate -y "${LVM_VG_NAME}" "${DEVICES[@]}"
  LVM_CREATE_COMMAND="lvcreate -y -i ${NUM_DEVICES} -I 64 -l 100%FREE -n ${LVM_NAME} ${LVM_VG_NAME}"
  if ! ${LVM_CREATE_COMMAND}; then
    error_exit "Failed to create LVM"
  else
    log "LVM (${LVM_PATH}) created successfully"
  fi
}

function check_lvm_exist {
  LVM_EXIST_COMMAND="lvs ${LVM_PATH} --nosuffix --noheadings -q"

  if ! ${LVM_EXIST_COMMAND} &>/dev/null; then
    log "LVM (${LVM_PATH}) does not exist"
    create_lvm
  else
    log "LVM (${LVM_PATH}) already exists"
  fi
}

function activate_lvm {
  LVM_STATE=$(lvs "${LVM_PATH}" --nosuffix --noheadings -o lv_attr | xargs | cut -c5)
  log "Found LVM (${LVM_PATH}) in state (${LVM_STATE})"

  if [[ "${LVM_STATE}" != "${LVM_ACTIVE_STATE}" ]]; then
    log "Activating LVM (${LVM_PATH})"
    LVM_ACTIVATE_COMMAND="lvchange -ay ${LVM_PATH}"
    if ! ${LVM_ACTIVATE_COMMAND}; then
      error_exit "Failed to activate LVM"
    else
      log "LVM (${LVM_PATH}) activated successfully"
    fi
  fi
}

function format_lvm {
  LVM_FS_TYPE=$(lsblk "${LVM_PATH}" --noheadings -o FSTYPE | xargs)
  log "Found LVM (${LVM_PATH}) FS type (${LVM_FS_TYPE})"

  if [[ "${LVM_FS_TYPE}" != "${FS_TYPE}" ]]; then
    log "Formatting LVM (${LVM_PATH}) with FS type (${FS_TYPE})"
    LVM_FORMAT_COMMAND="mkfs -t ${FS_TYPE} ${LVM_PATH}"
    if ! ${LVM_FORMAT_COMMAND}; then
      error_exit "Failed to format LVM"
    else
      log "LVM (${LVM_PATH}) formatted successfully"
    fi
    sync
    sleep 1
  else
    log "LVM (${LVM_PATH}) already formatted with FS type (${LVM_FS_TYPE})"
  fi
}

function mount_lvm {
  LVM_MOUNTPOINT=$(lsblk "${LVM_PATH}" -o MOUNTPOINT --noheadings | xargs)

  if [[ -z ${LVM_MOUNTPOINT} ]]; then
    log "LVM (${LVM_PATH}) not mounted, mounting on (${INPUT_MOUNTPOINT})"
    # create mount
    mkdir -p "${INPUT_MOUNTPOINT}"
    LVM_MOUNT_COMMAND="mount -v -t ${FS_TYPE} -o ${MOUNT_OPTIONS} ${LVM_PATH} ${INPUT_MOUNTPOINT}"
    if ! ${LVM_MOUNT_COMMAND}; then
      error_exit "Failed to mount LVM"
    else
      log "LVM (${LVM_PATH}) mounted successfully"
    fi
    # set mount permission
    chmod 1777 "${INPUT_MOUNTPOINT}"
  else
    log "LVM (${LVM_PATH}) already mounted on (${LVM_MOUNTPOINT})"
  fi
}

function main {
  parameter_check
  check_instance_store
  check_lvm_exist
  activate_lvm
  format_lvm
  mount_lvm
}

main

ここまで確認できたこと

  • /usr/local/sbin/setup-ephemeral-drives.shが実行されるとインスタンスストアをマウントする
  • /usr/local/sbin/setup-ephemeral-drives.sh/etc/systemd/system/setup-ephemeral.serviceサービス起動により実行される
  • /etc/systemd/system/setup-ephemeral.serviceサービス起動は Chef の cookbook で管理されている

次は Chef の cookbook はどの時点でコンピュートノードに保存されていたのか確認します。

コンピュートノードのユーザーデータ

コンピュートノードの起動時の設定は起動テンプレートに入っています。起動テンプレートのユーザーデータで何を実行しているのか確認します。起動テンプレートの作成自体はpcluster create-cluster実行時に CDK(CloudFormation)によって起動テンプレートを作成されています。

ユーザーデータの内容から以下のことがわかりました。

  1. ParallelCluster 用の cookbook をダウンロードします
  2. Chef は localmode で実行します

ParallelCluster の cookbook については事前に保存してあるわけではなく、コンピュートノードが起動する都度ダウンロードして展開していることがわかりました。

この先は Chef 動作に詳しくないため正しく理解できていないのですが...

最初の方に確認していた/etc/chef/cookbooks/aws-parallelcluster-config/recipes/base.rbにインスタンスストアをマウントするサービスを起動するレシピがありましたので、aws-parallelcluster::configの箇所で実行していものかと思います。

UserData 抜粋

--- snip ---
  curl --retry 3 -v -L -o /etc/chef/aws-parallelcluster-cookbook.tgz ${cookbook_url}
--- snip ---
jq --argfile f1 /tmp/dna.json --argfile f2 /tmp/extra.json -n '$f1 * $f2' > /etc/chef/dna.json || ( echo "jq not installed or invalid extra_json"; cp /tmp/dna.json /etc/chef/dna.json)
{
  pushd /etc/chef &&
  cinc-client --local-mode --config /etc/chef/client.rb --log_level info --force-formatter --no-color --chef-zero-port 8889 --json-attributes /etc/chef/dna.json --override-runlist aws-parallelcluster::init &&
  /opt/parallelcluster/scripts/fetch_and_run -preinstall &&
  cinc-client --local-mode --config /etc/chef/client.rb --log_level info --force-formatter --no-color --chef-zero-port 8889 --json-attributes /etc/chef/dna.json --override-runlist aws-parallelcluster::config &&
  /opt/parallelcluster/scripts/fetch_and_run -postinstall &&
  cinc-client --local-mode --config /etc/chef/client.rb --log_level info --force-formatter --no-color --chef-zero-port 8889 --json-attributes /etc/chef/dna.json --override-runlist aws-parallelcluster::finalize &&
  popd
} || error_exit 'Failed to run bootstrap recipes. If --norollback was specified, check /var/log/cfn-init.log and /var/log/cloud-init-output.log.'
--- snip ---

ユーザーデータ全文は長いため折りたたんであります。

折りたたみ
Content-Type: multipart/mixed; boundary="==BOUNDARY=="
MIME-Version: 1.0

--==BOUNDARY==
Content-Type: text/cloud-boothook; charset="us-ascii"
MIME-Version: 1.0

#!/bin/bash -x

which dnf 2>/dev/null; dnf=$?
which yum 2>/dev/null; yum=$?

if [ "${dnf}" == "0" ]; then
  echo "proxy=" >> /etc/dnf/dnf.conf
elif [ "${yum}" == "0" ]; then
  echo "proxy=_none_" >> /etc/yum.conf
else
  echo "Not yum system"
fi

which apt-get && echo "Acquire::http::Proxy \"false\";" >> /etc/apt/apt.conf || echo "Not apt system"

proxy=NONE
if [ "${proxy}" != "NONE" ]; then
  proxy_host=$(echo "${proxy}" | awk -F/ '{print $3}' | cut -d: -f1)
  proxy_port=$(echo "${proxy}" | awk -F/ '{print $3}' | cut -d: -f2)
  echo -e "[Boto]\nproxy = ${proxy_host}\nproxy_port = ${proxy_port}\n" >/etc/boto.cfg
  cat >> /etc/profile.d/proxy.sh <<PROXY
export http_proxy="${proxy}"
export https_proxy="${proxy}"
export no_proxy="localhost,127.0.0.1,169.254.169.254"
export HTTP_PROXY="${proxy}"
export HTTPS_PROXY="${proxy}"
export NO_PROXY="localhost,127.0.0.1,169.254.169.254"
PROXY
fi

--==BOUNDARY==
Content-Type: text/cloud-config; charset=us-ascii
MIME-Version: 1.0

package_update: false
package_upgrade: false
repo_upgrade: none

datasource_list: [ Ec2, None ]
output:
  all: "| tee -a /var/log/cloud-init-output.log | logger -t user-data -s 2>/dev/console"
write_files:
  - path: /tmp/dna.json
    permissions: '0644'
    owner: root:root
    content: |
      {
        "cluster": {
          "stack_name": "I4iParallelcluster",
          "enable_efa": "NONE",
          "raid_parameters": "NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE",
          "base_os": "ubuntu2004",
          "preinstall": "NONE",
          "preinstall_args": "NONE",
          "postinstall": "s3://hpc-dev-postinstall-files/sample-ubuntu-docker/postinstall.sh",
          "postinstall_args": "NONE",
          "region": "us-east-2",
          "efs_fs_id": "NONE",
          "efs_shared_dir": "NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE",
          "fsx_fs_id": "NONE",
          "fsx_mount_name": "",
          "fsx_dns_name": "",
          "fsx_options": "NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE,NONE",
          "scheduler": "slurm",
          "disable_hyperthreading_manually": "false",
          "ephemeral_dir": "/scratch",
          "ebs_shared_dirs": "NONE,NONE,NONE,NONE,NONE",
          "proxy": "NONE",
          "ddb_table": "parallelcluster-I4iParallelcluster",
          "log_group_name": "/aws/parallelcluster/I4iParallelcluster-202205090742",
          "dns_domain": "i4iparallelcluster.pcluster.",
          "hosted_zone": "Z02054841BVWJ1BFSLAC3",
          "node_type": "ComputeFleet",
          "cluster_user": "ubuntu",
          "enable_intel_hpc_platform": "false",
          "cw_logging_enabled": "true",
          "scheduler_queue_name": "i4i",
          "scheduler_compute_resource_name": "large8x",
          "enable_efa_gdr": "NONE",
          "custom_node_package": "",
          "custom_awsbatchcli_package": "",
          "use_private_hostname": "false",
          "head_node_private_ip": "10.0.1.12",
          "directory_service": {
            "enabled": "false"
          }
        }
      }
  - path: /etc/chef/client.rb
    permissions: '0644'
    owner: root:root
    content: cookbook_path ['/etc/chef/cookbooks']
  - path: /tmp/extra.json
    permissions: '0644'
    owner: root:root
    content: |
      {}

--==BOUNDARY==
Content-Type: text/x-shellscript; charset="us-ascii"
MIME-Version: 1.0

#!/bin/bash -x

function error_exit
{
  echo "Bootstrap failed with error: $1"
  # wait logs flush before signaling the failure
  sleep 10
  # TODO: add possibility to override this behavior and keep the instance for debugging
  shutdown -h now
  exit 1
}
function vendor_cookbook
{
  mkdir /tmp/cookbooks
  cd /tmp/cookbooks
  tar -xzf /etc/chef/aws-parallelcluster-cookbook.tgz
  HOME_BAK="${HOME}"
  export HOME="/tmp"
  for d in `ls /tmp/cookbooks`; do
    cd /tmp/cookbooks/$d
    LANG=en_US.UTF-8 /opt/cinc/embedded/bin/berks vendor /etc/chef/cookbooks --delete || error_exit 'Vendoring cookbook failed.'
  done;
  export HOME="${HOME_BAK}"
}
[ -f /etc/profile.d/proxy.sh ] && . /etc/profile.d/proxy.sh
custom_cookbook=NONE
export _region=us-east-2
s3_url=amazonaws.com
if [ "${custom_cookbook}" != "NONE" ]; then
  if [[ "${custom_cookbook}" =~ ^s3://([^/]*)(.*) ]]; then
    bucket_region=$(aws s3api get-bucket-location --bucket ${BASH_REMATCH[1]} | jq -r '.LocationConstraint')
    if [[ "${bucket_region}" == null ]]; then
      bucket_region="us-east-1"
    fi
    cookbook_url=$(aws s3 presign "${custom_cookbook}" --region "${bucket_region}")
  else
    cookbook_url=${custom_cookbook}
  fi
fi
export PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/opt/aws/bin
export parallelcluster_version=aws-parallelcluster-3.1.3
export cookbook_version=aws-parallelcluster-cookbook-3.1.3
export chef_version=17.2.29
export berkshelf_version=7.2.0
if [ -f /opt/parallelcluster/.bootstrapped ]; then
  installed_version=$(cat /opt/parallelcluster/.bootstrapped)
  if [ "${cookbook_version}" != "${installed_version}" ]; then
    error_exit "This AMI was created with ${installed_version}, but is trying to be used with ${cookbook_version}. Please either use an AMI created with ${cookbook_version} or change your ParallelCluster to ${installed_version}"
  fi
else
  error_exit "This AMI was not baked by ParallelCluster. Please use pcluster createami command to create an AMI by providing your AMI as parent image."
fi
if [ "${custom_cookbook}" != "NONE" ]; then
  curl --retry 3 -v -L -o /etc/chef/aws-parallelcluster-cookbook.tgz ${cookbook_url}
  vendor_cookbook
fi
cd /tmp

mkdir -p /etc/chef/ohai/hints
touch /etc/chef/ohai/hints/ec2.json
jq --argfile f1 /tmp/dna.json --argfile f2 /tmp/extra.json -n '$f1 * $f2' > /etc/chef/dna.json || ( echo "jq not installed or invalid extra_json"; cp /tmp/dna.json /etc/chef/dna.json)
{
  pushd /etc/chef &&
  cinc-client --local-mode --config /etc/chef/client.rb --log_level info --force-formatter --no-color --chef-zero-port 8889 --json-attributes /etc/chef/dna.json --override-runlist aws-parallelcluster::init &&
  /opt/parallelcluster/scripts/fetch_and_run -preinstall &&
  cinc-client --local-mode --config /etc/chef/client.rb --log_level info --force-formatter --no-color --chef-zero-port 8889 --json-attributes /etc/chef/dna.json --override-runlist aws-parallelcluster::config &&
  /opt/parallelcluster/scripts/fetch_and_run -postinstall &&
  cinc-client --local-mode --config /etc/chef/client.rb --log_level info --force-formatter --no-color --chef-zero-port 8889 --json-attributes /etc/chef/dna.json --override-runlist aws-parallelcluster::finalize &&
  popd
} || error_exit 'Failed to run bootstrap recipes. If --norollback was specified, check /var/log/cfn-init.log and /var/log/cloud-init-output.log.'

if [ ! -f /opt/parallelcluster/.bootstrapped ]; then
  echo ${cookbook_version} | tee /opt/parallelcluster/.bootstrapped
fi
# End of file
--==BOUNDARY==

おまけ Pre / Postinstall 処理の実態

ユーザーデータで cookbook と同時に実行される/opt/parallelcluster/scripts/fetch_and_runスクリプトが気になったので確認しました。

UserData抜粋

  /opt/parallelcluster/scripts/fetch_and_run -preinstall &&
  /opt/parallelcluster/scripts/fetch_and_run -postinstall &&

コンフィグファイルが別途あり、Postinstallで指定した S3 バケットとスクリプト名が書き込まれていました。Preinstall は使用していないため Postinstall について確認します。

/etc/parallelcluster/cfnconfig

stack_name=I4iParallelcluster
cfn_preinstall=NONE
cfn_preinstall_args=(NONE)
cfn_postinstall=s3://hpc-dev-postinstall-files/sample-ubuntu-docker/postinstall.sh
cfn_postinstall_args=(NONE)
cfn_region=us-east-2
cfn_scheduler=slurm
cfn_scheduler_slots=vcpus
cfn_instance_slots=16
cfn_ephemeral_dir=/scratch
cfn_ebs_shared_dirs=NONE,NONE,NONE,NONE,NONE
cfn_proxy=NONE
cfn_node_type=ComputeFleet
cfn_cluster_user=ubuntu
cfn_head_node=ip-10-0-1-12
cfn_head_node_private_ip=10.0.1.12
cfn_scheduler_queue_name=i4i

対象が S3 バケットなら aws s3 cp でダウンロードし、実行権限を付与してスクリプトを実行していました。

/opt/parallelcluster/scripts/fetch_and_run

#!/bin/bash

cfnconfig_file="/etc/parallelcluster/cfnconfig"
. ${cfnconfig_file}

# Check expected variables from cfnconfig file
function check_params () {
  if [ -z "${cfn_region}" ] || [ -z "${cfn_preinstall}" ] || [ -z "${cfn_preinstall_args}" ] || [ -z "${cfn_postinstall}" ] || [ -z "${cfn_postinstall_args}" ]; then
    error_exit "One or more required variables from ${cfnconfig_file} file are undefined"
  fi
}

# Error exit function
function error_exit () {
  script=`basename $0`
  echo "parallelcluster: ${script} - $1"
  logger -t parallelcluster "${script} - $1"
  exit 1
}

function download_run (){
    url=$1
    shift
    scheme=$(echo "${url}"| cut -d: -f1)
    tmpfile=$(mktemp)
    trap "/bin/rm -f $tmpfile" RETURN
    if [ "${scheme}" == "s3" ]; then
      /opt/parallelcluster/pyenv/versions/3.7.10/envs/cookbook_virtualenv/bin/aws --region ${cfn_region} s3 cp ${url} - > $tmpfile || return 1
    else
      wget -qO- ${url} > $tmpfile || return 1
    fi
    chmod +x $tmpfile || return 1
    $tmpfile "$@" || error_exit "Failed to run ${ACTION}, ${file} failed with non 0 return code: $?"
}

function run_preinstall () {
  if [ "${cfn_preinstall}" != "NONE" ]; then
    file="${cfn_preinstall}"
    if [ "${cfn_preinstall_args}" != "NONE" ]; then
        download_run ${cfn_preinstall} "${cfn_preinstall_args[@]}"
    else
        download_run ${cfn_preinstall}
    fi
  fi || error_exit "Failed to run preinstall"
}

function run_postinstall () {
  RC=0
  if [ "${cfn_postinstall}" != "NONE" ]; then
    file="${cfn_postinstall}"
    if [ "${cfn_postinstall_args}" != "NONE" ]; then
        download_run ${cfn_postinstall} "${cfn_postinstall_args[@]}"
    else
        download_run ${cfn_postinstall}
    fi
  fi || error_exit "Failed to run postinstall"
}

check_params

ACTION=${1#?}
case ${ACTION} in
  preinstall)
    run_preinstall
    ;;
  postinstall)
    run_postinstall
    ;;
  *)
    echo "Unknown action. Exit gracefully"
    exit 0
esac

Postinstall 処理の実態は/opt/parallelcluster/scripts/fetch_and_runのスクリプトが実行されて、そこからユーザーが用意したスクリプトを実行する流れだったんですね。

確認結果

インスタンスストアがマウントされるまで過程

  1. pcluster create-clusterにより CDK(CloudFormation)で起動テンプレートが作成される
    1. 起動テンプレートのユーザーデータにコンピュートノードの起動処理が書き込まれている
  2. コンピュートノードが起動するとユーザーデータに従い Chef の cookbook のダウンロードと実行が走る
  3. cookbook の実行で/etc/systemd/system/setup-ephemeral.serviceサービスが起動する
  4. /etc/systemd/system/setup-ephemeral.serviceサービスが起動すると/usr/local/sbin/setup-ephemeral-drives.shが実行される
  5. /usr/local/sbin/setup-ephemeral-drives.shの実行によりインスタンスストアのマウントが行われる

おわりに

AWS ParallelCluster というフレームワークを使うことで手軽にクラウドHPC 環境を手に入れることができます。裏側ではないをやってくれているのか?を確認してみるといろいろと発見がありました。今回は興味本位でインスタンスストアのマウント処理を調べていので面白かったので良いのですが、調べるのに半日かかりましたのでほどほどにですね。

トラブルシュートするときには一見知らなくて良さそうな知識がいきてくるはずので ParallelClusterで トラブったら呼んでください。なにかお力になれるかもしれません。

最後にインスタンスストアがマウントされるパス/scratchは変更可能です。こちらはドキュメントに載っています。

Scheduling section - AWS ParallelCluster

参考