📘

Docker開発環境(8): クラウドでの開発(EC2のスポットインスタンスを活用する)

2021/08/21に公開

前回の続きです。

https://zenn.dev/anyakichi/articles/56ee804875e15e

これまでに作ってきた Docker によるビルド環境と編集環境 (myenv) を組み合わせて EC2 のスポットインスタンス上で Yocto のビルド(開発)を行う応用を考えていきます。

クラウド上でビルドというとどちらかというと CI で自動ビルド、というユースケースはたくさん見かけるのですがソースコードを直しながらのインクリメンタルビルドというユースケースはほとんど見かけません。特にみんな困っていない、という可能性もあるのですが、個人的には「こうやればいい感じになる」という定跡化が進んでいないのが一因なのではないかと思っています。

今回は野心的な試みとして、クラウド環境でのビルドの定跡化を進めてみたいと思います。

インスタンスの考え方

クラウドビルド環境を考えたときに、64 vCPU のマシンを常時起動して使いましょう、とか言えば部分的にはそれで終わりなんですが、普通はそうはなりません。これは単にローカルに強力なマシンを調達するのと比較しても、ランニングコストが高すぎるためです。かと言ってあまりに非力な構成のマシンが常時起動していたところで、あまりビルドがはかどらないのも事実です。

ローカルに対するクラウドの利点としては、本当に必要なときだけ超強力なマシンでブーストして、普段は 2 vCPU くらいでちびちびやるみたいな、動的にマシンが交換できるところにあると思います。ここは最大限活用したいところです。

また、「編集しながらビルドする」環境は、その特性として自分が使っている時間だけ起動していればよく、常時起動している必要はありません。つまり、その時データセンター内でたまたま空いているマシンをちょっと借りて、終わったら返す、みたいな使い方でもほとんど十分だということです。

ここから以下の発想が生まれます。

  • インスタンスとしてはスポットインスタンスを使う。
  • 必要に応じてインスタンスの種類は切り替える。

AWS の EC2 では混んできたら追い出されてしまうスポットインスタンスは、通常のオンデマンドインスタンスのだいたい6〜7割引くらいで使用できます。うまく使いこなすとランニングコストを飛躍的に下げることが可能です(実際のところ、古くて高いインスタンスタイプのディスカウント幅が大きいだけで、現時点での c5a など性能が良くて安いインスタンスタイプは 6 割引きにもなっていないですが)。

ストレージの考え方

EC2 でのビルドを考えたときに、AMI をどうするか、ビルドデータをどこに置くか、キャッシュデータをどこに置くか、というのが最初の問題になります。ひとまず AMI とビルドデータ・キャッシュデータは分離しておくことにして(大きなルートディスクに全部置くという構成は考えないことにして)、実際の運用方法を考えてみます。

AMI をどうするか

AMI ですが、ひとまず今回は Amazon Linux 2 の環境を使うことにします。buildenv と myenv を使う前提で考えれば、ホスト OS は正直 Docker さえ動けばなんでも良いです。

アイデアとしては myenv は省略して、予め編集環境を整えた AMI を作っておく方法もあると思います。私の場合は普段使うのは Arch Linux なのですが、(オフィシャルではないですが)Arch Linux の公開されている AMI をベースに、必要なパッケージ類と自分の開発環境をパッケージして、それを AMI として保存しておく、みたいな方式ですね。

当初はこの方法も検討したのですが、パッケージのアップグレードなどで AMI をメンテナンスするのも面倒なこと(しかもだんだんと散らかっていくと思われること)、後述の user data など含めて EC2 として使えることになっている機能がすべてきちんと使えるのかどうかが不明なこと(aws cli を使った自動化などで支障が出る可能性がある)、などなど、最初はいいけど後で困っていることが発覚する可能性が高そうなので、予め避けることにしました。

普段 Ubuntu の人は Ubuntu ベースで環境を作るのもありだとは思いますが、この場合でもやはり AMI メンテナンスが手間になりそうなので個人的にはカスタム AMI 方式はあまりおすすめとは考えていません。

