serverless-dynamodb-autoscaling
Advanced tools
Comparing version 0.2.0 to 0.3.0
{ | ||
"name": "serverless-dynamodb-autoscaling", | ||
"description": "Serverless Plugin for Amazon DynamoDB Auto Scaling configuration.", | ||
"version": "0.2.0", | ||
"description": "Serverless Plugin for Amazon DynamoDB Auto Scaling.", | ||
"version": "0.3.0", | ||
"main": "src/plugin.js", | ||
@@ -6,0 +6,0 @@ "scripts": { |
# ⚡️ Serverless Plugin for DynamoDB Auto Scaling | ||
[![npm](https://img.shields.io/npm/v/serverless-dynamodb-autoscaling.svg)](https://www.npmjs.com/package/serverless-dynamodb-autoscaling) | ||
[![CircleCI](https://img.shields.io/circleci/project/github/sbstjn/serverless-dynamodb-autoscaling.svg)](https://circleci.com/gh/sbstjn/serverless-dynamodb-autoscaling) | ||
[![CircleCI](https://img.shields.io/circleci/project/github/sbstjn/serverless-dynamodb-autoscaling/master.svg)](https://circleci.com/gh/sbstjn/serverless-dynamodb-autoscaling) | ||
[![license](https://img.shields.io/github/license/sbstjn/serverless-dynamodb-autoscaling.svg)](https://github.com/sbstjn/serverless-dynamodb-autoscaling/blob/master/LICENSE.md) | ||
[![Coveralls](https://img.shields.io/coveralls/sbstjn/serverless-dynamodb-autoscaling.svg)](https://coveralls.io/github/sbstjn/serverless-dynamodb-autoscaling) | ||
With this plugin for [serverless](https://serverless.com) you can set DynamoDB Auto Scaling configuratin in your `serverless.yml` file. The plugin supports multiple tables and separate configuration sets for `read` and `write` capacities using AWS [native DynamoDB Auto Scaling](https://aws.amazon.com/blogs/aws/new-auto-scaling-for-amazon-dynamodb/). | ||
With this plugin for [serverless](https://serverless.com), you can enable DynamoDB Auto Scaling for tables and **Global Secondary Indexes** easily in your `serverless.yml` configuration file. The plugin supports multiple tables and indexes, as well as separate configuration for `read` and `write` capacities using Amazon's [native DynamoDB Auto Scaling](https://aws.amazon.com/blogs/aws/new-auto-scaling-for-amazon-dynamodb/). | ||
@@ -19,7 +19,5 @@ ## Usage | ||
# Via npm | ||
$ npm install serverless-dynamodb-autoscaling --save-dev | ||
$ npm install serverless-dynamodb-autoscaling | ||
``` | ||
## Configuration | ||
Add the plugin to your `serverless.yml`: | ||
@@ -32,4 +30,6 @@ | ||
Configure DynamoDB Auto Scaling in `serverless.yml` with references to your DynamoDB CloudFormation resources for the `table` property: | ||
## Configuration | ||
Configure DynamoDB Auto Scaling in `serverless.yml` with references to your DynamoDB CloudFormation resources for the `table` property. The `index` configuration is optional to apply Auto Scaling *Global Secondary Index*. | ||
```yaml | ||
@@ -39,2 +39,4 @@ custom: | ||
- table: CustomTable # DynamoDB Resource | ||
index: # List or single index name | ||
- custom-index-name | ||
read: | ||
@@ -48,18 +50,32 @@ minimum: 5 # Minimum read capacity | ||
usage: 0.5 # Targeted usage percentage | ||
- table: AnotherTable | ||
read: | ||
minimum: 5 | ||
maximum: 1000 | ||
# usage: 0.75 is the default | ||
``` | ||
That's it! With the next deployment (`sls deploy`) serverless will add a CloudFormation configuration to enable Auto Scaling for the DynamoDB resources `CustomTable` and `AnotherTable`. | ||
That's it! With the next deployment, [serverless](https://serverless.com) will add a CloudFormation configuration to enable Auto Scaling for the DynamoDB resources `CustomTable` and its *Global Secondary Index* called `custom-index-name`. | ||
You must of course provide at least a configuration for `read` or `write` to enable Auto Scaling. The value for `usage` has a default of 75 percent. | ||
You must provide at least a configuration for `read` or `write` to enable Auto Scaling! | ||
**Notice:** *With the relese of `v0.2.x` the plugin introduced a breaking change. Starting with `v0.2.0` you need to provide the CloudFormation reference for the `table` property. In `v0.1.x` the plugin used a `name` property with the DynamoDB table name.* | ||
### Defaults | ||
```yaml | ||
maximum: 200 | ||
minimum: 5 | ||
usage: 0.75 | ||
``` | ||
### Index | ||
If you only want to enable Auto Scaling for the index, use `indexOnly: true` to skip Auto Scaling for the general DynamoDB table. | ||
### API Throtteling | ||
CloudWatch has very strict [API rate limits](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_limits.html)! If you plan to configure Auto Scaling for multiple DynamoDB tables or *Global Secondary Indexes*, request an increase of the rate limits first! Otherwise, you might run into an error like this: | ||
``` | ||
An error occurred while provisioning your stack: XYZ - Unable to create alarms for scaling policy XYZ due to reason: | ||
Rate exceeded (Service: AmazonCloudWatch; Status Code: 400; Error Code: Throttling; Request ID: XYZ). | ||
``` | ||
## DynamoDB | ||
The configuration above works fine for a default DynamoDB table configuration. | ||
The example serverless configuration above works fine for a DynamoDB table CloudFormation resource like this: | ||
@@ -82,2 +98,12 @@ ```yaml | ||
WriteCapacityUnits: 5 | ||
GlobalSecondaryIndexes: | ||
- IndexName: custom-index-name | ||
KeySchema: | ||
- AttributeName: key | ||
KeyType: HASH | ||
Projection: | ||
ProjectionType: ALL | ||
ProvisionedThroughput: | ||
ReadCapacityUnits: 5 | ||
WriteCapacityUnits: 5 | ||
``` | ||
@@ -98,2 +124,2 @@ | ||
To make sure you have a pleasant experience, please read the [code of conduct](CODE_OF_CONDUCT.md). It outlines core values and believes and will make working together a happier experience. | ||
To make sure you have a pleasant experience, please read the [code of conduct](CODE_OF_CONDUCT.md). It outlines core values and beliefs and will make working together a happier experience. |
const util = require('util') | ||
const clean = (input) => input.replace(/[^a-z0-9+]+/gi, '') | ||
function clean (input) { | ||
return input.replace(/[^a-z0-9+]+/gi, '') | ||
} | ||
const policyScale = (table, read) => clean(util.format('Table%sScalingPolicy-%s', read ? 'Read' : 'Write', table)) | ||
const policyRole = (table) => clean(util.format('DynamoDBAutoscalePolicy-%s', table)) | ||
const dimension = (read) => util.format('dynamodb:table:%sCapacityUnits', read ? 'Read' : 'Write') | ||
const target = (table, read) => clean(util.format('AutoScalingTarget%s-%s', read ? 'Read' : 'Write', table)) | ||
const metric = (read) => clean(util.format('DynamoDB%sCapacityUtilization', read ? 'Read' : 'Write')) | ||
const role = (table) => clean(util.format('DynamoDBAutoscaleRole-%s', table)) | ||
function policyScale (table, read, index, stage) { | ||
return clean( | ||
util.format( | ||
'Table%sScalingPolicy-%s%s%s', | ||
read ? 'Read' : 'Write', | ||
table, | ||
index || '', | ||
stage || '' | ||
) | ||
) | ||
} | ||
module.exports = { dimension, metric, policyScale, policyRole, role, target, clean } | ||
function policyRole (table, index, stage) { | ||
return clean( | ||
util.format( | ||
'DynamoDBAutoscalePolicy-%s%s%s', | ||
table, | ||
index || '', | ||
stage || '' | ||
) | ||
) | ||
} | ||
function dimension (read, index) { | ||
return util.format( | ||
'dynamodb:%s:%sCapacityUnits', | ||
index ? 'index' : 'table', | ||
read ? 'Read' : 'Write' | ||
) | ||
} | ||
function target (table, read, index, stage) { | ||
return clean( | ||
util.format( | ||
'AutoScalingTarget%s-%s%s%s', | ||
read ? 'Read' : 'Write', | ||
table, | ||
index || '', | ||
stage || '' | ||
) | ||
) | ||
} | ||
function metric (read) { | ||
return clean( | ||
util.format( | ||
'DynamoDB%sCapacityUtilization', | ||
read ? 'Read' : 'Write' | ||
) | ||
) | ||
} | ||
function role (table, index, stage) { | ||
return clean( | ||
util.format( | ||
'DynamoDBAutoscaleRole-%s%s%s', | ||
table, | ||
index || '', | ||
stage || '' | ||
) | ||
) | ||
} | ||
module.exports = { | ||
clean, | ||
dimension, | ||
metric, | ||
policyRole, | ||
policyScale, | ||
role, | ||
target | ||
} |
const names = require('./names') | ||
class Policy { | ||
constructor (table, value, read, scaleIn, scaleOut) { | ||
constructor (table, value, read, scaleIn, scaleOut, index, stage) { | ||
this.table = table | ||
this.index = index | ||
this.stage = stage | ||
this.value = parseFloat(value, 10) * 100 | ||
@@ -10,16 +12,20 @@ this.read = !!read | ||
this.scaleOut = parseInt(scaleOut, 10) | ||
this.dependencies = [] | ||
} | ||
setDependencies (list) { | ||
this.dependencies = list | ||
return this | ||
} | ||
toJSON () { | ||
return { | ||
[names.policyScale(this.table, this.read)]: { | ||
[names.policyScale(this.table, this.read, this.index, this.stage)]: { | ||
'Type': 'AWS::ApplicationAutoScaling::ScalingPolicy', | ||
'DependsOn': [ | ||
this.table, | ||
names.target(this.table, this.read) | ||
], | ||
'DependsOn': [ this.table, names.target(this.table, this.read, this.index, this.stage) ].concat(this.dependencies), | ||
'Properties': { | ||
'PolicyName': names.policyScale(this.table, this.read), | ||
'PolicyName': names.policyScale(this.table, this.read, this.index, this.stage), | ||
'PolicyType': 'TargetTrackingScaling', | ||
'ScalingTargetId': { 'Ref': names.target(this.table, this.read) }, | ||
'ScalingTargetId': { 'Ref': names.target(this.table, this.read, this.index, this.stage) }, | ||
'TargetTrackingScalingPolicyConfiguration': { | ||
@@ -26,0 +32,0 @@ 'PredefinedMetricSpecification': { |
const names = require('./names') | ||
class Role { | ||
constructor (table) { | ||
constructor (table, index, stage) { | ||
this.table = table | ||
this.index = index | ||
this.stage = stage | ||
this.dependencies = [] | ||
} | ||
setDependencies (list) { | ||
this.dependencies = list | ||
return this | ||
} | ||
toJSON () { | ||
return { | ||
[names.role(this.table)]: { | ||
[names.role(this.table, this.index, this.stage)]: { | ||
'Type': 'AWS::IAM::Role', | ||
'DependsOn': [ | ||
this.table | ||
], | ||
'DependsOn': [ this.table ].concat(this.dependencies), | ||
'Properties': { | ||
'RoleName': names.role(this.table), | ||
'RoleName': names.role(this.table, this.index, this.stage), | ||
'AssumeRolePolicyDocument': { | ||
@@ -31,3 +38,3 @@ 'Version': '2012-10-17', | ||
{ | ||
'PolicyName': names.policyRole(this.table), | ||
'PolicyName': names.policyRole(this.table, this.index, this.stage), | ||
'PolicyDocument': { | ||
@@ -34,0 +41,0 @@ 'Version': '2012-10-17', |
const names = require('./names') | ||
class Target { | ||
constructor (table, min, max, read) { | ||
constructor (table, min, max, read, index, stage) { | ||
this.table = table | ||
this.index = index | ||
this.stage = stage | ||
this.min = parseInt(min, 10) | ||
this.max = parseInt(max, 10) | ||
this.read = !!read | ||
this.dependencies = [] | ||
} | ||
setDependencies (list) { | ||
this.dependencies = list | ||
return this | ||
} | ||
toJSON () { | ||
const resource = [ 'table/', { 'Ref': this.table } ] | ||
if (this.index) { | ||
resource.push('/index/', this.index) | ||
} | ||
return { | ||
[names.target(this.table, this.read)]: { | ||
[names.target(this.table, this.read, this.index, this.stage)]: { | ||
'Type': 'AWS::ApplicationAutoScaling::ScalableTarget', | ||
'DependsOn': [ | ||
this.table, | ||
names.role(this.table) | ||
], | ||
'DependsOn': [ this.table, names.role(this.table, this.index, this.stage) ].concat(this.dependencies), | ||
'Properties': { | ||
'MaxCapacity': this.max, | ||
'MinCapacity': this.min, | ||
'ResourceId': { 'Fn::Join': [ '', [ 'table/', { 'Ref': this.table } ] ] }, | ||
'RoleARN': { 'Fn::GetAtt': [ names.role(this.table), 'Arn' ] }, | ||
'ScalableDimension': names.dimension(this.read), | ||
'ResourceId': { 'Fn::Join': [ '', resource ] }, | ||
'RoleARN': { 'Fn::GetAtt': [ names.role(this.table, this.index, this.stage), 'Arn' ] }, | ||
'ScalableDimension': names.dimension(this.read, this.index), | ||
'ServiceNamespace': 'dynamodb' | ||
@@ -26,0 +38,0 @@ } |
@@ -1,3 +0,1 @@ | ||
'use strict' | ||
const _ = require('lodash') | ||
@@ -11,4 +9,15 @@ const util = require('util') | ||
const text = { | ||
INVALID_CONFIGURATION: 'Invalid serverless configuration', | ||
ONLY_AWS_SUPPORT: 'Only supported for AWS provicer', | ||
NO_AUTOSCALING_CONFIG: 'Not Auto Scaling configuration found' | ||
} | ||
class Plugin { | ||
constructor (serverless, options) { | ||
/** | ||
* Constructur | ||
* | ||
* @param {object} serverless | ||
*/ | ||
constructor (serverless) { | ||
this.serverless = serverless | ||
@@ -20,15 +29,26 @@ this.hooks = { | ||
/** | ||
* Validate the request and check if configuration is available | ||
*/ | ||
validate () { | ||
assert(this.serverless, 'Invalid serverless configuration') | ||
assert(this.serverless.service, 'Invalid serverless configuration') | ||
assert(this.serverless.service.provider, 'Invalid serverless configuration') | ||
assert(this.serverless.service.provider.name, 'Invalid serverless configuration') | ||
assert(this.serverless.service.provider.name === 'aws', 'Only supported for AWS provider') | ||
assert(this.serverless, text.INVALID_CONFIGURATION) | ||
assert(this.serverless.service, text.INVALID_CONFIGURATION) | ||
assert(this.serverless.service.provider, text.INVALID_CONFIGURATION) | ||
assert(this.serverless.service.provider.name, text.INVALID_CONFIGURATION) | ||
assert(this.serverless.service.provider.name === 'aws', text.ONLY_AWS_SUPPORT) | ||
assert(this.serverless.service.custom.capacities, 'No Auto Scaling configuration found') | ||
assert(this.serverless.service.provider.stage, text.INVALID_CONFIGURATION) | ||
assert(this.serverless.service.custom, text.NO_AUTOSCALING_CONFIG) | ||
assert(this.serverless.service.custom.capacities, text.NO_AUTOSCALING_CONFIG) | ||
} | ||
/** | ||
* Parse configuration and fill up with default values when needed | ||
* | ||
* @param {object} config | ||
* @return {object} | ||
*/ | ||
defaults (config) { | ||
return { | ||
table: config.table, | ||
read: { | ||
@@ -47,51 +67,104 @@ usage: config.read && config.read.usage ? config.read.usage : 0.75, | ||
process () { | ||
this.serverless.service.custom.capacities.map( | ||
config => { | ||
// Skip set if no read or write scaling configuration is available | ||
if (!config.read && !config.write) { | ||
return this.serverless.cli.log( | ||
util.format(' - Skipping configuration for resource "%s"', config.table) | ||
) | ||
} | ||
/** | ||
* Create CloudFormation resources for table (and optional index) | ||
* | ||
* @param {string} table | ||
* @param {string} index | ||
* @param {object} config | ||
*/ | ||
resources (table, index, config) { | ||
const resources = [] | ||
const stage = this.serverless.service.provider.stage | ||
const data = this.defaults(config) | ||
// Fill configuration with defaults for missing values | ||
const table = this.defaults(config) | ||
const resources = [] | ||
// Start processing configuration | ||
this.serverless.cli.log( | ||
util.format(' - Building configuration for resource "table/%s%s"', table, (index ? ('/index/' + index) : '')) | ||
) | ||
// Start processing configuration | ||
this.serverless.cli.log( | ||
util.format(' - Adding configuration for resource "%s"', table.table) | ||
// Add role to manage Auto Scaling policies | ||
resources.push(new Role(table, index, stage)) | ||
// Only add Auto Scaling for read capacity if configuration set is available | ||
if (config.read) { | ||
resources.push( | ||
// ScaleIn/ScaleOut values are fix to 60% usage | ||
new Policy(table, data.read.usage, true, 60, 60, index, stage), | ||
new Target(table, data.read.minimum, data.read.maximum, true, index, stage) | ||
) | ||
} | ||
// Only add Auto Scaling for write capacity if configuration set is available | ||
if (config.write) { | ||
resources.push( | ||
// ScaleIn/ScaleOut values are fix to 60% usage | ||
new Policy(table, data.write.usage, false, 60, 60, index, stage), | ||
new Target(table, data.write.minimum, data.write.maximum, false, index, stage) | ||
) | ||
} | ||
return resources | ||
} | ||
/** | ||
* Generate CloudFormation resources for DynamoDB table and indexes | ||
* | ||
* @param {string} table | ||
* @param {object} config | ||
*/ | ||
generate (table, config) { | ||
let resources = [] | ||
let lastRessources = [] | ||
const indexes = this.normalize(config.index) | ||
if (!config.indexOnly) { | ||
indexes.unshift(null) // Horrible solution | ||
} | ||
indexes.forEach( | ||
index => { | ||
const current = this.resources(table, index, config).map( | ||
resource => resource.setDependencies(lastRessources).toJSON() | ||
) | ||
// Add role to manage Auto Scaling policies | ||
resources.push(new Role(table.table)) | ||
resources = resources.concat(current) | ||
lastRessources = current.map(item => Object.keys(item).pop()) | ||
} | ||
) | ||
// Only add Auto Scaling for read capacity if configuration set is available | ||
if (config.read) { | ||
resources.push( | ||
new Policy(table.table, table.read.usage, true, 60, 60), | ||
new Target(table.table, table.read.minimum, table.read.maximum, true) | ||
) | ||
} | ||
return resources | ||
} | ||
// Only add Auto Scaling for write capacity if configuration set is available | ||
if (config.write) { | ||
resources.push( | ||
new Policy(table.table, table.write.usage, false, 60, 60), | ||
new Target(table.table, table.write.minimum, table.write.maximum, false) | ||
) | ||
} | ||
/** | ||
* Check if parameter is defined and return as array if only a string is provided | ||
* | ||
* @param {array|string} data | ||
* @return {array} | ||
*/ | ||
normalize (data) { | ||
if (data && data.constructor !== Array) { | ||
return [ data.toString() ] | ||
} | ||
// Inject templates in serverless CloudFormation template | ||
resources.forEach( | ||
return (data || []).slice(0) | ||
} | ||
/** | ||
* Process the provided configuration | ||
* | ||
* @return {Promise} | ||
*/ | ||
process () { | ||
this.serverless.service.custom.capacities.filter( | ||
config => !!config.read || !!config.write | ||
).forEach( | ||
config => this.normalize(config.table).forEach( | ||
table => this.generate(table, config).forEach( | ||
resource => _.merge( | ||
this.serverless.service.provider.compiledCloudFormationTemplate.Resources, | ||
resource.toJSON() | ||
resource | ||
) | ||
) | ||
} | ||
) | ||
) | ||
return Promise.resolve() | ||
} | ||
@@ -101,17 +174,11 @@ | ||
return Promise.resolve().then( | ||
this.validate.bind(this) | ||
() => this.validate() | ||
).then( | ||
() => this.serverless.cli.log( | ||
util.format('Configure DynamoDB Auto Scaling …') | ||
) | ||
() => this.serverless.cli.log(util.format('Configure DynamoDB Auto Scaling …')) | ||
).then( | ||
this.process.bind(this) | ||
() => this.process() | ||
).then( | ||
() => this.serverless.cli.log( | ||
util.format('Added DynamoDB Auto Scaling to CloudFormation!') | ||
) | ||
() => this.serverless.cli.log(util.format('Added DynamoDB Auto Scaling to CloudFormation!')) | ||
).catch( | ||
err => this.serverless.cli.log( | ||
util.format('Skipping DynamoDB Auto Scaling: %s!', err.message) | ||
) | ||
er => this.serverless.cli.log(util.format('Skipping DynamoDB Auto Scaling: %s!', er.message)) | ||
) | ||
@@ -118,0 +185,0 @@ } |
@@ -18,2 +18,6 @@ const names = require('../../src/aws/names') | ||
it('creates name for Role with index and stage', () => { | ||
expect(names.role('test-with-invalid-characters', 'index', 'stage')).toBe('DynamoDBAutoscaleRoletestwithinvalidcharactersindexstage') | ||
}) | ||
it('creates name for Metric (read)', () => { | ||
@@ -20,0 +24,0 @@ expect(names.metric(true)).toBe('DynamoDBReadCapacityUtilization') |
@@ -5,25 +5,32 @@ 'use strict' | ||
it('creates CloudFormation configuration', () => { | ||
let config = { | ||
service: { | ||
custom: { | ||
'dynamodb-autoscaling': [ | ||
{ name: 'table-name' } | ||
] | ||
}, | ||
provider: { | ||
region: 'test-region', | ||
compiledCloudFormationTemplate: { | ||
Resources: {} | ||
} | ||
} | ||
describe('Normalize', () => { | ||
it('converts everything to an array', () => { | ||
const test = new Plugin() | ||
expect(test.normalize('test')).toEqual(['test']) | ||
expect(test.normalize(['test'])).toEqual(['test']) | ||
expect(test.normalize(['test', 'foo'])).toEqual(['test', 'foo']) | ||
expect(test.normalize([])).toEqual([]) | ||
expect(test.normalize()).toEqual([]) | ||
}) | ||
}) | ||
describe('Defaults', () => { | ||
it('creates object with defaults', () => { | ||
let config = { | ||
read: { maximum: 100, usage: 1 }, | ||
write: { minimum: 20 } | ||
} | ||
} | ||
const test = new Plugin(config) | ||
test.beforeDeployResources() | ||
const test = new Plugin() | ||
const data = test.defaults(config) | ||
// const data = config.service.provider.compiledCloudFormationTemplate.Resources | ||
expect(data.read.minimum).toBe(5) | ||
expect(data.read.maximum).toBe(100) | ||
expect(data.read.usage).toBe(1) | ||
expect(true).toBe(true) | ||
expect(data.write.minimum).toBe(20) | ||
expect(data.write.maximum).toBe(200) | ||
expect(data.write.usage).toBe(0.75) | ||
}) | ||
}) |
124872
522
120