Sharing common Serverless Framework code & config
When you build a project with the Serverless Framework it's quite likely that you get started with a single service that contains all of your Lambda functions and other resources. Over time that service grows to effectively become a "serverless monolith" which becomes messy and leads to problems like hitting the CloudFormation resource limit.
In the interest of following the software engineering design principle of separation of concerns, you may want to consider splitting your monolithic service into smaller, encapsulated microservices. The Serverless Framework is designed to support this use case (arguably, it's the architecture it expects by default) but there's a key limitation that will soon become a frustration - it's difficult to effectively share Serverless Framework configuration.
In this article we'll explore a multi-service monorepo setup that overcomes that limitation.
Multiple services in one repository
A "service" in the context of the Serverless Framework is a collection of
Lambda functions and cloud platform resources defined by a serverless.yml
file. There is nothing stopping you having many of those files in one
repository. The directory structure that we use at Orange Jellyfish looks
something like this:
└── services/
├── hello-service/
│ └── src/
│ ├── functions/
│ │ └── hello/
│ │ ├── index.js
│ │ └── index.yml
│ ├── resources/
│ │ └── s3.yml
│ ├── package.json
│ └── serverless.yml
└── goodbye-service/
└── src/
├── functions/
│ └── goodbye/
│ ├── index.js
│ └── index.yml
├── package.json
└── serverless.yml
With this structure each service is effectively a standalone Serverless
Framework application and can be deployed independently. Each service defines
its own dependencies in its own package.json
file. The structure within each
service directory can vary but consistency is important and we follow the
structure and conventions set by our Serverless starter kit in all of
them.
Sharing code between services
It's likely that many of your services will depend on the same code. For
example, you might have a set of models, or some database utilities. If such
code cannot be split out into standalone packages and pulled in via npm we need
a place in the monorepo for them. A good approach is to add a top-level
directory alongside services
:
your-app/
├── services/
│ ├── hello-service/
│ └── goodbye-service/
└── common/
└── s3-utils.js
This structure requires a change to the serverless.yml
files of the services.
By default, the Serverless Framework treats the directory in which
serverless.yml
resides as the boundary of the service and will not allow
files outside of that directory tree to be imported. You can change this with
the projectDir
configuration option. Set it to the top-level of the monorepo,
relative to the service directory itself, to allow imports from anywhere in
that tree:
service: hello-service
projectDir: '../../'
If you're bundling your Lambda function code with Webpack (if you use our
starter kit this will already be the case) you can set up an alias to
tidy up the long messy relative import paths. For the common
directory shown
above that would require the following change to webpack.config.js
:
const path = require('path');
module.exports = {
resolve: {
alias: {
'~common': path.resolve(__dirname, 'common'),
},
},
};
In your Lambda function code you can then replace those long paths with the alias:
import s3 from '~common/s3-utils';
Sharing configuration between services
We're now at a point where we've removed the need to duplicate code between
services but it's very likely there's still a lot of common configuration in
all those serverless.yml
files. A good example is a list of Serverless
Framework plugins used by all of the services, because you probably want to use
serverless-webpack in every case.
We can define common configuration files, and install common dependencies, in the root of our monorepo:
your-app
services/
common/
package.json
serverless-plugins.yml
We can then import those files with the Serverless Framework file
variable
syntax in the individual serverless.yml
files of our services:
# your-app/serverless-plugins.yml
- serverless-webpack
# your-app/services/hello-service/serverless.yml
service: hello-service
projectDir: '../../'
plugins: ${file(../../serverless-plugins.yml)}
This works well enough for most of the properties supported by serverless.yml
but it falls down if you try to share some provider
configuration, which is a
shame because it's almost a certainty that each service is deployed to the same
cloud provider and region. The Serverless Framework does not provide a hook
that runs early enough to set those properties in a way that would allow us to
share them across services.
Using serverless-config-merge
To solve this specific problem at Orange Jellyfish we developed the serverless-merge-config tool, a command-line utility to merge Serverless Framework config files prior to deployment. You can install the utility in the root of your monorepo and then use a syntax inspired by the YAML merge key proposal to define parts of your configuration that should be merged before deployment:
# your-app/serverless-provider-defaults.yml
name: aws
region: eu-west-1
# your-app/services/hello-service/serverless.yml
service: hello-service
projectDir: '../../'
provider:
$<<: ${file(../../serverless-provider-defaults.yml)}
The final step is to write a deployment script to run serverless-merge-config
before running serverless deploy
:
#!/bin/sh
OUT_FILE=serverless-merged.json
cd services/"$npm_config_service" || exit
sls-config-merge -o $OUT_FILE
sls deploy --config $OUT_FILE --stage="$SLS_STAGE"
rm $OUT_FILE
Add this as a script in your top-level package.json
file:
{
"scripts": {
"deploy:env:service": "./scripts/deploy-env-service",
}
}
And finally you can deploy individual services with the command as follows:
npm run deploy:env:service --service=hello-service