カスタム AMI ではなく、公式の Ubuntu イメージを Amazon Linux 2 の代わりに使うのはありだと思います。

ビルドデータをどうするか

ビルドデータについては、Yocto の場合は 100GB 程度の EBS を用意しておけばだいたい十分かと思います。ただし、EBS は availability zone に固有ですが、スポットインスタンスの起動可能性を上げるためには availability zone をフリーにしておく必要があり、ここを考慮する必要があります。例えば EBS が c ゾーンにあるけどスポットインスタンスが a ゾーンで起動されてしまった場合、EBS をインスタンスにアタッチできません。逆に EBS が c ゾーンにあるのでスポットインスタンスを c ゾーン固定で起動したい、と思った場合 EC2 の状況によってインスタンスが起動できなかったり料金が他のゾーンに比べて高くなったりすることがあります。

そのためスポットインスタンスのゾーンに EBS のゾーンを合わせるのが良いのですが、このあたりは参考になる事例があるので、これをベースにすれば良いでしょう。

https://aws.amazon.com/jp/blogs/news/train-deep-learning-models-on-gpus-using-amazon-ec2-spot-instances/

基本的にはビルドデータの EBS はインスタンスが終了したらスナップショット化してボリューム本体は削除します。その後スポットインスタンス起動時に、このスナップショットから動的にインスタンスの配置された availability zone に EBS ボリュームを作成してアタッチします。

ちなみに上記の事例では動的にスナップショットの作成までしているのですが、スナップショットの作成は結構時間がかかることがあるので、スポットインスタンスが起動してから行うのではスポットインスタンスの起動時間がかなり無駄になる可能性があります。

今回はひとまず「スナップショットは作成してある」前提で考えることにします。

キャッシュデータをどうするか

最後にキャッシュデータが一番選択肢が多く難しいところです。下記 3 パターンが有力です。

  • ビルドデータと同じ EBS に格納してしまう。
  • 専用の EBS (20GB くらい) に分けて格納しておく。
  • s3fs などを使って S3 に置く。

それぞれ一長一短です。ビルドデータと同じ EBS に置くのはセットアップが一番楽ですし、構成的にもシンプルでわかりやすいです。

専用の EBS に分けるのは、スナップショット化するにせよビルドデータ本体は結構サイズが大きいので、キャッシュだけ残してビルドデータ用ボリューム・スナップショットは削除してしまおうというアイデアです。費用的なメリットがある反面、ボリューム・スナップショットの管理は煩雑になります。

キャッシュデータを S3 に置くのは専用 EBS を分けるのと同じメリットがあります。なおかつストレージコストが安く、ボリュームサイズを予め決める必要がない利点もあります。欠点は遅いことと、API リクエストに課金がされるためストレージコストは安いもののトータルコストが安くならない可能性があることです(転送料金については、EC2 と S3 を同一リージョンに置くことで回避します)。

今回は起動時の設定次第で、どの方式のキャッシュモデルでも使える構成を考えてみます。

ストレージ構成

ストレージ構成をまとめると以下のようになります。

  • AMI は Amazon Linux 2
  • ビルドデータは /data 以下に専用 EBS を作ってマウント
  • キャッシュディレクトリは /data/cache に作る
    • 起動時に指定があれば、/data/cache には別の EBS をマウントする
    • 起動時に指定があれば、/data/cache/s3 以下には S3 をマウントする

IAM の準備

まずは IAM を準備します。上記での検討のとおり、起動したスポットインスタンスからは以下の操作などができる必要があります。

  • スナップショットからの EBS ボリュームの動的作成
  • EBS ボリュームの自インスタンスへのアタッチ
  • s3fs で使う S3 バケットへのアクセス

EC2 での buildenv 用として、ec2-buildenv ポリシーを作っておきましょう(まだ検討段階で、厳密には不要なものも入れたままになっています)。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:AttachVolume",
                "ec2:DeleteVolume",
                "ec2:DescribeInstances",
                "ec2:DescribeVolumeStatus",
                "ec2:CancelSpotFleetRequests",
                "ec2:CreateTags",
                "ec2:DescribeVolumes",
                "ec2:CreateSnapshot",
                "ec2:DescribeSpotInstanceRequests",
                "ec2:DescribeSnapshots",
                "ec2:CreateVolume",
                "ec2:DescribeSpotFleetRequests"
            ],
            "Resource": "*"
        }
    ]
}

