Spot Fleet を使って割安にリモート (AWS) に開発環境を持つためのアレコレ
Tweetみなさん、開発をやっていますか? わたしはやっています。
わたしはどちらかといえば「なるべくモノを持たない主義」なので、家に計算機をあまり置かないようにしています。とはいえ、IX2105 といったルータやスイッチの類や、HP の MicroServer N54L が元気に稼働してたりはしますが..。
それはさておき、家に開発用に大きなデスクトップマシンを置きたくないということもあり、ここ数年はさくらの VPS で借りたインスタンスをリモートの開発マシンとして利用していました。ただ、ご家庭の計算機環境の Infrastructure as Code を実践し始める前からの年季の入ったインスタンスで、雑草が生え放題になっていてツギハギでなんとか動いているといった様子でした。また、使っていないときも動いているため、月額で契約する VPS は割高だなあという気持ちもありました。
そこで、開発環境をガッと AWS に移すことに決め、さらにスポットインスタンスを活用して、性能の良いインスタンスを割安に使える環境を作ることに決めました。
いろいろモゾモゾと整備して、まだ改善点はあるものの、だいぶいい感じになってきたのでメモがてらブログ記事にまとめようと思います。
開発環境の立ち上げから終了までのフロー
詳細に入る前に、まずはどのような感じで開発環境を立ち上げて、作業に飽きたら終了しているのかを順番に書いていきます。
rake workbench:launch
という Rake タスクで Spot Fleet request を作ってインスタンスを立ち上げる。ssh workbench-001.apne1.aws.mozami.me
といった感じでインスタンスにログインして作業する。- 作業に疲れたら
rake workbench:terminate
という Rake タスクで Spot Fleet request を削除し、インスタンスを terminate する。
常に起動しているインスタンスに ssh するだけという手軽さには劣りますが、3 分もすれば起動するので、その間にコーヒーをいれるなりうさぎを撫でるなりしていれば、あっという間に作業環境が整います。
インスタンスを立ち上げるための Rake タスク
ご家庭の計算機環境を Infrastructure as Code するためのリポジトリは GitHub にあるのですが、さすがにこれはパブリックにはできないので、そのリポジトリに含まれるインスタンスを立ち上げるための Rake タスクをチラ見せします。
require 'aws-sdk-ec2' require 'time' module WorkbenchHelper SUBNETS = { az_1a_public: 'subnet-342e687d', az_1c_public: 'subnet-47b80a1c', az_1d_public: 'subnet-ba84a292', } @ec2 = Aws::EC2::Client.new @logger = Logger.new($stdout) def launch request_spot_fleet_resp = @ec2.request_spot_fleet( spot_fleet_request_config: { allocation_strategy: 'lowestPrice', fulfilled_capacity: 1.0, iam_fleet_role: 'arn:aws:iam::123456789012:role/aws-ec2-spot-fleet-tagging-role', launch_specifications: [ launch_specification('m5d.xlarge', SUBNETS.fetch(:az_1a_public)), launch_specification('m5d.xlarge', SUBNETS.fetch(:az_1c_public)), launch_specification('m5d.xlarge', SUBNETS.fetch(:az_1d_public)), launch_specification('m5a.xlarge', SUBNETS.fetch(:az_1a_public)), launch_specification('m5a.xlarge', SUBNETS.fetch(:az_1d_public)), launch_specification('m5.xlarge', SUBNETS.fetch(:az_1a_public)), launch_specification('m5.xlarge', SUBNETS.fetch(:az_1c_public)), launch_specification('m5.xlarge', SUBNETS.fetch(:az_1d_public)), launch_specification('t3.xlarge', SUBNETS.fetch(:az_1a_public)), launch_specification('t3.xlarge', SUBNETS.fetch(:az_1c_public)), launch_specification('t3.xlarge', SUBNETS.fetch(:az_1d_public)), launch_specification('z1d.large', SUBNETS.fetch(:az_1a_public)), launch_specification('z1d.large', SUBNETS.fetch(:az_1c_public)), launch_specification('z1d.large', SUBNETS.fetch(:az_1d_public)), launch_specification('c5.xlarge', SUBNETS.fetch(:az_1a_public)), launch_specification('c5.xlarge', SUBNETS.fetch(:az_1c_public)), launch_specification('c5.xlarge', SUBNETS.fetch(:az_1d_public)), launch_specification('c5d.xlarge', SUBNETS.fetch(:az_1a_public)), launch_specification('c5d.xlarge', SUBNETS.fetch(:az_1c_public)), launch_specification('c5d.xlarge', SUBNETS.fetch(:az_1d_public)), launch_specification('c5n.xlarge', SUBNETS.fetch(:az_1a_public)), launch_specification('c5n.xlarge', SUBNETS.fetch(:az_1d_public)), launch_specification('r5.xlarge', SUBNETS.fetch(:az_1a_public)), launch_specification('r5.xlarge', SUBNETS.fetch(:az_1c_public)), launch_specification('r5.xlarge', SUBNETS.fetch(:az_1d_public)), launch_specification('r5a.xlarge', SUBNETS.fetch(:az_1a_public)), launch_specification('r5a.xlarge', SUBNETS.fetch(:az_1d_public)), launch_specification('r5d.xlarge', SUBNETS.fetch(:az_1a_public)), launch_specification('r5d.xlarge', SUBNETS.fetch(:az_1c_public)), launch_specification('r5d.xlarge', SUBNETS.fetch(:az_1d_public)), ], target_capacity: 1.0, type: 'maintain', instance_interruption_behavior: 'stop', }, ) File.write('tmp/workbench-sfr', request_spot_fleet_resp.spot_fleet_request_id) @logger.info("Created a spot fleet request '#{request_spot_fleet_resp.spot_fleet_request_id}'.") end def terminate create_image_resp = @ec2.create_image( instance_id: active_instance_id, name: "arch-usagoya-workbench-#{Time.now.strftime('%Y%m%d%H%M')}", ) @ec2.wait_until( :image_available, { image_ids: [create_image_resp.image_id], }, { before_wait: -> (_, _) { @logger.info("Waiting to finish create AMI '#{create_image_resp.image_id}'...") }, }, ) @ec2.create_tags( resources: [create_image_resp.image_id], tags: [ { key: 'Role', value: 'workbench', }, ], ) @logger.info("Create a tag for AMI '#{create_image_resp.image_id}'.") @ec2.cancel_spot_fleet_requests( spot_fleet_request_ids: [active_spot_fleet_request_id], terminate_instances: true, ) @logger.info("Spot fleet request '#{active_spot_fleet_request_id}' has been canceled.") end def latest_image_id return @latest_image_id if @latest_image_id images_by_role = @ec2.describe_images( filters: [ { name: 'tag:Role', values: [ 'base', 'workbench', ], }, { name: 'state', values: ['available'], }, ], owners: ['self'], ).images.group_by { |a| a.tags.find { |t| t.key == 'Role' }.value } @latest_image_id = unless images_by_role['workbench'].nil? images_by_role['workbench'].sort_by { |i| Time.parse(i.creation_date) }.reverse[0].image_id else images_by_role['base'].sort_by { |i| Time.parse(i.creation_date) }.reverse[0].image_id end end def launch_specification(instance_type, subnet_id) { image_id: latest_image_id, instance_type: instance_type, key_name: 'id_rsa.private', weighted_capacity: 1.0, block_device_mappings: [ device_name: '/dev/sda1', ebs: { delete_on_termination: true, volume_type: 'gp2', volume_size: 32, }, ], iam_instance_profile: { arn: 'arn:aws:iam::123456789012:instance-profile/EC2Workbench', }, network_interfaces: [ { device_index: 0, subnet_id: subnet_id, delete_on_termination: true, associate_public_ip_address: true, groups: [ 'sg-b04bf3d7', # default 'sg-0f3eea55d27a96cf1', # workbench ], }, ], tag_specifications: [ { resource_type: 'instance', tags: [ { key: 'Name', value: 'workbench-001', }, { key: 'Role', value: 'workbench', }, ], }, ], } end def active_spot_fleet_request_id @active_spot_fleet_request_id ||= File.read('tmp/workbench-sfr') end def active_instance_id @active_instance_id ||= @ec2.describe_spot_fleet_instances( spot_fleet_request_id: active_spot_fleet_request_id, ).flat_map(&:active_instances)[0].instance_id end module_function :launch, :terminate, :latest_image_id, :launch_specification, :active_spot_fleet_request_id, :active_instance_id end namespace :workbench do desc '#' task :launch do WorkbenchHelper.launch end desc '#' task :terminate do WorkbenchHelper.terminate end end
コードとしてはちょっと長いように見えますが、ほとんどが Spot Fleet request や launch specification の設定値で、やっていることは単純です。
- 起動時
- Role タグが
workbench
になっている AMI の中から最新のものを探す。なければbase
となっている AMI を探す。 - 最新の AMI の ID を launch specification の設定に含める。
- その launch specification の設定を使って Spot Fleet request を作成する。
- 適当にインスタンスタイプと availability zone の候補を
launch_specification
として並べておき、allocation_strategy
をlowestPrice
に設定しておけば、その瞬間の時価で一番安いインスタンスが立ち上がる。
- 適当にインスタンスタイプと availability zone の候補を
- Spot Fleet request の ID をローカルのディスクに書く。
- Role タグが
- 終了時
- 動いているインスタンスの AMI を作る。
- ディスクから Spot Fleet request の ID を読んで、その request をキャンセルする。
- キャンセルにより無事インスタンスが終了する。
だいぶとサボった実装ですが、Spot Fleet request をディスクに書き込む代わりに、S3 を KVS として利用するのも手かもしれませんね。そもそも、Spot Fleet request がタグをサポートすればこんなことをする必要はないのですが..。
また、インスタンス名は workbench-001 と固定するよりも、workbench-i-xxxxxx のように、インスタンス ID を利用した名前にするともっと良いかもしれません。ただ、この場合は開発用途なので重複して立ち上げることはなく、これで十分です。
この設定だと、だいたいはもっとも安い価格で安定している t3.xlarge が立ち上がってきます。Spot Fleet request は現在起動しているインスタンスの時価を見て立ち上げ直すということはしてくれないため、そこは注意する必要があります。
また、起動と終了を繰り返すたびにモリモリと AMI や EBS snapshot が増えていくため、定期的に古いものを削除したほうが節約になります。このあたりはまだ自動化しておらず、気づいたときにちまちま消しています。
ssh でシュッとインスタンスにログインする
インスタンスの DNS 名の管理は、CloudWatch events からインスタンスの立ち上げと終了のイベントを取得して、Route 53 の private hosted zone に A レコードを追加/削除するだけの雑な Lambda function で管理しています。ご家庭にひとつはありそうな仕組みですね。
これにより workbench-001.apne1.aws.mozami.me という名前でインスタンスが立ち上がったときに A レコードが追加されてシュッと ssh ログインできます。いわゆる bastion が常設されていて、EC2 に ssh ログインするときはそれを踏み台にしているため、この名前は VPC 内で引けるだけで OK です。
手元のマシンの SSH config は Nymphia という Ruby の DSL で SSH config を生成するツールを利用しており、開発環境として利用するインスタンス名のみ、StrictHostKeyChecking
を no
に設定し、UseKnownHostsFile
も /dev/null
に設定しています。
mozamimy/nymphia: Create your SSH config with Ruby, and without any pain.
こんな感じの設定を DSL を使って書いて、
group('mozami.me (AWS)') { use_gateway 'gw.mozami.me' default_params { user 'mozamimy' port 22 forward_agent 'yes' use_identify_file :usagoya } host('workbench-001.apne1.aws.mozami.me') { hostname 'workbench-001.apne1.aws.mozami.me' strict_host_key_checking 'no' user_known_hosts_file '/dev/null' } host('*.apne1.aws.mozami.me') host('10.33.*') }
Nymphia で設定を生成すると、このような SSH config が出てきます。
Host workbench-001.apne1.aws.mozami.me
User mozamimy
Port 22
ForwardAgent yes
IdentityFile ~/.ssh/usagoya.pem
ProxyCommand ssh gw.mozami.me -q -W %h:%p
Hostname workbench-001.apne1.aws.mozami.me
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
以前は Rust や Go を書くときに限っては VS Code を利用し、それ以外では Vim を使っていました。なので、リモートの開発環境でも VS Code で書きたいときに困っていたのですが、少し前に VS Code Remote Development が発表されて実用段階になり、今ではほぼ完全に VS Code に乗り換えました。
データの置き場
スポットインスタンスを開発環境としたときに困るのが、データの置き場です。具体的には常用するためのユーザのホームディレクトリですね。開発用のインスタンスのプロビジョニングは Packer や mitamae を使っているため、base AMI からいつでも簡単に作ることができるので大した問題ではありません。
この点に関しては、EFS を利用することでクリアしました。EFS は AWS が提供するマネージドな NFS サービスで、~/var
というディレクトリを NFS のマウントポイントとしています。これにより、突然 spot interruption を食らってもデータを失う心配がグッと減ります。データ用の EBS をアタッチし、毎度スナップショットをとってインスタンスを立ち上げるときにリストアしてアタッチして.. という方法もありますが、めんどくさい。
ただし、ストレージとしてのパフォーマンスは gp2 の EBS に比べるとかなり残念なので、その場合には ~/var
以外のディレクトリ、たとえば ~/tmp
で作業することが多いです。データを失っても問題ないという前提付きになりますが。
実際の使用感と料金
このような構成で数ヶ月過ごしてきましたが、今の今まで、ただの一度も spot interruption を受けたことがありませんし、インスタンスの価格も約 70% off で開発環境をリモートで運用することができており、たいへん快適です。
以下のスクリーンショットは、Cost Explorer で、この開発環境周りのリソースに絞ってコストを集計したものです (2019-05)。
平日は平均 1 ~ 2 時間、外出などをしない休日は 8 時間以上立ち上げることもありますが、t3.xlarge といったそこそこ贅沢なインスタンスを利用しているにもかかわらず、1 ヶ月にかかる料金としては、EC2 周りの料金と EFS を足し合わせて $10 を少し上回る程度です。さくらの VPS を利用していた頃は、¥3,888/month の 4G プランを利用していたので、かなり安上がりに済んでいますね。
この先、価格変動が大きくなったりするとここまで安定して使えなくなるかもしれませんが、逆に言えば安定している今こそ使い時だと言えます。みなさんも思い思いの、スポットインスタンスを利用したぼくがかんがえたさいきょうのリモート開発環境を構築してみてはどうでしょうか。たのしいですよ。