多数のEC2上で動作するアプリケーションの構成情報をAPIで取得できる様にした話 (boto3を使ったS3ファイルの並列ダウンロード)

# こんな人向けの話です

* EC2上のアプリケーション等の設定を一覧する仕組みがほしい
* lambdaでS3上の多數のファイルを並列ダウンロードしたい
    * 他の話はさておき上記だけさくっと知りたい方は以下で実装例に飛んでください
    * [情報取得lambda](#情報取得-lambda-1)
# はじめに

こんにちは、@mitonoです。みらい翻訳でインフラ周りを担当しています。

私の担当しているシステムではGPU搭載のEC2を使い、その上でサービスの中核となるアプリケーションを動作させています。
このアプリケーションはいくつかモジュールを組み合わせてプロセスとして立ち上げ、機能を実現する物です。モジュールにはバリエーションがありEC2上には複数のプロセスが起動可能、という事でややこしいのですが目的に応じて多様な組み合わせのEC2を動作させています。

簡単に表現すると以下の様な感じです。(組み合わせるモジュールの種別をA,B,C, バリエーションを色で表しています)
* EC2#1
    * プロセス#1
        * モジュールA_red
        * モジュールB_red
        * モジュールC_red
    * プロセス#2
        * モジュールA_blue
        * モジュールB_blue
        * モジュールC_blue

* EC2#2
    * プロセス#1
        * モジュールA_yellow
        * モジュールB_yellow
        * モジュールC_yellow
    * プロセス#2
        * モジュールA_blue
        * モジュールB_blue
        * モジュールC_blue
    * プロセス#3
        * モジュールA_green
        * モジュールB_green
        * モジュールC_green

この様なEC2が弊社の各種サービスで使われているため、複数のAWSアカウントに多數のEC2が起動している状態です。

各モジュールは頻繁にバージョンアップされるのですが、モジュール開発者が特定の社内リリース行為をすれば、全環境のEC2がこれを検知して勝手に適用するみたいな仕組みを構築しています。
この仕組みは良く働き、モジュール更新の度に全環境のEC2をAMIから作り直していた頃に比べると、新モジュール完成から実際にお客様が使えるようになるまでの時間が格段に短縮されました。


# 課題

この仕組みを運用する中で課題として出てきたのが、
* 今各モジュールってバージョン幾つが実装されてるのだっけ？

という問いに根拠を以て答える事ができず、EC2側に実装した機能で自律更新させている事から、「恐らく最新のバージョンが実装されてると思う」という状態になっていました。

正確なところを知るには、各EC2にログインして適用されているモジュールのバージョンを確認する必要があるのですがやってられませんし、できればEC2にログインできる権限を持つ者の手を借りずとも誰でも手軽に知る事ができる情報にしたいところです。

# 構想

要件というか、どんな事が実現したいのか雑に考えてみました。

* 1コマンドで全環境EC2の該当アプリケーションの構成情報を取得したい   
* 社内の誰でも使えるよう、APIとして公開したい
* API叩くと、複数のAWSアカウント/複数のシステムに実装された該当アプリケーション/EC2の情報が配列データとしてjson形式で返ってくる感じ。利用者がjq等つかって好きなように情報取れるようにしたい。
* 情報のリアルタイム性はあまり必要ない(毎時更新される程度でよい)。
* EC2側はspotも使っており入れ替わりが激しい。古いインスタンスの情報は出てこないようにしたい(ここもリアルタイム性はあんまり必要ない)
* 対象となるアプリケーションは弊社のコアな機能で、多様なサービス/多様なモジュール構成で使われるため、システム単位での増減や場合によってはAWSアカウントも含めたレベルで増減がある。できる限りメンテナンスフリーにしたい
* できる限り手軽に作りたい。

これらをどの様に実現するのか具体的に考えてみます。

### ツールの入り口 (API Gateway)
これはAPI Gatewayでよい。29秒の時間制限に注意が必要になります。
API Gatewayからは、情報収集を担うlambdaを実行する事にします。

### 情報取得 (lambda)
今現在の情報断面を正確に返すのであれば、APIリクエストを受けてから複数AWSアカウントに散在する対象EC2に対して情報取得のリクエストを飛ばすのでしょうけど、これをやろうとすると
* 情報取得対象EC2の管理 (spot増減の対応が必要だったり、新システム構築した時にメンテが必要だったり...現実的にはEC2にタグ付けてSSMで一括制御になるかな)
* 29秒以内に応答しないといけないので、並列での情報取得リクエストと対象毎のステータス制御(取得完了/取得中/タイムアウト/エラー)

みたいな事は最低でもやらないと行けなさそうです。面倒くさいなというのが第一印象。
今回はあくまで手軽にという事もあり、リアルタイム性はあまり求めていません。
手軽に実装するという事を考えると情報は普段から溜めておいて、リクエストを受けたら溜まってる情報返すだけ、というのが楽そうです。情報を溜めておくのはS3バケットで良いでしょう。

具体的には、
* S3の特定バケットに、EC2のinstance-idをファイル名として必要な情報をjson形式で記録したファイルが、対象EC2からuploadされている事を前提とする
* 情報取得lambdaの基本的な処理としてはこのS3バケットにあるファイルの内容を配列に詰めて返すだけ
* ただこの時ファイルの中に記録されてるupdate時刻をみて古い物(今回は2時間以上前としました)は除外する

というシンプルな処理をするだけにしました。

### 情報の格納場所 (S3)
複数のAWSアカウント上で動作している各EC2からの情報は、EC2のinstance-idを名前とするファイルとして、特定のS3バケットに集まる様にします。

EC2側はspotインスタンスを利用していたり、検証環境では夜間はインスタンス破棄していたり、システム内若しくは新システムとしてEC2の増減設があったりします。なのでこのS3バケット内に集まる情報はメンテしないと既に存在しないEC2の情報が溜まって行ってしまいます。

メンテの実装も面倒なので、ここはS3バケットのライフサイクルを利用して最終更新から1日経過したファイルは勝手に削除される様にしました。
後述しますがEC2側からのファイルuploadは1時間毎に行うようにしますので、インスタンスが動作している内が更新し続けられ、又インスタンスが破棄された物は更新されなくなるので自動削除される、という具合です。

### 情報の収集 (EC2内のスクリプト定期起動)
肝心の各EC2の情報収集部分です。収集というと中央設備が対象EC2に情報取得のリクエストを投げるpull型の印象を受けますが、今回は各EC2が自律で情報を送信するpush型での実装になります。

やっている事はシンプルで、必要な情報を集めてjson形式にしてS3バケットにuploadするスクリプトを作っておき、これをcronで毎時実行させるだけです。


### 全体として
絵にするとこんな感じでしょうか
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192181/9b815d22-2234-c2d9-9810-2792ec9d5ff4.png)

