CDKでECS+Fargate上にDigdagを立てる

(2019-07-31)

AWSでワークフローエンジンDigdagを立てるにあたりスケールを見越してECS+Fargateで動かす。 全体のコードはGitHubにある。

FargateでECSを使う - sambaiz-net

リソースはCDKで作る。最近GAになったので高レベルのクラスを積極的に使っている。

AWS CDKでCloudFormationのテンプレートをTypeScriptから生成しデプロイする - sambaiz-net

$ npm run cdk -- --version
1.2.0 (build 6b763b7)

VPC

FargateなのでVPCが必要。 テンプレートを書くとSubnetやRouteTable、NATGatewayなど記述量が多くなるところだが、CDKだとこれだけで済む。

CloudFormationでVPCを作成してLambdaをデプロイしAurora Serverlessを使う - sambaiz-net

const vpc = new ec2.Vpc(this, 'VPC', {
  cidr: props.vpcCidr,
  natGateways: 1,
  maxAzs: 2,
  subnetConfiguration: [
    {
      name: 'digdag-public',
      subnetType: ec2.SubnetType.PUBLIC,
    },
    {
      name: 'digdag-private',
      subnetType: ec2.SubnetType.PRIVATE,
    },
    {
      name: 'digdag-db',
      subnetType: ec2.SubnetType.ISOLATED,
    }
  ]
})

DB

DigdagはPostgreSQLを使う。

const db = new rds.DatabaseCluster(this, 'DBCluster', {
  engine: rds.DatabaseClusterEngine.AURORA_POSTGRESQL,
  engineVersion: '10.7',
  instances: 1,
  masterUser: {
    username: 'digdag',
  },
  defaultDatabaseName: databaseName,
  port: dbPort,
  instanceProps: {
    instanceType: ec2.InstanceType.of(ec2.InstanceClass.R5, ec2.InstanceSize.LARGE),
    vpc: vpc,
    vpcSubnets: {
      subnetType: ec2.SubnetType.ISOLATED
    }
  },
  parameterGroup: new rds.ClusterParameterGroup(this, 'DBClusterPArameterGroup', {
    family: 'aurora-postgresql10',
    parameters: {
      application_name: 'digdag',
    }
  }),
  removalPolicy: cdk.RemovalPolicy.DESTROY // for test
})

ECS

ECS Cluster, Service, TaskやRole, Route53のRecordを作り、 デフォルトでDBのIngressが空なのでServiceからアクセスできるようにする。 ecsPatterns.LoadBalancedFargateService によって少ない記述量で実現できている。

ecs.ContainerImage.fromAsset でStackをデプロイする前にDocker buildが走って cdk bootstrap で作られたBucketに上がる。

const cluster = new ecs.Cluster(this, 'ECSCluster', {
  clusterName: 'digdag',
  vpc: vpc
})
const executionRole = new iam.Role(this, 'ExecutionRole', {
  assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
  managedPolicies: [
    iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy')
  ]
})
const taskRole = new iam.Role(this, 'TaskRole', {
  assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
  managedPolicies: [
    iam.ManagedPolicy.fromAwsManagedPolicyName('AdministratorAccess')
  ]
})
const domainZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
  hostedZoneId: props.route53ZoneId,
  zoneName: props.route53ZoneName
})
const service = new ecsPatterns.LoadBalancedFargateService(this, 'Service', {
  cluster,
  image: ecs.ContainerImage.fromAsset('./docker'),
  loadBalancerType: ecsPatterns.LoadBalancerType.APPLICATION,
  certificate: certificatemanager.Certificate.fromCertificateArn(this, 'Certificate', props.acmArn),
  domainName: `${props.route53RecordName}.${props.route53ZoneName}.`,
  domainZone,
  cpu: 256,
  memoryLimitMiB: 512,
  environment: {
    DB_USERNAME: db.secret!.secretValueFromJson('username').toString(),
    DB_PASSWORD: db.secret!.secretValueFromJson('password').toString(),
    DB_HOST: db.secret!.secretValueFromJson('host').toString(),
    DB_PORT: db.secret!.secretValueFromJson('port').toString(),
    DB_DATABASE: databaseName,
    S3_LOG_BUCKET: props.logBucket
  },
  executionRole,
  taskRole
})
const dbSG = ec2.SecurityGroup.fromSecurityGroupId(this, 'DBSG', db.securityGroupId)
const serviceSG = ec2.SecurityGroup.fromSecurityGroupId(this, 'ServiceSG', service.service.connections.securityGroups[0].securityGroupId)
dbSG.addIngressRule(serviceSG, ec2.Port.tcp(dbPort))
dbSG.addIngressRule(serviceSG, ec2.Port.tcp(dbPort))

ALB認証

ECSに飛ばす前にCognito認証を挟む。デフォルトでALBのSGのEgressがServiceへの80番ポートしか開いていないため、HTTPS通信できず認証前に500エラーになってしまう。 そのためSGのIDを取ってルールを追加することになるのだが、 string[] である service.loadBalancer.loadBalancerSecurityGroups から loadBalancerSecurityGroups[0] のように取ろうとすると Tokenが解決されず #{TOKEN[Foo.1234]} のような文字列になってしまうため Fn::Selectを使う必要がある。

LambdaとALBでCognito認証をかけて失敗したらログイン画面に飛ばす - sambaiz-net

const lbSG = ec2.SecurityGroup.fromSecurityGroupId(this, 'LBSG',
  cdk.Fn.select(0, service.loadBalancer.loadBalancerSecurityGroups))
lbSG.addEgressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443))
lbSG.addEgressRule(ec2.Peer.anyIpv6(), ec2.Port.tcp(443))
new loadbalancer.CfnListenerRule(this, 'AuthListenerRule', {
  conditions: [{
    field: 'path-pattern',
    values: ['/*']
  }],
  listenerArn: service.listener.listenerArn,
  priority: 10,
  actions: [{
    type: 'authenticate-cognito',
    order: 1,
    authenticateCognitoConfig: {
      onUnauthenticatedRequest: 'authenticate',
      userPoolArn: props.userPoolArn,
      userPoolClientId: props.userPoolClientId,
      userPoolDomain: props.userPoolDomain
    }
  }, {
    type: 'forward',
    order: 2,
    targetGroupArn: service.targetGroup.targetGroupArn
  }]
})

digdag.properties

ログはS3に送る。

server.bind = 0.0.0.0
server.port = 80
database.type = postgresql
database.user = <DB_USERNAME>
database.password = <DB_PASSWORD>
database.host = <DB_HOST>
database.port = <DB_PORT>
database.database = <DB_DATABASE>
log-server.type=s3
log-server.s3.bucket=<S3_LOG_BUCKET>
log-server.s3.path=logs/
log-server.s3.direct_download=false

実行時に環境変数の値に書き換えている。

#!/bin/sh 
sed -i -e "s/<DB_USERNAME>/${DB_USERNAME}/" \
    -e "s/<DB_PASSWORD>/${DB_PASSWORD}/" \
    -e "s/<DB_HOST>/${DB_HOST}/" \
    -e "s/<DB_PORT>/${DB_PORT}/" \
    -e "s/<DB_DATABASE>/${DB_DATABASE}/" \
    -e "s/<S3_LOG_BUCKET>/${S3_LOG_BUCKET}/" digdag.properties

/usr/local/bin/digdag server --config digdag.properties

ワークフローの実行

認証後WebUIに飛び、適当なdigファイルを作りRunボタンを押してログが出たら成功。

+hello:
    echo>: "Hello Digdag"

実行結果

参考

DigdagをHA構成にしてみた - ZOZO Technologies TECH BLOG