Summary: Make your toy web apps more robust and manageable by following the Twelve-Factor App methodology.
This is the second part in a series of posts detailing the 12 Factor App. In the last post we looked at:
- Your codebase and made it deployable across different environments
- Dependencies and why they’re important in reproducibility
- Configuration files and how to make them useful across different environments
- Backing services, what they are and how decoupling gives your app more flexibility
If you need a refresher or aren’t familiar with the above already, take a look at the last post. Many of the earlier concepts are used as building blocks for the concepts in this post.
Strictly separate build and run stages - https://12factor.net/build-release-run
Many of the previous sections finally start coming together here. This may be one of the more time consuming sections or steps, but also the one that will improve your development and release cycles tremendously. These steps are also what people usually refer to as Continuous Integration/Continuous Deployment, or CI/CD. Let’s step through from the start.
In the build step, the goal is to get all code and assets into a usable state at the run step. The end product can differ depending on whether you’re building for development or for production. In a development environment, for example, we could skip optimizations such as compressing files and compiling frontend assets (HTML/CSS/JS) into bundles that would normally live in a CDN.
In general, the build step can look like the following:
- Pin your release on a specific commit or tag, if using git (Factor I). This keeps everything at a known starting point.
- Start compiling your code. This depends on the codebase, but in general, this would be:
- Gather all the app’s dependencies (Factor II) via
PyPI, git clones, etc.
- Compile code where needed. This could mean using a bundler like
webpack, or compiling binaries and libraries like Java .jar files.
- Gather all the app’s dependencies (Factor II) via
- Log all build processes running.
- Build process should have a mechanism to keep track of attempted builds -- whether or not they were successful.
- If any of the above fails to complete, stop the entire build-release-run process and send notifications or some sort of message to the developer about the failure.
In the release step, the main product of the release step is to have your compiled and built code ready to run, publish, or use for the end-user in some way.
The release process can look like the following:
- Apply config specific to this build’s environment (Factor III).
- For example, a development environment can point to a database running on a cheap server instance, while a production version would point to a much more robust version on Amazon RDS with backups enabled.
- Run your tests! This would include unit, integration, and end-to-end tests. These tests would run against the compiled build and with the proper config applied. If any tests fail, we could immediately cancel any further actions and send out notifications/messages about the failure.
- Any other preparations you need before getting to the run phase.
- If using Docker, this is when you would create an image of all the parts of your application that you want deployed. This image is a snapshot of the application code where you know all tests have passed and the build process ran successfully.
At this point, all the previous steps should have given us high confidence that your application will work as expected. We’ve compiled and prepared all code and assets, ensuring that the application is set up correctly and does not have any build-time problems. We’ve tested the application itself with run-time tests, and maybe even end-to-end tests. Now all we have to do is just deploy the thing.
The Run step should be fairly straightforward. We’ll be assuming you’re using Docker, or some other containerization tool:
- Upload your Docker image(s) from the release step to your code’s final running destination.
- Run your application.
- Notify/message any other external services that your application is up and running.
- If scaling to multiple instances, there are infrastructure considerations that need to be made. You would need a load balancer like nginx or HAProxy. Some cloud services also handle this automatically like Amazon ECS, so double check with your provider docs as well. At the higher end of complexity, much of this can also be handled with Kubernetes, but that in itself would require more than a few blog posts to introduce.
The build-release-run workflow is very well-supported on platforms like GitHub and GitLab with GitHub Actions and GitLab CI/CD, respectively. You can also customize your own build process with tools like Jenkins and CircleCI. When using those services, the build and release steps are covered, but the run step will require a container hosting service such as Amazon ECS. There are also services that encompass all the steps such as Heroku (which developed this 12 Factor methodology).
At this point, we actually have a running app. We could stop here, but we have millions of users to take care of and the application needs to scale easily!
Execute the app as one or more stateless processes - https://12factor.net/processes
This section is mainly how to think about your application processes in the context of scaling. At its simplest case, we can think of a single-purpose application that resizes images. This application would get image data, resize it, and finally upload it to a cloud storage service like Amazon S3.
For this application, we have no shared state from other processes. We can even imagine 10s or 100s of instances of these running independently in parallel. They all don’t require any additional context data nor do they need to share data. They only need an input (image data) and they return an output (successful upload to S3).
What’s the key aspect to processes?
Processes are stateless
That is, they don't expect data in memory or on disk will exist permanently. The easiest way to think about this is to ask: If the app were to be completely torn down and redeployed from a Docker image, would it be a catastrophic failure?
In a twelve-factor app, all the states that we need to persist (database, cloud storage, session data, etc.) are saved in backing services (Factor IV) that our app uses. These backing services are defined in our app’s config (Factor III) which has been applied in the release step of our build-release-run process (Factor V). The app itself should be highly recoverable if it goes down, and at the opposite end, the app should easily scale up to more instances.
This architecture will play a key role in a few of the next sections.
This post covered sections V-VI of the Twelve-Factor App methodology. Hopefully, this has shown the interconnectedness of all the factors and how smaller efforts in your application architecture can build up to something that can scale and have more resilience. Here at Anvil we follow many of these concepts in our development process and we believe that sharing our experiences helps everyone create awesome products. If you’re developing something cool with PDFs or paperwork automation, let us know at firstname.lastname@example.org. We’d love to hear from you.
Follow the rest of the completed series below: