フューチャー技術ブログ

Jenkinsのエージェントノードをストレージを永続化しながらスポットインスタンスで運用する

はじめに

はじめまして。フューチャーインスペースの平井です。

昨今の円安でAWS使用料が増加したことにより、構成見直し等で費用削減を図っている方も多いと思います。

私の現場でも費用削減の一環として、先日Jenkinsで使用しているエージェントノード(※1)をオンデマンドインスタンスからスポットインスタンスに移行しました。

※1:昨今のマスタースレーブといったプログラミング用語置き換えの流れにより、Jenkinsもマスターノードをコントローラーノード、スレーブノードをエージェントノードへと改名したようです。

今回は移行時に工夫した点を紹介いたします。

環境/構成

  • コントローラーノード
    • OS:CentOS Linux release 7.7.1908 (Core)
    • Jenkins: 2.190
    • EC2 Plugin: 1.50.3
  • エージェントノード
    • OS:CentOS Linux release 7.7.1908 (Core)
    • openjdk:1.8.0_232

前提

まず、元々オンデマンドインスタンスで動かしていたジョブについて説明します。

対象のジョブではプロジェクト開発支援のため日々ソースファイル解析が行われていました。
解析対象ソースはSubversionで管理されており、以下のような課題がありました。

  • 取得処理の課題
    • svnリポジトリが100超あり、65万ファイル、容量70GBと巨大
    • ソース解析の中でファイル間の依存関係解決をするため、常に全量取得する必要がある
    • ジョブはソースタイプ毎に分かれているため、ジョブ単位で取得するとストレージが肥大化する

そこで、処理短縮とストレージ節約を図るため、対および実現案を考えました。

  • 対策
    • svn checkoutしたワーキングディレクトリを保持してsvn updateにより差分で最新ソース取得し時間を短縮化させる
    • svn checkout/updateするジョブを1つに制限し、複数ジョブで1つのworkspaceを直接参照する
  • 実現案
    1. Shared Workspace Plugin(※2)を使って各ジョブで共有
      • ※2:共有元ジョブのworkspaceををzipに固めてmasterノードに転送、共有先ジョブではmasterから転送してzip解凍するJenkinsプラグイン
    2. Copy Artifact Plugin(※3)を使ってworkspaceを成果物保存して各ジョブでコピー
      • ※3:ジョブで作られたファイルを成果物としてmasterノードに転送、共有先ジョブではmasterから転送するJenkinsプラグイン
    3. 1つのsvn checkout/updateジョブのworkspaceをs3に転送して永続化、次回実行時はs3から取得してsvn update実行
    4. エージェントノード(オンデマンドインスタンス)でsvn checkout状態をEBSに永続化
      • オンデマンドインスタンスはジョブ実行時のみ起動・停止

1,2は大量ファイルの場合転送コストが大きく、ストレージもジョブごとに消費するためコストメリットがなく却下。

3も1,2と同様転送コストが大きいことに加えSCMポーリングとの相性が悪いため却下。

4のオンデマンドインスタンス方式を採用していました。

スポットインスタンスへの移行

その後もオンデマンドインスタンスで運用していましたが、冒頭の通り費用削減の一環でスポットインスタンス移行を検討することとなりました。

結果、費用削減の他にもメリットがあることを確認し、本格的に移行する流れとなりました。

主なメリット

費用削減

インスタンスクラスにもよりますが、オンデマンドインスタンスと比較して約70%の割引を受けることができます。
参考:AWSコスト削減のためのスポットインスタンス活用術

また、オンデマンド・スポット共通ですが、高負荷な処理にエージェントノードを使うことでマスターノードのインスタンスタイプを小さくして運用することができます。

つまり、コスト削減と高パフォーマンスの両取りが可能です。

構成のシンプル化

現在の構成では起動/停止ジョブでエージェントノード(オンデマンドインスタンス)を制御する必要がありました。

しかし移行後の構成ではAmazon EC2 Plugin(※4)でエージェントノード(スポットインスタンス)を制御できるため、構成がシンプルになりました。