S3 については、mybucket というバケットがあるとして、s3-mybucket というポリシーを作ります。s3fs にどこまでのパーミッションが求められるのかよくわからないので、とりあえず bucket だけ制限しておきます(もっと手抜きをするなら AmazonS3FullAccess で)。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "s3:*",
            "Resource": [
	        "arn:aws:s3:::mybucket",
		"arn:aws:s3:::mybucket/*"
	    ]
        }
    ]
}

最後に両者を含む role を作成しておいてください。

ユーザーデータスクリプト

EC2 ではインスタンス起動時に実行してほしいスクリプトを userData として渡しておくことができます。ここに貼るにはちょっと長いので、私の myenv に同梱されている実物の URL を記載しておきます。

https://github.com/anyakichi/docker-myenv/blob/main/aws/user_data_script.sh

このスクリプト内では以下の処理を実行します。

  • パッケージのインストール
    • docker と s3fs-fuse をインストールする。
    • このとき docker.sock が ec2-user のグループで作られるように細工しておく(myenv からホストの Docker デーモンへのアクセスを行うため)。
    • din もインストールしておく。
  • データボリュームの作成・マウント
    • 既にデータ・キャッシュ用のボリュームがあれば、他のインスタンスが起動中かもしれないのでインスタンスは終了する。
    • データ・キャッシュ用のスナップショットが存在すればそのスナップショットから、なければ新規にデータ・キャッシュ用のボリュームを作成する。
    • 作成したボリュームを起動中のインスタンスにアタッチする。
    • アタッチされたらボリュームをマウントする。
    • S3 のバケットが指定されていれば s3fs で /data/cache/s3 に S3 バケットをマウントする。
  • ユーザー設定
    • buildenv や myenv を使いやすくするための関数の登録や、docker の detachKeys を C-\ C-x にずらすなどしておく。
  • 使うべきコンテナとビルドディレクトリが指定されていた場合、自動的にビルドを開始する。またビルドが完了したら自動的にシャットダウンする。

このスクリプトへの引数というか、変数的なものはタグを使用して渡せるようになっています。また、このタグは

  • スポットフリートリクエストに設定されたもの
  • インスタンスに設定されたもの

の順に使用されます(どうしてこういう構成なのかは後述します)。設定可能なパラメータは以下のとおりです。

  • builddir: ビルドディレクトリ。 yocto-1 など。
  • buildenv: ビルドに使うイメージ anyakichi/yocto-builder:dunfell など。
  • ebs_cache_name: キャッシュデータ用 EBS 名。buildenv-cache など。空ならキャッシュボリュームは使わない。
  • ebs_cache_size: キャッシュデータ用 EBS のサイズ (GB)。
  • ebs_data_name: データ用 EBS 名。buildenv-data など。
  • ebs_data_size: データ用 EBS のサイズ (GB)。
  • myenv: myenv に使うイメージ anyakichi/myenv など。
  • s3_bucket: キャッシュ用 s3fs で使う S3 バケット名。空なら S3 は使わない。

ebs_data_nameebs_data_size だけが指定必須です。その他動作上いろいろ細かいことがありますが、実際のサンプルファイルを参照してくだい。

Launch Template を準備する

スポットフリートリクエストを簡単に出すために、Launch Template を用意しておきましょう。

  • 名前は buildenv など好きな名前をつけてください。
  • AMI は Amazon Linux 2 を指定します。
  • インスタンスタイプは好みのもので。c5a.large か c5a.xlarge くらいでしょうか。
  • Key pair は通常使用するものを指定しておきます。
  • SSH で入れる程度のセキュリティグループは作って指定しておきます。
  • ストレージは好みに合わせてください。私は gp3 の 8GB、IOPS と Throughput は最小値にしています。
  • タグは ebs_data_name と ebs_data_size は値を埋めておきます。name は他とかぶらなければ何でも良いです(私は buildenv-data にしています)。size は Yocto のビルドの場合は 100 くらいで十分です。
  • Advanced Details から IAM は先程作成したものを指定します。
  • 最後に user data として bash スクリプトをまるごと登録します。

