もしかして App Runner 上 Next.js の SSR で直接 DynamoDB 呼べますか?

11 min read読了の目安(約10100字

TL;DR - YES!

はい、IAM Role を設定すればできます。Next.js と データソースの間に API を挟まなくてもよいです。便利ですね。ブログ記事をDynamoDBから取り出す想定で試しました。以下の手順でいけます。

  1. 記事を保管するDynamoDBを作成する(AWS CDK)
  2. DynamoDB へのアクセス権限をもった IAM ロールを作成する(AWS CDK)
  3. ブログ用 Next.js アプリの getServerSideProps で AWS SDK 経由での DynamoDB 操作を書く
  4. AWS App Runner(以下 App Runner)にて ② で作成した IAM を割り当てつつサービスを作成する(手作業)

経緯など

前に書いたこの記事で Next.js の App Runner へのデプロイを試したのですが、SSRの可能性を試したくなりました。サーバー上で動くのなら getServerSideProps でAWS SDK が使えるのかどうかという点です。もしそれができるならば、これまで例えば

  • ブラウザ => React SPA => API Gateway => Lambda Function => DynamoDB

としていたところが、

  • ブラウザ => Next.js(App Runner) => DynamoDB

というようにかなりショートカットできます。Fargateと違いVPCも考慮する必要がありません。何らかのブログ記事が DynamoDB に保存されているという想定のもと、早速ためしてみました。

バージョン情報

利用ツール バージョン
Next.js 10.2.3
aws-sdk-js 2.918.0

順番

  1. DynamoDB と IAM を AWS CDK で作成する
  2. blog-starter-typescript を修正する
  3. App Runner をデプロイして試す

という感じで進めます。

DynamoDB と IAM を AWS CDK で作成する

AWS CDK は TypeScriptなどの プログラミング言語でAWSリソースを定義・デプロイするためのツールです。手でポチポチ作成するよりも素早く再現デプロイできるのでおすすめです。気になる方は DevelopersIO のタグを覗いてみてください。

AWS CDK でリソース定義&デプロイ

DynamoDB と IAM Role をデプロイするための記述例です:

packages/infra-aws/lib/blog-service-stack.ts
import * as cdk from '@aws-cdk/core';
import { RemovalPolicy, Stack } from '@aws-cdk/core';
import * as dynamo from '@aws-cdk/aws-dynamodb';
import { AttributeType, ProjectionType } from '@aws-cdk/aws-dynamodb';
import * as iam from '@aws-cdk/aws-iam';
import { GlobalProps } from './global-props';

export async function blogServiceApplicationStack(
    scope: cdk.Construct,
    id: string,
    global: GlobalProps,
): Promise<Stack> {
    const stack = new cdk.Stack(scope, id, {
        stackName: global.getStackName(id),
    });

    // ①
    const articleTable = new dynamo.Table(stack, 'ArticleTable', {
        tableName: global.getTableName('Article'),
        partitionKey: { name: 'id', type: AttributeType.STRING },
        removalPolicy: RemovalPolicy.DESTROY,
    });
    articleTable.addGlobalSecondaryIndex({
        indexName: 'slug-index',
        partitionKey: { name: 'slug', type: AttributeType.STRING },
        projectionType: ProjectionType.ALL,
    });

    // ②
    new iam.Role(stack, 'BlogInstanceRole', {
        roleName: global.getRoleName('BlogInstance'),
        assumedBy: new iam.ServicePrincipal('tasks.apprunner.amazonaws.com'),
        managedPolicies: [
            iam.ManagedPolicy.fromAwsManagedPolicyName(
                'AmazonDynamoDBFullAccess',
            ),
        ],
    });

    // App Runner does not support CDK yet, please handmade:)

    return stack;
}
  1. 記事用のテーブルを作成しています。getServerSideProps で使うことを考えるとおそらく slug からコンテンツを読み出す展開になるので、Global Secondary Index を貼って slug から特定できるようにしました。
  2. DynamoDB にアクセスできるロールを作成しています。
  • slug だけでただひとつの記事を特定できる前提です。
  • プロダクションでは IAM ロールは必要最小限の権限におさえてください。

これを使ってデプロイします。CDKコマンド例です。

cdk deploy --app 'npx ts-node bin/blog.ts' --require-approval never BlogServiceStack
  • dev-xxx-Article-table
  • dev-xxx-BlogInstance-role

というふたつのリソースがAWS上に作成されます。

記事データを作成

Article テーブルに記事データを作成しておきましょう。ここは、手作業でいきます。


AWS Console > DynamoDB > Items: dev-greeting-service-Article-table > Item editor

図のような感じでコンテンツを埋めました。大事なのは slug です。getServerSideProps にてこれをもとに記事を探すようにするので、控えておきます。

blog-starter-typescript を修正する

またお世話になります。作成したいディレクトリに移動し、yarn createします。

yarn create next-app --example blog-starter-typescript next-server

素の状態では Static Site Generation するコードなので、改変します。ターゲットファイルは next-server/pages/posts/[slug].tsx です。以下のように改変しました。

next-server/pages/posts/[slug]
import { useRouter } from "next/router";
import ErrorPage from "next/error";
import Container from "../../components/container";
import PostBody from "../../components/post-body";
import Header from "../../components/header";
import PostHeader from "../../components/post-header";
import Layout from "../../components/layout";
import PostTitle from "../../components/post-title";
import Head from "next/head";
import { CMS_NAME } from "../../lib/constants";
import markdownToHtml from "../../lib/markdownToHtml";
import PostType from "../../types/post";
import { GetServerSideProps } from "next";
import * as aws from "aws-sdk";
import Author from "../../types/author";

const Region = process.env.REGION!;
const BlogTableName = process.env.BLOG_TABLE_NAME!;
const dynamodb = new aws.DynamoDB.DocumentClient({
  apiVersion: "2012-08-10",
  region: Region,
  signatureVersion: "v4",
});

type Props = {
  post: PostType;
  morePosts: PostType[];
  preview?: boolean;
};

const Post = ({ post, morePosts, preview }: Props) => {
  console.log("post", post);
  const router = useRouter();
  if (!router.isFallback && !post?.slug) {
    return <ErrorPage statusCode={404} />;
  }
  return (
    <Layout preview={preview}>
      <Container>
        <Header />
        {router.isFallback ? (
          <PostTitle>Loading…</PostTitle>
        ) : (
          <>
            <article className="mb-32">
              <Head>
                <title>
                  {post.title} | Next.js Blog Example with {CMS_NAME}
                </title>
                <meta property="og:image" content={post.ogImage.url} />
              </Head>
              <PostHeader
                title={post.title}
                coverImage={post.coverImage}
                date={post.date}
                author={post.author}
              />
              <PostBody content={post.content} />
            </article>
          </>
        )}
      </Container>
    </Layout>
  );
};

export default Post;

type Params = {
  params: {
    slug: string;
  };
};

type Article = {
  slug: string;
  title: string;
  date: string;
  coverImage: string;
  author: Author;
  excerpt: string;
  ogImage: {
    url: string;
  };
  content: string;
  createAt: string;
};

export const getServerSideProps: GetServerSideProps<Props> = async ({
  params,
}) => {
  console.log("params", params);
  const result = await dynamodb
    .query({
      TableName: BlogTableName,
      IndexName: "slug-index",
      Limit: 1,
      KeyConditionExpression: "#slug = :slug",
      ExpressionAttributeNames: {
        "#slug": "slug",
      },
      ExpressionAttributeValues: {
        ":slug": (params?.slug as string) ?? "",
      },
    })
    .promise();

  const article: Article = result.Items![0] as Article;
  console.log(article);

  const content = await markdownToHtml(article.content);

  return {
    props: {
      post: {
        ...article,
        content,
      },
      morePosts: [],
    },
  };
};

少し多いので分割して見ていきます。

DynamoDB クライアント生成部分

next-server/pages/posts/[slug]
import * as aws from "aws-sdk";

const Region = process.env.REGION!;
const BlogTableName = process.env.BLOG_TABLE_NAME!;
const dynamodb = new aws.DynamoDB.DocumentClient({
  apiVersion: "2012-08-10",
  region: Region,
  signatureVersion: "v4",
});

環境変数からテーブル名を読み、AWS SDKで、DynamoDB のクライアントを生成しています。

getServerSideProps 部分

next-server/pages/posts/[slug]
export const getServerSideProps: GetServerSideProps<Props> = async ({
  params,
}) => {
  console.log("params", params);

  // ①
  const result = await dynamodb
    .query({
      TableName: BlogTableName,
      IndexName: "slug-index",
      Limit: 1,
      KeyConditionExpression: "#slug = :slug",
      ExpressionAttributeNames: {
        "#slug": "slug",
      },
      ExpressionAttributeValues: {
        ":slug": (params?.slug as string) ?? "",
      },
    })
    .promise();

  const article: Article = result.Items![0] as Article;
  console.log(article);

  // ②
  const content = await markdownToHtml(article.content);

  return {
    props: {
      post: {
        ...article,
        content,
      },
      morePosts: [],
    },
  };
};
  1. 今回のテーマです。DynamoDB クライアント をつかって slug から記事を探しています。つまり、URLで /posts/app-runner-nextjs を開くと app-runner-nextjs という slug で検索をかけることになります。これが実行できるかどうかが成否をわけます。
  2. DynamoDB Item には 記事の中身が含まれているので、それをHTMLに変換しています。SSG の場合ここは Markdown ファイルから読み出す動きでしたが、今回は DynamoDB に Markdown テキストが入っています。

あとは props として return しています。props を受け取って JSX を返す部分は変えていません。変更点を GitHub にPUSHしてください。次は App Runner にデプロイしていきましょう。

App Runner にデプロイ

GitHub に PUSH したら、そのリポジトリをソースにして App Runner にソースコードデプロイします。AWS App Runner にSSRありの Next.js アプリをデプロイする(ソースコードデプロイ)も参照ください。今回違うのは、環境変数を設定するのと、作成した IAM Role を割り当てる点です。どちらもAWSコンソールで設定できます。

環境変数と IAM Role の設定

  • Environment variables: Next.js で DynamoDB クライアントを生成するのに必要なので、環境変数をふたつ設定します
  • Instance role: 先程 CDK で作成した IAM Role が指定できるはずなので、選びます

あとはサービスを作成でOKです。しばらく待ちます。

/posts/app-runner-nextjs にアクセスしてみる

デプロイが終わったら DynamoDB に存在する slug でアクセスしてみます。SSRできていれば、DynamoDBと接続し、slug: app-runner-nextjs に該当するコンテンツを検索してくれるはずです。

/posts/app-runner-nextjs へのアクセスに対して、ブログページが正常に表示されていることがわかります。IAM Role を設定すれば、Next.js のSSRで直接 DynamoDB へアクセスできることがわかりました。

おわりに

今回試した結果より、IAMさえ設定すればあらゆるAWSのパブリックサービスに App Runner からアクセスできるということを意味します。Next.js はページごとにSSR/ISR/SSGを切り替えられるので、がっつりAWSリソースにアクセスするページはSSRで、ビルド時にS3から取得できれば良いページはSSGで…など、AWSのエコシステムとの相性が良さそうです。Lambda Function ではなかなか制限が大きく、FargateではVPCとの往来を考慮する必要があるというもどかしい状況でしたが、App Runner のおかげで一気に視界が開けたと感じます。Next.js と App Runner の組み合わせは他にも可能性を秘めている気がするので、今後もいろいろ試していきたいと思います。

ソースコード

モノレポでやっていますので一部yarnコマンドが変わっています。yarn next-devなど。フォークして試す場合は注意してください。

https://github.com/cm-wada-yusuke/aws-serverless-monorepo-starter/tree/feature/next-integration

関連記事

https://zenn.dev/intercept6/articles/495daf5c9b0c931f23ee