AWS App RunnerでAWS X-Rayを使った分散トレースをAWS CDKで構築してみる(App Runner + Aurora Serverless v2)

2022.10.17

CX事業部Delivery部の新澤です。

App Runnerサービスから外部サービスやAWSサービスへのアクセスをトレースしてX-Rayで可視化してみます。

今回は、App RunnerサービスからVPC内部のAurora Serverless v2クラスターへアクセスするパターンのトレースを取得してみたいと思います。

今回作成したサンプルは以下にあります。

システム構成・動作概要

サンプルアプリケーションのシステム構成です。

  • ECRリポジトリのコンテナイメージがデプロイされたApp Runnerサービスが、VPCに配置されたAurora Serverless v2クラスターにVPC Connector経由でアクセスします。

  • データベースへのアクセスに必要な接続情報は、App Runnerサービス起動時にNAT Gateway経由でSecrets Managerから取得します。

  • アプリケーションにアクセスすると、アプリケーションに組み込んだOpenTelemetry SDKからX-Rayへトレースデータが送信されます。

引用元: Tracing an AWS App Runner service using AWS X-Ray with OpenTelemetry

サンプルアプリケーションの作成

それではまずサンプルアプリケーションを作成します。

今回、アプリケーションの作成にtypescript-express-starterを利用してみました。

用意されたテンプレートを選択するだけで、Expressアプリケーションの雛形を作成することができます。

今回はDBにアクセスするアプリケーションを作成したいので、"typeorm"を選びます。("prisma"などでもよいと思います)

その他の選択肢はデフォルトを選びます。

$ npx typescript-express-starter app
Need to install the following packages:
  typescript-express-starter@9.2.0
Ok to proceed? (y) y
? Please select the template you want typeorm
? Do you want to update all packages in the node_modules directory and dependency ? Yes
? However, updating to the latest version may cause package dependency issues. Do you still want to update ? Yes
? Do you want to Used to removed duplicate packages at npm ? Yes
[ 1 / 3 ] 🔍  copying project...
[ 2 / 3 ] 🚚  fetching node_modules...
[ 3 / 3 ] 🔗  linking node_modules...
──────────────────────────────────────────
✔ Complete setup project

作成が終わると、以下のような構成が出来上がりました。

$ cd app
$ tree -I 'node_modules'
.
├── docker-compose.yml
├── Dockerfile
├── ecosystem.config.js
├── jest.config.js
├── Makefile
├── nginx.conf
├── nodemon.json
├── package.json
├── package-lock.json
├── src
│   ├── app.ts
│   ├── config
│   │   └── index.ts
│   ├── controllers
│   │   ├── auth.controller.ts
│   │   ├── index.controller.ts
│   │   └── users.controller.ts
│   ├── databases
│   │   └── index.ts
│   ├── dtos
│   │   └── users.dto.ts
│   ├── entities
│   │   └── users.entity.ts
│   ├── exceptions
│   │   └── HttpException.ts
│   ├── http
│   │   ├── auth.http
│   │   └── users.http
│   ├── interfaces
│   │   ├── auth.interface.ts
│   │   ├── routes.interface.ts
│   │   └── users.interface.ts
│   ├── middlewares
│   │   ├── auth.middleware.ts
│   │   ├── error.middleware.ts
│   │   └── validation.middleware.ts
│   ├── migration
│   ├── routes
│   │   ├── auth.route.ts
│   │   ├── index.route.ts
│   │   └── users.route.ts
│   ├── server.ts
│   ├── services
│   │   ├── auth.service.ts
│   │   └── users.service.ts
│   ├── tests
│   │   ├── auth.test.ts
│   │   ├── index.test.ts
│   │   └── users.test.ts
│   └── utils
│       ├── logger.ts
│       ├── util.ts
│       └── validateEnv.ts
├── swagger.yaml
└── tsconfig.json

15 directories, 40 files

docker-compose.ymlも作成されているので、ローカルで実行してみます。