ここでのポイントとしては、Launch Template 内で指定するタグは常に使う「固定値」となるものだけ指定します。テンプレート内で指定した値は効力が強く、後にスポットフリートリクエストを出す際に上書きができません。builddir や buildenv などビルドの対象によって変わるものはここでは指定せず、スポットフリートリクエストを出す際に指定するようにします。

S3 を使う場合は API リクエスト回数で思ったより課金される場合があるので、気をつけてください(最初は指定なしで試してみるのが良いと思います)。

スポットフリートリクエストを出す

それではスポットインスタンスを起動してみます。初回はボリュームもスナップショットも何もなくても自動で作られるので大丈夫です。

スポットリクエストで先程作成したテンプレートを指定してリクエストを発行すると、インスタンスが起動するはずなので ssh でアクセスします。起動したインスタンスの性能にもよるのですが、user data で指定したスクリプトの実行が終わるまでに数分かかることもあります。ひとまず /data 以下にボリュームがマウントされて docker コマンドがインストールされるくらいまでは待ちましょう(ちなみに T インスタンスで起動するとバースト課金される場合があるので注意しましょう)。

セットアップが完了したら、/data にデータボリュームがマウントされ、/data/cache がキャッシュデータ置き場として使える状態になっているはずです。さらに docker がインストールされた状態で、din に起動引数を追加する alias や myenv, buildnev などの関数が .bashrc に登録されます。ssh でログインするタイミングが .bashrc の書き換えよりも早いと設定が反映されていない場合があるので、その場合は .bashrc を改めて source するか再ログインしてください。

あとはいつも通り

$ mkdir /data/yocto-1 && cd $_
$ din anyakichi/yocto-builder:dunfell

などとやってビルドするだけです。user_data_script.sh に埋め込まれた設定では、Yocto のダウンロードディレクトリは EBS の /data/cache/yocto/downloads に、sstate ディレクトリは S3 の /data/cache/s3/yocto/sstate に保存するようになっています。

もしも s3_bucket タグを設定しなければ /data/cache/s3 には S3 はマウントされず EBS の状態になるので、S3 を使いたくない場合は単にタグの省略をすれば大丈夫です(もちろん YOCTO_SSTATE_DIR の値を変更するのでも大丈夫です)。

利用を停止する場合は poweroff してください。

スナップショットの作成

インスタンスを停止したらスナップショットを作っておきましょう。これは snapshot.sh スクリプトを使えばできるようになっています(ただし、ボリュームに buildenv-data, buildenv-cache という名前がついている前提になっています)。

https://github.com/anyakichi/docker-myenv/blob/main/aws/snapshot.sh

$ ./aws/snapshot.sh

スナップショットを作成したら元のボリュームは削除してください(残ったままだと、次回起動時に自動的にシャットダウンするようになっています)。スナップショットの作成をトリガーしてからスナップショット作成完了前にボリュームを削除しても、スナップショットが完了するまではボリュームが deleting ステータスで残っているのでこのまま放置でも大丈夫っぽいのですが、このやり方が正しいという文献が見当たらないので安全重視の場合はスナップショットが作成完了してからボリュームは削除してください(探し方が甘いだけかもしれません)。

スナップショットが作成してあれば、次回スポットフリートリクエストからテンプレートを使って起動すると、前回終了時点のボリュームの状態からスタートできます。あとは

  • スポットインスタンスを起動
  • スナップショットを作成
  • ボリュームを削除

をぐるぐると繰り返していけば良いです。スナップショットについては常に最新のものを使うようになっているので、何世代か残しておいても良いですし、最新だけ残して古いやつは消していくのでも良いです。

