AWS LambdaをCodeCommitで管理する

## はじめに

正直、このネタは旬が過ぎているというかオワコンというのかAWS CodeCommitが新規アカウントでは利用できなくなっている現在においては書くのをためらった。

これを元々実装したのが約1年前で当時Lambdaを多く書く案件があり、ついでにCodeCommitで管理したいという要望も受けたためである。
当時はローカル環境にAWS CLIを入れたりしたり割と自由に環境構築ができた。

一方、世の中にはセキュリティでガチガチに固められた条件下でAWSを使うっていうのも当たり前の話であり、そうなるとローカルに開発環境を作るなんてことは許されなかったりもする。
加えて上記環境にGithub等の外部サービスも使えなかったりするとAWSのマネージドコンソールで完結するような開発環境の構築が必要となる。

そうなるとCloud9(これもオワコン)を使いたくなるしCodeCommitでバージョン管理したくなる。はず。
なのでそういったものすごくニッチな要望も一部ではあるのではと思い今回の投稿に至った(前置きが長い)。

::: note
そもそもLambdaをGithubで管理するような情報はネット上で多く散見される。
:::

::: note
Cloud9はAWS CLI、SAM、CDKも最初から使えるのですばらしい環境なんだが。。。
なお使い勝手
:::


## 対象読者

* LambdaをGit管理したい方
* SAMやCDKがいいのはわかってるけど後回しにしたい方
* セキュリティの条件から開発環境がAWSコンソールに限定されている方

## LambdaをGitで管理するための考え方

たぶん、LambdaをGitで管理したい場合でも新規作成はAWSのコンソールからやったほうが楽だと思う。いろんな設定ができるし、実行ロールも勝手に作ってくれる。

なのでこう考えた。

1. AWSコンソールでLambdaを新規作成する
2. 一旦、コーディングしてデプロイする
3. 上記Lambdaをローカルの開発環境にダウンロードする
4. ダウンロードしたファイルはGitの管理下に置いて修正したらコミット＆プッシュする
5. 上記イベントをEventBridgeのトリガーで処理し、CodeCommitのプッシュ内容を解析しLambdaへ反映する

でもやっぱり

1. AWSコンソールでLambdaを新規作成するか開発環境でも作成できるようにする

も考えてみた。
従って今回の成果物は以下の2本である。

* Lambdaダウンローダー
* Lambdaアップローダー

## Lambdaダウンローダー

文字通りLambaをダウンロードするものであり、これ自体はCodeCommitとは関係ない。AWS上のLambdaをローカル環境等でソース管理するために使えるから汎用性があるかなと思う。

```python:download.py
import io
import json
import os
import zipfile
from pprint import pprint

import boto3
import botocore
import requests

CHARSET = "utf-8"
DATA_DIR = "data" # 適当に
WITH_DIRECTORY = True
FUNCTION_FILENAME = "lambda_function.py"
LIST_FILENAME = "function-list.json"
CONFIG_FILENAME = "config.json"

from config import (
    AWS_ACCESS_KEY_ID,
    AWS_DEFAULT_REGION,
    AWS_SECRET_ACCESS_KEY,
)

client = boto3.client(
    "lambda",
    aws_access_key_id=AWS_ACCESS_KEY_ID,
    aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
    region_name=AWS_DEFAULT_REGION,
)


def main():
    success_list = list()
    error_list = list()
    response_list = list()
    function_list = list()
    try:
        response = client.list_functions()
        response_list.append(response)
        function_list.extend([f["FunctionName"] for f in response["Functions"]])
        
        while "NextMarker" in response:
            response = client.list_functions(Marker=response["NextMarker"])
            function_list.extend([f["FunctionName"] for f in response["Functions"]])
            response_list.append(response)

        # 全データを書き出す
        with open(
            os.path.join(DATA_DIR, LIST_FILENAME), "w", encoding=CHARSET
        ) as f:
            f.write(json.dumps(response, indent=2, ensure_ascii=False))

        for name in function_list:
            try:
                response = client.get_function(FunctionName=name)
                url = response["Code"]["Location"]

                res = requests.get(url)
                buffer = io.BytesIO(res.content)

                with zipfile.ZipFile(buffer, "r") as zf:
                    namelist = zf.namelist()
                    # pythonファイルが1つしか含まれないものを対象とする。
                    if FUNCTION_FILENAME not in namelist:
                        continue
                    function_name = namelist[namelist.index(FUNCTION_FILENAME)]
                    filepath = name
                    
                    if WITH_DIRECTORY:
                        os.makedirs(filepath, exist_ok=True)
                        # CONFIG_FILENAMEが設定されていれば各情報を書き出す
                        if CONFIG_FILENAME:
                            with open(
                                os.path.join(filepath, CONFIG_FILENAME),
                                "w",
                                encoding=CHARSET,
                            ) as f:
                                json.dump(
                                    response["Configuration"],
                                    f,
                                    indent=2,
                                    ensure_ascii=False,
                                )
                        filepath = os.path.join(filepath, FUNCTION_FILENAME)
                    with open(filepath, "w", encoding=CHARSET) as f:
                        f.write(
                            zf.read(name=function_name).decode(CHARSET),
                        )
                    print(f"download success {name}")
                    success_list.append(name)
            except botocore.exceptions.ClientError as e:
                message = e.response["Error"]["Message"]
                error_list.append(message)
            except Exception as e:
                error_list.append(str(e))

        return {
            "statusCode": 200,
            "body": json.dumps({"success": success_list, "error": error_list}),
        }
    except botocore.exceptions.ClientError as e:
        message = e.response["Error"]["Message"]
        return {"statusCode": 500, "body": message}


if __name__ == "__main__":
    response = main()
    # pprint(response["body"])
```

