Amplify Data(Gen2)からMySQL接続を試してみた

2024.05.30

NTT東日本の中村です。

Amplify Gen2では、プレビュー版では行えなかったAmplify DataでのMySQL接続ができるようになり、調査を行ってみました。

Amplify Data(Gen2)のMySQL接続の概要

以前のAmplifyは、バックエンドのデータベースと接続する場合、Amplify APIというカテゴリで、AppSyncサービスのデータソースとして既存のMySQL、PostgreSQLを選択できるようになっていました。 Amplify Gen2では、Amplify Dataという名前に変わりましたが、同じ接続ができます。プレビューでは既存データベースに接続できなかったのですが、正式にサポートされました。

Gen1(V6)のドキュメント:

Gen2のドキュメント:

実際に試してみた

MySQLとの接続を試してみました。

MySQLデータベースの作成

この機能は、「既存のMySQLから構成をインポートする機能」なので、先にMySQLデータベースとテーブルを用意します。

今回はカスタムリソースでRDSを作成しています。 検証のため、リスクのあるPublic SubnetにRDSを設置しています。AWSサービスに拘らず、MySQLサーバであれば問題無く接続できます。

import { defineBackend } from "@aws-amplify/backend";
import { auth } from "./auth/resource";
import { data } from "./data/resource";
import { storage } from "./storage/resource";
import {
  Credentials,
  DatabaseInstance,
  DatabaseInstanceEngine,
  MysqlEngineVersion,
} from "aws-cdk-lib/aws-rds";
import {
  InstanceClass,
  InstanceSize,
  InstanceType,
  Peer,
  Port,
  SecurityGroup,
  SubnetType,
  Vpc,
} from "aws-cdk-lib/aws-ec2";

const backend = defineBackend({
  storage,
  auth,
  data,
});

const vpc = new Vpc("SubStack", "ampxVPC", {
maxAzs: 2,
  subnetConfiguration: [
    {
      cidrMask: 24,
      name: "public",
      subnetType: SubnetType.PUBLIC,
    },
  ],
});

const securityGroup = new SecurityGroup("SubStack", "RDSSecurityGroup", {
  vpc,
  allowAllOutbound: true,
});
securityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(3306));
securityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(443));

const rdsInstance = new DatabaseInstance("SubStack", "RDSInstance", {
  engine: DatabaseInstanceEngine.mysql({
    version: MysqlEngineVersion.VER_8_0_36,
  }),
  credentials: Credentials.fromGeneratedSecret("admin"),
  instanceType: InstanceType.of(InstanceClass.BURSTABLE3, InstanceSize.SMALL),
  vpc,
  vpcSubnets: {
    subnetType: SubnetType.PUBLIC,
  },
  securityGroups: [securityGroup],
  databaseName: "ampxdb",
  publiclyAccessible: true,
});

backend.addOutput({
  custom: {
    value: rdsInstance.dbInstanceEndpointAddress,
  },
});

Amplify DataがMySQLと接続する場合、内部ではAppSyncが動いており、リゾルバからLambdaを経由してMySQLに接続する仕組みです。 加えて、今回のMySQLはAWS VPC上のRDSに接続するため、LambdaはVPC内に作成されます(いわゆるVPC Lambda)。

Gyazo

既存データベースの接続で生成されるLambdaについては、下記に説明があります。

更に、作成されたLambdaは、Secret Managerに格納されたデータベース接続情報を使用してデータベースにアクセスするので、配置されたセキュリティグループは443ポートの許可が必要です。 CDKでも443ポートを許可しています。

Gyazo

カスタムリソースを作成して、サンドボックスを起動します。

npx ampx sandbox

エンドポイントが出力されました。

データベースの接続情報はSecret Managerに出力されるので、控えておきます。

MySQLクライアントから、データベースに接続できることを確認しました。

テーブルとレコードの作成

テーブルとレコードを作成します。

CREATE TABLE `Team` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `createdAt` datetime DEFAULT NULL,
  `updatedAt` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
)
sql> desc team;
+-----------+-------------+------+-----+---------+-------+
| Field     | Type        | Null | Key | Default | Extra |
+-----------+-------------+------+-----+---------+-------+
| id        | int         | NO   | PRI | null    |       |
| name      | varchar(64) | YES  |     | null    |       |
| updatedAt | datetime    | YES  |     | null    |       |
| createdAt | datetime    | YES  |     | null    |       |
+-----------+-------------+------+-----+---------+-------+

