Let’s look at CloudFormation loops
On July 26th, AWS announced that AWS CloudFormation was now providing a “loop” syntax. I am very excited about that announcement because for more than 6 years I considered the absence of “loops” as one of the main drawbacks of CloudFormation compared to Terraform (along with its inability to manage a multi-account/multi-region stack). Of course, let’s be fair, CloudFormation also has huge advantages like being simpler and far more robust than Terraform due to its ability to rollback on failure.
Anyway, today I want to share my experience refactoring a simple VPC template using the new Fn::ForEach syntax!
A simple VPC
The templates I’m presenting here create a simple IPv4 VPC with 2 levels of subnet (public and private) in 3 Availability Zones (so, 6 subnets in total):
I’m using NAT instances because often I just need a VPC for testing purposes and paying for NAT Gateways seems a waste of my money when all I need is to provide Internet access to a handful of small instances that will just fetch some Linux packages.
Finally, as always, I’m adding the 2 gateway VPC endpoints for Amazon S3 and Amazon DynamoDB regardless of whether or not I actually need them because they are completely free so it cannot have any negative impact on the setup. Best case scenario, they will save me money and/or offer better performances.
A look at the classic template
The “classic” CloudFormation template to create such a VPC can be found on my GitHub: classic template. I encourage you to have a look to better feel the tidy repetitions.
Without going in too much details, here is a sample of the resources present in the template:
Resources: VPC: Type: AWS::EC2::VPC PublicSubnetA: Type: AWS::EC2::Subnet PublicSubnetRouteTableAssociationA: Type: AWS::EC2::SubnetRouteTableAssociation PrivateSubnetA: Type: AWS::EC2::Subnet NATInstanceA: Type: AWS::EC2::Instance PrivateRouteTableA: Type: AWS::EC2::RouteTable PrivateRouteA: Type: AWS::EC2::Route PrivateSubnetRouteTableAssociationA: Type: AWS::EC2::SubnetRouteTableAssociation PublicSubnetB: Type: AWS::EC2::Subnet PublicSubnetRouteTableAssociationB: Type: AWS::EC2::SubnetRouteTableAssociation PrivateSubnetB: Type: AWS::EC2::Subnet NATInstanceB: Type: AWS::EC2::Instance PrivateRouteTableB: Type: AWS::EC2::RouteTable PrivateRouteB: Type: AWS::EC2::Route PrivateSubnetRouteTableAssociationB: Type: AWS::EC2::SubnetRouteTableAssociation PublicSubnetC: Type: AWS::EC2::Subnet PublicSubnetRouteTableAssociationC: Type: AWS::EC2::SubnetRouteTableAssociation PrivateSubnetC: Type: AWS::EC2::Subnet NATInstanceC: Type: AWS::EC2::Instance PrivateRouteTableC: Type: AWS::EC2::RouteTable PrivateRouteC: Type: AWS::EC2::Route PrivateSubnetRouteTableAssociationC: Type: AWS::EC2::SubnetRouteTableAssociation
Can you see where I’m driving at here? All those resources suffixed with A, B or C are exactly the same but created for each Availability Zone. If you look at the actual template, you will see that the values for the properties of those resources are essentially only varying by one letter. It seems like a good use-case for our first loop!
Cut it in half with loops!
Like before, the “loop” CloudFormation template that create the same VPC with loops can be found on my GitHub: loop template. And of course, you can use it as you wish!
The new looping capability of CloudFormation is introduced through a new intrinsic function: Fn::ForEach. In order to use this new function, you must signal CloudFormation to use a Transform by adding the following line at the beginning of your template:
Transform: AWS::LanguageExtensions
There you have a first hint at what will actually happen: the CloudFormation engine has not really changed, it is just capable to apply some preprocessing to your template and expand it. As such, you will need to explicitly authorize CloudFormation to do so by providing the CAPABILITY_AUTO_EXPAND capability at stack creation, thus telling it that you understand that the template you are providing will not be the real template used to create the stack.
The modification of my first template to use loops is actually pretty straightforward. We already pointed out that resources are repeated with only a letter changing from AZ to AZ. So that seems the obvious starting point: looping on the values A, B and C for that letter.
Without too much details, our sample Resource section will now look like that:
Resources: VPC: Type: AWS::EC2::VPC Fn::ForEach::AZ: - az - [A, B, C] - PublicSubnet${az}: Type: AWS::EC2::Subnet PublicSubnetRouteTableAssociation${az}: Type: AWS::EC2::SubnetRouteTableAssociation PrivateSubnet${az}: Type: AWS::EC2::Subnet NATInstance${az}: Type: AWS::EC2::Instance PrivateRouteTable${az}: Type: AWS::EC2::RouteTable PrivateRoute${az}: Type: AWS::EC2::Route PrivateSubnetRouteTableAssociation${az}: Type: AWS::EC2::SubnetRouteTableAssociation
And by the magic of Fn::ForEach
, CloudFormation will pre-process the template and expand our sample to exactly the same as before.
To use Fn::ForEach
, you must give it a unique name, here I’m choosing Fn::ForEach::AZ
.
As you can see, Fn::ForEach
expects 3 arguments:
- The name of the iteration variable, here I use
az
but it is arbitrary; - A collection of strings on which ForEach will iterate;
- The “stuff” to expand with that ForEach loop.
You can use Fn::ForEach
in Conditions, Resources, resource properties and Outputs.
If you read my actual template, you will see some additional tricks I use like getting my collection from a mapping or using another mapping to convert letters to integers. Also there is an example of a double (imbricated) ForEach in my Outputs section. It is quite intuitive 😉
Limitations
No execution-dynamic loops
Keep in mind that your loops will be expanded before CloudFormation starts to really process your template and create resources. Consequently, it is impossible to use the return values of a provisioned resource as a collection to iterate through. For example, !GetAtt VPC.Ipv6CidrBlocks
is indeed a collection but it cannot possibly be known before actually creating the VPC, therefore you cannot use that as a looping collection for Fn::ForEach
.
Iterate only on lists
One of my disappointments was to discover that only lists of strings are accepted as the collection to iterate on and therefore the iteration variable is always a simple string.
I confess I was hoping for something more advanced like lists of objects, with the iteration variable being an object. I feel that would have been a far more powerful feature without making it fundamentally trickier to implement or understand.
In my simple VPC example, we can see that by only having a single value iterating over the list [A, B, C]
, I’m required to cheat a little in order to map the letters to indexes I can use to select the right AZ or the right subnet CIDR. I could have used an other workaround, such as iterating over ['0', '1', '2']
instead (which works with !Select
) but that would mean changing my naming convention to use numbers instead of letters and accepting a zero-based index in human-readable names… Not satisfying at all.
Having an object iterator over a list of objects that we could use with !GetAtt object.property
or the ${object.property}
syntax would have been far more convenient in my opinion, but hey! Maybe one day, right? In the meantime, I suppose that using Mappings as a workaround, as I did, is acceptable.
Breaks aws cloudformation package
At the time of writing this blogpost, using Fn::ForEach
in your template completely breaks the aws cloudformation package
command. The command simply crashes and unfortunately I found no workaround. So you probably cannot use this new feature in your CICD pipelines right now, unless you don’t use aws cloudformation package
of course but that seems unlikely.
I hope AWS will fix that very quickly and I created a GitHub issue that will probably be updated to track resolution: https://github.com/aws/aws-cli/issues/8075.
Conclusion
While there are some disappointing limitations, it is a very nice new feature. What I like is that it is very simple: there is no need to change our habits because the new ForEach fits intuitively into our toolbox. It does not create too much confusion and our CloudFormation templates stay very human-readable (as long as you use YAML of course). I’m willing to bet that someone stumbling upon this feature while reading a template will quickly understand what it does.
All in all, I had nearly 400 lines of code to describe my VPC and Fn::ForEach
allowed me to get the exact same result in less than 250, which is nice ?