BIGLOBEの「はたらく人」と「トガッた技術」

Pythonのデコレータを使ってAWS Lambdaを圧倒的に読みやすくする

f:id:biglobe-editor2:20220207115547j:plain

基盤系システム部の梶田です。

BIGLOBEではAmazon Web Services(AWS)の活用を推進しています。AWSマネージドサービスの活用機会が増えると、イベントハンドラやフィルターとしてLambda Functionを書く機会も増えてきます。

数をこなしているうちに、Lambda Functionのイベントハンドラにはマネージドサービス毎におきまりのパターン化(お作法)があることに気づきました。

何度も現れるパターンを再利用するには、Pythonのデコレータ機能がうってつけです。このBlogではAWS CodeDeployを題材にして、Lambda Functionを簡素化していった過程をご紹介します。

最後のコードは驚くほど読みやすくなりますので、少々お付き合いください。

CodeDeployのイベントハンドラ

AWS CodeDeployは、Amazon EC2やAmazon ECSに対する新しいサービスのリリース作業を自動化するマネージドサービスです。このサービスはリリース作業の各ステップ毎にイベントハンドラを呼び出すことができます。EC2にリリースするときはリリース媒体に同封されたスクリプトファイルを実行しますが、ECSにリリースするときはLambda Functionを実行します。

今回はECSサービスのBlue/Green Deploymentを利用します。Blue/Green Deploymentでは、リリースする(古いサービスと新しいサービスを入れ替える)直前にイベントハンドラを呼び出して新しいサービスに対してテストを流す事ができます。このテストで問題なければリリースを実行し、駄目ならリリースを停止して元のサービスを継続します。

これを実現するためのLambda Functionをざっくり書くとこんな感じになります。 これはAWS CodeDeployのAfterAllowTestTrafficイベントで呼び出すことを想定したイベントハンドラです。ファイル名はhandler.pyとします。

実際のテストコードは21行目から24行目までで、残りはAWS CodeDeployと対話するためのお作法のようなものです。

# -*- coding: utf-8 -*-
import logging
import boto3
 
logger = logging.getLogger()
logger.setLevel(logging.INFO)
 
def invoke(event, context):
  """Lambda Functionのハンドラ"""
  # CodeDeploy Event Hookの情報を取得
  deployment_id = event['DeploymentId'] if 'DeploymentId' in event else None
  lifecycle_event_hook_execution_id = event['LifecycleEventHookExecutionId'] if 'LifecycleEventHookExecutionId' in event else None
 
  # CodeDeploy Eventでなければ、何もせずに終了
  if deployment_id == None or lifecycle_event_hook_execution_id == None:
    logger.error("CodeDeployのLifeCycle Eventではありません。")
    return

  codedeploy = boto3.client('codedeploy')
  try:
    # テストの実行
    logger.info("テスト実行開始")
    logger.info("o.k.")
    logger.info("テスト実行完了")

    # (1) CodeDeploy Event Hookのステータスを「成功」にする
    codedeploy.put_lifecycle_event_hook_execution_status(
        deploymentId=deployment_id,
        lifecycleEventHookExecutionId=lifecycle_event_hook_execution_id,
        status='Succeeded'
    )
 
  except:
    logger.exception("エラーが発生しました")
    # (2) CodeDeploy Event Hookのステータスを「失敗」にする
    codedeploy.put_lifecycle_event_hook_execution_status(
      deploymentId=deployment_id,
      lifecycleEventHookExecutionId=lifecycle_event_hook_execution_id,
      status='Failed'
    )

AWS CodeDeployはLambda Functionを非同期で呼び出します。そのため、Lambda Functionの処理結果をAWS CodeDeployに伝える必要があります。27行目(コメント1)、36行目(コメント2)で呼び出しているput_lifecycle_event_hook_execution_status()メソッドがそれになります。

特にデータ不正や通信エラーといった例外は正しく拾って失敗したことを伝えてあげないと、AWS CodeDeployはLambda Functionの完了を永遠に待つことになります。そういうことも考慮してプログラムしていくと、結果的に「8割がお作法のコード」ができあがります。

このようなLambda Functionが一つだけならなんとか理解できますが、増えてくると書く人も読む人もかなり苦痛になってきます。

デコレータを使ってお作法を隠蔽する

というわけでお作法を簡素化する方法を考えてみます。

BIGLOBEの基幹システムはJavaとSpring Frameworkを使って開発を進めています。Spring FrameworkにはDIやAOPなど、DBトランザクション処理のようなお作法を隠蔽する機能が豊富に用意されています。