※4:ジョブ実行時に自動でEC2を作成し、エージェントノードとして使用できるようにするプラグイン。EC2はジョブ終了後に自動で終了される。

  • 現在の構成(ジョブによるエージェントノード制御)image.png
  • 移行後の構成(Amazon EC2 Pluginによるエージェントノード制御)image.png

リタイアメント対応が不要

スポットインスタンスはジョブ実行毎に作成/終了されるため、オンデマンドインスタンスで意識する必要のあったEC2リタイアメント対応が不要となりました。

参考:インスタンスのリタイア

実装のポイント

checkout状態をEBSで永続化する構成のため、スポットインスタンスの場合は起動時にEBSをアタッチさせる必要があります。なお起動時のEBSアタッチについては起動テンプレートを使う方法がAWS公式から案内されています。

参考:起動時に永続的なセカンダリ EBS ボリュームを新しい EC2 Linux スポットインスタンスに自動的に添付するにはどうすればよいですか。

当初、Amazon EC2 Pluginに起動テンプレートを指定してスポットインスタンスを動かせれば、と考えたのですが、プラグインが起動テンプレートに対応していませんでした。

そこで起動テンプレート以外の方法を探り、最終的に同プラグインのinit scriptにEBSアタッチ処理を組み込んで実現できるか確認することとしました。

init scriptについて

init scriptではスポットインスタンス起動時に実行したい処理をshellで書くことができます。
今回は以下の通りとしました。

#!/bin/bash

## 初期化
INSTANCE_ID=
UUID=