sql> INSERT INTO `ampxdb`.`Team` (`id`, `name`, `createdAt`, `updatedAt`) VALUES ('1', 'teamA', ' 2024-04-01 00:00:00', ' 2024-04-01 00:00:00');

レコードの作成が完了しました。

スキーマの生成

スキーマを生成する為に、接続情報をSandboxのSecretとして設定します。 SQL_CONNECTION_STRINGは、DBユーザ名、ホスト、接続先データベースを繋いで文字列にしたものを使用します。

% npx ampx sandbox secret set SQL_CONNECTION_STRING
? Enter secret value
mysql://admin:hogepass@hogehost.ap-northeast-1.rds.amazonaws.com:3306/ampxdb

接続情報を元に、ampx generate schema-from-databaseを実行しますが、エラーが出て生成に失敗しました。SecretManagerのアクセスに失敗しているようです。

% npx ampx generate schema-from-database --connection-uri-secret SQL_CONNECTION_STRING --out amplify/data/schema.sql.ts --stack amplify-nextamplifygen2-38ampx-sandbox-72d9a45c5b  --debug
TypeError [ERR_INVALID_ARG_TYPE]: The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received undefined
[DEBUG] 2024-05-21T05:40:23.753Z: TypeError [ERR_INVALID_ARG_TYPE]: The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received undefined
at new NodeError (node:internal/errors:405:5)
at Hash.update (node:internal/crypto/hash:107:11)
at getHash (/Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/platform-core/lib/backend_identifier_conversions.js:80:10)
at BackendIdentifierConversions.toStackName (/Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/platform-core/lib/backend_identifier_conversions.js:53:22)
at getBackendIdentifierPathPart (/Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/platform-core/lib/parameter_path_conversions.js:52:176)
at getBackendParameterPrefix (/Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/platform-core/lib/parameter_path_conversions.js:45:24)
at getBackendParameterFullPath (/Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/platform-core/lib/parameter_path_conversions.js:65:15)
at ParameterPathConversions.toParameterFullPath (/Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/platform-core/lib/parameter_path_conversions.js:30:20)
at SSMSecretClient.getSecret (file:///Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/backend-secret/lib/ssm_secret.js:19:47)
at Object.handler (file:///Users/cnw/pj/next-amplify-gen2/node_modules/@aws-amplify/backend-cli/lib/commands/generate/schema-from-database/generate_schema_command.js:40:61)

一度だけ生成を行えれば良いので、今回はnode_modulesを書き換えてしまいました。 getSecretを呼ばず、直接SQL_CONNECTION_STRINGの値を割り当てています。 (ここは原因が分かり次第、更新します)

31:     handler = async (args) => {
32:         const backendIdentifier = await this.backendIdentifierResolver.resolve(args);
33:         if (!backendIdentifier) {
34:             throw new AmplifyFault('BackendIdentifierFault', {
35:                 message: 'Could not resolve the backend identifier',
36:             });
37:         }
38:         const outputFile = args.out;
39:         await this.schemaGenerator.generate({
40:             connectionUri: {
41:                 secretName:"SQL_CONNECTION_STRING",
42:                 value: "mysql://admin:hogepass@hogehost.ap-northeast-1.rds.amazonaws.com:3306/ampxdb"
43:             },
44:             out: outputFile,
45:         });
46:     };

スキーマの生成に成功しました。

% npx ampx generate schema-from-database --connection-uri-secret SQL_CONNECTION_STRING --out amplify/data/schema.sql.ts --stack amplify-nextamplifygen2-38ampx-sandbox-72d9a45c5b --debug
✔ Successfully fetched the database schema.
The host you provided is for an RDS instance. Consider using an RDS Proxy as your data source instead.
See the documentation for a discussion of how an RDS proxy can help you scale your application more effectively.

schema.sql.tsを見ると、RDSに接続するためのSecretと、VPCの設定、Mysqlから生成したスキーマがあります。

amplify/data/schema.sql.ts

/* eslint-disable */
/* THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. */
import { a } from "@aws-amplify/data-schema";
import { configure } from "@aws-amplify/data-schema/internals";
import { secret } from "@aws-amplify/backend";

export const schema = configure({
    database: {
        identifier: "IDMTBXNvNs0ACioBPbtDpBBg",
        engine: "mysql",
        connectionUri: secret("SQL_CONNECTION_STRING"),
        vpcConfig: {
            vpcId: "vpc-0864eb9f9a79c9117",
            securityGroupIds: [
                "sg-02b414f3c026fb280"
            ],
            subnetAvailabilityZones: [
                {
                    subnetId: "subnet-0071bd35ddef18ce1",
                    availabilityZone: "ap-northeast-1c"
                },
                {
                    subnetId: "subnet-068e4b4221ed25cfe",
                    availabilityZone: "ap-northeast-1a"
                }
            ]
        }
    }
}).schema({
    "Team": a.model({
        id: a.integer().required(),
        name: a.string(),
        createdAt: a.datetime(),
        updatedAt: a.datetime()
    }).identifier([
        "id"
    ])
});

既存のスキーマと合わせて、defineDataを設定します。 combinedSchemaの名前の通り、AppSyncにDynamoDBとMySQLの2つのデータソースが設定されています。

import { type ClientSchema, a, defineData } from "@aws-amplify/backend";
import { schema as baseSqlSchema } from "./schema.sql";

const schema = a.schema({
  Todo: a
    .model({
      tag: a.string().required(),
      content: a.string(),
      done: a.boolean(),
      createDate: a.string().required(),
      priority: a.enum(["low", "medium", "high"]),
    })
    .identifier(["tag", "createDate"])
    .authorization((allow) => [allow.owner()]),
});

const sqlSchema = baseSqlSchema.authorization((allow) => allow.authenticated());
const combinedSchema = a.combine([sqlSchema, schema]);
export type Schema = ClientSchema<typeof combinedSchema>;

export const data = defineData({
  schema: combinedSchema,
  authorizationModes: {
    defaultAuthorizationMode: "userPool",
  },
});

フロント部分です。use clientで作成しています。

/Users/cnw/pj/next-amplify-gen2/app/page.tsx

"use client";

import { generateClient } from 'aws-amplify/data';
import { type Schema } from '@/amplify/data/resource';

import "@aws-amplify/ui-react/styles.css";
import { useEffect, useState } from "react";
const client = generateClient<Schema>();

type Team = Schema['Team']['type'];

function App({
  searchParams,
}: {
  searchParams?: { [key: string]: string };
}) {

  const [teamList, setTeamList] = useState<Team[]>([]);
  async function addTeam(data: FormData) {
    const dataStr = new Date().toISOString()
    const response = await client.models.Team.create({
      id: "2",
      name: data.get("title") as string,
      createdAt: dataStr,
      updatedAt: dataStr,
    });
    setTeamList([...teamList, response.data!])
  }

  useEffect(() => {
    (async () => {
      const response: any = await client.models.Team.list();
      setTeamList(response.data!);
    })();
  }, []);

  console.log(teamList)

  return (
    <>
      <h1>Hello, Amplify 👋</h1>
      <form action={addTeam}>
        <input
          type="text"
          name="title"
        />
        <button type="submit">Add Team</button>
      </form>
      <ul>
        {teamList.map((team) => (
          <li key={team!.name}>
            <a>{team!.name}</a>
          </li>
        ))}
      </ul>
    </>
  );
}

export default App;

レコードの表示

MySQLに保存されたデータを表示することができました。

レコードの格納

レコードの格納ですが、DynamoDBがデータソースの時と同様、model.createを使用して値を格納します。 今回のTeamsテーブルは、idはInt型であり、Auto Incrementが付与されたプライマリーキーなので、idを省略したデータ登録が可能か試しましたが、エラーが出てしまいました。明示的に値を指定する必要があります。当然PKなので一意の値を指定するべきですが、今回は1レコードだけ登録を行いたく、2を指定しています。 かつ、idの入力の型としてはstringを求められるので、数字の2ではなく、文字列の2を入れることになりました。将来的に型の統一がされると思われます。

また、createdAtや、updatedAtも省略できなかったため、明示的に投入する必要がありました。 パイプラインリゾルバを使えば自動化できるようにも思えますが・・・

    const response = await client.models.Team.create({
      id: "2",
      name: data.get("title") as string,
      createdAt: dataStr,
      updatedAt: dataStr,
    });

 

MySQLのデータの登録も確認できました。

まとめ

Amplify Data(Gen2)では、AppSyncのデータソースに、DynamoDBだけではなく、既存のMySQLやPostgreSQLも接続できるようになりました。 型指定やデータ登録周りなど、やや荒削りな印象ですが、RDBMSで構築を行いたい場合の良い選択肢になると思いました。