- Mar 24, 2024
- 7 Min Read
- AWS
Using Configuration Files in AWS Fargate
Utilizing Configuration Files in AWS Fargate through Init Containers, AWS Secret Manager, and Terraform
Configuration management can be challenging. During development, we often use environment variables to configure our app, while in production, we rely on using a configuration file that gets loaded when the app starts.
When you're deploying your app in a containerized manner, you're often faced with the question:
How do I get the configuration file into the container?
One option is to write the config file during the build step of the container image and therefore bake it into the image. While this works, the downside is that you have to rebuild your image whenever the configuration changes.
A more flexible approach is to have the possibility of mounting the file into the container before it starts. With that in place, you "just" have to restart your container, and it will pick up the updated configuration.
If you're familiar with Kubernetes, you might be aware of the Secret object that can be used to store configuration entries and mount these entries as a volume into the Pod in which your app is running.
But what options are in place if you're using AWS Fargate, for example? In this article, I will demonstrate how to mount a configuration file into an ECS task, although there is no out-of-the-box solution from AWS.
The Plan
In our example, we will deploy the Cosmo Router on AWS Fargate. These are our main objectives:
- We will use the AWS Secret Manager to store the configuration entries.
- We will create an init container definition that reads the entries from our secret, renders a
config.yaml
and places that file on a mountable volume. - In our main container, we will mount that volume and tell the Cosmo Router where to find the configuration file.
Because I'm a proponent of Infrastructure as Code, I will demonstrate all that using Terraform.
Creating the Secret
To keep things simple, we will have a super minimalistic configuration file, which looks like this:
version: "1"
listen_addr: 0.0.0.0:3002
graph: token: ${GRAPH_API_TOKEN}
As you can see, the GRAPH_API_TOKEN
will be the value we store in our secret.
So let's create the secret first. Head over to the AWS Secret Manager and click on Store a new secret.
Then choose the secret type and add the values:
Then name your secret and add an optional description. The next steps are not crucial for our example and needs to be adjusted accordingly to fit your project needs.
When done, please copy the Secret ARN as we need it in the next steps. Now where we have our secret in place, let's close the AWS Console and jump right into the code.
The Secret Sauce: An Init Container
The crucial part of writing the configuration file is our Init Container. The Init Container runs before our main container starts. This is the perfect hook for reading the entry from our secret, rendering the config file, and writing it to a volume, which can then be mounted by our main container:
resource "aws_ecs_task_definition" "cosmo_router" { # ...
volume { name = "cosmo-config-volume" }
container_definitions = jsonencode([ # The Init Container { name = "cosmo-config" image = "openformation/envsubst:0.21"
# When init container fails, all other containers fail as well essential = true
environment = [ { name = "COSMO_CONFIG" value = base64encode(file("/path/to/your/cosmo-router-template.yaml")) }, ]
secrets = [ { name = "GRAPH_API_TOKEN" valueFrom = "<the-arn-to-your-secret>:GRAPH_API_TOKEN::" }, ]
mountPoints = [ { containerPath = "/etc/cosmo" sourceVolume = "cosmo-config-volume" } ]
command = [ "bash", "-c", "echo $COSMO_CONFIG | base64 -d - | tee /tmp/config.yaml.template > /dev/null && envsubst < /tmp/config.yaml.template > /etc/cosmo/config.yaml" ] } ])
# ...}
There is a lot to digest here, so let's dive right into the container definition of this init container.
image
The first thing to note is that we use the container image openformation/envsubst which is an Ubuntu 22.04 with pre-installed envsubst. This tool is required to substitute our variables in the config file seen above.
environment
When applying the infrastructure via Terraform, we read the config file template (see above) and place it in the container environment as COSMO_CONFIG
. Please note that this config template needs to be local on the machine where you execute the terraform apply
.
We base64 encode the contents of the template in order to avoid dealing with line breaks and stuff.
secrets
We mount the values from our secret we created in the AWS Secret Manager earlier. This will live as GRAPH_API_TOKEN
in the environment of the init container as well.
mountPoints
Here, we tell ECS that the volume (defined above the container definition) should be mounted at /etc/cosmo
.
command
In this section, everything falls into place. We execute bash, base64 decode the contents of our config template, write the contents of that template to a temp path, and then use envsubst
to inject the environment variables into the file. The final (rendered) config file will then live on our volume at /etc/cosmo/config.yaml
.
Using the Configuration File in Cosmo Router
The heavy lifting is done. 🎉 Now the configuration file lives on the shared volume. We only have to define the container definition of the Cosmo Router, mount the volume and tell the router where to find the config file:
resource "aws_ecs_task_definition" "cosmo_router" { # ...
volume { name = "cosmo-config-volume" }
container_definitions = jsonencode([ # The Init Container # ...
# The Cosmo Router Container { name = "cosmo-router", image = "ghcr.io/wundergraph/cosmo/router:latest", essential = true
portMappings = [ { name = "http" containerPort = 3002 hostPort = 3002 protocol = "tcp" }, ]
dependsOn = [ { condition = "COMPLETE" containerName = "cosmo-config" } ]
mountPoints = [ { containerPath = "/etc/cosmo" sourceVolume = "cosmo-config-volume" } ]
environment = [ { name = "CONFIG_PATH" value = "/etc/cosmo/config.yaml" }, ] } ])
# ...}
That was pretty straightforward. The parts to highlight:
dependsOn
We tell Fargate that it should start this container only when the cosmo-config
init container completed (successfully).
environment
This is app-specific, but in the realm of the Cosmo Router, we tell it where to find its configuration file.
Bringing All Puzzle Pieces Together
All two container definitions are in place, so let's finalize our complete task definition:
resource "aws_ecs_task_definition" "cosmo_router" { family = "cosmo-router" requires_compatibilities = ["FARGATE"] network_mode = "awsvpc"
volume { name = "cosmo-config-volume" }
container_definitions = jsonencode([ # # The Init Container # { name = "cosmo-config" image = "openformation/envsubst:0.21"
# When init container fails, all other containers will fail as well essential = true
environment = [ { name = "COSMO_CONFIG" value = base64encode(file("/path/to/your/cosmo-router-template.yaml")) }, ]
secrets = [ { name = "GRAPH_API_TOKEN" valueFrom = "<the-arn-to-your-secret>:GRAPH_API_TOKEN::" }, ]
mountPoints = [ { containerPath = "/etc/cosmo" sourceVolume = "cosmo-config-volume" } ]
command = [ "bash", "-c", "echo $COSMO_CONFIG | base64 -d - | tee /tmp/config.yaml.template > /dev/null && envsubst < /tmp/config.yaml.template > /etc/cosmo/config.yaml" ] },
# # The Cosmo Router Container # { name = "cosmo-router", image = "ghcr.io/wundergraph/cosmo/router:latest", essential = true
portMappings = [ { name = "http" containerPort = 3002 hostPort = 3002 protocol = "tcp" }, ]
dependsOn = [ { condition = "COMPLETE" containerName = "cosmo-config" } ]
mountPoints = [ { containerPath = "/etc/cosmo" sourceVolume = "cosmo-config-volume" } ]
environment = [ { name = "CONFIG_PATH" value = "/etc/cosmo/config.yaml" }, ] } ])}
Conclusion
Managing configuration files in a containerized environment can be a tough task. However, by leveraging AWS Secret Manager and an init container, we can inject configuration files into containers running on AWS Fargate. This provides a flexible solution that allows for changes in configuration without the need to rebuild the entire image. The init container reads the entries from the secret, renders a config file, and places it on a mountable volume. The main container then mounts this volume and reads the configuration file. This method is efficient, secure, and easily adaptable to various applications and environments.