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などのコンテナ前提のサービスが利用候補から外れますね。
VisualStudioBuildToolの利用
UE4のビルドでVisual Studioを使うとライセンスで問題ありそうなのでライセンスで問題なさそうなVisualStudioBuildToolを使うことにします。
- デフォルトテナントで実行される AWS インスタンスで MSDN を使用できますか?
- Microsoft Visual Studio 2019 リモート デバッガー、スタンドアロン プロファイラー、IntelliTrace、スナップショット デバッガー、その他のデバッガーおよびビルド ツール - Visual Studio
StepFunctions + SSMの構成
ベースの構成は以下の記事を参考にしています。
Run CommandとStep Functionsでサーバレス(?)にEC2のバッチを動かす - Qiita
EC2
AMI
ビルドで用いるEC2のAMIは以下のような感じです。
- OSはWindows Server 2019です。
- g4dn系インスタンスでNVIDIAドライバをインストールしています。
- Windows インスタンスへの NVIDIA ドライバーのインストール - Amazon Elastic Compute Cloud
- UE4をソースコードビルドして配置するために、ue4-dockerのDockerfilesをue4-build-prerequisites、ue4-source、ue4-engineあたりの順で参照してコマンドラインベースでセットアップしています。
- 上記にプラスして以下のようなものをインストールしています。
- aws cli:必要かと思って入れたがSSMのRunCommandでAWS Tools for Windows PowerShellが使えているのでいらないかも
- ue4cli:ue4-dockerでも使われているUnrealEngineのコマンドラインツールの利用をサポートしてくれるツール
- ssm-agent:SSMでRunCommandできるようにするために必要
- EC2LaunchV2:これをインストールしておかないと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)を利用しました。
こちらのaws-sam-cliのpython3.9のapp-templateをベースにしました。
StepFunctionsからLambdaまでの構成とコードをまとめて管理できてよいです。
template.yamlは以下のような感じで4つのLambda関数とStepFunctionsのStateMachineを定義しています。
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は以下のようになります。
{
"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の処理がされない課題があります。
- 参照:AWS Stepfunctionsのエラー捕捉のデザインパターン|Wano Group Developers Blog
- 参照:Step Functions のエラー処理 - AWS Step Functions
States.ALL での再試行またはキャッチでは States.Runtime エラーは検出されません。
ビルドの終了を待つ処理
UE4ビルドは時間がかかるためポーリングして状態確認していると状態遷移が多くなったり完了時にすぐ検知できなかったりするのでタスクトークンのコールバックを利用するようにしました。
サービス統合パターン - AWS Step Functions
Lambda関数
StartInstanceFunction
EC2を起動する関数です。
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の状態を確認する関数です。
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を取り出すようにしています。
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に送っています。
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)