garbagetown

個人の日記です

App Mesh と ECS のサンプルを試したら CloudFormation とシェルスクリプトや jq の知見が詰まっていて X-Ray も付いてきた話

Medium 記事にもなっている AWS App Mesh のサンプルを試してみたらいい内容だったので、備忘も兼ねて一年半ぶりにブログを書いてみます。

TL; DR

  • App Mesh のサンプルに多い EKS ではなく ECS
  • App Mesh だけでなく CloudFormation やシェルスクリプト、jq の使い方も学べる
  • X-Ray も学べる

以下、サンプルの内容に沿って紹介します。

Prerequisites

サンプルを試す前提条件は以下の通りです。

  • AWS CLI 1.16.124 以上をインストールしている
  • AWS CLI を default または名前付きのプロファイルで適切に設定している
  • EC2 インスタンスにログインする SSH キーペアを作っている
  • github.com/aws/aws-app-mesh-examples リポジトリをクローンしている
  • jq をインストールしている

Create the VPC and other core Infrastructure

まず以下の環境変数を指定してシェルスクリプトを実行し CloudFormation で VPC とサブネットを作るところから始めます。

  • AWS_PROFILE: AWS CLI のプロファイル
  • AWS_DEFAULT_REGION: リージョン
  • ENVIRONMENT_NAME: デモに使う環境の名前

このシェルスクリプトは基本的に aws cloudformation deploy を実行しているだけで難しいことはしていません。

#!/bin/bash

set -ex

DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"

aws --profile "${AWS_PROFILE}" --region "${AWS_DEFAULT_REGION}" \
    cloudformation deploy \
    --stack-name "${ENVIRONMENT_NAME}-vpc" \
    --capabilities CAPABILITY_IAM \
    --template-file "${DIR}/vpc.yaml" \
    --parameter-overrides \
    EnvironmentName="${ENVIRONMENT_NAME}"

ここで使っている CloudFormation テンプレートには、知っている人には当たり前でも知らない人やどう書いたらいいか迷っている人の参考になる知見が詰まっています。

例えば !Select!GetAZsアベイラビリティゾーンを指定したり、!Join ではなく !Sub と変数で文字列を組み上げたり

  PublicSubnet1: 
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [ 0, !GetAZs '' ]
      CidrBlock: !Ref PublicSubnet1CIDR
      MapPublicIpOnLaunch: true
      Tags: 
        - Key: Name 
          Value: !Sub ${EnvironmentName} Public Subnet (AZ1)

!DependsOn で依存するリソースの作成順序を制御したり

  NatGateway1EIP:
    Type: AWS::EC2::EIP
    DependsOn: InternetGatewayAttachment
    Properties: 
      Domain: vpc

!GetAtt で作成したリソースの属性を参照する方法も、通常はトライアンドエラーで覚えるものなので、このようなサンプルでショートカットして学べるのはとてもいいと思います。

  NatGateway1: 
    Type: AWS::EC2::NatGateway
    Properties: 
      AllocationId: !GetAtt NatGateway1EIP.AllocationId
      SubnetId: !Ref PublicSubnet1

クロススタック参照する Outputs の名前の付け方も地味に悩むので参考になります。

Outputs: 

  VPC: 
    Description: A reference to the created VPC
    Value: !Ref VPC
    Export: 
      Name: !Sub "${EnvironmentName}:VPC"

Create an App Mesh

先ほどと同様に環境変数に App Mesh の名前を指定してシェルスクリプトを実行します。

  • MESH_NAME: App Mesh の名前

ここでは AWS::AppMesh::Mesh リソースを作っているだけです。

Create compute resources

続いて ECS クラスターを作ります。

サービスディスカバリで使う Route 53 プライベートホストゾーンもここで作るので環境変数に指定します。

コンテナインスタンスは AutoScaling グループでプライベートサブネットに作成されますが、パブリックサブネットに踏み台インスタンスも作成されるので SSH キーペアも指定します。

  • SERVICES_DOMAIN: プライベートホストゾーン名
  • KEY_PAIR_NAME: SSH キーペア名

シェルスクリプトはこれまでと同じで CloudFormation スタックを作るだけです。

ここで使う CloudFormation テンプレートも参考になります。

コンテナインスタンスと踏み台インスタンスに使う AMI ID はパラメータストアから取得することができます。

  ECSAmi:
    Description: ECS AMI ID
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: "/aws/service/ecs/optimized-ami/amazon-linux/recommended/image_id"

  EC2Ami:
    Description: EC2 AMI ID
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"