$ docker-compose up
(略)
server    | 2022-10-17 00:26:52 info: =================================
server    | 2022-10-17 00:26:52 info: ======= ENV: development =======
server    | 2022-10-17 00:26:52 info: 🚀 App listening on the port 3000
server    | 2022-10-17 00:26:52 info: =================================
server    | (node:37) UnhandledPromiseRejectionWarning: Error: connect ECONNREFUSED 127.0.0.1:5432
server    |     at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1146:16)
server    | (Use `node --trace-warnings ...` to show where the warning was created)
server    | (node:37) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
server    | (node:37) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

作成されたアプリケーションでは、/api-docsにアクセスすると、API仕様を確認することができます。

また、サンプルアプリケーションの設定を行なっているapp/src/config/index.tsを確認すると、"DB_"で始まる環境変数から接続情報を取得していることがわかります。

app/src/config/index.ts

import { config } from 'dotenv';
config({ path: `.env.${process.env.NODE_ENV || 'development'}.local` });

export const CREDENTIALS = process.env.CREDENTIALS === 'true';
export const { NODE_ENV, PORT, DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_DATABASE, SECRET_KEY, LOG_FORMAT, LOG_DIR, ORIGIN } = process.env;

これらの環境変数がApp Runnerサービス起動時にSecrets Managerから読み込まれるように修正します。

まず、Secrets Managerからシークレットを取得するgetsecretvalue.jsを作成します。

取得時に指定が必要なシークレット名は、環境変数DB_SECRET_NAMEから受け取ることとします。

app/getsecretvalue.js

#!/usr/local/bin/node

const AWS = require('aws-sdk');
const region = process.env.AWS_DEFAULT_REGION;
const secretName = process.env.DB_SECRET_NAME;

const client = new AWS.SecretsManager({
  region: region,
});

const main = async () => {
  try {
    const secret = await client.getSecretValue({ SecretId: secretName }).promise();
    console.log(secret.SecretString);
  } catch (err) {
    console.error(err);
  }
};

main();

次に、getsecretvalue.jsを実行して環境変数に値をセットするentrypoint.shを作成します。

app/entrypoint.sh

#!/bin/sh

echo 'Start entrypoint.sh...'

export SECRET_JSON=$(/app/getsecretvalue.js)

export DB_USER=$(echo $SECRET_JSON | jq -r .username)
export DB_PASSWORD=$(echo $SECRET_JSON | jq -r .password)
export DB_HOST=$(echo $SECRET_JSON | jq -r .host)
export DB_PORT=$(echo $SECRET_JSON | jq -r .port)
export DB_DATABASE=$(echo $SECRET_JSON | jq -r .dbname)

echo 'Start Service...'

exec "$@"

最後にDockerfileを修正します。修正点は以下3点です。

  • jqのインストール
  • スクリプトの追加、実行権付与
  • ENTRYPOINTの追加

Dockerfile

# Common build stage
FROM node:16.17.1-alpine3.15 as common-build-stage

COPY . ./app

WORKDIR /app

RUN npm install

EXPOSE 3000


# Development build stage
FROM common-build-stage as development-build-stage

ENV NODE_ENV development

CMD ["npm", "run", "dev"]

# Production build stage
FROM common-build-stage as production-build-stage

ENV NODE_ENV production

COPY entrypoint.sh /
RUN apk add --no-cache jq && \
  chmod +x /entrypoint.sh && \
  chmod +x /app/getsecretvalue.js

ENTRYPOINT [ "/entrypoint.sh" ]
CMD ["npm", "run", "start"]

OpenTelemetry SDKを実装

アプリケーションからX-Rayにトレース情報を送信するため、以下を参考にOpenTelemetry SDKを実装します。

まず、依存ライブラリをインストールします。

$ npm install --save \
    @opentelemetry/api \
    @opentelemetry/sdk-trace-node \
    @opentelemetry/auto-instrumentations-node \
    @opentelemetry/exporter-trace-otlp-grpc \
    @opentelemetry/id-generator-aws-xray \
    @opentelemetry/propagator-aws-xray \
    aws-sdk