結構割り切った仕組みなのでデメリットも多く、以下にまとめてみます。

* メリット
    * API叩かれたらlambdaがS3の情報を配列にまとめて返すだけ、というお手軽実装
    * どれだけAPIが叩かれてもS3に存在する情報を返しているだけなので、サービスを担っているEC2側には負荷はかからない
    * spotの増減や設備増減、システム増減で特にメンテが必要ない。(新たなAWSアカウント使う様になった時、S3側にアクセス許可追加するくらい)
* デメリット
    * 今現在の情報が取れるわけではなく、最遅で1時間前の情報になる
    * 情報収集スクリプトがEC2内に実装されてるので、スクリプト更新の俊敏性がない。(全EC2のAMI作成と入れ替えが必要になるので、どうしてもモジュール更新のついでに、、、という事になる)
        * SSMやCodeDeployなんかで更新楽にする事はできそうですね。この辺は別途。
    * EC2内の情報更新の有無や、そもそもの情報取得APIのリクエスト有無に関わらず、毎時情報収集とS3へのuploadを行っている。(無駄な動作)


# 実装
この仕組みはCloudFormation + 各EC2内で動作するスクリプト、という形で実現しました。都合上全てを掲載はできず、主要部分を一部掲載用に改変しながら掲載という形になりますので、そのまま流せる物ではない事ご容赦ください。