Fn::ImportValue!Sub でクロススタック参照したり

  ECSInstancesSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties: 
      GroupDescription: "Security group for the instances"
      VpcId:
        'Fn::ImportValue': !Sub "${EnvironmentName}:VPC"
...

AutoScalingGroup の UpdatePolicy 属性 もちゃんと指定しています。

  ECSAutoScalingGroup:
    Type: AWS::AutoScaling::AutoScalingGroup
...
    UpdatePolicy:
      AutoScalingRollingUpdate:
        MinInstancesInService: 1
        MaxBatchSize: 1
        PauseTime: PT15M
        SuspendProcesses:
          - HealthCheck
          - ReplaceUnhealthy
          - AZRebalance
          - AlarmNotification
          - ScheduledActions
      WaitOnResourceSignals: true

UserData で SSM エージェントをインストールしているので、コンテナインスタンスにセッションマネージャで接続することもできます。

  ECSLaunchConfiguration:
    Type: AWS::AutoScaling::LaunchConfiguration
...
      UserData:
        "Fn::Base64": !Sub |
          #!/bin/bash
          yum install -y https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_amd64/amazon-ssm-agent.rpm
          yum install -y aws-cfn-bootstrap hibagent
          /opt/aws/bin/cfn-init -v --region ${AWS::Region} --stack ${AWS::StackName} --resource ECSLaunchConfiguration
          /opt/aws/bin/cfn-signal -e $? --region ${AWS::Region} --stack ${AWS::StackName} --resource ECSAutoScalingGroup
          /usr/bin/enable-ec2-spot-hibernation

UserData で指定するには複雑過ぎる設定は AWS::CloudFormation::Init リソースを使って指定します。

    Metadata:
      AWS::CloudFormation::Init:
        config:
          packages:
            yum:
              awslogs: []

          commands:
            01_add_instance_to_cluster:
              command: !Sub echo ECS_CLUSTER=${ECSCluster} >> /etc/ecs/ecs.config
          files:
            "/etc/cfn/cfn-hup.conf":
              mode: 000400
              owner: root
              group: root
...

Configure App Mesh resources

いよいよ本命の App Mesh を作ります。

シェルスクリプトはこれまでと同様で、

CloudFormation テンプレートで VirtualNode, VirtualRouter, Route, VirtualService を作ります。

バーチャルバーチャル言ってて何のこっちゃ分からんという感情が先行しますが、雰囲気が掴めてくるとたしかに仮想的なノードと、それに対するルートを提供するサービスだなあという気もしてきます。

自分はブログ記事 "Learning AWS App Mesh" の "DJ App revisited" の辺りを読んで腑に落ちた感じがしたので、もし未読の方は一読されるといいかもしれません。

Deploy services to ECS

ここまででネットワークインフラを作って ECS クラスタを作って App Mesh を作ったので、最後に実際に ECS サービスをデプロイします。

Deploy images to ECR for your account

まず AWS CLI で ECR を作ってコンテナイメージを push します。

これまでのシェルスクリプトとは毛色が違いますが、基本的に docker build して作った ECR に push しているだけです。

#!/usr/bin/env bash
# vim:syn=sh:ts=4:sw=4:et:ai

set -ex

if [ -z $COLOR_GATEWAY_IMAGE ]; then
    echo "COLOR_GATEWAY_IMAGE environment variable is not set"
    exit 1
fi

# build
docker build -t $COLOR_GATEWAY_IMAGE .

# push
if [ -z $AWS_PROFILE  ]; then
    $(aws ecr get-login --no-include-email --region $AWS_DEFAULT_REGION)
else
    $(aws ecr get-login --no-include-email --region $AWS_DEFAULT_REGION --profile $AWS_PROFILE)
fi
docker push $COLOR_GATEWAY_IMAGE

Dockerfile では真面目にマルチステージビルドしています。

FROM golang:1.10 AS builder

# Download and install the latest release of dep
ADD https://github.com/golang/dep/releases/download/v0.4.1/dep-linux-amd64 /usr/bin/dep
RUN chmod +x /usr/bin/dep

# Copy the code from the host and compile it
WORKDIR $GOPATH/src/github.com/username/repo
COPY Gopkg.toml Gopkg.lock ./
RUN dep ensure --vendor-only
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix nocgo -o /app .

FROM scratch
COPY --from=builder /app ./
ENTRYPOINT ["./app"]

バックエンドサービスを build して push するシェルスクリプト、Dockerfile も同じ内容です。

Deploy gateway and colorteller services

環境変数に最新の Envoy コンテナイメージと先ほど ECR に push したコンテナイメージを指定したら、シェルスクリプトを実行して ECS サービスをデプロイします。

  • ENVOY_IMAGE: 最新の Envoy コンテナイメージ。現時点では v1.9.1.0-prod が最新
  • COLOR_GATEWAY_IMAGE: ゲートウェイサービスのコンテナイメージ
  • COLOR_TELLER_IMAGE: バックエンドサービスのコンテナイメージ