トレース情報を取得するtracer.jsを作成します。

app/src/tracer.js

const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-base');
const { Resource } = require('@opentelemetry/resources');
const { trace } = require('@opentelemetry/api');
const { AWSXRayIdGenerator } = require('@opentelemetry/id-generator-aws-xray');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
const { AWSXRayPropagator } = require('@opentelemetry/propagator-aws-xray');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');

module.exports = serviceName => {
  const tracerConfig = {
    idGenerator: new AWSXRayIdGenerator(),
    instrumentations: [
      getNodeAutoInstrumentations({
        '@opentelemetry/instrumentation-pg': {
          enhancedDatabaseReporting: true,
        },
      }),
    ],
    resource: Resource.default().merge(
      new Resource({
        [SemanticResourceAttributes.SERVICE_NAME]: serviceName,
      }),
    ),
  };

  const tracerProvider = new NodeTracerProvider(tracerConfig);
  const otlpExporter = new OTLPTraceExporter();

  tracerProvider.addSpanProcessor(new BatchSpanProcessor(otlpExporter));
  tracerProvider.register({
    propagator: new AWSXRayPropagator(),
  });

  return trace.getTracer(serviceName);
};

作成したtracer.jsをserver.tsから呼び出すよう修正します。

app/src/server.ts

import App from '@/app';
import AuthRoute from '@routes/auth.route';
import IndexRoute from '@routes/index.route';
import UsersRoute from '@routes/users.route';
import validateEnv from '@utils/validateEnv';

const tracer = require('./tracer')('sampleApp');

validateEnv();

const app = new App([new IndexRoute(), new UsersRoute(), new AuthRoute()]);

app.listen();

以上でサンプルアプリケーションの準備は完了です。

次はCDKでAWSリソースを定義していきます。

VPCを作成

CDKでVPCを作成します。

CDK初期化

$ ls
app
$ mkdir infra && cd infra
$ cdk init --language typescript

infra/lib/network.ts

import * as cdk from 'aws-cdk-lib';
import { Port, SecurityGroup, SubnetType, Vpc } from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';

export class Network extends Construct {

    readonly vpc: Vpc;
    readonly sgDataBase: SecurityGroup;
    readonly sgAppRunner: SecurityGroup;

    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id);

        // VPC
        this.vpc = new Vpc(this, 'Vpc', {
            vpcName: 'sampleapp-vpc',
            cidr: '10.0.0.0/16',
            subnetConfiguration: [
                {
                    cidrMask: 24,
                    name: 'public',
                    subnetType: SubnetType.PUBLIC,
                },
                {
                    cidrMask: 24,
                    name: 'app',
                    subnetType: SubnetType.PRIVATE_WITH_EGRESS,
                },
                {
                    cidrMask: 24,
                    name: 'db',
                    subnetType: SubnetType.PRIVATE_ISOLATED,
                }
            ],
            natGateways: 1,
            enableDnsHostnames: true,
            enableDnsSupport: true,
        });

        // AppRunner用セキュリティグループ
        this.sgAppRunner = new SecurityGroup(this, 'ApprunnerSecurityGroup', {
            securityGroupName: 'apprunner-sg',
            vpc: this.vpc,
            allowAllOutbound: true
        });

        // Aurora用セキュリティグループ
        this.sgDataBase = new SecurityGroup(this, 'DatabaseSecurityGroup', {
            securityGroupName: 'database-sg',
            vpc: this.vpc,
            allowAllOutbound: true
        });

        // AppRunnerからAuroraへの接続許可
        this.sgDataBase.addIngressRule(
            this.sgAppRunner,
            Port.tcp(5432)
        );

    }
}

Aurora Serverless v2を作成

Auroraクラスターと接続情報を格納するシークレットを作成します。

2022/10/17時点では、L2 Constructでのv2は未サポートのため、L1 Constructを使って先日サポートされたCloudFormationによるv2の定義と同じになるように下記記事を参考に修正しています。

