No Factor Apps is what we can call classic applications that people have been building since the dawn of time. This is a reference to the “factors” used by the folks at 12factor.net which is a fantastic resource for what approaches to use and how they contribute to improved delivery quality and speed.
If you have not had a chance to get up to speed on The Twelve-Factor App, please do so. It will make the rest of this article have a shot at making sense. For the record, the claim that cloud deployments “obviate” the need for system administrators doesn’t stand up to scrutiny. Others of the claims have some amount of creative license and can come off as dogmatic. These are not instructions, and taken as a mindset, it offers a nice mental launchpad to drive a gap analysis for any brownfield application.
One codebase tracked in revision control, many deploys
A lot of classic applications have this one already under control. At some point someone took the huge app, dependencies and all, and maybe other affiliated apps, and put them all into one huge repository in the source control system du jour. The main take-away here is that the code, as it moves through dev, to test, to production environments doesn’t change.
Depending on the architecture, some of the approaches to achieve this are not compatible with your projects. That is okay, but take some baby steps toward it. Any environmental differences should be scrutinized and registered as risks. Any time one of these can be remediated, take that opportunity.
Step 1. Measure changes in code from one environment to the next to reduce over time
Explicitly declare and isolate dependencies
This one is another that brownfield applications are notoriously rough. They will have dozens of jars or .NET DLLs that need to be in the same place as the code that is deployed. Rather than using
nuget to retrieve the dependencies, the code and compiled artifacts are part of the local development experience.
This enables Software Composition Analysis where an unchanged dependency doesn’t require re-validation for the next round of approvals.
Step 1. Make each dependency a distinct project and use semantic versioning so developers know what they’re referencing.
Store config in the environment
This one ties back to the codebase item where we don’t want different behaviors in different environments. One solution to this is to allow environments to control their operating patterns to some degree. All applications can receive configurations from environment variables.
An alternative to this is to have a configuration service that delivers environment-specific configurations to the apps when they start. This has the added benefit of centralization, though many orchestrators can inject environment variables anyway.
Step 1. Identify all the locations in the app where configurations are used, for each one, identify a location
IV. Backing services
Treat backing services as attached resources
This one can be quite a bit more difficult for a lot of legacy applications. Any that have used database functionality for business logic, for example, require rearchitecting that functionality into the application.
The other place this shows up often is persistent file storage living locally on the production server. Part of containerizing an app is figuring out how to handle this, but it plays into parallel processing, scalability, and congruent configuration management as well.
Step 1. Identify where the database does more than store data or other places that backing services are treated as local resources
V. Build, release, run
Strictly separate build and run stages
Generally making these discrete phrases helps to support configuration management, and many legacy applications do generally follow this approach. Certain languages such as PHP and Ruby are interpreted by servers as they’re running the application. This occasionally leads to reconfiguration at runtime or other odd behaviors.
Step 1: Every time a build is completed and a release artifact is created, it needs to be stamped with a version number (timestamp or semantic version)
Execute the app as one or more stateless processes
The most common problem in processes is when the application server uses memory to manage a session over time as the user interacts with the system. This locks in the single application server approach and prevents scaling and fault tolerance.
Step 1: Move session data out of memory and into a memcache
VII. Port binding
Export services via port binding
Legacy application may rely on injecting a runtime into a web server to execute the processes. This tightly couples a lot of different components together which can lead to deployment complexity and environmental discrepancies.
Step 1: Switch to an application service approach that serves the content and use Apache or Nginx as a router (reverse proxy)
Scale out via the process model
When an application is either a massive JVM or apache child process to run the code, it hides performance information from the orchestrator. Having a simple representation of performance mapped to processes allows for additional processes to be added to absorb that load.
Step 1: Identify if there is an easy way to measure performance and scale, see if that’s easier than switching out the underlying runtime
Maximize robustness with fast startup and graceful shutdown
This is often tied to fundamental application architecture decisions and approaches. It’s a great goal to shoot for, but is not always an option for large applications. For rails applications, they can meet most of the other requirements here, however they can take minutes to start up the first time if they’re large. This delay makes scaling up require working around delays which may lead to overcompensation.
Step 1: Measure start-up time and identify areas for optimization. This may not have been a priority in the past so it could have some easy wins.
X. Dev/prod parity
Keep development, staging, and production as similar as possible
When local resources were tough to come by, developers didn’t always run the whole database on their local devices for developing against. Rails examples typically had SQLite for
development and postgres for
production for example. Since real databases are now easy to run in a container or as a background process on a development device, consistent services and versions should be used across all environments.
Step 1: Get rid of any fake databases or SQLite in lower environments with postgres is in production.
Treat logs as event streams
In the event that some of the above factors have been worked on, visibility and observability become increasingly difficult. Concurrency especially makes reviewing log files difficult since they are on different systems.
That is, until, the central logging mechanisms are set in motion and event streams can be captured at a high enough rate that one system knows how the concurrent jobs individually performed and where any issues happen.
The biggest consideration here is, what if the log can’t be written? Does it “fail usable” or “fail broken”? Each approach has some benefits and drawbacks. Failing to the application continuing to function without logs means you are missing logs. Anything nefarious may be lost or hidden by the lack of logs. If it fails broken, that would mean a network condition issue or log receiver app problem could prevent a much larger system from operating.
Step 1: Create logging infrastructure where newer apps can send log events, then plan which app to bring onto this system and when
XII. Admin processes
Run admin/management tasks as one-off processes
Most legacy applications require development effort to run administrative tasks. For a .NET web application, it could be a special button in an admin interface that runs a one-off test or migration. Other languages solve the problem in different ways. Ruby offers
irb which the rails developers used to create a REPL that loads the rails application and allows execution of commands directly into that context.
This REPL capability allows administrative functions to be removed from the web service part of the project and placed into a task runner such as Rake. These should still be versioned in the application repository so synchronization with object definitions and persistence is upheld.
Step 1: Create a REPL mechanism for your legacy app which loads the objects and connects to backing services and waits for an input
Obviously, the step 1s above are not necessarily applicable, but with any luck the collection of them will help with prioritizing how to keep supporting your legacy application in the future. Here’s the list of all the steps, though the order is flexible.
- Measure changes in code from one environment to the next to reduce over time
- Make each dependency a distinct project and use semantic versioning so developers know what they’re referencing
- Identify all the locations in the app where configurations are used, for each one, identify a location
- Identify where the database does more than store data or other places that backing services are treated as local resources
- Every time a build is completed and a release artifact is created, it needs to be stamped with a version number (timestamp or semantic version)
- Move session data out of memory and into a memcache
- Switch to an application service approach that serves the content and use Apache or Nginx as a router (reverse proxy)
- Identify if there is an easy way to measure performance and scale, see if that’s easier than switching out the underlying runtime
- Measure start-up time and identify areas for optimization. This may not have been a priority in the past so it could have some easy wins
- Get rid of any fake databases or SQLite in lower environments with postgres is in production.
- Create logging infrastructure where newer apps can send log events, then plan which app to bring onto this system and when
- Create a REPL mechanism for your legacy app which loads the objects and connects to backing services and waits for an input