```python:config.py
AWS_ACCESS_KEY_ID = "<your access key>"
AWS_DEFAULT_REGION = "ap-northeast-1"
AWS_SECRET_ACCESS_KEY = "<your secret key>"
```

Lambda関数の設定もJSON形式で別途ダウンロードできるようにしているのがミソ。

## Lambdaアップローダー

アップローダーというよりもリポジトリにプッシュしたイベントをトリガーにしてLamdaを更新するというもの。

ただしソースコードしかアップロードしない。  
理由としては ~~作るのが面倒だったから~~ 関数の設定はAWSコンソールからやったほうが直感的でやりやすいのとクレデンシャル情報を環境変数に払い出す場合、それもソース管理に含ませるのが嫌だったからである。  

### 準備

事前に以下の準備をしておく

* Lambdaの実行ロール作成
* リポジトリの作成(ここでは test-repo とする)

#### Lambda実行ロール作成

賛否両論あるだろうが、汎用的な実行ロールを事前に作っておく。  
ポイントになるのはCloudWatch Logsへの書き込みをワイルドカード指定にしている点。

まずポリシーを作成

```json:AWSLambdaBasicExecutionCommonRolePolicy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "logs:CreateLogGroup",
            "Resource": "arn:aws:logs:ap-northeast-1:xxxxxxxxxxxx:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "arn:aws:logs:ap-northeast-1:xxxxxxxxxxxx:log-group:/aws/lambda/*:*"
            ]
        }
    ]
    
}
```

次にロールを作成

ロール>ロールを作成>AWSのサービス>ユースケース:`lambda`>許可ポリシー

* AWSLambdaBasicExecutionCommonRolePolicy(上記)
* AWSCodeCommitFullAccess
* AWSLambda_FullAccess

きっちりセキュリティを担保したい場合はFullAccessのところを見直す

#### Lambdaの作成

AWSコンソールから適当なLambda関数を作る。実行ロールは先程のやつ。
加えてトリガーの設定を行う。

* ソースを選択: CodeCommit
* Repository name: test-repo
* Trigger name: MyNotification
* Events: Push to existing branch (既存のブランチにプッシュする)
* Branch Names: mastere (or main)

そしてGit管理されている環境内へ先に説明したダウンローダーで保存する。

### 処理の流れ

CodeCommitのプッシュによるLambdaの更新手順

1. codecommitのpushイベントをトリガーにして起動開始
2. event(引数)のcodecommitからcommit_idを取得
3. eventのeventSourceARNからcodecommitのrepositoryとcommit_idを取得
4. repositoryとcommit_idからcommit情報を取得
5. commit情報から差分を取得
6. 差分情報からpathとcontent(ソースコード)を取得
7. 6のcontentをローカルのlambda_function.pyに書き込む
8. 7をzipで圧縮する
9. 8をAWSにアップロードする

