コスト削減完了!!EC2のバッチインスタンスをLambdaに移行する

こんにちは、広告事業本部でクライアントの受発注システムを担当しているリードアプリケーションエンジニアの花田です。
前回は「コスパ悪い? EC2のジョブインスタンスをlambdaに移行する」の記事でLambdaに置き換えました。
今回はコスト削減の最後の作業として、 バッチインスタンスをEC2からLambdaに移行 しました。

blog.engineer.adways.net

はじめに

今年から始めたコスト削減もいよいよバッチで最後になります。
現在バッチはEC2インスタンス上でRails + RDSの構成で運用しています。
こちらもジョブと同じようにLambda化していきます。

LambdaでRDS接続を行ってみよう

チュートリアルでRDSに簡単接続

LambdaからRDSに接続したことが無いため、初めはAWS公式の「Lambda関数を使用してAmazon RDSにアクセスする」を参考にLambda + RDS Proxyの検証しました。

チュートリアルの手順

1.[Lambda 接続をセットアップする] ページで、[新しい関数の作成] を選択します。
  [新しい Lambda 関数名] を test_rds に設定します。
2.[RDS プロキシ] セクションで、[RDS プロキシを使用して接続] オプションを選択します。さらに、[新しいプロキシの作成] を選択します。
 ・[データベース認証情報] として、[データベースのユーザー名とパスワード] を選択します。
 ・[ユーザー名] として、admin と指定します。
 ・[パスワード] として、データベースインスタンスのために作成したパスワードを入力します。
3. [セットアップ] を選択して、プロキシと Lambda 関数の作成を完了します。

RDS Proxyを用いることで簡単にLambdaからRDSまでの接続設定ができました。

Lambdaの検証用コード

require 'mysql2'

def lambda_handler(event:, context:)
  client = Mysql2::Client.new(
    host:     ENV['DB_HOST'],
    username: ENV['DB_USER'],
    password: ENV['DB_PASSWORD'],
    database: ENV['DB_NAME']
  )
  
  client.query("SELECT 1")

  client.close
  
  puts "DB connection and SELECT 1 query successful."
rescue StandardError => e
  puts "ERROR: Verification failed: #{e.message}"
end

LambdaからRDSに接続するテストにはGemのmysql2を用いており、SELECT 1 でSQL文が使用できるか検証しています。

RDS Proxyのコストは高い

チュートリアルで簡単にLambdaからRDSに接続できたのは良いのですが、RDS Proxyは常時料金が発生するためEC2の構成より高くなることがわかりました。
参考URL:Amazon Relational Database Service Proxy の料金

EC2とRDS Proxyのコスト比較

項目 EC2 インスタンス RDS Proxy
インスタンスタイプ t4g.medium db.t4g.medium
インスタンス数/構成 1台 ライター1台 (2vCPU) + リーダー1台 (2vCPU)
vCPU合計 2 vCPU 4 vCPU
時間単価 $0.0432 USD/時間 $0.018 USD/vCPU/時間
稼働時間 24時間/日 常時料金発生
月額概算 約 $32 USD 約 $53 USD
計算根拠 $0.0432 USD/時間 × 730 時間/月 = $31.54 /月 4 vCPU × $0.018/vCPU/時間 × 730 時間/月 = $52.56 /月

RDS Proxyは本当に必要?

RDS Proxyには接続数の効率化や高可用性の向上といったメリットがあります。
ただ今回のバッチ処理は、9つのLambdaが10分に一度だけRDSに接続する、比較的シンプルな内容です。
また接続数も少なく同時に実行されるLambdaも9つと少ないため、RDS Proxyは必要以上にリッチすぎると考えています。
しかし「Amazon RDS で AWS Lambda 使用する」には「本番環境ではプロキシの使用が推奨されます。」と記載されているため、念の為AWSサポートに問い合わせてみました。
サポートからは、「参考資料の記述は必ずしも本番環境でRDS Proxyを使う必要があることを示すものではない」との回答をいただきましたので今回はRDS Proxyを設定しない方針としました。

RDSの接続を行う

チュートリアルのRDS Proxyの接続設定はGUIでできましたが、RDS Proxyでなくとも適切なセキュリティグループを設定することで、こちらも簡単に接続設定が完了できました。

Lambdaの修正

RDSの接続にはActive Recordを利用してみよう