output/importの関係でS3の定義から掲載していきます。

### 情報の格納場所 (S3)
毎時各EC2インスタンスからの情報が集まり、日次で更新の無いファイルを自動削除するS3バケットです。

LifecycleConfigurationで1日更新が無いファイルを勝手に削除する様にしています。

```
Resources:
  appReportBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: "app-report-bucket"
      AccessControl: Private
      LifecycleConfiguration:
        Rules:
          - ExpirationInDays: 1
            Id: Delete1Day
            Status: Enabled
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      OwnershipControls:
        Rules:
          - ObjectOwnership: BucketOwnerEnforced

  appReportBucketPolicy:
    Type: AWS::S3::BucketPolicy
    DependsOn: appReportBucket
    Properties:
      Bucket: !Ref appReportBucket
      PolicyDocument:
        Statement:
          - Action: s3:PutObject
            Effect: Allow
            Resource: !Join
              - ''
              - - 'arn:aws:s3:::'
                - !Ref appReportBucket
                - /*
            Principal:
              AWS: 
                - arn:aws:iam::111111111111:root
                - arn:aws:iam::222222222222:root
                - arn:aws:iam::333333333333:root
                - arn:aws:iam::444444444444:root
Outputs:
  appReportBucketName:
    Value: !Ref appReportBucket
    Export:
      Name: appReportBucketName

```


### 情報取得 (lambda)
S3に溜まっている各EC2からのjson形式レポート(インスタンスIDがファイル名になっている)を全て取得して内容を配列形式に詰めて返却するlambdaです。

大したことをするlambdaでは無いのですが、今回ここが一番のハマりどころでした。最初はboto3のs3 clientのget_objectを使ってファイルの取得をしていたのですが、この実装だとファイルをひとつひとつシリアルでダウンロードする形になります。私達の環境ではレポート対象となるEC2の数(=S3上のファイルの数)が多く、S3からひとつずつファイルを取得して配列に詰めて、、、という処理が29秒以内に終わらずAPIがタイムアウトしてしまう問題が発生してしまいました。

net上の情報を参考にしつつ、複数ファイルを並列ダウンロードする様な実装とする事で今の所10秒程度の処理時間で済んでいます。

lambdaのpythonコード
```
import os
import json
import boto3
import boto3.s3.transfer as s3transfer
import botocore
import datetime
import tempfile

BUCKET_NAME = os.environ['BUCKET_NAME']
DIFF_JST_FROM_UTC = int(os.environ['DIFF_JST_FROM_UTC'])

def lambda_handler(event, context):
    result = getAppInfo()
    return {
        'statusCode': 200,
        'body': json.dumps(result)
    }

def getAppInfo():
    now_datetime = datetime.datetime.utcnow() + datetime.timedelta(hours=DIFF_JST_FROM_UTC)
    now_day = now_datetime.day
    now_hour = now_datetime.hour
    workers=100
    
    botocore_config = botocore.config.Config(max_pool_connections=workers)
    s3_client = boto3.client('s3', config=botocore_config)
    response = s3_client.list_objects_v2(Bucket=BUCKET_NAME)
    result = []
    
    transfer_config = s3transfer.TransferConfig(
        use_threads=True,
        max_concurrency=workers,
    )
    s3t = s3transfer.create_transfer_manager(s3_client, transfer_config)

    with tempfile.TemporaryDirectory() as tmpdirname:
        dstdir=tmpdirname
        for obj in response['Contents']:
            dst = os.path.join(dstdir, os.path.basename(obj['Key']))
            s3t.download(
                bucket=BUCKET_NAME, key=obj['Key'], fileobj=dst
            )
                
        s3t.shutdown()
        lsfiles=os.listdir(path=dstdir)

        for obj in lsfiles:
            try:
                f = open(os.path.join(dstdir, obj), encoding="utf_8")
                body = json.loads(f.read())
            except Exception as Err:
                f.close()
            else:
                f.close()
                update_dte = datetime.datetime.fromisoformat(body["update_date"].replace('+0900', '+09:00'))
                update_day = update_dte.day
                update_hour = update_dte.hour
                # 日付が今日かつ、前時以降に更新されているインスタンスの場合、配列に格納する
                if (now_day == update_day and now_hour - update_hour <= 1):
                    result.append(body)
    return result
```