## EBSアタッチ
INSTANCE_ID=$(curl http://169.254.169.254/latest/meta-data/instance-id)
if [ -n ${INSTANCE_ID} ] ; then
aws ec2 attach-volume --volume-id <ボリュームID> --device /dev/sdb --instance-id ${INSTANCE_ID} --region ap-northeast-1
sleep 10
else
exit 1
fi

## /dataマウント
UUID=$(lsblk -f | grep nvme1n1 | sed -e 's/ \+/ /g' |cut -f 3 -d " ")
if [ -n ${UUID} ] ; then
sudo mount UUID=${UUID} /data
else
exit 1
fi

## マウント確認
if ! mountpoint /data; then
exit 1
fi

順に解説します。

  • EBSアタッチ処理
    http://169.254.169.254/latest/meta-data/instance-id(※5)でインスタンスIDを取得し、後続のEBSアタッチ処理に渡しています。
    <ボリュームID>にはアタッチしたいEBSを指定します。また、sleep 10によりEBSアタッチが未完了のまま次処理に進むことを防いでいます。
    ※5:169.254.169.254はAWSインスタンスメタデータサービスの固定IPです。
    参考:インスタンスメタデータの取得
    ## EBSアタッチ
    INSTANCE_ID=$(curl http://169.254.169.254/latest/meta-data/instance-id)
    if [ -n ${INSTANCE_ID} ] ; then
    aws ec2 attach-volume --volume-id <ボリュームID> --device /dev/sdb --instance-id ${INSTANCE_ID} --region ap-northeast-1
    sleep 10
    else
    exit 1
    fi
  • EBSをjenkins_homeのパスにマウント
    EBSのデバイス名には規則があり、追加順に/dev/nvme[0-26]n1で採番されていきます。
    参考:Linux インスタンスの Amazon EBS および NVMe
    ルートボリュームのデバイス名は/dev/nvme0n1のため、今回のアタッチ分は/dev/nvme1n1となります。また、今回はjenkins_homeのパスを/data/jenkins_homeとしたいため、/data配下をマウントしました。
    ## /dataマウント
    UUID=$(lsblk -f | grep nvme1n1 | sed -e 's/ \+/ /g' |cut -f 3 -d " ")
    if [ -n ${UUID} ] ; then
    sudo mount UUID=${UUID} /data
    else
    exit 1
    fi

    ## マウント確認
    if ! mountpoint /data; then
    exit 1
    fi

スポットインスタンスへの変更

実際にエージェントノードをスポットインスタンスに変更し、想定通りの動きとなるか確認しました。

変更に伴い発生した作業を記載します。

AWS

エージェントノード用のIAM Role作成

エージェントノード内でEBSのアタッチを行うため、ec2:AttachVolume権限を付与したIAM Roleを作成しました。

エージェントノード用のセキュリティグループ作成

エージェントノード立ち上げ時、コントローラーノードからエージェントノードへssh接続する処理があるため、エージェントノードにSSH(22)を許可したセキュリティグループが必要となります。
今回は元々使用していたセキュリティグループを流用しました。

エージェントノード用のAMI作成

以下1~4を実施してAMIを作成しました。

1. Javaのインストール

エージェントノードは起動時にslave.jarを実行するためインストールしました。

2. AWS CLIのインストール

AWS CLIコマンドでEBSをアタッチするためインストールしました。

3. ジョブ実行用ユーザ作成

デフォルトユーザ(例:AL2ならec2-user)が使えるため作成しなくても良いのですが、既存のエージェントノードでjenkinsという専用ユーザを用意していたため今回も踏襲して作成しました。

4. visudo編集

ジョブ実行ユーザがNOPASSWDでmountコマンドを実行できるようにするため設定しました。

jenkins    ALL=(ALL)       NOPASSWD: /usr/bin/mount

Jenkins

EC2 Pluginインストール

jenkinsにAmazon EC2 Pluginをインストールしておきます。

エージェントノードの起動設定

「Jenkinsの管理 → システムの設定」のクラウド項目で「Amazon EC2」を選択すると表示されます。
今回は意識して設定した項目を抜粋します。

  • AMI ID

    • 上述の手順で作成したエージェント用のAMIを設定しました。
  • Instance Type

    • エージェントを起動するインスタンスタイプを設定します。
      今回は変更前と同タイプにしました。
  • Use Spot Instance

    • スポットインスタンスで起動させるためチェックを入れました。
  • Spot Max Bid Price

    • スポットインスタンス起動に許容できる最高価格を設定します。
      今回はできるだけ中断のリスクを避けたかったためデフォルト(オンデマンドインスタンスの価格)としました。
  • Security group names

    • エージェントノード用のセキュリティグループを設定しました。
  • Remote FS root

    • エージェントノードのjenkins_homeとなるパスを設定します。
      今回はアタッチするEBSを/data配下にマウントするため、/data/jenkins_home/としました。
  • Remote user

    • エージェントノードにログインするユーザを設定します。今回はjenkinsユーザとしました。
  • Labels

    • 一意なラベルを設定します。ここで指定したラベルを後述のジョブで設定します。
  • Init script

    • EBSアタッチ処理を記載します。
    #!/bin/bash

    ## 初期化
    INSTANCE_ID=
    UUID=

    ## EBSアタッチ
    INSTANCE_ID=$(curl http://169.254.169.254/latest/meta-data/instance-id)
    if [ -n ${INSTANCE_ID} ] ; then
    aws ec2 attach-volume --volume-id <ボリュームID> --device /dev/sdb --instance-id ${INSTANCE_ID} --region ap-northeast-1
    sleep 10
    else
    exit 1
    fi

    ## /dataマウント
    UUID=$(lsblk -f | grep nvme1n1 | sed -e 's/ \+/ /g' |cut -f 3 -d " ")
    if [ -n ${UUID} ] ; then
    sudo mount UUID=${UUID} /data
    else
    exit 1
    fi

    ## マウント確認
    if ! mountpoint /data; then
    exit 1
    fi
  • Number of Executors

    • ジョブの並列度を指定します。今回は変更前と同じとしています。
  • IAM Instance Profile

    • エージェントノード用に作成したIAM Roleを設定しました。

ジョブの設定

ジョブの設定 → 実行するノードを制限からさきほど設定したラベルを指定します。

動作確認

スポットインスタンスがEBSアタッチされた状態でエージェントノードとして起動するかを確認します。
なお一部情報はマスクしています。

  • まずログからスポットインスタンスが立ち上がることを確認します。
    2 21, 2023 2:48:02 午後 情報 hudson.plugins.ec2.EC2Cloud$1 call
    SlaveTemplate{ami='<AMI ID>', labels='<ラベル名>'} Node EC2 (<エージェントノード名>) - <インスタンス名> (<インスタンスID>) moved to RUNNING state in 5 seconds and is ready to be connected by Jenkins
    2 21, 2023 2:48:03 午後 情報 hudson.plugins.ec2.EC2RetentionStrategy start
    Start requested for EC2 (<エージェントノード名>) - <インスタンス名> (<インスタンスID>)
    2 21, 2023 2:48:03 午後 情報 hudson.plugins.ec2.EC2Cloud log
    Launching instance: <インスタンスID>
    • 立ち上がった段階ではルートボリュームのみ存在しています。2023-02-21_14h48_27.png
    • サーバ上でもルートボリューム(/dev/nvme0n1p1)のみであることが確認できます。
      [jenkins@<インスタンス名> ~]$ df -h
      ファイルシス サイズ 使用 残り 使用% マウント位置
      devtmpfs 16G 0 16G 0% /dev
      tmpfs 16G 0 16G 0% /dev/shm
      tmpfs 16G 17M 16G 1% /run
      tmpfs 16G 0 16G 0% /sys/fs/cgroup
      /dev/nvme0n1p1 30G 24G 6.1G 80% /
      tmpfs 3.1G 0 3.1G 0% /run/user/1101
  • SSH接続後、init scriptが実行されます。
    2 21, 2023 2:48:18 午後 情報 hudson.plugins.ec2.EC2Cloud log
    Connected via SSH.
    2 21, 2023 2:48:18 午後 情報 hudson.plugins.ec2.EC2Cloud log
    Creating tmp directory (/tmp) if it does not exist
    2 21, 2023 2:48:18 午後 情報 hudson.plugins.ec2.EC2Cloud log
    Executing init script
    • このタイミングで追加するEBSのアタッチを確認できます。2023-02-21_14h48_44.png
    • サーバ上でも追加したEBSがデバイス/dev/nvme1n1として/dataにマウントされました。
      [jenkins@<インスタンス名> ~]$ df
      ファイルシス 1K-ブロック 使用 使用可 使用% マウント位置
      devtmpfs 16128016 0 16128016 0% /dev
      tmpfs 16152604 0 16152604 0% /dev/shm
      tmpfs 16152604 16940 16135664 1% /run
      tmpfs 16152604 0 16152604 0% /sys/fs/cgroup
      /dev/nvme0n1p1 31445996 25533000 5912996 82% /
      tmpfs 3230524 0 3230524 0% /run/user/1101
      tmpfs 3230524 0 3230524 0% /run/user/1301
      tmpfs 3230524 0 3230524 0% /run/user/0
      /dev/nvme1n1 104832000 62866356 41965644 60% /data
  • 最後にjava -jar /tmp/remoting.jar -workDir /data/jenkins_home/が実行され、スポットインスタンスの起動を確認できました。
    2 21, 2023 2:48:32 午後 情報 hudson.plugins.ec2.EC2Cloud log
    Copying remoting.jar to: /tmp
    2 21, 2023 2:48:32 午後 情報 hudson.plugins.ec2.EC2Cloud log
    Launching remoting agent (via SSH client process): ssh -o StrictHostKeyChecking=no -i /tmp/ec2_8484693589346546536.pem jenkins@<エージェントノードIP> -p 22 java -jar /tmp/remoting.jar -workDir /data/jenkins_home/
    2 21, 2023 2:48:37 午後 情報 hudson.slaves.CommandLauncher launch
    agent launched for EC2 (<エージェントノード名>) - <インスタンス名> (<インスタンスID>)

おわりに

Amazon EC2 Pluginはinit scriptで自由に構成を変えられるので、ベースとなるAMIが一つで済むのも強みだと思いました。

また、今回は処理を直接init scriptに書きましたが、以下の方式にすれば処理内容をgitで管理することもできそうです。

  1. git管理している実行ファイルをgit clone
  2. git cloneした実行ファイルを呼び出す

今回の検証を足掛かりにし、引き続き構成改善をしていこうと思います。