シェルスクリプトは今まで変わり映えしないように見えますが、create-task-defs.sh を呼び出している点が異なります。

#!/bin/bash
...
# Creating Task Definitions
source ${DIR}/create-task-defs.sh

aws --profile "${AWS_PROFILE}" --region "${AWS_DEFAULT_REGION}" \
    cloudformation deploy \
    --stack-name "${ENVIRONMENT_NAME}-ecs-colorapp" \
...

このシェルスクリプトでは AWS CLI と jq をうまく使ってタスク定義を登録していました。

describe-stacks の結果を jq でパースして必要な値を取得したら

stack_output=$(aws --profile "${AWS_PROFILE}" --region "${AWS_DEFAULT_REGION}" \
    cloudformation describe-stacks --stack-name "${ENVIRONMENT_NAME}-ecs-cluster" \
    | jq '.Stacks[].Outputs[]')

task_role_arn=($(echo $stack_output \
    | jq -r 'select(.OutputKey == "TaskIamRoleArn") | .OutputValue'))

execution_role_arn=($(echo $stack_output \
    | jq -r 'select(.OutputKey == "TaskExecutionIamRoleArn") | .OutputValue'))
...

やはり jq の -f オプションで読み込んだテンプレートファイルに --arg--argjson オプションで変数の値を渡してタスク定義の入力となる JSON 文字列を組み上げ、AWS CLI でタスク定義を登録しています。

generate_color_teller_task_def() {
    color=$1
    task_def_json=$(jq -n \
    --arg NAME "$ENVIRONMENT_NAME-ColorTeller-${color}" \
    --arg STAGE "$APPMESH_STAGE" \
    --arg COLOR "${color}" \
    --arg APP_IMAGE $COLOR_TELLER_IMAGE \
    --arg AWS_REGION $AWS_DEFAULT_REGION \
    --arg ECS_SERVICE_LOG_GROUP $ecs_service_log_group \
    --arg AWS_LOG_STREAM_PREFIX_APP "colorteller-${color}-app" \
    --arg TASK_ROLE_ARN $task_role_arn \
    --arg EXECUTION_ROLE_ARN $execution_role_arn \
    --argjson ENVOY_CONTAINER_JSON "${envoy_container_json}" \
    --argjson XRAY_CONTAINER_JSON "${xray_container_json}" \
    -f "${DIR}/colorteller-base-task-def.json")
    task_def=$(aws --profile "${AWS_PROFILE}" --region "${AWS_DEFAULT_REGION}" \
    ecs register-task-definition \
    --cli-input-json "$task_def_json")
}

CloudFormation テンプレートでは登録したタスク定義を使用するサービスおよびサービスディスカバリレコード、そしてゲートウェイサービスにリクエストをフォワードする ALB を作ります。

その他にテスト用と思われるサービスやサービスディスカバリレコードも作っていますが本質ではないので割愛。

作成した ALB のエンドポイントに curl でリクエストを送ると App Mesh によってルーティングされたいずれかの ECS サービスから応答が得られます。

$ colorapp=$(aws cloudformation describe-stacks --stack-name=$ENVIRONMENT_NAME-ecs-colorapp --query="Stacks[0
].Outputs[?OutputKey=='ColorAppEndpoint'].OutputValue" --output=text); echo $colorapp
http://DEMO-Publi-M7WJ5RU13M0T-553915040.us-west-2.elb.amazonaws.com

$ curl $colorapp/color
{"color":"red", "stats": {"red":1}}

Shape traffic

Apply traffic rules

作成した CloudFormation スタックに含まれる AWS::AppMesh::Route リソースの WeightedTargets を変更して CloudFormation スタックを更新することで、App Mesh のルーティング情報を変更できます。

また、App Mesh コンソールで対象の Virtual routes を Edit して Virtual nodes の Weight を変更することでもルーティング情報を変更できます。

Monitor with AWS X-Ray

Envoy コンテナで X-Ray 統合が有効に設定されていて、かつ各サービス内で稼働する Go アプリケーションにも X-Ray SDK が設定されているので、X-Ray サービスマップからVirtualNode を経由して各アプリケーションにアクセスしている様子を確認できます。

各トレースをドリルダウンすることで詳細な内訳を確認できます。

まとめ

ちょっと App Mesh やってみようかなと軽い気持ちで手を出したサンプルから予想を大きく上回る収穫が得られて満足です。気になった方は是非お試しください。