lambda自体のCFnテンプレート
```
Resources:
  RoleForLambda:
    Type: AWS::IAM::Role
    Properties:
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /

  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties: 
      Code:
        S3Bucket: !Ref SourcesS3Bucket
        S3Key: !Ref LambdaZipFileName
      Environment:
        Variables:
          BUCKET_NAME: !ImportValue appReportBucketName	 
          DIFF_JST_FROM_UTC : 9
      FunctionName: appReport
      MemorySize: 1024
      Handler: appReport.lambda_handler
      Role: !GetAtt RoleForLambda.Arn
      Runtime: python3.8
      Timeout: 900

Outputs:
  appReportLambdaArn:
    Value: !GetAtt LambdaFunction.Arn
    Export:
      Name: appReportLambdaArn
  appReportLambdaName:
    Value: !Ref LambdaFunction
    Export:
      Name: appReportLambdaName
```

### ツールの入り口 (API Gateway)
GETでリクエストを受けたら前述のappReportというlambda関数を呼び出す様なAPIを作ります。社内利用に限定するため簡単ですがAPI-KEYで認証する事とします。

```
  appReportRestApi:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: app-report-apigw
      ApiKeySourceType: HEADER
      EndpointConfiguration:
        Types:
          - REGIONAL

  appReportRestApiResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref appReportRestApi
      PathPart: !Ref ApiName
      ParentId: !GetAtt appReportRestApi.RootResourceId

  appReportRestApiLambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !ImportValue appReportLambdaName
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
    DependsOn: appReportRestApiResource 

  appReportRestApiMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref appReportRestApi
      ResourceId: !Ref appReportRestApiResource
      HttpMethod: GET
      AuthorizationType: NONE
      ApiKeyRequired: true
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Join
          - ''
          - - 'arn:aws:apigateway:'
            - !Ref AWS::Region
            - ':lambda:path/2015-03-31/functions/'
            - !ImportValue appReportLambdaArn
            - '/invocations'
      MethodResponses:
        - ResponseModels:
            application/json: Empty
          StatusCode: 200

  appReportRestApiDeployment:
    Type: AWS::ApiGateway::Deployment
    Properties:
      RestApiId: !Ref appReportRestApi
    DependsOn: appReportRestApiMethod

  appReportRestApiStage:
    Type: AWS::ApiGateway::Stage
    Properties:
      DeploymentId: !Ref appReportRestApiDeployment
      StageName: v1
      RestApiId: !Ref appReportRestApi
      MethodSettings:
      - HttpMethod: '*'
        LoggingLevel: ERROR
        ResourcePath: '/*'
    DependsOn: appReportRestApiDeployment

  appReportRestApiKey:
    Type: AWS::ApiGateway::ApiKey
    Properties:
      Name: app-report-apigw-key
      Enabled: true

  appReportRestApiUsagePlan:
    Type: AWS::ApiGateway::UsagePlan
    DependsOn: appReportRestApiStage
    Properties:
      UsagePlanName: app-report-apigw-usage-plan
      ApiStages:
        - ApiId: !Ref procReportRestApi
          Stage: v1

  appReportRestApiUsagePlanKey:
    Type: AWS::ApiGateway::UsagePlanKey
    Properties:
      KeyId: !Ref appReportRestApiKey
      KeyType: API_KEY
      UsagePlanId: !Ref appReportRestApiUsagePlan
```

### 情報の収集 (EC2内のスクリプト定期起動)

