IaC for developers using AWS Copilot (part 2)
This is the second (and last) part of a series of articles about AWS Copilot. In the first one, I talked about why we need a tool like this, and now we will see how Copilot works.
Let’s continue where we left off. Remember that, given a container-based application, and after two commands and a one-line edit in a descriptor file, we had this:
A highly available application listening in a public HTTP url.
Anatomy of a copilot application
Before going deeper into how copilot works, let’s stop to check three of the core concepts of copilot: Application, Service, and Environment.
- Application. The concept of application can be understood as a group of services and environments that work together. Apart from this gluing of deployments, there is not many operations and attributes that work at an application level (create, delete, DNS configuration, not many more).
- Service. A service is a deployable container that is part of an application. The most important attribute of a service is its type. We’ve already used the Load Balanced Web Service in our first example, which translates into an internet-facing service with horizontal scaling. There are other types of services, like services that listen to a message queue, that we’ll see later. There’s another related concept called Job, it’s like services but for batch processes. As far as you have a buildable docker project, release it as a service, and copilot will deal with all the deployment details (ECR, rollbacks in case of error, VPCs, subnets, scaling, balancing, etc.)
- Environment. An environment contains all the base resources needed to deploy our services (VPC, Subnet, ECS Cluster, Route53 configuration). We can create as many environments as we want. We can use them for a classic environment configuration (e.g. dev → uat → prod); or for ephemeral environments that are created and destroyed on demand.
In one sentence, we can say that an AWS copilot application is a group of services that are deployed on environments.
Now, let’s continue improving our simple example to show more Copilot features.
Persistence
If something is missing in our example, it’s a place to store data for our application. We can create a container-based database and deploy it as a copilot service, but we are on AWS, and given the array of possibilities that Amazon gives us, we shouldn’t need that. Copilot simplifies using AWS persistence services by allowing us to declare our interest in using them. So if we want to use an S3 bucket, we can go to the root of our service and run:
copilot storage init -t S3
It’ll ask us the bucket name (let’s say our_bucket), and next time we deploy our service (remember, just copilot deploy) the bucket will be there. How can we access it in our code? Copilot will inject an environment variable in our container named OUR_BUCKET_NAME containing the bucket name.
At the time of writing, AWS Copilot’s storage command supports S3 buckets, DynamoDB tables, and Aurora Serverless clusters. If we need something different, we can create add-ons, and we will see them later.
There’s another persistence feature, EFS volumes, that doesn’t use the storage command. In this case, we declare them in the yaml descriptor. We could append to the end of the file:
storage:
volumes:
myManagedEFSVolume:
efs: true
path: /var/efs
read_only: false
and after deployment we will have an EFS volume created, configured and mounted in the container. In my experience, to do this using just CloudFormation is quite a tedious piece of work.
DNS and HTTPS Configuration
In our example, the entry point to our application is the load balancer, and as we know, load balancers have not-so-friendly URLs. AWS Copilot can help us with this as long as we have a Route53 hosted zone that manages the domain that hosts the application.
To configure our application domain (and assuming that my-app-domain.com is managed by Route53), we must do this when we create the application:
copilot app init --domain my-app-domain.com
It’s a pity, but this implies that the domain must be known at application creation time, which might not always be the case. If we want to change it, or configure it, afterward, the only option is to delete the application and create it again.
Configured like this, every time we deploy a Load Balanced service to an environment, it will have a name like this: service.environment.application.domain. If we don’t like it, we can give a different name to a service by using the alias property in the manifest yaml file.
Also, at deploy time copilot manages the creation of an AWS Certificate Manager certificate and its configuration in the load balancer for HTTPS.
Let’s add more services
As it grows, we probably want to build our application as a set of services that work together.
Asynchronous communication
If services are not front-faced, then you’ll probably prefer to call them asynchronously. For this, copilot supports SQS and SNS and implements the fan-out pattern, a great representation of the semantics of service asynchronous communication.
Let’s use an example to see how to implement it in AWS autopilot. Suppose we have a system where anytime we create a new customer account, we have to send a communication to them. Also, let’s assume that we have a customer management service and a communications service that manages all our external communications using our new and shiny third-party service.
When a new customer is created in the customer management service, it will communicate this fact to the rest of the world by sending an SNS event, which is like saying
Hey! to everyone interested, a new customer has been created, here you have the details
The customer service will include this in its yaml descriptor:
publish:
topics:
- name: new-customer
and when we deploy it, we will have an SNS topic created (and its ARN injected as an environment variable in the container).
Anyone interested in this event will be subscribed to the SNS topic, including the communications service, which will use an SQS queue as the interface to receive calls. A queue semantics is a bit different than a topic, in this case, it will be like the communications service saying
Hey! If anyone wants me to send a communication, please put a message in this queue, and I will do it as soon as I can.
SQS queues allow for error processing, retries, etc. so they are a perfect match for services that can process their workload asynchronously from their callers.
For services that use SQS as its call mechanism, AWS autopilot has the Worker service type.
So the communications service will have a
type: Worker Service
in its definition. And also, the declaration of its queue and to which SNS topics it will listen.
subscribe:
topics:
- name: new-customer
service: customer
queue:
timeout: 300s
dead_letter:
tries: 3
First, we say that this service queue will subscribe to the new-customer topic defined in the customer service. Then we declare the configuration for the SQS queue, in this case:
- messages will have a 5 minutes timeout before a retry
- after three retries, messages will go to a dead letter queue*
*dead letter queue creation included with copilot
Internal services
If you have services that need an HTTP interface but are internal to your system, then you can create a Backend Service that will be very similar to a Load Balanced one but with no internet-facing load balancer rules. They can be declared as:
type: Backend Service
Batch Jobs
AWS copilot also supports the need for scheduled recurrent jobs inside our application. Jobs are very similar in their definition and operation to Services. The only main differences would be in the descriptor:
type: Scheduled Job
on:
schedule: "@daily"
retries: 3
timeout: 1h
So, the type, the schedule (it could be a cron expression if we prefer), a timeout, and the number of retries for failure processing.
Frontend application
When we have a frontend javascript web application, deploying it as a container (with an nginx, express, etc. server embedded) could be overkill, especially with AWS having a great solution for this; the combination of S3 with Cloudfront. Fortunately, AWS copilot has recently introduced support for this pattern, which is a first, because it’s not a container-based solution, it could open the door to a whole new world for copilot.
Anyway, the example for this as it appears in the copilot documentation is very self-explanatory:
name: example
type: Static Site
http:
alias: 'example.com'
files:
- source: src/someDirectory
recursive: true
- source: someFile.html
Basically, we tell copilot where are the static resources it has to upload to S3, and we will have an extremely highly available web application published on the internet.
What if I need a different AWS resource?
It’s not an edge case that we would like to use an AWS resource not directly supported by AWS copilot (think documentDB, Elasticache, or OpenSearch, and those are just the first to come to my mind). We would like not to manage them by ourselves “outside” of the copilot services ecosystem. For this, we have addons.
Addons probably are my favorite feature of copilot. Thanks to them, it moves from being a good tool for quickly launching new products, to being the operational foundation of bigger systems, as it allows the integration of almost any resource that can be created with CloudFormation into our copilot environments.
Basically, an addon is a CloudFormation template written into the addons folder of our service repository. Anytime we deploy the service, the template will create or update the template’s stack for the environment we are deploying to. The trick here is that any value we declare in the template Output section will be injected as an environment variable in the container, if it is an IAM policy, the Task Role of our service will have it.
The limitations
Of course, AWS copilot is not perfect, and it has its limitations. Reflecting on them, I feel that most of those limitations come from the fact that copilot was, in the beginning, the cli tool for ECS, so now that it provides more than one would expect from such a tool, we, as users, want it to do things that are far from its original design guidelines.
Addons, services and shared resources
An addon is always owned by one service and not visible(in a copilot way, that is) from other services, and this is because addons cannot be services by themselves. They always have to be attached to a container.
Attach shared addons to existing services and use SSM Parameter Store or CloudFormation import as an intermediary, do work, but it’s not the ideal solution.
Another option that could be a solution, and doesn’t exist, is that of “common” or “shared” addons. Although I would prefer the notion of addons as services for most of my use cases, there are cases for shared addons, for example, IAM resources.
Services are only containers (not anymore)
There are cases where a different architecture could be more suitable than containers in Fargate. I’m thinking of using Lambda functions for Workers or Scheduled jobs when the 15-minute timeout limitation is not an issue.
Again, copilot is a tool for containers, but now that we have the static site type, The door’s open for including new types of deployments.
To sum up
We have seen how AWS Copilot is a powerful command-line tool that simplifies the development and deployment of containerized applications on AWS. Yes, like many other tools, it tries to follow the mantra “You can focus on your application logic and let us handle the underlying infrastructure and orchestration”. But what intrigues me, and I want to keep exploring, is that, unless most of them, it seems to find a sweet point between freedom and complexity while still enforcing good practices.