修正点は、以下の3点です。

  • DatabaseClusterに”ServerlessV2ScalingConfiguration”を追加
  • DatabaseClusterから"EngineMode"プロパティを削除
  • DBInstanceのdbInstanceClassを"db.serverless"に変更
    • DatabaseClusterクラスから直接DBInstanceクラスを参照することが出来なかったので、DBInstanceに付与されるインスタンスIDのプレフィクスを指定しておき、DatabaseCluster.node.childrenプロパティから指定したプレフィクスをIDに持つノードを抽出して、dbInstanceClassを書き換えています

infra/lib/aurora.ts

import * as cdk from 'aws-cdk-lib';
import { InstanceClass, InstanceSize, InstanceType, SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2';
import { AuroraPostgresEngineVersion, CfnDBCluster, CfnDBInstance, Credentials, DatabaseCluster, DatabaseClusterEngine, DatabaseInstance, ServerlessCluster } from 'aws-cdk-lib/aws-rds';
import { Secret } from 'aws-cdk-lib/aws-secretsmanager';
import { Construct, IConstruct } from 'constructs';

interface AuroraProps {
    vpc: Vpc,
    sgDatabase: SecurityGroup,
}

export class Aurora extends Construct {
    readonly secret: Secret;

    constructor(scope: Construct, id: string, props: AuroraProps) {
        super(scope, id);

        // Aurora用クレデンシャル
        this.secret = new Secret(this, 'DBSecret', {
            secretName: 'AuroraClusterSecret',
            generateSecretString: {
                excludePunctuation: true,
                includeSpace: false,
                generateStringKey: 'password',
                secretStringTemplate: JSON.stringify({
                    username: 'appuser',
                })
            }
        })

        // Aurora Serverless V2クラスター
        const instanceIdPrefix = 'Instance'; // 後でインスタンスクラスを書き換えるため、インスタンスIDのプレフィクスを指定
        const cluster = new DatabaseCluster(this, 'AuroraCluster', {
            engine: DatabaseClusterEngine.auroraPostgres({
                version: AuroraPostgresEngineVersion.VER_14_3
            }),
            credentials: Credentials.fromSecret(this.secret),
            defaultDatabaseName: 'sampledb',
            instanceIdentifierBase: instanceIdPrefix,
            instanceProps: {
                vpc: props.vpc,
                vpcSubnets: props.vpc.selectSubnets({ subnetGroupName: 'db' }),
                securityGroups: [props.sgDatabase],
                instanceType: InstanceType.of(InstanceClass.BURSTABLE3, InstanceSize.MEDIUM), // 後でインスタンスクラスを書き換えるため、ダミーを設定
            },
            removalPolicy: cdk.RemovalPolicy.DESTROY,
        });

        // 現時点ではL2クラスがServerless V2未対応のため、L1クラスから書き換え
        const cfnCluster = cluster.node.defaultChild as CfnDBCluster;
        cfnCluster.addPropertyOverride('ServerlessV2ScalingConfiguration', {
            'MaxCapacity': 5,
            'MinCapacity': 0.5,
        });
        cfnCluster.addPropertyDeletionOverride('EngineMode');

        // 指定したインスタンスIDプレフィクスを持つノードを抽出してインスタンスインスタンスクラスを書き換える
        const children = cluster.node.children;
        for (const child of children) {
            if (child.node.id.startsWith(instanceIdPrefix)) {
                (child as CfnDBInstance).dbInstanceClass = 'db.serverless';
            }
        }
    }
}

App Runnerサービスを作成

App Runnerサービスと、VPCにアクセスするためのVPC Connectorを作成します。

App Runnerは2022/10/17時点ではalphaパッケージのため、別途インストールが必要です。

$ npm install --save -D @aws-cdk/aws-apprunner-alpha

今回、App Runnerサービスにするアプリケーションは、ECRリポジトリからデプロイしたいので、ECRも作成します。

また、サービスのデプロイもCDKデプロイ時に一括して行いたいので、cdk-ecr-deploymentも別途インストールしておきます。

$ npm install --save -D cdk-ecr-deployment

注意点として、今回のサンプルアプリケーションは、起動時にSecrets ManagerからDB接続情報を取得する必要があるため、VPC Connector経由でSecrets Manager APIにアクセスできるように構成する必要があります。構成方法としては、EC2やECSで動作するアプリケーションからのAWSサービスAPIへのアクセスと同様に、NAT Gateway経由、もしくはPrivateLinkを作成してアクセスする2通りから選択します。(今回はNAT Gateway)

App Runnerサービスのトレースの設定は、L2 Construct未サポートのため、例によってL1 Constructで対応しています。

infra/lib/apprunner.ts

import { Service, Source, VpcConnector } from '@aws-cdk/aws-apprunner-alpha';
import { CfnOutput, RemovalPolicy, Stack } from 'aws-cdk-lib';
import { CfnObservabilityConfiguration, CfnService } from 'aws-cdk-lib/aws-apprunner';
import { SecurityGroup, SubnetType, Vpc } from 'aws-cdk-lib/aws-ec2';
import { Repository } from 'aws-cdk-lib/aws-ecr';
import { DockerImageAsset, Platform } from 'aws-cdk-lib/aws-ecr-assets';
import { Effect, ManagedPolicy, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import { Secret } from 'aws-cdk-lib/aws-secretsmanager';
import { DockerImageName, ECRDeployment } from 'cdk-ecr-deployment';
import { Construct } from 'constructs';
import * as path from "path";

type AppStackProps = {
    vpc: Vpc,
    sgAppRunner: SecurityGroup,
    dbSecret: Secret,
}

export class AppRunnerService extends Construct {
    constructor(scope: Construct, id: string, props: AppStackProps) {
        super(scope, id);

        // VPC Connector
        const vpcConnector = new VpcConnector(this, 'VpcConnector', {
            vpc: props.vpc,
            vpcSubnets: props.vpc.selectSubnets({ subnetType: SubnetType.PRIVATE_WITH_EGRESS }),
            vpcConnectorName: 'sampleapp-vpc-connector',
            securityGroups: [props.sgAppRunner],
        });

        // ECRリポジトリ
        const repo = new Repository(this, 'AppRunnerRepository', {
            repositoryName: 'sampleapp',
            removalPolicy: RemovalPolicy.DESTROY,
        });

        // コンテナイメージをECRリポジトリにデプロイ
        const image = new DockerImageAsset(this, 'AppRunnerDockerImage', {
            directory: path.join(__dirname, '../../app/'),
            platform: Platform.LINUX_AMD64,
            target: 'production-build-stage',
        });
        new ECRDeployment(this, 'DeployDockerImage', {
            src: new DockerImageName(image.imageUri),
            dest: new DockerImageName(repo.repositoryUri),
        });

        // App Runner Service インスタンスロール
        const instanceRole = new Role(this, 'AppRunnerInstanceRole', {
            roleName: 'SampleAppRunnerInstanceRole',
            assumedBy: new ServicePrincipal('tasks.apprunner.amazonaws.com'),
            managedPolicies: [
                ManagedPolicy.fromAwsManagedPolicyName('AWSXRayDaemonWriteAccess')
            ],
            inlinePolicies: {
                "AllowGetSecretValue": new PolicyDocument({
                    statements: [
                        new PolicyStatement({
                            effect: Effect.ALLOW,
                            actions: [
                                "secretsmanager:GetResourcePolicy",
                                "secretsmanager:GetSecretValue",
                                "secretsmanager:DescribeSecret",
                                "secretsmanager:ListSecretVersionIds"
                            ],
                            resources: [
                                `arn:aws:secretsmanager:${(scope as Stack).region}:${(scope as Stack).account}:secret:${props.dbSecret.secretName}-*`
                            ]
                        })
                    ]
                })
            }
        });
        // App Runner Service
        const service = new Service(this, 'AppRunnerService', {
            source: Source.fromEcr({
                repository: repo,
                imageConfiguration: {
                    port: 3000,
                    environment: {
                        "DB_SECRET_NAME": props.dbSecret.secretName,
                    }
                }
            }),
            serviceName: 'sampleApp',
            vpcConnector: vpcConnector,
            instanceRole: instanceRole,
        });
        service.applyRemovalPolicy(RemovalPolicy.DESTROY);

        // トレースの有効化
        const cfnObservabilityConfig = new CfnObservabilityConfiguration(this, 'SampleAppRunnerObservConfig', {
            observabilityConfigurationName: 'SampleAppRunnerObservConfig',
            traceConfiguration: {
                vendor: 'AWSXRAY'
            }
        });
        const cfnService = service.node.defaultChild as CfnService;
        cfnService.addPropertyOverride('ObservabilityConfiguration', {
            'ObservabilityEnabled': true,
            'ObservabilityConfigurationArn': cfnObservabilityConfig.ref
        })

        // AppRunnerサービスURL
        new CfnOutput(this, 'ServiceURL', {
            exportName: 'AppRunnerServiceUrl',
            value: service.serviceUrl
        });
        // AppRunnerサービスARN
        new CfnOutput(this, 'ServiceARN', {
            exportName: 'AppRunnerServiceArn',
            value: service.serviceArn
        });
    }
}

スタックを作成

最後に上記で作成したものをスタックにまとめます。

infra/lib/infra-stack.ts

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Aurora } from './aurora';
import { Network } from './network';
import { AppRunnerService } from './apprunner';

export class InfraStack extends cdk.Stack {
  readonly network: Network;
  readonly aurora: Aurora;
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Network
    this.network = new Network(this, 'Network');

    // Aurora
    this.aurora = new Aurora(this, 'AuroraCluster', {
      vpc: this.network.vpc,
      sgDatabase: this.network.sgDataBase
    });

    // App Runner Service
    new AppRunnerService(this, 'AppStack', {
      vpc: this.network.vpc,
      sgAppRunner: this.network.sgAppRunner,
      dbSecret: this.aurora.secret,
    });

  }
}

