Structuring Infrastructure as Code with a Layered Approach

Structuring Infrastructure as Code with a Layered Approach

One of the problems that I face frequently when writing Infrastructure as a Code is how you structure it. To answer this question, I think there are a few indicators that decide how we want to structure.

  1. One resource will have less change compared to another resource. For example: your VPC resource may not change for the lifespan of the project but the application resource may change every day. How can I structure it in a way that I can make sure the changes in application don't have any relationship with the change in VPC?

  2. Changes in some resources are significant. Such as IAM Roles, Accounts, Billing, etc. They may bring down the whole system. How to make sure it's properly encapsulated? So that the code responsible for the application is guaranteed to have read-only access to the IAM Roles.

Layered Approach

Then, I read this fantastic write-up I've included in the references section of this blog. We can think differently. What if we can distinguish every resource in different layers based on the two principles:

  1. Rate of Change: The resources with a similar rate of change should go in the same layer.

  2. Encapsulation: A layer should encapsulate all the necessary resources which change with it.

If you think of these layers, they resemble somewhat like the OSI layer model in computer networking. Each layer encapsulates the data passing only the required data to the lower layer. The rate of change and the effect of change increases drastically as you go up the layer chain.

Layer Representation

This is the diagram of different layers as presented in the blog by Lee Briggs.

LayerNameExample Resources
0BillingAWS Organization/Azure Account/Google Cloud Account
1PrivilegeAWS Account/Azure Subscription/Google Cloud Project
2NetworkAWS VPC/Google Cloud VPC/Azure Virtual Network
3PermissionsAWS IAM/Azure Managed Identity/Google Cloud Service Account
4DataAWS RDS/Azure Cosmos DB/Google Cloud SQL
5ComputeAWS EC2/Azure Container Instances/GKE
6IngressAWS ELB/Azure Load Balancer/Google Cloud Load Balancer
7ApplicationKubernetes Manifests/Azure Functions/ECS Taks/Google Cloud Functions

We can discard the upper two layers. It's best if we manage those layers manually because the lifespan of those resources is generally equivalent to the lifespan of your whole IaC project. And, we have this sweet problem always existing of "if IaC manages state using a resource which can't be managed by that IaC, what manages that resource? For example s3 bucket". For this kind of resource, it's best to have some scripts that do it.

So, the approach that I'd like to take is the following.

LayerNameExample Resources
0IAMIAM Users, IAM Roles etc.
1NetworkAWS VPC / Security Groups / NAT / IGW / Route Tables / Subnets
2CertificatesAWS Certificate Manager, Route53 Records for Certificates
3DataAWS RDS, AWS DocumentDB, DynamoDB
4AssetsS3 Buckets, ECR Repositories
5IngressAWS ELB, Target Groups, Cloudfront Distributions, DNS Records for Route53
6ComputeECS, EKS, EC2 Instances, Autoscaling Groups, Lambda Functions
7ApplicationECS Services, EKS Resource, Lambda Functions

If you prefer a tree view, this is how I've structured the codebase written using Pulumi SDK in Golang.

./layers/
├── application
│   ├── main.go
│   ├── Pulumi.dev.yaml
│   └── Pulumi.yaml
├── assets
│   ├── main.go
│   ├── Pulumi.dev.yaml
│   └── Pulumi.yaml
├── certs
│   ├── main.go
│   ├── Pulumi.dev.yaml
│   └── Pulumi.yaml
├── compute
│   ├── main.go
│   ├── Pulumi.dev.yaml
│   └── Pulumi.yaml
├── data
│   ├── main.go
│   ├── Pulumi.dev.yaml
│   └── Pulumi.yaml
├── ingress
│   ├── main.go
│   ├── Pulumi.dev.yaml
│   └── Pulumi.yaml
└── network
    ├── main.go
    ├── Pulumi.dev.yaml
    └── Pulumi.yaml

Complete Project Structure

If you look at the complete project structure, it resembles something like this.

.
├── assets
├── base
│   ├── Dockerfile.infras-builder
│   └── Makefile
├── env
│   └── override.mk
├── go.mod
├── go.sum
├── internal
│   └── file
│       └── main.go
├── layers
│   ├── application
│   │   ├── main.go
│   │   ├── Pulumi.dev.yaml
│   │   └── Pulumi.yaml
│   ├── assets
│   │   ├── main.go
│   │   ├── Pulumi.dev.yaml
│   │   └── Pulumi.yaml
│   ├── certs
│   │   ├── main.go
│   │   ├── Pulumi.dev.yaml
│   │   └── Pulumi.yaml
│   ├── compute
│   │   ├── main.go
│   │   ├── Pulumi.dev.yaml
│   │   └── Pulumi.yaml
│   ├── data
│   │   ├── main.go
│   │   ├── Pulumi.dev.yaml
│   │   └── Pulumi.yaml
│   ├── ingress
│   │   ├── main.go
│   │   ├── Pulumi.dev.yaml
│   │   └── Pulumi.yaml
│   └── network
│       ├── main.go
│       ├── Pulumi.dev.yaml
│       └── Pulumi.yaml
├── main.go
├── Makefile
├── pkg
│   ├── acm-certificate
│   │   └── main.go
│   ├── ecr-repository
│   │   └── main.go
│   ├── ecs-cluster
│   │   └── main.go
│   ├── ecs-service-v2
│   │   ├── main.go
│   │   └── types.go
│   ├── label
│   │   └── main.go
│   ├── load-balancer
│   │   ├── main.go
│   │   └── target_group.go
│   ├── mongo-database
│   │   └── main.go
│   ├── postgres-database
│   │   └── main.go
│   ├── s3-cloudfront-website
│   │   └── main.go
│   ├── security-group
│   │   ├── main.go
│   │   └── rule.go
│   └── ssm-parameters
│       └── main.go
├── policies
│   └── ssm-parameter.access.json
└── targets
    ├── docker.mk
    ├── go.mk
    └── pulumi.mk

Explanation

I've heavily used Makefile for this architecture.

targets

Everything that needs to be run from automation is grouped into the CLI it invokes. For example: dockerized commands, go into the docker.mk, go building/ linting/ vendoring commands go int othe go.mk file

pkg

This folder contains the Pulumi components that I've created to abstract all the resources required for one logical component. For example: the ecs-service-v2 creates EBS volumes, ECS task definitions, and ECS service itself.

layers

Every layer of the above diagram resides in a respective folder inside of the layers folder. Each of those packages produces a binary which is then passed with LAYER_NAME to the make command to operate on a single layer at a time.

env

This folder contains the environment variables required for the whole deployment to function. It also sets some handy variables like GOCACHE, GOPATH, and PULUMI_HOME to ensure I have a caching mechanism and a pretty fast build cycle in local environment.

These things will be directly set when running the IaC in CI/CD platform.

Reference

Structuring your Infrastructure as Code | lbr. (leebriggs.co.uk)