検証ではmysql2のみで行っていたのですが、実際の運用ではActive Recordを使ったほうが保守性が高いのでActive Recordをインストールしました。
以下のGemをGemfileに記載してインストールします。

gem 'mysql2'
gem 'activerecord'

※ Active Recordを利用するにはmysql2も必要です。

MysqlClientクラス
lambda_handler側でmysql_client.connectを呼び出すことで、MySQLのコネクションを確立するようにしました。

require 'mysql2'
require 'active_record'

class MysqlClient
  def initialize; end

  def connect
    ActiveRecord::Base.establish_connection(
      adapter:  'mysql2',
      host:     AppConfig.db_host,
      username: AppConfig.db_username,
      password: AppConfig.db_password,
      database: AppConfig.db_name,
    )
  rescue => e
    raise "[ERROR] DB Connection Failed: #{e.message}"
  end

  def disconnect
    ActiveRecord::Base.remove_connection
  end
end

modelクラス
Lambda内でモデルクラスを定義しバリデーションも設定しています。

require 'active_record'

class User < ActiveRecord::Base
  validates :name, presence: true
end

class Price < ActiveRecord::Base
  validates :value, presence: true
end

Lambdaの成功/エラー通知

CloudWatch Alarmでエラー通知をしよう

CloudWatchのメトリクスフィルターで?ERROR ?error ?Error ?FATAL ?fatal ?timeoutのフィルターパターンを定義しています。
これによりキーワードが1回でもフィルターパターンに一致した場合、Alarmが発動しSlackにエラー通知を送る仕組みを構築しています。

エラーが解消したら成功通知を行う

バッチ処理は10分に1回実行されるため、成功したときに頻繁に通知を送りたくありませんでした。
そこでエラーが解消された後にのみ成功を通知するように、Lambdaに成功時の通知処理を追加しました。

CloudWatchClient
Lambdaが成功した際は、必ず対象メトリクスデータの数値を 0 にするようにしました。

require 'aws-sdk-cloudwatch'

$cloudwatch_client = Aws::CloudWatch::Client.new(region: 'ap-northeast-1')

class CloudWatchClient
  def initialize
    @client = $cloudwatch_client
  end

  def put_metric_zero_data()
    @client.put_metric_data(
      namespace: 'batch',
      metric_data: [
        {
          metric_name: "#{ENV['AWS_LAMBDA_FUNCTION_NAME']}_error",
          value: 0,
          unit: 'Count',
        }
      ]
    )
  rescue StandardError => e
     puts "[ERROR] An unexpected error occurred while sending metric data: #{e.message}"
  end
end

エラーが起きていないときは、メトリクスの数値が0のままなので通知されません。
エラーが発生してメトリクスの数値が1以上になった際に0になることでslack通知されるようにしています。

Lambdaのタイムアウトエラー検証時に、なぜか成功通知が送られる問題発生

Lambdaのタイムアウトエラーで正しくメトリクスが1になるか検証していた際に、「失敗」→「成功」通知が繰り返し発生する現象がありました。
Lambda内の処理でメトリクスを0にしているためタイムアウトエラーでは成功通知が送られない仕様になっています。
調査した結果、CloudWatchのメトリクスフィルターのデフォルト値が0になっていたことが原因でした。
Lambda関数開始時の「START RequestId: ...」というログイベントが記録された際に、フィルターパターンの?ERROR ?error...と一致せず成功通知が送られていました。
もし同様のことが起きた場合はメトリクスフィルターのデフォルト値を疑ってみるといいかもしれません。

コストをどのぐらい削減できそうなの?

stagingとproductionのバッチをLambdaに移行することで月に約$47削減できる見込みです。
ジョブが月に約$26でしたので、想定より削減できそうで良かったです。

コスト削減の総括

今年の1月からコスト削減に向けた取り組みを進めてきました。
さまざまな学びも得ながら、結果的に月あたり約1500ドルの削減を実現できたので良い取り組みだったと思います。
コスト削減対策として「開発工数が少なくコスト削減効果が大きいもの」や「逆に開発工数が多くコスト削減効果が限定的なもの」もありました。
しかしコスト削減効果が限定的でも開発保守などのメリットのある作業も行えたのは良かったと感じています。

最後に

今回はバッチインスタンスをLambdaに移行することでコスト削減を実現しました。
今後はコスト削減作業から離れて、AI系の開発をする想定ですので、また知見が溜まったらブログで内容を公開したいと思います。