AWS(StepFunctions+SSM)でなるべくサーバーレスにUE4のWin64ビルドする環境を作ってみる

AWSでUE4のWin64向けのパッケージビルドをするためにStepFunctionsとSSM(Systems Manager)を利用してなるべくサーバーレスで実行する環境を作ってみました。
StepFunctionsでEC2の起動、終了を管理しSSMのRun CommandでEC2に対してビルドコマンドを実行します。
ここでは作成したAWS環境について記述します。

# 前提
## UE4のクロスコンパイル

[WindowsのUE4でLinux向けのクロスコンパイルは可能](https://docs.unrealengine.com/4.27/ja/SharingAndReleasing/Linux/GettingStarted/)ですがLinuxのUE4でWindows向けのクロスコンパイルはサポートしてないようなのでWin64ビルドするためにはWindows環境が必要そうです。

## Windowsのコンテナ利用でのビルド

以下の理由のためにコンテナを用いたWindowsでのUE4ビルドは現時点では諦めEC2でビルドすることにしました。
(Fargateも利用したいがGPUをサポートしてない)
この時点でCodeBuildやBatchなどのコンテナ前提のサービスが利用候補から外れますね。

* UE4では公式のコンテナがWindowsではRuntime版(Editorなし)しかサポートしていない([参照](https://docs.unrealengine.com/4.27/ja/SharingAndReleasing/Containers/ContainersOverview/))
* Windows開発イメージには技術的および法的な制限がある([参照](https://docs.unrealengine.com/4.27/ja/SharingAndReleasing/Containers/KnownLimitations/))

## VisualStudioBuildToolの利用

UE4のビルドでVisual Studioを使うとライセンスで問題ありそうなのでライセンスで問題なさそうなVisualStudioBuildToolを使うことにします。

* [デフォルトテナントで実行される AWS インスタンスで MSDN を使用できますか?](https://aws.amazon.com/jp/windows/faq/)
* [Microsoft Visual Studio 2019 リモート デバッガー、スタンドアロン プロファイラー、IntelliTrace、スナップショット デバッガー、その他のデバッガーおよびビルド ツール - Visual Studio](https://visualstudio.microsoft.com/ja/license-terms/mlt031519/)

# StepFunctions + SSMの構成

ベースの構成は以下の記事を参考にしています。
[Run CommandとStep Functionsでサーバレス(?)にEC2のバッチを動かす - Qiita](https://qiita.com/runtakun/items/9fa766d7cb725a0574b4)

## EC2

### AMI

ビルドで用いるEC2のAMIは以下のような感じです。

* OSはWindows Server 2019です。
* g4dn系インスタンスでNVIDIAドライバをインストールしています。
 * [Windows インスタンスへの NVIDIA ドライバーのインストール - Amazon Elastic Compute Cloud](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/WindowsGuide/install-nvidia-driver.html)
* UE4をソースコードビルドして配置するために、[ue4-dockerのDockerfiles](https://github.com/adamrehn/ue4-docker/tree/master/ue4docker/dockerfiles)を[ue4-build-prerequisites](https://github.com/adamrehn/ue4-docker/blob/master/ue4docker/dockerfiles/ue4-build-prerequisites/windows/Dockerfile)、[ue4-source](https://github.com/adamrehn/ue4-docker/blob/master/ue4docker/dockerfiles/ue4-source/windows/Dockerfile)、[ue4-engine](https://github.com/adamrehn/ue4-docker/blob/master/ue4docker/dockerfiles/ue4-engine/windows/Dockerfile)あたりの順で参照してコマンドラインベースでセットアップしています。
* 上記にプラスして以下のようなものをインストールしています。
 * [aws cli](https://aws.amazon.com/jp/cli/)：必要かと思って入れたがSSMのRunCommandで[AWS Tools for Windows PowerShell](https://aws.amazon.com/jp/powershell/)が使えているのでいらないかも
 * [ue4cli](https://github.com/adamrehn/ue4cli)：ue4-dockerでも使われているUnrealEngineのコマンドラインツールの利用をサポートしてくれるツール
 * [ssm-agent](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/sysman-install-win.html)：SSMでRunCommandできるようにするために必要
 * [EC2LaunchV2](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/WindowsGuide/ec2launch-v2-install.html)：これをインストールしておかないとAMIからEC2を起動した際にインスタンスメタデータにアクセスできずにssm-agentも起動しませんでした。

### IAM Role

ビルドで用いるEC2のIAM Roleは以下のようなポリシーを設定しています(もっと権限絞った方がいいかも)。

* AmazonSSMManagedInstanceCore
* AmazonSSMFullAccess
* カスタムポリシーで以下を許可
 * logs:CreateLogStream
 * logs:DescribeLogStreams
 * logs:CreateLogGroup
 * logs:PutLogEvent
 * states:SendTaskSuccess
 * states:SendTaskFailure
 * states:SendTaskHeartbeat

## SAM

構成管理にはSAM(Serverless Application Model)を利用しました。
[こちら](https://github.com/aws/aws-sam-cli-app-templates/tree/master/python3.9/cookiecutter-aws-sam-step-functions-sample-app/%7B%7Bcookiecutter.project_name%7D%7D)のaws-sam-cliのpython3.9のapp-templateをベースにしました。
StepFunctionsからLambdaまでの構成とコードをまとめて管理できてよいです。

template.yamlは以下のような感じで4つのLambda関数とStepFunctionsのStateMachineを定義しています。

```yaml:template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  unrealengine-worker

  Sample SAM Template for unrealengine-worker

Resources:
  UnrealEngineWorkerStateMachine:
    Type: AWS::Serverless::StateMachine # More info about State Machine Resource: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-statemachine.html
    Properties:
      DefinitionUri: statemachine/stock_trader.asl.json
      DefinitionSubstitutions:
        StartInstanceFunctionArn: !GetAtt StartInstanceFunction.Arn
        ConfirmInstanceStateFunctionArn: !GetAtt ConfirmInstanceStateFunction.Arn
        TerminateInstanceFunctionArn: !GetAtt TerminateInstanceFunction.Arn
        StartJobFunctionArn: !GetAtt StartJobFunction.Arn
        StartJobFunctionName: !Ref StartJobFunction
      Policies: # Find out more about SAM policy templates: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-policy-templates.html
        - LambdaInvokePolicy:
            FunctionName: !Ref StartInstanceFunction
        - LambdaInvokePolicy:
            FunctionName: !Ref ConfirmInstanceStateFunction
        - LambdaInvokePolicy:
            FunctionName: !Ref TerminateInstanceFunction
        - LambdaInvokePolicy:
            FunctionName: !Ref StartJobFunction

  StartInstanceFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/start_instance/
      Handler: app.lambda_handler
      Runtime: python3.9
      Timeout: 20
      Policies:
        - Statement:
            - Sid: StartInstancePolicy
              Effect: Allow
              Action:
                - "ec2:RunInstances"
                - "iam:PassRole"
              Resource:
                - "*"
      Architectures:
        - arm64

  ConfirmInstanceStateFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/confirm_instance_state/
      Handler: app.lambda_handler
      Runtime: python3.9
      Timeout: 20
      Policies:
        - Statement:
            - Sid: StartInstancePolicy
              Effect: Allow
              Action:
                - "ec2:DescribeInstances"
                - "ec2:DescribeInstanceStatus"
              Resource:
                - "*"
      Architectures:
        - arm64

  TerminateInstanceFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/terminate_instance/
      Handler: app.lambda_handler
      Runtime: python3.9
      Timeout: 20
      Policies:
        - Statement:
            - Sid: TerminateInstancePolicy
              Effect: Allow
              Action:
                - "ec2:TerminateInstances"
              Resource:
                - "*"
      Architectures:
        - arm64

  StartJobFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/start_job/
      Handler: app.lambda_handler
      Runtime: python3.9
      Timeout: 20
      Policies:
        - Statement:
            - Sid: StartJobPolicy
              Effect: Allow
              Action:
                - "ssm:SendCommand"
              Resource:
                - "*"
      Architectures:
        - arm64

Outputs:
  # StockTradingStateMachineHourlyTradingSchedule is an implicit Schedule event rule created out of Events key under Serverless::StateMachine
  # Find out more about other implicit resources you can reference within SAM
  # https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-specification-generated-resources.html
  UnrealEngineWorkerStateMachineArn:
    Description: "Stock Trading State machine ARN"
    Value: !Ref UnrealEngineWorkerStateMachine
  UnrealEngineWorkerStateMachineRoleArn:
    Description: "IAM Role created for Stock Trading State machine based on the specified SAM Policy Templates"
    Value: !GetAtt UnrealEngineWorkerStateMachineRole.Arn

```

## State Machine

StepFunctionsのState Machineは以下のようになります。

![stepfunctionsdef.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16768/b4963a5b-2776-f355-aa9f-6853c40c8551.png)

```json:stock_trader.asl.json
{
    "Comment": "A state machine that does mock stock trading.",
    "StartAt": "StartInstance",
    "States": {
        "StartInstance": {
            "Type": "Task",
            "Resource": "${StartInstanceFunctionArn}",
            "Next": "WorkflowBody"
        },
        "WorkflowBody": {
            "Type": "Parallel",
            "Branches": [
                {
                    "StartAt": "WaitInstanceState",
                    "States": {
                        "WaitInstanceState": {
                            "Type": "Wait",
                            "Seconds": 60,
                            "Next": "ConfirmInstanceState"
                        },
                        "ConfirmInstanceState": {
                            "Type": "Task",
                            "Resource": "${ConfirmInstanceStateFunctionArn}",
                            "Next": "ChoiceInstanceState"
                        },
                        "ChoiceInstanceState": {
                            "Type": "Choice",
                            "Default": "FailInstanceState",
                            "Choices": [
                                {
                                    "Or": [
                                        {
                                            "Variable": "$.instance_state",
                                            "StringEquals": "pending"
                                        },
                                        {
                                            "Variable": "$.instance_status",
                                            "StringEquals": "initializing"
                                        },
                                        {
                                            "Variable": "$.instance_system_status",
                                            "StringEquals": "initializing"
                                        }
                                    ],
                                    "Next": "WaitInstanceState"
                                },
                                {
                                    "And": [
                                        {
                                            "Variable": "$.instance_state",
                                            "StringEquals": "running"
                                        },
                                        {
                                            "Variable": "$.instance_status",
                                            "StringEquals": "passed"
                                        },
                                        {
                                            "Variable": "$.instance_system_status",
                                            "StringEquals": "passed"
                                        }
                                    ],
                                    "Next": "StartJob"
                                }
                            ]
                        },
                        "FailInstanceState": {
                            "Type": "Fail",
                            "Cause": "Failed to launch instance"
                        },
                        "StartJob": {
                            "Type": "Task",
                            "Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
                            "HeartbeatSeconds": 900,
                            "Parameters": {
                                "FunctionName": "${StartJobFunctionName}",
                                "Payload": {
                                    "instance_id.$": "$.instance_id",
                                    "task_token.$": "$$.Task.Token"
                                }
                            },
                            "Retry": [{
                                "ErrorEquals": [
                                     "InvalidInstanceId"
                                ],
                                "IntervalSeconds": 60,
                                "MaxAttempts": 4,
                                "BackoffRate": 2
                            }],
                            "End": true
                        }
                    }
                }
            ],
            "Catch": [
                {
                    "ErrorEquals": [
                        "States.ALL"
                    ],
                    "ResultPath": null,
                    "Next": "FailPostTask"
                }
            ],
            "Next": "TerminateInstance"
        },
        "TerminateInstance": {
            "Type": "Task",
            "Resource": "${TerminateInstanceFunctionArn}",
            "End": true
        },
        "FailPostTask": {
            "Type": "Task",
            "Resource": "${TerminateInstanceFunctionArn}",
            "Next": "FailState"
        },
        "FailState": {
            "Type": "Fail",
            "Cause": "Failed to StepFunctions"
        }
    }
}
```

### EC2の起動を待つ処理

EC2は起動した後に2/2 OKな状態になるまでポーリングして状態を確認しています。

### RunCommand実行時のリトライ

EC2の起動を待ってもRunCommand(StartJobFunctionを実行)する際にInvalidInstanceIdエラーが発生することがあるためリトライ処理を入れています。

### finallyでのEC2の終了処理

EC2の終了はfinally的に確実に実行したかったので以下の記事を参考にしてState Machineを組みました。
ParallelタイプのStateの中でEC2起動後の処理群を実行しエラー時はCatchで確実に終了処理を行います。

[The “finally” block in AWS Step Functions state machine | by Gary Lu | The Startup | Medium](https://medium.com/swlh/the-finally-block-in-aws-step-functions-state-machine-40048faaeffe)

#### 課題

実行時に必須パラメータをjsonに指定していないような場合にStates.Runtimeエラーが発生してState.Allで補足できずにfinallyの処理がされない課題があります。

* 参照：[AWS Stepfunctionsのエラー捕捉のデザインパターン｜Wano Group Developers Blog](https://developers.wano.co.jp/1547/)
* 参照：[Step Functions のエラー処理 - AWS Step Functions](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/concepts-error-handling.html)
> States.ALL での再試行またはキャッチでは States.Runtime エラーは検出されません。

### ビルドの終了を待つ処理

UE4ビルドは時間がかかるためポーリングして状態確認していると状態遷移が多くなったり完了時にすぐ検知できなかったりするのでタスクトークンのコールバックを利用するようにしました。

[サービス統合パターン - AWS Step Functions](https://docs.aws.amazon.com/ja_jp/step-functions/latest/dg/connect-to-resource.html#connect-wait-token)

## Lambda関数

### StartInstanceFunction

EC2を起動する関数です。

```python:app.py
import boto3

def lambda_handler(event, context):
    client = boto3.client('ec2', region_name='ap-northeast-1')
    resp = client.run_instances(
        BlockDeviceMappings=[
            {
                'DeviceName': '/dev/sda1',
                'Ebs': {
                    'VolumeSize': event.get('disksize', 250),
                }
            },
        ],
        ImageId=event.get('imageid', '[AMI ID]'),
        MinCount=1,
        MaxCount=1,
        KeyName=event.get('keyname', '[Key Name]'),
        SecurityGroups=event.get('securitygroups', ['[SG Name]']),
        UserData="",
        InstanceType=event.get('instancetype', 'g4dn.xlarge'),
        IamInstanceProfile={
            'Name': event.get('iamrole', '[Role Name]'),
        },
    )

    event['instance_id'] = resp['Instances'][0]['InstanceId']

    return event
```

### ConfirmInstanceStateFunction

EC2の状態を確認する関数です。

```python:app.py
import boto3

def lambda_handler(event, context):
    ec2 = boto3.resource('ec2', region_name='ap-northeast-1')
    instance = ec2.Instance(event['instance_id'])
    status = instance.meta.client.describe_instance_status()['InstanceStatuses'][0]

    event['instance_state'] = instance.state['Name']
    event['instance_status'] = status['InstanceStatus']['Details'][0]['Status']
    event['instance_system_status'] = status['SystemStatus']['Details'][0]['Status']

    return event
```

### TerminateInstanceFunction

EC2を終了させる関数です。
正常終了時とエラー終了時の形式の異なる入力それぞれからinstance_idを取り出すようにしています。

```python:app.py
import boto3

def lambda_handler(event, context):
    ec2 = boto3.resource('ec2', region_name='ap-northeast-1')
    instance_id = event[0]['instance_id'] if isinstance(event, list) else event['instance_id']
    instance = ec2.Instance(instance_id)
    resp = instance.terminate()

    return event
```

### StartJobFunction

SSMのRun CommandでEC2に対してUE4のビルドコマンドを実行する関数です。
PowerShellのStart-Jobを用いて非同期で定期的にTaskTokenに対するHeartbeatを実行しています。
また、正常終了時にはTaskTokenに対して成功、エラー時には失敗を返すようにしています。

SSMのパラメータストアを利用してプライベートリポジトリがcloneするようにしています。
ビルドとデプロイのメイン処理はリポジトリ中のスクリプトで実施しています。
Run CommandのログはCloudWatchLogsに送っています。

```python:app.py
import boto3
import json
from string import Template

class MyTemplate(Template):
  delimiter = '#'

def lambda_handler(event, context):
    command_template = MyTemplate("""\
$ErrorActionPreference="Stop"
Function Exec($command) {
  $commands = $command.Split(" ", 2)
  if ($commands.Length -gt 1) {
    $ret = Start-Process $commands[0] -Wait -NoNewWindow -PassThru -ArgumentList $commands[1]
  }
  else {
    $ret = Start-Process $commands[0] -Wait -NoNewWindow -PassThru
  }

  if ($ret.ExitCode) {
    throw "exitcode:" + $ret.ExitCode + ", " + $command
  }
}

try {
  $JobCmd = {
    while ($true) {
      Send-SFNTaskHeartbeat -TaskToken #{task_token}
      sleep (60 * 5)
    }
  }
  Start-Job -ScriptBlock $JobCmd

  $PAT = (Get-SSMParameterValue -Names [PAT Name] -WithDecryption $True).Parameters[0].Value
  Exec("git clone -b main --depth=1 https://{{ssm:[PAT User Name]}}:${PAT}@[Repository URL] [Dir Path]")
  [Dir Path]\Build.ps1
  Send-SFNTaskSuccess -Output '#{result_json}' -TaskToken "#{task_token}"
} catch {
  Send-SFNTaskFailure -TaskToken "#{task_token}" -Cause $_.Exception.Message
  echo "Message"
  echo $_.Exception.Message
  echo "InvocationInfo"
  echo $PSItem.InvocationInfo
  echo "StackTrace"
  echo $PSItem.ScriptStackTrace
  exit 1
}
""")

    client = boto3.client('ssm', region_name='ap-northeast-1')
    exetimeout = 60 * 60 * 5
    resp = client.send_command(
        InstanceIds=[event['instance_id']],
        DocumentName='AWS-RunPowerShellScript',
        MaxConcurrency='1',
        Parameters={
            'commands': (command_template.substitute(result_json=json.dumps(event), 
                                                     task_token=event['task_token'])).splitlines(),
            'executionTimeout': [str(exetimeout)],
        },
        TimeoutSeconds=600,
        CloudWatchOutputConfig={
            'CloudWatchLogGroupName': '/unrealengine-worker',
            "CloudWatchOutputEnabled": True,
        },
    )

    event['command_id'] = resp['Command']['CommandId']

    return event
```

###おまけ(TerminateInstanceのテスト)

[moto](https://github.com/spulec/moto)の@mock_ec2をつけておくと実際にec2がつくられることなくテストできて便利です。

```python
from functions.terminate_instance import app
import boto3
from moto import mock_ec2
import pprint

def assert_instance_terminated(client, instance_id):
  insts_res = client.describe_instances(InstanceIds=[instance_id])
  #pprint.pprint(insts_res)
  instances = insts_res['Reservations'][0]['Instances']
  instance = instances[0]
  assert instance['State']['Name'] == 'terminated'

def create_instance(client):
  inst_resp = client.run_instances(
      ImageId='ami-03cf127a',
      KeyName="key",
      MinCount=1,
      MaxCount=1,
      SecurityGroups=['default'],
      InstanceType='g4dn.xlarge',
  )
  return inst_resp['Instances'][0]['InstanceId']
  

@mock_ec2
def test_terminate_instance_on_failed():
  ec2 = boto3.client('ec2', region_name='ap-northeast-1')
  instance_id = create_instance(ec2)

  event = {'instance_id': instance_id}
  context = {}
  app.lambda_handler(event, context)
  
  assert_instance_terminated(ec2, instance_id)


@mock_ec2
def test_terminate_instance_on_completed():
  ec2 = boto3.client('ec2', region_name='ap-northeast-1')
  instance_id = create_instance(ec2)

  event = [{'instance_id': instance_id}]
  context = {}
  app.lambda_handler(event, context)

  assert_instance_terminated(ec2, instance_id)
```