また、myenv と buildenv を組み合わせてその場で開発を行いたい場合は、前回記事で myenv の使い方を紹介したとおりにすれば行えるはずですので、参考にしてみてください。

https://zenn.dev/anyakichi/articles/56ee804875e15e

自動ビルド

上記の使い方はインスタンスが起動してからマニュアルで操作する方式でしたが、user_data_script.sh ではインスタンス起動時に自動的にビルドを始めて、終わったらシャットダウンする方式もサポートされています。

やり方は簡単で、スポットフリートリクエストを出す際に、タグとして buildenvanyakichi/yocto-builder:dunfell などの Docker イメージ名を、builddiryocto-1 などのビルドディレクトリ名を指定してください。

端的に言えば、上記のように設定してあると

$ mkdir /data/yocto-1 && cd $_
$ din anyakichi/yocto-builder:dunfell extract -y
$ din anyakichi/yocto-builder:dunfell build -y

相当が走ります。実際には /data/yocto-1 が既に存在すれば extract は省略して build だけするようになっていたりしますが、詳細についてはスクリプト本体を参照してください。

自動ビルド中に様子を見たければ、ssh でログインしてから

$ buildenv

と叩くとコンテナにアタッチできます。user_script_data.sh のログは /var/log/cloud-init-output.log から参照可能ですので、何かうまく動いていないことなどがあれば、こちらを参照してみてくだい。

その他留意事項

  • Yocto クラスのビルドだと、正直 2 vCPU だとあまりはかどらないです(do_fetch が渋滞します)。かと言ってあまり vCPU が多くても依存関係でボトルネックになるパッケージ (glibc など) があったりするので、思ったより暇をしている感じになります。だいたい 4 〜 8 vCPU くらいが適当ではないかと思います(多くても 16 vCPU くらいでしょうか)。
  • S3 をキャッシュにすると、想像以上に API リクエストが出ます。素の Yocto の core-image-minimal を作る場合で概ね 1000 くらいの sstate が利用可能になるのですが、まずビルド開始時にこれらのファイルの有無の確認が入ります(これだけで 1000 Tier2 リクエスト)。手元の結果では、フルセットビルド 2 回と途中までを 20 回くらい(?)やって、Tier1 が 257,202 回、Tier2 が 713,243 回という結果が出てきています(金額にしてオレゴンリージョンで $1.58 くらい)。
  • 厳密に現象を追えていないのですが、s3fs に Yocto の DL_DIR を作る場合、use_cache にしておかないとインターネットから tarball をダウンロードしてこれを直接 s3fs に書く場合に、通信が遅いと S3 側がタイムアウトして書き込みに失敗しているようなケースが見られました(S3 にファイルが保存されていないのに、ファイルの書き込みが正常終了する?)。s3fs を使う場合でも sstate に限定した方が良いかもしれません。

S3 キャッシュは別途 HTTP で外部ホストと共有というところまでの活用を考えないのであれば、EBS に格納するのに比べて高く付く可能性が高いです。

まとめ

ほとんど AWS の説明になってしまいましたが、EC2 のスポットインスタンスを使用したインスタント開発環境の構築と利用方法についてでした。

ポイントは

  • スポットインスタンスで費用を抑える
  • EBS はスナップショット化して費用を抑えつつ、スポットインスタンスの起動するゾーンの柔軟性を得る
  • ユーザーデータスクリプトを使って起動時に自動的にビルド環境を整備する
  • タグを使うことによってユーザーデータスクリプトに引数を与えることができるようにする
  • 引数(インスタンスもしくはスポットフリートリクエストに与えるタグ)次第で起動時に自動でビルドを開始・終了できるようにする(buildenv が固定手順でビルドできることを利用する)

あたりになります。

基本的には最初のビルドなどパワーが必要な場面では強力なインスタンスで自動ビルドを実施し、その後のインクリメンタルビルドではインスタンスのスペックを落として myenv を活用しつつ編集しながらビルドをしていくイメージです。

次回は最終回として、AWS Lambda で動作する Rust バイナリを docker-buildenv で構築する方法について見ていきます。

Discussion