```python:LAMBDA_UPDATER/lambda_function.py
"""
環境変数で設定するもの(「設定」タブ>「環境変数」)

* SOURCE_DIR: Lambda関数を配置するディレクトリ
* RUNTIME: lambdaを実行するランタイム
* ROLE: `AWSLambdaBasicExecutionRefloopCommonRole`のarn
"""

import io
import json
import logging
import os
import zipfile

import boto3
import botocore

SOURCE_DIR = os.getenv("SOURCE_DIR", "lambda")
if not SOURCE_DIR.endswith("/"):
    SOURCE_DIR += "/"
RUNTIME = os.getenv("RUNTIME", "python3.12")
ROLE = os.getenv(
    "ROLE", "arn:aws:iam::xxxxxxxxxx:role/AWSLambdaBasicExecutionCommonRole"
)
# Lambda関数はlambda_function.pyというファイルを含みその中のlambda_hander関数がハンドラである
# HANDER = "lambda_function.lambda_handler" # としてもよい
FUNCTION_FILENAME = "lambda_function.py"
HANDLER = f"{os.path.splitext(FUNCTION_FILENAME)[0]}.lambda_handler"

try:
    # ローカル環境で動かす場合(要config.py)
    from config import AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY

    codecommit = boto3.client(
        "codecommit",
        aws_access_key_id=AWS_ACCESS_KEY_ID,
        aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
        region_name=AWS_DEFAULT_REGION,
    )
    lambda_client = boto3.client(
        "lambda",
        aws_access_key_id=AWS_ACCESS_KEY_ID,
        aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
        region_name=AWS_DEFAULT_REGION,
    )
except:
    # AWS上で動かす場合
    codecommit = boto3.client("codecommit")
    lambda_client = boto3.client("lambda")

logger = logging.getLogger()
logger.setLevel(logging.INFO)
formatter = logging.Formatter(
    "[%(levelname)s]\t%(asctime)s.%(msecs)dZ\t%(aws_request_id)s\t%(message)s\n",
    "%Y-%m-%dT%H:%M:%S",
)
for handler in logger.handlers:
    handler.setFormatter(formatter)


def lambda_handler(event, context):
    # 1. CodecCmmitのpushイベントをトリガーにして起動開始

    # 2. eventのcodecommitからcommit_idの一覧を取得
    commit_list = {x["commit"] for x in event["Records"][0]["codecommit"]["references"]}
    # logger.info(f"commit id: {commit_list}")

    # 3. eventのeventSourceARNからcodecommitのrepositoryとcommit_idを取得
    repository = event["Records"][0]["eventSourceARN"].split(":")[5]
    # logger.info(f"repository {repository}")

    for commit_id in commit_list:
        # 4. repositoryとcommit_idからcommit情報を取得
        commit = codecommit.get_commit(repositoryName=repository, commitId=commit_id)
        try:
            parents = commit["commit"]["parents"]
            message = f"commit id {commit_id} has parents {parents}"
            logger.info(message)
        except:
            message = f"commit id {commit_id} has no parent"
            logger.warn(message)
            continue
        # 5. commit情報から差分を取得
        response = codecommit.get_differences(
            repositoryName=repository,
            beforeCommitSpecifier=parents[0],
            afterCommitSpecifier=commit_id,
        )
        differences = []
        while "nextToken" in response:
            differences += response["differences"]
            response = codecommit.get_differences(
                repositoryName=repository,
                beforeCommitSpecifier=commit["commit"]["parents"][0],
                afterCommitSpecifier=commit_id,
                nextToken=response["nextToken"],
            )
        else:
            differences += response["differences"]
        # logging.info(f"differences: {differences}")

        blob_list = []
        for difference in differences:
            # 6. 差分情報からpathとcontentを取得
            blob_map = {}
            change_type = difference.get("changeType")

            # 6. 差分情報からpathとcontentを取得
            blob_map = {}
            key = "beforeBlob" if change_type == "D" else "afterBlob"
            path = difference[key].get("path")
            blob_id = difference[key].get("blobId")
            if not blob_id:
                message = f"commit id {commit_id} has no {key}"
                logger.info(message)
            try:
                content = codecommit.get_blob(
                    repositoryName=repository, blobId=blob_id
                )["content"].decode("utf-8")
            except:
                message = f"commit id {commit_id} cannot to get blob"
                logger.warn(message)
            blob_map = {
                "path": path,
                "content": content,
                "change_type": change_type,
            }
            # logger.info(blob_map)
            blob_list.append(blob_map)

        response_list = []
        for blob in blob_list:
            # パス名がSOURCE_DIRで指定したディレクトリから始まっていないと飛ばす
            if not blob["path"].startswith(SOURCE_DIR):
                message = f"{blob['path']} skipped"
                logger.info(message)
                continue

            # ソースコードがディレクトリ単位なのかファイル単位なのかで関数名が変わる
            # ディレクトリ単位の場合 - blob["path"]のbasename
            name = os.path.basename(blob["path"])
            # ファイル単位の場合 - blob["path"]のディレクトリのbasename
            if os.path.basename(name) == FUNCTION_FILENAME:
                name = os.path.basename(os.path.dirname(blob["path"]))

            change_type = blob["change_type"]
            content = blob["content"]

            if change_type == "D":
                # 削除の場合
                logger.info(f"Delete {name}")
                try:
                    response = lambda_client.delete_function(FunctionName=name)
                    logger.info(response)
                except botocore.exceptions.ClientError as e:
                    message = e.response["Error"]["Message"]
                    logger.fatal(message)
            else:
                # 新規、更新の場合
                buffer = io.BytesIO()
                with zipfile.ZipFile(
                    buffer, "w", compression=zipfile.ZIP_DEFLATED
                ) as zf:
                    zf.writestr("lambda_function.py", content)

                if change_type == "A":
                    # 新規の場合
                    logger.info(f"Add new {name}")
                    try:
                        response = lambda_client.create_function(
                            FunctionName=name,  # 関数名
                            Runtime=RUNTIME,  # ランタイム
                            Role=ROLE,  # 実行ロール
                            Handler=HANDLER,  # ハ ンドラ
                            Code={
                                "ZipFile": buffer.getvalue()
                            },  # ソースコード（ZIPファイルアップロード）
                            # Timeout=3,       # タイムアウト（秒数）
                            # MemorySize=128,  # メモリ（MB）
                            # Environment={ "Variables":{'AAA':'aaa', 'BBB':'bbb'} }, # 環境変数
                        )
                        logger.info(response)
                    except botocore.exceptions.ClientError as e:
                        message = e.response["Error"]["Message"]
                        logger.fatal(message)
                elif change_type == "M":
                    # 更新の場合
                    logger.info(f"Modifiy {name}")
                    try:
                        response = lambda_client.update_function_code(
                            FunctionName=name, ZipFile=buffer.getvalue()
                        )
                        logger.info(response)
                    except botocore.exceptions.ClientError as e:
                        message = e.response["Error"]["Message"]
                        logger.fatal(message)
                else:
                    logger.warn("Do nothing")
                    response = {}
            response_list.append(response)

        # response_listが1要素しかない場合はそのまま返す
        response = response_list[0] if len(response) == 1 else response_list

    return {"statusCode": 200, "body": json.dumps(response)}


if __name__ == "__main__":
    """ローカルで動かす場合(要vars.py)"""
    from pprint import pprint

    try:
        from vars import event
    except:
        event = {}
    context = None
    response = lambda_handler(event, context)

    pprint(response)
```