以上でCDKの作成完了です。

デプロイ

作成したCDKのスタックをデプロイしてみます。

$ cdk deploy
Try to get prebuilt lambda

✨  Synthesis time: 33.92s

InfraStack: building assets...
(略)
InfraStack: assets built

This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
┌───┬─────────────────────────────────────────────┬────────┬─────────────────────────────────────────────┬──────────────────────────────────────────────┬───────────┐
│   │ Resource                                    │ Effect │ Action                                      │ Principal                                    │ Condition │
├───┼─────────────────────────────────────────────┼────────┼─────────────────────────────────────────────┼──────────────────────────────────────────────┼───────────┤
│ + │ ${AppStack/AppRunnerInstanceRole.Arn}       │ Allow  │ sts:AssumeRole                              │ Service:tasks.apprunner.amazonaws.com        │           │
├───┼─────────────────────────────────────────────┼────────┼─────────────────────────────────────────────┼──────────────────────────────────────────────┼───────────┤
│ + │ ${AppStack/AppRunnerRepository.Arn}         │ Allow  │ ecr:BatchCheckLayerAvailability             │ AWS:${AppStack/AppRunnerService/AccessRole}  │           │
│   │                                             │        │ ecr:BatchGetImage                           │                                              │           │
│   │                                             │        │ ecr:GetDownloadUrlForLayer                  │                                              │           │
├───┼─────────────────────────────────────────────┼────────┼─────────────────────────────────────────────┼──────────────────────────────────────────────┼───────────┤
│ + │ ${AppStack/AppRunnerService/AccessRole.Arn} │ Allow  │ sts:AssumeRole                              │ Service:build.apprunner.amazonaws.com        │           │
├───┼─────────────────────────────────────────────┼────────┼─────────────────────────────────────────────┼──────────────────────────────────────────────┼───────────┤
│ + │ ${Custom::CDKECRDeploymentbd07c930edb94112a │ Allow  │ sts:AssumeRole                              │ Service:lambda.amazonaws.com                 │           │
│   │ 20f03f096f53666512MiB/ServiceRole.Arn}      │        │                                             │                                              │           │
├───┼─────────────────────────────────────────────┼────────┼─────────────────────────────────────────────┼──────────────────────────────────────────────┼───────────┤
│ + │ *                                           │ Allow  │ ecr:GetAuthorizationToken                   │ AWS:${AppStack/AppRunnerService/AccessRole}  │           │
│ + │ *                                           │ Allow  │ ecr:BatchCheckLayerAvailability             │ AWS:${Custom::CDKECRDeploymentbd07c930edb941 │           │
│   │                                             │        │ ecr:BatchGetImage                           │ 12a20f03f096f53666512MiB/ServiceRole}        │           │
│   │                                             │        │ ecr:CompleteLayerUpload                     │                                              │           │
│   │                                             │        │ ecr:DescribeImageScanFindings               │                                              │           │
│   │                                             │        │ ecr:DescribeImages                          │                                              │           │
│   │                                             │        │ ecr:DescribeRepositories                    │                                              │           │
│   │                                             │        │ ecr:GetAuthorizationToken                   │                                              │           │
│   │                                             │        │ ecr:GetDownloadUrlForLayer                  │                                              │           │
│   │                                             │        │ ecr:GetRepositoryPolicy                     │                                              │           │
│   │                                             │        │ ecr:InitiateLayerUpload                     │                                              │           │
│   │                                             │        │ ecr:ListImages                              │                                              │           │
│   │                                             │        │ ecr:ListTagsForResource                     │                                              │           │
│   │                                             │        │ ecr:PutImage                                │                                              │           │
│   │                                             │        │ ecr:UploadLayerPart                         │                                              │           │
│   │                                             │        │ s3:GetObject                                │                                              │           │
├───┼─────────────────────────────────────────────┼────────┼─────────────────────────────────────────────┼──────────────────────────────────────────────┼───────────┤
│ + │ arn:aws:secretsmanager:${AWS::Region}:${AWS │ Allow  │ secretsmanager:DescribeSecret               │ AWS:${AppStack/AppRunnerInstanceRole}        │           │
│   │ ::AccountId}:secret:{"Fn::Select":[0,"{\"Fn │        │ secretsmanager:GetResourcePolicy            │                                              │           │
│   │ ::Split\":[\"-\",\"{\\\"Fn::Select\\\":[6,\ │        │ secretsmanager:GetSecretValue               │                                              │           │
│   │ \\"{\\\\\\\"Fn::Split\\\\\\\":[\\\\\\\":\\\ │        │ secretsmanager:ListSecretVersionIds         │                                              │           │
│   │ \\\\",\\\\\\\"${AuroraCluster/DBSecret}\\\\ │        │                                             │                                              │           │
│   │ \\\"]}\\\"]}\"]}"]}-*                       │        │                                             │                                              │           │
└───┴─────────────────────────────────────────────┴────────┴─────────────────────────────────────────────┴──────────────────────────────────────────────┴───────────┘
IAM Policy Changes
┌───┬───────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────┐
│   │ Resource                                                                      │ Managed Policy ARN                                                            │
├───┼───────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ + │ ${AppStack/AppRunnerInstanceRole}                                             │ arn:${AWS::Partition}:iam::aws:policy/AWSXRayDaemonWriteAccess                │
├───┼───────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
│ + │ ${Custom::CDKECRDeploymentbd07c930edb94112a20f03f096f53666512MiB/ServiceRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRol │
│   │                                                                               │ e                                                                             │
└───┴───────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────┘
Security Group Changes
┌───┬───────────────────────────────────────────┬─────┬────────────┬───────────────────────────────────────────┐
│   │ Group                                     │ Dir │ Protocol   │ Peer                                      │
├───┼───────────────────────────────────────────┼─────┼────────────┼───────────────────────────────────────────┤
│ + │ ${Network/ApprunnerSecurityGroup.GroupId} │ Out │ Everything │ Everyone (IPv4)                           │
├───┼───────────────────────────────────────────┼─────┼────────────┼───────────────────────────────────────────┤
│ + │ ${Network/DatabaseSecurityGroup.GroupId}  │ In  │ TCP 5432   │ ${Network/ApprunnerSecurityGroup.GroupId} │
│ + │ ${Network/DatabaseSecurityGroup.GroupId}  │ Out │ Everything │ Everyone (IPv4)                           │
└───┴───────────────────────────────────────────┴─────┴────────────┴───────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y
InfraStack: deploying...
(略)
 ✅  InfraStack

✨  Deployment time: 864.18s

Outputs:
InfraStack.AppStackServiceARNF1A41CC5 = arn:aws:apprunner:ap-northeast-1:xxxxxxxxxxxx:service/AppStackAppRunnerService200-fb8RQJqnCpDY/2ec4f0a4d20c40938274aa8b0748c702
InfraStack.AppStackServiceURLC723A12D = xxxxxxxxxx.ap-northeast-1.awsapprunner.com
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:xxxxxxxxxxxx:stack/InfraStack/df0a7ae0-4dec-11ed-b88e-0663443373c3

デプロイが成功すると、App Runnerサービスに割り当てられたURLが表示されますので、それを使ってアプリケーションにリクエストを送信してみます。

アプリケーションにリクエスト送信

ローカル実行で確認したAPI仕様に従って、アプリケーションにリクエストを送信してみます。

まず、データを登録してみます。

※デプロイ時に表示されたURL(xxxxxxx.ap-northeast-1.awsapprunner.com)で書き換えて実行してください。

$ curl --location --request POST 'https://<デプロイ時に発行されたURL>/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "test@email.com",
    "password": "1q2w3e4r!"
}' | jq .
{
  "data": {
    "email": "test@email.com",
    "password": "$2b$10$zkFL.kg9TQRM8WpDwxmpceT1khjPf1miFhMKj.jYsWNN0owzHbi3S",
    "id": 1,
    "createdAt": "2022-10-17T07:45:20.531Z",
    "updatedAt": "2022-10-17T07:45:20.531Z"
  },
  "message": "created"
}