自インスタンス上の情報を取得してjsonに詰めてS3アップロードするスクリプト。(アプリのバージョンや構成取得部分等適当にそれっぽくしてるだけなので、参考程度にしてください)
```
#!/bin/sh
OUTPUT_JSON=/tmp/app_info.json
JSON_BUFFER="{}"
APP_CONF_DIR=/opt/app/conf

get_basic_info ()
{
  UPDATE_DATE=`date --iso-8601="seconds"`
  JSON_BUFFER=`echo "${JSON_BUFFER}" | jq --arg UPDATE_DATE "$UPDATE_DATE" '.update_date |= $UPDATE_DATE'`

  OS_START_DATE=`uptime -s`
  JSON_BUFFER=`echo "${JSON_BUFFER}" | jq --arg OS_START_DATE "$OS_START_DATE" '.os_start_date |= $OS_START_DATE'`

  INSTANCE_ID=`curl -s 169.254.169.254/latest/meta-data/instance-id/`
  JSON_BUFFER=`echo "${JSON_BUFFER}" | jq --arg INSTANCE_ID "$INSTANCE_ID" '.instance_id |= $INSTANCE_ID'`

  INSTANCE_NAME=`curl -s 169.254.169.254/latest/meta-data/tags/instance/Name`
  JSON_BUFFER=`echo "${JSON_BUFFER}" | jq --arg INSTANCE_NAME "$INSTANCE_NAME" '.instance_name |= $INSTANCE_NAME'`

  INSTANCE_TYPE=`curl -s 169.254.169.254/latest/meta-data/instance-type/`
  JSON_BUFFER=`echo "${JSON_BUFFER}" | jq --arg INSTANCE_TYPE "$INSTANCE_TYPE" '.instance_type |= $INSTANCE_TYPE'`

  INSTANCE_LIFE_CYCLE=`curl -s 169.254.169.254/latest/meta-data/instance-life-cycle/`
  if [ "spot" != "${INSTANCE_LIFE_CYCLE}" ] ; then
    INSTANCE_LIFE_CYCLE="ondemand"
  fi
  JSON_BUFFER=`echo "${JSON_BUFFER}" | jq --arg INSTANCE_LIFE_CYCLE "$INSTANCE_LIFE_CYCLE" '.instance_life_cycle |= $INSTANCE_LIFE_CYCLE'`
}

get_app_version ()
{
  APP_VER=`/opt/app/bin/app --version | grep '^version' | grep -o '[0-9]*\..*$'`
  JSON_BUFFER=`echo "${JSON_BUFFER}" | jq --arg APP_VER ${APP_VER} '.app_version |=$APP_VER'`
}

get_app_processes ()
{
  for APP_CONF_FILEPATH in `ls ${APP_CONF_DIR}/*.conf`
  do
    TMP_BUFFER='{}'
    MODULE_A_FILENAME=`grep '^ *module_A_file *=' ${APP_CONF_FILEPATH} | cut -d'=' -f2`
    TMP_BUFFER=`echo "${TMP_BUFFER}" | jq -c --arg MODULE_A_FILENAME ${MODULE_A_FILENAME} '. |= .+{"module_A_filename": $MODULE_A_FILENAME}'`

    MODULE_B_FILENAME=`grep '^ *module_B_file *=' ${APP_CONF_FILEPATH} | cut -d'=' -f2`
    TMP_BUFFER=`echo "${TMP_BUFFER}" | jq -c --arg MODULE_B_FILENAME ${MODULE_B_FILENAME} '. |= .+{"module_B_filename": $MODULE_B_FILENAME}'`

    MODULE_C_FILENAME=`grep '^ *module_C_file *=' ${APP_CONF_FILEPATH} | cut -d'=' -f2`
    TMP_BUFFER=`echo "${TMP_BUFFER}" | jq -c --arg MODULE_C_FILENAME ${MODULE_C_FILENAME} '. |= .+{"module_C_filename": $MODULE_C_FILENAME}'`

    JSON_BUFFER=`echo "${JSON_BUFFER}" | jq '.processes |= .+['${TMP_BUFFER}']'`

  done
}

output_json ()
{
  echo "${JSON_BUFFER}" > ${OUTPUT_JSON}
}

get_basic_info
get_app_version 
get_app_processes

output_json

# ここでは本質ではないので省いていますが、同時多発なS3アップロードを避けるためにランダム値でのsleepやリトライ処理も実装しています
aws s3 cp ${OUTPUT_JSON} s3://app-report/${INSTANCE_ID}.json --region ap-northeast-1

```


# 結果

```
curl -X GET -H "x-api-key: dummyApiKey" "https://dummyId.execute-api.ap-northeast-1.amazonaws.com/v1/app-report" | jq
```
上記の様にcurl等でAPIを叩く事で、以下の様なEC2/アプリケーション構成の情報が取得できる様になりました。

```
[
  {
    "update_date": "2023-12-12T07:00:01+09:00",
    "os_start_date": "2023-12-11 19:11:22",
    "instance_id": "i-123123123123",
    "instance_name": "prod_service1_instance001",
    "instance_type": "g4dn.2xlarge",
    "instance_life_cycle": "spot",
    "app_version": "1.2.3",
    "processes": [
      {
        "module_A_filename": "A_red_1.1",
        "module_B_filename": "B_red_1.8.5",
        "module_C_filename": "C_red_3.0.1"
      },
      {
        "module_A_filename": "A_blue_4.2.2",
        "module_B_filename": "B_blue_1.0.1",
        "module_C_filename": "C_blue_2.2"
      }
    ]
  },
  {
    "update_date": "2023-12-12T07:00:01+09:00",
    "os_start_date": "2023-12-11 19:11:22",
    "instance_id": "i-234234234234",
    "instance_name": "stg_service3_instance005",
    "instance_type": "g4dn.2xlarge",
    "instance_life_cycle": "ondemand",
    "app_version": "1.2.3",
    "processes": [
      {
        "module_A_filename": "A_yellow_1.1",
        "module_B_filename": "B_yellow_1.8.5",
        "module_C_filename": "C_yellow_3.0.1"
      },
      {
        "module_A_filename": "A_blue_4.2.2",
        "module_B_filename": "B_blue_1.0.1",
        "module_C_filename": "C_blue_2.2",
      },
      {
        "module_A_filename": "A_green_1.1.2",
        "module_B_filename": "B_green_2.2.2",
        "module_C_filename": "C_green_3.3.3"
      }
    ]
  },
:
: (後略)
:
```

検証環境含め、全ての環境で今動作している該当アプリケーションが搭載されたEC2の構成情報が1コマンドで手に入る様になったため、「今実際にどのバージョンで動いているんだっけ」という事の確認が根拠を以てできる様になりました。
jsonで返ってくるため目的に応じて整形するのもjqで楽々です。

又、該当アプリケーションの搭載されたEC2を構築する時は、情報uploadのスクリプトも併せて実装される様に構成管理を組んだので、普段の業務で行っているこのアプリケーションの載ったEC2の増設/減設作業では全く意識しなくてもこのレポート構成が維持されるメンテナンスフリーも実現できました。

# おわりに
このAPI、完成後に社内の定例会の場で説明したり、今では「今の商用バージョンなんだっけ」といった質問には回答に添えてAPIの紹介したり、という感じで地道に布教活動しています。

せっかくAPIとして作ったのだし、情報欲しい時に手でcurl叩く以外の実装もできそうです。試しに、このAPIを定期的に叩いて結果を整形して、社内利用しているdocbase記事としてアップする様な仕組みも作ったりしました。
が、、、docbase側の容量制限が厳しくて実用に足るコンテンツにはできていません(汗
まあコンセプトは良いと思うので改良続けていこうと思っています。

今回の様によく必要になる情報取得をAPI化すると、これを利用して更に便利な物をつくろうというモチベーションが湧いてきますね。こっちが最大のメリットかもしれないなと、今回の実装を通じて感じたのでした。