EMR不使用時のコスト削減のために自動terminate処理を実装してみた


はじめに

企業活動を円滑にすすめるためには、ムリムダムラと呼ばれるものはなるべく削除したほうがよい、ということで本投稿内容をやってみました。対象は、AWSサービスの中でも分散処理に使用することの多いEMRサービスです。

背景

分散処理に、AWSのEMRを使用しているのですが、利用後に落とし忘れる人が多いです。
AWSを使っている人ならだれでも気になると思うのですが、立ち上げっぱなしだとコストがかかります。つまり、使わないインスタンスを落とし忘れると無駄な費用が掛かってしまいます。
「もったいない」精神による「ムダなコストは敵だ」の認識のもと、この敵を倒すために落とし忘れ防止策を作ってみました。

今回やりたいこと

・落とし忘れ防止のためのEMRクラスタの自動terminate処理

自動terminate処理を実装してみた

すでにEMRクラスタを立ち上げたり、各アプリが使用するクラスタを管理するアプリケーションがあって、それがrailsで実装されているので、今回もrailsで実装してみました。

削除対象のリストの取得

今回処理を追加しているEMR管理アプリでは、クラスタの情報をDBに保持しているのでlistはDBから取得しています。

cluster_lists = Cluster.where.not(state: 'TERMINATED')

ちなみに取得元のテーブルはこのような感じです。

mysql> desc clusters;
+--------------------+------------------+------+-----+---------+----------------+
| Field              | Type             | Null | Key | Default | Extra          |
+--------------------+------------------+------+-----+---------+----------------+
| id                 | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
| aws_cluster_id     | varchar(255)     | NO   |     | NULL    |                |
| state              | varchar(255)     | NO   |     | NULL    |                |
| public_dns         | varchar(255)     | YES  |     | NULL    |                |
| created_at         | datetime         | NO   |     | NULL    |                |
| updated_at         | datetime         | NO   |     | NULL    |                |
+--------------------+------------------+------+-----+---------+----------------+
6 rows in set (0.01 sec)

メインであるクラスタ削除を行うメソッドを実装

クラスタのterminate処理から実装してみました。
参考にしたのは、もちろん公式のAPIリファレンスです。
Aws::EMR::Clientを定義して、各APIをメソッドとして実装しています。

# Emr Gateway
class EmrGateway
  # 初期化
  #
  # @param args [Hash] 引数
  # @param client_options [Hash] Emr Client オプション
  def initialize(args = {})
    @client_options = args[:client_options] || { region: Settings.aws.default_region }
  end

  # クラスターにぶらさがるインスタンスの一覧
  #
  # @param cluster_id [String] emrクラスタID
  # @param instance_group_types [Array<String>] インスタンスグループの種類 MASTER CORE TASK
  # @return [Array] 渡されたクラスタIDとグループにぶらさがるインスタンス群
  def list_instances(cluster_id, instance_group_types)
    client.list_instances(
      cluster_id: cluster_id,
      instance_group_types: instance_group_types
    )
  end

  # クラスターそのものの情報を取得
  #
  # @param cluster_id [String] emrクラスタID
  # @return [Aws::EMR::Types::DescribeClusterOutput]
  def describe_cluster(cluster_id)
    client.describe_cluster(cluster_id: cluster_id)
  end

  # クラスターの削除
  #
  # @params cluster_ids [Array] emrクラスタIDのArray
  # 
  def terminate_job_flows(cluster_ids)
    client.terminate_job_flows(job_flow_ids: cluster_ids)
  end

  # Emrクラスタのプロビジョン
  #
  # @param provision_params [Hash] Emrクラスタ設定群
  # @return N/A
  delegate :run_job_flow, to: :client

  private

  # AWS Clientインスタンス生成
  #
  # @return [Aws::EMR::Client]
  def client
    @client ||= Aws::EMR::Client.new(@client_options)
  end
end

実際にクラスタ削除処理のメソッドを実装できたかrails cで確認してみました。

[220] pry(main)> emr=EmrGateway.new
[220] pry(main)> emr.terminate_job_flows(["j-1CG53E3YDQDDO"])
=> #<struct Aws::EmptyStructure>

あとはlist-clustersで回して削除処理を行えばいいだけ・・・と思っていた時期が私にもありました。

EMRクラスタでの処理はSTEPを追加することで行っているのですが、今のままだと処理中にもかかわらず強制終了させてしまうことになります。もし数日かかる処理をEMRクラスタに追加し終了を待っていたとしたら、追加された処理にも関わらず翌日クラスタが削除されていたら泣きたくなるでしょう。