登録できたので、今度は取得してみます。

$ curl --location --request GET 'https://<デプロイ時に発行されたURL>/users' | jq .
{
  "data": [
    {
      "id": 1,
      "email": "test@email.com",
      "password": "$2b$10$zkFL.kg9TQRM8WpDwxmpceT1khjPf1miFhMKj.jYsWNN0owzHbi3S",
      "createdAt": "2022-10-17T07:45:20.531Z",
      "updatedAt": "2022-10-17T07:45:20.531Z"
    }
  ],
  "message": "findAll"
}

取得できました!

トレース情報を確認

それでは、実行したリクエストのトレース情報をX-Rayで確認してみます。

X-Rayマネージドコンソールにアクセスすると、サービスマップが表示できました。

App RunnnerサービスのアプリケーションからAuroraのエンドポイントに接続する際のDNSリクエストと、AuroraへのSQL実行が確認できました。

トレース中央の"AWS::AppRunner::Service"のサービス名がAuroraのデータベースURLになってしまっていますが、今回は原因について特定できませんでした。こちらについては、別途調査してみたいと思います。

サービスマップをクリックして、表示された「サービスの詳細」から「トレースの表示」をクリックし、詳細なトレースを表示してみます。

DNSとSQL実行のリクエストのトレースが確認できました。

最後に

App RunnerサービスもLambdaやECSと同様に、X-Rayでのトレーサビリティを整えておくことで、トラブル発生時に問題発生箇所の特定を迅速に行える可能性が高くなりますので、積極的に使っていきたいです。

今回は検証内容に含めていませんが、アプリケーションのログにもトレースIDを含めることによって、X-Rayのトレースから特定した問題箇所のログを探すのが速くなると思われます。