はじめに
オンプレミス環境からCodeCommitへのレプリカを非同期でもいいので取りたく、
git操作&AWS CLI操作をサーバレスで構えたい機会があった。
フックするのはリポジトリの都合によって決まる。
実行部分は、AWS CLIをDockerで作って、Fargateで実行させようとしたが、
マネコンからポチポチするとだいぶ作業が多い。CFnでやりたい。
制約の多いVPCだと毎度困る話だと思うので、まとめてみた。
制約条件
- 既存のVPCとプライベートサブネットがある状態から環境を用意したい
- NATゲートウェイも使えないプライベートなサブネットでFargateを使いたい
- 極力VCPEndpointの料金を抑えたい(24時間Fargateタスクが立ち上がるわけではない)
- 料金を抑えられるならECSタスク実行が遅れても良い
やったこと
- CloudFomationでECS環境を全部つくる(スタック①)
- VCPEndpointを用意するCloudFormation(スタック②)もつくる
- コスト効果を狙って使用時以外、スタック②は削除する
つくったもの
Docker部分も全部まとめてGitHubに置いた。
Step1 スタック①の作成
Fargateを実行するために必要なESSのタスク定義、ECSタスクをキックするためのLambdaなど、ECS周りで必要なものをまとめて定義した。スタック②作成時に必要となるCloudFormationのサービスロールも定義する。
AWSTemplateFormatVersion: "2010-09-09"
Parameters:
UUID:
Description: UUID of stack items
Type: String
subnetID:
Description: subnetID
Type: AWS::EC2::Subnet::Id
Default: subnet-XXXXXXXXXXXXXXXXX
vpcEndpointStackName:
Description: subnetID
Type: String
Default: stack-Interface
Resources:
MyECSTaskRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ecs-tasks.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
RoleName: !Sub "MyECSTaskRole-${UUID}"
taskdefinition:
Type: AWS::ECS::TaskDefinition
Properties:
ContainerDefinitions:
- Essential: true
Image: !Sub "${AWS::AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/fargate-private"
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-create-group: true
awslogs-group: !Sub "/ecs/myEcsLogs-${UUID}"
awslogs-region: ap-northeast-1
awslogs-stream-prefix: fgtLog
Name: !Sub "myContainer-${UUID}"
PortMappings:
- ContainerPort: 80
HostPort: 80
Protocol: tcp
Cpu: '1024'
ExecutionRoleArn: !Sub "arn:aws:iam::${AWS::AccountId}:role/ecsTaskExecutionRole"
TaskRoleArn: !Ref MyECSTaskRole
Family: !Sub "taskDefinition-${UUID}"
Memory: '2048'
NetworkMode: awsvpc
RequiresCompatibilities:
- FARGATE
RuntimePlatform:
OperatingSystemFamily: LINUX
ECSCluster:
Type: AWS::ECS::Cluster
Properties:
ClusterName: !Sub "MyCluster-${UUID}"
DependsOn: taskdefinition
LambdaServiceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: InlinePolicy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action: ecs:RunTask
Resource: !Sub "arn:aws:ecs:${AWS::Region}:${AWS::AccountId}:task-definition/taskDefinition-${UUID}"
- Effect: Allow
Action: cloudformation:CreateStack
Resource: !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${vpcEndpointStackName}/*"
- Effect: Allow
Action: cloudformation:DescribeStacks
Resource: !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${vpcEndpointStackName}/*"
- Effect: Allow
Action: cloudformation:GetTemplate
Resource: !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${vpcEndpointStackName}/*"
- Effect: Allow
Action: iam:PassRole
Resource: '*'
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
- arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
RoleName: !Sub "LambdaServiceRole-${UUID}"
DependsOn: ECSCluster
MyLambdaFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: !Sub "MyLambda-${UUID}"
Handler: index.lambda_handler
Role: !GetAtt LambdaServiceRole.Arn
Runtime: python3.9
Timeout: 300
Environment:
Variables:
privateSubnet: !Sub "${subnetID}"
clusterArn: !GetAtt ECSCluster.Arn
taskDefinitionArn: !Sub "arn:aws:ecs:ap-northeast-1:${AWS::AccountId}:task-definition/taskDefinition-${UUID}"
cfnArn: !GetAtt CFnServiceRole.Arn
Code:
ZipFile: |
# coding: utf-8
import json
import os
import time
import boto3
ecs_client = boto3.client("ecs",region_name="ap-northeast-1")
cfn_client = boto3.client("cloudformation", region_name="ap-northeast-1")
vpcEndpointStackName='stack-Interface'
vpcEndpointStackTemplateURL = "https://XXXXXXXXXXXXXXXXX/interfaceEndpoint.yaml"
def checkEdnpointAlive(client, stackName):
res = False
try:
response = client.get_template(
StackName=stackName
)
if "VPCEndpoint" in response["TemplateBody"]:
res = True
return res
#no stack case
except:
pass
def updatewait(client, stackName):
attempts = 120
delay = 30
time.sleep(delay)
try:
for i in range(attempts):
res = client.describe_stacks(
StackName=stackName
)
stack = res["Stacks"][0]
if (stack["StackStatus"] is 'CREATE_COMPLETE'):
break
else:
pass
#no stack case
except:
pass
def createStackFromTemplate(client, stackName, templateURL, roleArn):
client.create_stack(
StackName=stackName,
TemplateURL=templateURL,
Capabilities=["CAPABILITY_NAMED_IAM"],
RoleARN=roleArn
)
def lambda_handler(event, context):
# preparing interface endpoint
updatewait(cfn_client, vpcEndpointStackName)
if not(checkEdnpointAlive(cfn_client, vpcEndpointStackName)):
createStackFromTemplate(cfn_client, vpcEndpointStackName, vpcEndpointStackTemplateURL, os.environ["cfnArn"])
updatewait(cfn_client, vpcEndpointStackName)
# ecsExecute
ecs_client.run_task(
cluster=os.environ["clusterArn"],
launchType="FARGATE",
networkConfiguration={
"awsvpcConfiguration": {
"subnets": [os.environ["privateSubnet"]],
"assignPublicIp": "DISABLED",
}
},
taskDefinition=os.environ["taskDefinitionArn"]
)
return {
'statusCode': 200,
'body': json.dumps('Hello from Lambda!')
}
CFnServiceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- cloudformation.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: InlinePolicy
PolicyDocument:
Statement:
- Effect: Allow
Action:
- ec2:*
Resource:
- "*"
RoleName: !Sub "CFnServiceRole-${UUID}"
- 2つのStack Statusを監視させかったのでboto3のwaiterは使わず、自作waiterとした。
- Dockerで
aws s3 ls
させるため、LambdaServiceRoleにS3の読み取り許可アクセスを付けた。
作成時に詰まったところ
CloudFomationのcreate-stackで使用されるIAMロール
普段、意識せずにローカルのコンソール環境にてaws cloudformation create-stack
をすれば、スタックが無事作成されるが、今回Lambdaからスタック②を立ち上げたり更新するときに何度も権限不足のエラーに見舞われた。
どうやら明示的にLambdaから、--role-arn
を設定しなかったことが、原因の様子。
ドキュメントを見ると、サービスの仕様としては、
- 既に使われたロールがあれば、その権限が使用される。
- 何も定義されていない場合、ユーザ認証情報の権限が使用される。
The Amazon Resource Name (ARN) of an Identity and Access Management (IAM) role that CloudFormation assumes to create the stack. CloudFormation uses the role's credentials to make calls on your behalf. CloudFormation always uses this role for all future operations on the stack. Provided that users have permission to operate on the stack, CloudFormation uses this role even if the users don't have permission to pass it. Ensure that the role grants least privilege.
If you don't specify a value, CloudFormation uses the role that was previously associated with the stack. If no role is available, CloudFormation uses a temporary session that's generated from your user credentials.
今回は間違いなくスタック操作ができるように、スタック②の作成に必要な権限をサービスロールCFnServiceRole
で定義した。
Step2 スタック②の作成
プライベートサブネットにおけるFargateの利用では、Fargateエージェントが使用するECRやClodudWatchLogsなどのエンドポイントの名前解決ができるように各種エンドポイントを作成することが必要である。Logsへ通信が疎通できないと、ログが残らないため、タスクが成功したのか失敗したのかデバッグもできずたちが悪い。
S3はGateway型エンドポイントのため、立ち上げ時間に対する課金もないため立ち下げの必要もないが、ルートテーブルの設定が必要だったりとInterface型と異なるところもあるため、必要なリソースをまとめて定義し、作ったロールでcreate-stackするようにした。
参考:
AWSTemplateFormatVersion: "2010-09-09"
Parameters:
#VPCID
fargateVPC:
Description: "VPC ID"
Type: AWS::EC2::VPC::Id
Default: vpc-XXXXXXXXXXXXXXXXX
#InterfaceSubnet1
fargateSubnetId1:
Description: "Interface Subnet 1st"
Type: AWS::EC2::Subnet::Id
Default: subnet-XXXXXXXXXXXXXXXX
Resources:
VpcRouteTalbe:
Type: "AWS::EC2::RouteTable"
Properties:
VpcId: !Ref fargateVPC
VpcRouteTalbeAssociation:
Type: "AWS::EC2::SubnetRouteTableAssociation"
Properties:
RouteTableId: !Ref VpcRouteTalbe
SubnetId: !Ref fargateSubnetId1
EndpointSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: "Security Group for VPC Endpoints"
SecurityGroupEgress:
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0
VpcId: !Ref fargateVPC
LogEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
PrivateDnsEnabled: true
SecurityGroupIds:
- !Ref EndpointSecurityGroup
ServiceName: !Sub com.amazonaws.${AWS::Region}.logs
SubnetIds:
- !Ref fargateSubnetId1
VpcEndpointType: Interface
VpcId: !GetAtt EndpointSecurityGroup.VpcId
EcrDkrEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
PrivateDnsEnabled: true
SecurityGroupIds:
- !Ref EndpointSecurityGroup
ServiceName: !Sub com.amazonaws.${AWS::Region}.ecr.dkr
SubnetIds:
- !Ref fargateSubnetId1
VpcEndpointType: Interface
VpcId: !GetAtt EndpointSecurityGroup.VpcId
EcrApiEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
PrivateDnsEnabled: true
SecurityGroupIds:
- !Ref EndpointSecurityGroup
ServiceName: !Sub com.amazonaws.${AWS::Region}.ecr.api
SubnetIds:
- !Ref fargateSubnetId1
VpcEndpointType: Interface
VpcId: !GetAtt EndpointSecurityGroup.VpcId
SMEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
PrivateDnsEnabled: true
SecurityGroupIds:
- !Ref EndpointSecurityGroup
ServiceName: !Sub com.amazonaws.${AWS::Region}.secretsmanager
SubnetIds:
- !Ref fargateSubnetId1
VpcEndpointType: Interface
VpcId: !GetAtt EndpointSecurityGroup.VpcId
S3Endpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
ServiceName: !Sub com.amazonaws.${AWS::Region}.s3
RouteTableIds: [!Ref VpcRouteTalbe]
VpcEndpointType: Gateway
VpcId: !GetAtt EndpointSecurityGroup.VpcId
Step3 テンプレート②の立ち下げ
VPCエンドポイント(Interface型)は、立ち上げているだけで東京リージョンで0.014USD/時間かかる。一度立ち上げると、課金が始まり、立ち上げ時間が1時間未満でも1時間とカウントされる。コスト効果を考えると、Fargateタスクが発生しないときはInterfaceエンドポイントを立ち下げたい。
1 時間未満の VPC エンドポイント使用時間は、1 時間分として請求されます。
スタック②の起動時間とテンプレートの内容から、
VPCエンドポイントの稼働状態・稼働時間を判断し、
スタックごと削除するLambda関数を作った。
このLambdaとEventBridge(Rate30Minutes)を使えば、
期待値で45分はスタック②を起動した状態を保てる。
# coding: utf-8
import json
import boto3
import time
import datetime
import os
vpcEndpointStackName='stack-Interface'
cfn_client = boto3.client('cloudformation')
def updatewait(client, stackName):
attempts = 120
delay = 30
time.sleep(delay)
try:
for i in range(attempts):
res = client.describe_stacks(
StackName=stackName
)
stack = res["Stacks"][0]
if (stack["StackStatus"] is 'CREATE_COMPLETE'):
break
else:
pass
#no stack case
except:
pass
def checkEdnpointAlive(client, stackName):
res = False
try:
response = client.get_template(
StackName=stackName
)
if "VPCEndpoint" in response["TemplateBody"]:
res = True
return res
#no stack case
except:
pass
def lambda_handler(event, context):
# TODO implement
nowTime = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=+9), 'JST'))
if checkEdnpointAlive(cfn_client,vpcEndpointStackName):
updatewait(cfn_client, vpcEndpointStackName)
response_des = cfn_client.describe_stacks(StackName=vpcEndpointStackName)
for stack in response_des['Stacks']:
lastTime = stack["CreationTime"]
if "LastUpdatedTime" in list(stack.keys()):
lastTime = stack["LastUpdatedTime"]
if ((nowTime-lastTime).seconds) > int(os.environ["timeToTerminate"]):
response = cfn_client.delete_stack(
StackName=vpcEndpointStackName
)
return {
'statusCode': 200,
'body': json.dumps('Hello from Lambda!')
}
まとめ
- CloudFormationからプライベートサブネットでFargateを使えるようにした(テンプレート①)
- 時間課金部分VPCエンドポイントを作成する部分だけ別のテンプレート②で管理した
⇒この②部分だけ必要に応じて立ち下げ/立ち上げすることで、コスト低減できる。
今後やりたい
とりあえず動くが、この構成の問題点もいくつか。
- VPCエンドポイントを1時間弱をフルで使いきれない。
- Fargateタスクが間欠的に発生する場合だと、
VPCエンドポイントの作成を繰り返すより、立ち上げっぱな方がコスト的に有利。 - Lambdaを頻繁に叩くことになる(とは言え30分に一回か)
EventBridge SchedulerとCFnをうまく使って、
VPCエンドポイントを使い切る構成としたい。