Pythonでもデコレータという機能を使えば同じようなことができそうです。デコレータとは@デコレータ名の形で関数を装飾するだけでその関数に機能追加できるPythonの機能です。

というわけで、まずはCodeDeployのお作法を付与するlifecycle_event_hookという新しいデコレータを作ってみます。ファイル名はcodedeploy.pyとします。

# -*- coding: utf-8 -*-
import logging
import boto3
 
logger = logging.getLogger()
logger.setLevel(logging.INFO)
 
def lifecycle_event_hook(func): # (3)
  def wrapper(*args, **kwargs):
    """Lambda Functionのハンドラ"""
    # CodeDeploy Event Hookの情報を取得
    event = args[0]
    deployment_id = event['DeploymentId'] if 'DeploymentId' in event else None
    lifecycle_event_hook_execution_id = event['LifecycleEventHookExecutionId'] if 'LifecycleEventHookExecutionId' in event else None
 
    # CodeDeploy Eventでなければ、何もせずに終了
    if deployment_id == None or lifecycle_event_hook_execution_id == None:
      logger.error("CodeDeployのLifeCycle Eventではありません。")
      return
 
    codedeploy = boto3.client('codedeploy')
    try:
      func(*args, **kwargs)
 
      # CodeDeploy Event Hookのステータスを「成功」にする
      codedeploy.put_lifecycle_event_hook_execution_status(
        deploymentId=deployment_id,
        lifecycleEventHookExecutionId=lifecycle_event_hook_execution_id,
        status='Succeeded'
      )
 
    except:
      logger.exception("エラーが発生しました")
      # CodeDeploy Event Hookのステータスを「失敗」にする
      codedeploy.put_lifecycle_event_hook_execution_status(
        deploymentId=deployment_id,
        lifecycleEventHookExecutionId=lifecycle_event_hook_execution_id,
        status='Failed'
      )
  return wrapper

デコレータの正体は、関数を引数に受け取り関数を返す関数です。

一見さっきのコードと同じように見えますがいくつか違うところがあります。 8行目(コメント3)のlifecycle_event_hook関数がデコレータ定義です。lifecycle_event_hook関数は関数(func)を引数に受け取り、関数オブジェクト(wrapper)を返しています。

lifecycle_event_hookの中で定義されているwrapper関数が実際にデコレータの処理を実行します。wrapper関数は最初のコードとほぼ同じですが、テストコードを実行していた部分が引数で受け取った関数の実行に変わっています。

次に最初のコードhandler.pyをデコレータを使うように修正してみます。

# -*- coding: utf-8 -*-
import logging
from codedeploy import lifecycle_event_hook
 
 
logger = logging.getLogger()
logger.setLevel(logging.INFO)
 
@lifecycle_event_hook
def invoke(event, context):
  """Lambda Functionのハンドラ"""
  # テストの実行
  logger.info("テスト実行開始")
  logger.info("o.k.")
  logger.info("テスト実行完了")

テストコード以外がなくなって非常にすっきりしました。ついでになにもテストしていないことが白日の下に晒されました。明日のリリースはいろいろと荒れそうです。

@デコレータ名で修飾すると、修飾された関数を引数にデコレータの関数が実行され、返された関数で元の関数を上書きします。

実際にinvoke関数を実行すると、lifecycle_event_hook関数で定義したwrapper関数が実行されます。そして、lifecycle_event_hook関数の引数funcにはinvoke関数が入っているのでwrapper関数の22行目でオリジナルのinvoke関数が実行されます。

おわりに

こうしてLambda Functionのハンドラ関数側(handler.py)にはやりたいこと(テストをしてるふり)だけが記載され、デコレータ名からAWS CodeDeployのLifecycle Event Hookであることが推測できるようになりました。あとから知らない人が見ても理解しやすくなったと思います。

AWS Lambdaには、Lambda Layersという機能があります。これは、複数のLambda Functionでモジュールを共有する機能です。作ったデコレータをLambda Layersに入れておけば他のLambda Functionからも再利用ができるようになります。

BIGLOBEではAWS、Spring Framework / Java、DDDを活用した開発に注力しています。一緒に働きたいエンジニアを募集していますので、このBlogで興味を持った方はカジュアル面談でぜひ現場の様子を紹介させてください。

hrmos.co

※ Amazon Web Servicesは、米国その他の諸国における、 Amazon.com, Inc.またはその関連会社の商標です。

※ Javaは、Oracle Corporationおよびその子会社、関連会社の米国およびその他の国における登録商標です。

※ Pythonは、Python Software Foundationの商標または登録商標です。

※ Spring Framework は、米国Pivotal Software, Inc. の米国またはその他の国における商標または登録商標です。