Lambdaの実行時間は多めに取っておいたほうがよい。まあ1分もあればよいかな。
適切なevent情報を持ったvars.pyを作ればローカルでもテストできる。
AWS上でしか動かさないなら余計なコードを取り除いていただければよい。

## まとめ

CDKやSAMを使うやり方に比べてスマートさにかけるが、Gitを更新するだけでAWS上のLamdaも更新されるので簡易的なCI/CDっぽく、自分としては重宝している。
とは言うものの、AWSの新規アカウント発行者にはCodeCommitのサービス提供が無いので食品で例えると賞味期限切れだが消費期限にはまだ間に合うような内容になってしまった。

一方で元々teraform使いなのでIaCに関しては多少の初期学習コストをかけても十分お釣りが来ることがわかっているのでSAM等は是非導入すべきだとは思う。

::: note
本格的な案件だとDockerイメージを使ったりレイヤーを設定したくなったり、ポリシーもLamdaごとに細かく設定したくなるのでここで紹介した方法では無理がある
:::

ただいわゆるIaCとは違ったYet Another的なアプローチととして参考に慣れば幸いである。

## リンク

* [CodeCommit \- Boto3 1\.28\.73 documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/codecommit.html)
* [Lambda \- Boto3 1\.28\.73 documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/lambda.html)
* [CodeCommitへpushと同時にLambda関数に反映　前編 \#Python \- Qiita](https://qiita.com/suguru_y_zdh/items/8e16d4ab33fbb2a2fc09)
* [CodeCommitへpushと同時にLambda関数に反映　後編 \#Python \- Qiita](https://qiita.com/suguru_y_zdh/items/0b92f0ae786a4a160a63)
* [Lambda関数をCodeCommitで管理する \#Git \- Qiita](https://qiita.com/afukuma/items/a08648763d60045667bf)