LoginSignup
1

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-02-08

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

前提

UE4のクロスコンパイル

WindowsのUE4でLinux向けのクロスコンパイルは可能ですがLinuxのUE4でWindows向けのクロスコンパイルはサポートしてないようなのでWin64ビルドするためにはWindows環境が必要そうです。

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

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

  • UE4では公式のコンテナがWindowsではRuntime版(Editorなし)しかサポートしていない(参照)
  • Windows開発イメージには技術的および法的な制限がある(参照)

VisualStudioBuildToolの利用

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

StepFunctions + SSMの構成

ベースの構成は以下の記事を参考にしています。
Run CommandとStep Functionsでサーバレス(?)にEC2のバッチを動かす - Qiita

EC2

AMI

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

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)を利用しました。
こちらのaws-sam-cliのpython3.9のapp-templateをベースにしました。
StepFunctionsからLambdaまでの構成とコードをまとめて管理できてよいです。

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

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

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

課題

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

States.ALL での再試行またはキャッチでは States.Runtime エラーは検出されません。

ビルドの終了を待つ処理

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

サービス統合パターン - AWS Step Functions

Lambda関数

StartInstanceFunction

EC2を起動する関数です。

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の状態を確認する関数です。

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を取り出すようにしています。

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に送っています。

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@mock_ec2をつけておくと実際にec2がつくられることなくテストできて便利です。

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)

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
1