クラスタが未処理のSTEPの保持を確認するためのメソッドを追加

# Emr Gateway
class EmrGateway

  # クラスターのSTEPあるかどうかを確認
  #
  # @params cluster_id [String] emrクラスタID
  # @return 
  def list_steps(cluster_id, status)
    client.list_steps(cluster_id: cluster_id, step_states: status)
  end

同じくrails cで確認を取ってみました。

[12] pry(main)> emr.list_steps("j-1TQ7Y5A9JVMD6",["PENDING"]).steps.count
=> 17
[13] pry(main)> emr.list_steps("j-1TQ7Y5A9JVMD6",["PENDING","RUNNING"]).steps.count
=> 18
[14] pry(main)> emr.list_steps("j-1TQ7Y5A9JVMD6",["PENDING","COMPLETED"]).steps.count
=> 50

ちゃんとSTEPのステータスに応じた確認ができそうです。

プログラム実装

実際にプログラムを組むとこんな感じになりました。
もう少しうまい書き方はある気がします・・・

module EmrResources
  class TerminateClusterUsecase
    def terminate_clusters
      @emr = EmrGateway.new
      cluster_lists = Cluster.where.not(state: 'TERMINATED')

      terminate_cluster(cluster_lists)
    end

    private

    # クラスタの削除処理
    #
    # @params [Array] cluster_ids
    # @return [Aws::EmptyStructure]
    def terminate_cluster(clusters)
      terminate_cluster_ids = [] # 初期化
      # クラスタID指定でSTEPの有無を確認する
      # クラスタをterminatedする
      clusters.each do |cluster|
        terminate_cluster_ids << cluster.aws_cluster_id if @emr.list_steps(cluster.aws_cluster_id,["PENDING","RUNNING"]).steps.count == 0
      end
      @emr.terminate_job_flows(terminate_cluster_ids)
    end
  end
end

実行結果

実際に実行してみた結果です。
今回の削除対象はDBに格納している以下のEMRクラスターです。

mysql> select * from clusters;
+----+-----------------+---------+--------------------------------------------------+---------------------+---------------------+
| id | aws_cluster_id  | state   | public_dns                                       | created_at          | updated_at          |
+----+-----------------+---------+--------------------------------------------------+---------------------+---------------------+
|  1 | j-1HJKN2CY5OCLE | RUNNING | ip-172-16-23-89.us-west-2.compute.internal       | 2017-10-24 11:00:16 | 2017-10-24 11:00:16 |
+----+-----------------+---------+--------------------------------------------------+---------------------+---------------------+
1 row in set (0.00 sec)

実際に実行してみました。
想定通りのレスポンスが返ってきてくれました。

[121] pry(main)> ET = EmrResources::TerminateClusterUsecase.new
=> #<EmrResources::TerminateClusterUsecase:0x0055ce68778580>
[122] pry(main)> ET.terminate_clusters
  Cluster Load (0.6ms)  SELECT `clusters`.* FROM `clusters` WHERE (`clusters`.`state` != 'TERMINATED')
=> #<struct Aws::EmptyStructure>

念のためにcliでも確認を取ってみることに・・・
ちゃんとterminatedになっていてよかったです。

root@VirtualBox:~$ aws emr describe-cluster --cluster-id j-1HJKN2CY5OCLE
{
    "Cluster": {
        "Status": {
            "Timeline": {
                "ReadyDateTime": 1513060872.93, 
                "CreationDateTime": 1513060265.909, 
                "EndDateTime": 1513558939.982
            }, 
            "State": "TERMINATED", 
            "StateChangeReason": {
                "Message": "Terminated by user request", 
                "Code": "USER_REQUEST"

あとはcronで登録するだけで自動削除が完成!

まとめ

今回はEMRの落とし忘れ防止のために、EMRの自動削除プログラムをrailsで実装してみました。
STEP処理後に自動でクラスタが削除される方法もありますが、開発用だと毎回立ち上げる手間が惜しいので、なかなか運用にのらず・・・
とはいえ、何もしないのに立ち上がり続けるEMRクラスタのコストは馬鹿にできないので、今回の実装をしてみました。

本格的に運用に載せていくには、STEP以外の処理への配慮とか、どれくらいの頻度で回すのか、すべてのクラスタを一度に落としていいのか、などなど考えることは満載なので、今後も開発を続けていけるといいなと思います。