A driver to use the aws aurora serverless rds data api in the doctrine database abstraction layer
This is a driver to use the aws rds-data api on projects
that are using dbal for their database access.
It emulates a MySQL connection including transactions.
However: the driver does never establish a persistent connection.
This is experimental. I implemented it in a symfony project
with the doctrine orm and with this driver it worked fine.
I tested the schema tools, migrations and transactions.
At this moment, I wouldn’t recommend using the rds-data api at all
because of it’s 1000 request per second limitation that they secretly added at the end of 2020.
It is listed as “Data API requests per second” in your service quota list but not in the documentation.
First you must store your database credentials as a secret including the username.
Then make sure to correctly configure access to your database to use the secret and the database.
If you create a iam user then there is a “AmazonRDSDataFullAccess” policy that can be used directly.
If you use dbal directly than this is the way:
<?php
$connectionParams = array(
'driverClass' => \Nemo64\DbalRdsData\RdsDataDriver::class,
'host' => 'eu-west-1', // the aws region
'user' => '[aws-api-key]', // optional if it is defined in the environment
'password' => '[aws-api-secret]', // optional if it is defined in the environment
'dbname' => 'mydb',
'driverOptions' => [
'resourceArn' => 'arn:aws:rds:eu-west-1:012345678912:cluster:database-1np9t9hdbf4mk',
'secretArn' => 'arn:aws:secretsmanager:eu-west-1:012345678912:secret:db-password-tSo334',
]
);
$conn = \Doctrine\DBAL\DriverManager::getConnection($connectionParams);
Or use the short url syntax which is easier to change using environment variables:
<?php
$connectionParams = array(
'driverClass' => \Nemo64\DbalRdsData\RdsDataDriver::class,
'url' => '//eu-west-1/mydb'
. '?driverOptions[resourceArn]=arn:aws:rds:eu-west-1:012345678912:cluster:database-1np9t9hdbf4mk'
. '&driverOptions[secretArn]=arn:aws:secretsmanager:eu-west-1:012345678912:secret:db-password-tSo334'
);
$conn = \Doctrine\DBAL\DriverManager::getConnection($connectionParams);
Since I developed in symfony project, I might as well add how to define the driver in symfony:
# doctrine.yaml
doctrine:
dbal:
# the url can override the driver class
# but I can't define this driver in the url which is why i made it the default
# Doctrine\DBAL\DriverManager::parseDatabaseUrlScheme
driver_class: Nemo64\DbalRdsData\RdsDataDriver
url: '%env(resolve:DATABASE_URL)%'
# .env
# you must not include a driver in the database url
# in this case I also didn't include the aws tokens in the url
DATABASE_URL=//eu-west-1/mydb?driverOptions[resourceArn]=arn&driverOptions[secretArn]=arn
# the aws-sdk will pick those up
# they are automatically configured in lambda and ec2 environments
#AWS_ACCESS_KEY_ID=...
#AWS_SECRET_ACCESS_KEY=...
#AWS_SESSION_TOKEN=...
Other than the configuration it should work exactly like any other dbal connection.
resourceArn
(string; required) this is the ARN of the database cluster.secretArn
(string; required) this is the ARN of the secret that stores the database password.timeout
(int; default=45) The timeout setting of guzzle. Set to 0 for indefinite but that might not be useful.continueAfterTimeout
option.$dbalConnection->getWrappedConnection()->getClient()
to get the aws-sdk client.pauseRetries
(int; default=0) The amount of retries when the database is paused.pauseRetryDelay
to ensure a somewhat correct retry time.pauseRetryDelay
(int; default=10) The amount of seconds to wait until another attempt is madeSure, here is a CloudFormation template to configure Aurora Serverless and a Secret,
putting both together and setting an environment variable with the needed information.
This might be serverless flavoured but you should get the hang of it.
# [...]
iamRoleStatements:
# allow using the rds-data api
# https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/data-api.html#data-api.access
- Effect: Allow
Resource: '*' # it isn't supported to limit this
Action:
# https://docs.aws.amazon.com/IAM/latest/UserGuide/list_amazonrdsdataapi.html
- rds-data:ExecuteStatement
- rds-data:BeginTransaction
- rds-data:CommitTransaction
- rds-data:RollbackTransaction
# this rds-data endpoint will use the same identity to get the secret
# so you need to be able to read the password secret
- Effect: Allow
Resource: !Ref DatabaseSecret
Action:
# https://docs.aws.amazon.com/IAM/latest/UserGuide/list_awssecretsmanager.html
- secretsmanager:GetSecretValue
# [...]
environment:
DATABASE_URL: !Join
- ''
- - '//' # rds-data is set to default because custom drivers can't be named in a way that they can be used here
- !Ref AWS::Region # the hostname is the region
- '/mydb'
- '?driverOptions[resourceArn]='
- !Join [':', ['arn:aws:rds', !Ref AWS::Region, !Ref AWS::AccountId, 'cluster', !Ref Database]]
- '&driverOptions[secretArn]='
- !Ref DatabaseSecret
# [...]
# Make sure that there is a default VPC in your account.
# https://console.aws.amazon.com/vpc/home#vpcs:isDefault=true
# If not, click "Actions" > "Create Default VPC"
# While your applications doesn't need it, the database must still be provisioned into a VPC so use the default.
Database:
Type: AWS::RDS::DBCluster # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbcluster.html
Properties:
Engine: aurora
EngineMode: serverless
EnableHttpEndpoint: true # https://stackoverflow.com/a/58759313 (not fully documented in every language yet)
DatabaseName: 'mydb'
MasterUsername: !Join ['', ['{{resolve:secretsmanager:', !Ref DatabaseSecret, ':SecretString:username}}']]
MasterUserPassword: !Join ['', ['{{resolve:secretsmanager:', !Ref DatabaseSecret, ':SecretString:password}}']]
BackupRetentionPeriod: 1 # day
# https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-rds-dbcluster-scalingconfiguration.html
ScalingConfiguration: {MinCapacity: 1, MaxCapactiy: 2, AutoPause: true}
DatabaseSecret:
Type: AWS::SecretsManager::Secret # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-secretsmanager-secret.html
Properties:
GenerateSecretString:
SecretStringTemplate: '{"username": "admin"}'
GenerateStringKey: "password"
PasswordLength: 41 # max length of a mysql password
ExcludeCharacters: '"@/\'
DatabaseSecretAttachment:
Type: AWS::SecretsManager::SecretTargetAttachment # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-secretsmanager-secrettargetattachment.html
Properties:
SecretId: !Ref DatabaseSecret
TargetId: !Ref Database
TargetType: AWS::RDS::DBCluster
I also wrote an article going more into detail on how to setup and share an Aurora Serverless between multiple stacks:
www.marco.zone/shared-aurora-serverless-using-cloudformation
The rds data api only provides error messages, not error codes.
To correctly map those errors to dbal exceptions I use a huge regular expression.
See: Nemo64\DbalRdsData\RdsDataException::EXPRESSION
.
Since most of it is generated using the mysql error documentation,
it should be fine but it might not be 100% reliable.
I asked for this in the amazon developer forum but haven’t gotten a response yet.
If an Aurora Serverless is paused, you’ll get this error message:
Communications link failure
The last packet sent successfully to the server was 0 milliseconds ago.
The driver has not received any packets from the server.
I mapped this error message to error code 6000
(server errors are 1xxx and client errors 2xxx).
It’ll also be converted to dbal’s Doctrine\DBAL\Exception\ConnectionException
which existing application might already handle gracefully.
But the most important thing is that you can catch and handle code 6000
specifically
to better tell your user that the database is paused and will probably be available soon.
While ExecuteStatement does support parameters, it only supports named parameters.
Question mark placeholders need to be emulated (which is funny because
the mysqli
driver only supports question mark placeholders and no named parameters).
This is achieved by replacing ?
with :1
, :2
etc.
The replacement algorithm will avoid replacing ?
within string literals but be aware that you
shouldn’t mix heavy string literals and question mark placeholders, just to be safe.
Every driver has some form of connection aware literal string escape function.
But because the rds-data api is connectionless, it doesn’t have such a method (except for parameters of course).
To emulate the escape feature, a check for none ASCII characters is performed.
If the string is pure ASCII it’ll just pass though addslashes
and gets quotes.
If it has more exotic characters it’ll be base64 encoded to prevent any chance of multibyte sql injection attacks.
This should work transparently in most situations but you should definitely avoid using the literal
function
and instead use parameter binding whenever possible.