Deploy bootstrapped EC2 with CDK

Trond Kristiansen
Towards AWS
Published in
11 min readMay 27, 2021

--

In this guide I use CDK with typescript to deploy an EC2 instance that is ready to go with an application, domain joining as well as custom configuration.

I have found very few examples online on how to use CDK to deploy a Windows EC2 instance that is bootstrapped with custom settings, domain joined etc. I have done this in the past using Cloudformation, but it took me a long time before I bothered to investigate how to do this using CDK. As I now know how to do this, I thought it would be nice to share my knowledge with you. I based a lot of the concepts from an extensive template that my colleague Vadrariu Alex created for doing the same thing in Cloudformation.

It is possible to do this both using the level 3 construct (CfnInstance) and the level 2 construct (Instance), I’m gonna use the latter. Examples online in some cases use the level 3 construct to achieve proper bootstrapping and the whole thing was confusing to me in the beginning, but I decided to achieve this using the level 2 construct and this is how I did it.

In this example I will deploy the following:

Windows 2019 EC2 instance deployed using a Level 2 construct with the following configuration:

  • Contains a standalone encrypted volume as D drive, properly named with Name tag for visibility in the AWS console
  • Root volume (C drive) will also be properly named for visibility in the AWS console.
  • Machine name and Name tag set from parameter
  • Domain joined using that same machine name
  • MySQL msi downloaded and installed using the chocolatey package manager
  • Windows firewall turned off
  • Have the instance signaling back to the stack if everything was successful or not

The Security groups and IAM stuff necessary for the instance

A network load balancer that will publish the MySQL port 3306 to the instance, as well as the rdp port 3389, so that you are able to connect to the server, as well as the MySQL service, from the outside.

Prerequisites

To be able to automatically domain join the server there are some prerequisites. This can be achieved in different ways, but I have found that the most elegant way is to use an SSM (AWS Systems Manager) domain join document. To make this work you would also need a reference to a directory in AWS Directory Service. This can be achieved by using either an AWS hosted directory or, as in my case, an AD connector. This is a proxy service to connect your own organization’s active directory to your AWS account.

The domain join document is part of the SSM service and can be created with the AWS CLI. This document also lets you control the OU where the computer objects are created in AD. To set it up create a document like this

The document holds references to the directory id, the directory name, the OU for computer objects as well as the DNS servers to use.

In this example I also use the parameter store to get values that would be account specific. So cases where you are deploying the same thing to a test environment, a stage environment and a prod environment, you could use the same template all together, but have different values such as instance type etc.

The parameters must be present in the account you are deploying to. If you don’t want to use that, you could just override the values in the variables. The naming convention I use for the parameter names are like this.

/acme/cdk/ec2/app-mysql/servername

Create a CDK project using

cdk init — language typescript

I also have a bootstrap script that can contain whatever you want done on the instance. This one only sets a name tag on the root volume. Take the following content

And save it as appBootstrapscript.ps1 in the root of your CDK project.

Setting variables

The first thing I set in my CDK template are the variables, some values from the parameter store and some static ones.

The next thing I would do is to set some values for the EC2 instance type to use based on the values from the parameter store, this is just to be able to differentiate in different environments/aws accounts.

The next step is to create a VPC from the existing vpc in the account that has the name acme-standard-vpc

Let’s also create subnet selections from existing subnets that exist in the vpc. This will be used when we decide which subnet we will put the instance and the NLB in. I create two subnet selections, one private for the instance and one public for the NLB. Examples show both to use a static value and to import value from another Cloudformation stack.

Next step is to create the network load balancer (NLB) that will make us able to access the rdp and MySQL service on the instance from the outside.

We will also create an IAM role with some permissions for the instance profile of the EC2 instance. This is for it to have the permissions to do the things we need it to do. In this example we are creating a standalone volume for the D drive that is encrypted with kms that exists in a centralized account. This is attached using the AWS CLI from inside the instance itself, so the policy gives it permission to do ec2.AttachVolume as well as some KMS permissions to get this key and to use it. We have given it permission to do ds.CreateComputer which is used for the domain join, as well. We also attach some AWS managed SSM policies that are needed. You could probably be even more granular with the permission if you want to.

The next step is to create the security group for the instance. In this case there is a network load balancer as well, so even if you would create this in a private subnet, which you should, this can be reached on rdp and MySQL ports from the internet. When using a network load balancer it is the security group on the instance that would be used to accept/deny the traffic and not on the load balancer itself which is the case with for instance the application load balancer (ALB).

I will also here use the public-ip packet here as well. Install it with npm i public-ip, and allow the machine you develop from to access the instance on the rdp and MySQL ports. This packet is async and can be used inside our CDK project, for instance like this. It finds your public ip and creates security group ingress rules with that as source.

Next we are creating the encrypted standalone volume. If you don’t pass the centralized encryptionKey it would use the default KMS key in the account which is also fine.

Now we are ready to start preparing to create the EC2 instance. We will use the level 2 construct for this, but first we must prepare some things that we need for the instance properties.

CDK has the ability to use init options to create files and to run commands on the instance. This is depending on two things. cfn-init.exe is run to start whatever configuration you want to do on the instance. This is typically done in the UserData field which is script commands that typically is run the first time an instance is booted. It also runs something called cfn-signal.exe which takes the status of whatever happens with cfn-init.exe, if it fails or succeeds, and reports it back to the Cloudformation stack to say whether the deployment was successful or not. In the case of CDK the fact that you pass init and initData when creating the instance makes it so that the UserData is generated automatically. I discovered in this case however that cfn-signal.exe was never sent back to the stack because my configuration of the instance has a reboot in it and the way UserData works is that it’s not persistent, meaning that it runs only the first time the instance is started. I solved this by passing along the persist flag to the userdata making it run each time.

Update: I thought about this and discussed it with colleagues and I found that even if this is solvable by using persistent userdata it’s not very elegant, as this would be running each time the server boots, for all eternity. I decided that it is better to have this as the last of the init commands. To achieve this I needed to override the instance’s automatically generated logical id that CDK creates on all resources, as the cfn-signal.exe command needs a reference to that logical id. When doing it like this you don’t need to pass along the userData from above, but I’ll leave it in so that you can use it if you need to add some other custom stuff to userData for other reasons.

The way I would do this is after the instance is created I would do.

And then the last of the commands would look like this. As this is run last it will return to the stack the status of the other commands, all of them have to be successful for it to report a successful state.

Next I set the device name as the D drive volume will have when viewed in the AWS console.

Lastly I create the initData which consists of all I want to happen on the instance.

Explanation of the Init data

InitData is what is called AWS::CloudFormation::Init: in cloudformation. This is a property you would pass along when creating an EC2 instance.

As I understand it, this is run in the order of the key of each command, but that key is evaluated as a string. That’s why the 10th command is 91 and not 10. I also put this in the same order as naming of the keys. You can basically do everything that is possible from a cmd/powershell terminal on the instance. You also have the AWS Powershell Tools cmdlet's available as well as the aws cli and you are authenticated against the aws account. What you can do is determined by the custom IAM policy we created earlier. Here is a short explanation of what each step does.

Create a powershell script from a file that lies in the root of my cdk project

ec2.InitFile.fromAsset(“c:\\cfn\\appBootstrapScript.ps1”, “./appBootstrapScript.ps1”),

The script takes the servername from the Name tag of the instance and sets this as the Name tag of the Root volume (C drive) for visibility in the AWS console.

Install the AWS cli from the official msi

ec2.InitPackage.msi(“https://s3.amazonaws.com/aws-cli/AWSCLI64.msi"),

Install chocolatey package manager which I here use to install MySQL, but it can be used to install whatever software on the instance very easily. Its important to make sure that quoting is done correctly on this commands since we have strings inside strings etc.

‘powershell.exe [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString(\’https://chocolatey.org/install.ps1\'))'

Rename the computer to the same as the Name tag of the instance

“powershell.exe -Command Rename-Computer (Get-EC2Tag -Filter @{Name=’resource-id’; Values=(Invoke-WebRequest http://169.254.169.254/latest/meta-data/instance-id -UseBasicParsing).Content}).Where({$_.Key -eq ‘Name’}).Value”

Make sure the SSM agent service is running to make the domain join work

‘powershell.exe -Command Restart-Service AmazonSSMAgent”’

Attach the standalone volume, D drive

‘powershell.exe Add-EC2Volume -InstanceId (Invoke-WebRequest http://169.254.169.254/latest/meta-data/instance-id -UseBasicParsing).Content -VolumeId ‘+dVolume.volumeId+’ -Device ‘+targetDevice+’ -Region ‘+process.env.CDK_DEFAULT_REGION

Run the bootstrap script that was created earlier

‘powershell.exe -File “c:\\cfn\\appBootstrapScript.ps1”’

Domain joining the instance

‘powershell.exe -Command Send-SSMCommand -InstanceId (Invoke-WebRequest http://169.254.169.254/latest/meta-data/instance-id -UseBasicParsing).Content -DocumentName ‘+domainJoinDocument+’ -TimeoutSecond 600 -Region ‘+process.env.CDK_DEFAULT_REGION

After the domain join I am going to do a restart of the server. You see that on this one I set wait after completion to forever so we are sure cfn-init is picked up again after reboot.

ec2.InitCommand.shellCommand(‘powershell.exe -Command Restart-Computer -force’, {key: “6-Restart”, waitAfterCompletion: ec2.InitCommandWaitDuration.forever()})

Disable windows firewall

‘powershell.exe -Command Set-NetFirewallProfile -Profile Domain,Public,Private -Enabled False’

Initialize the volume that was attached earlier to make it visible and usable. Initializedisks.ps1 is an AWS provided script that will exist by default on an amazon provided AMI.

‘powershell.exe -File C:\\ProgramData\\Amazon\\EC2-Windows\\Launch\\Scripts\\InitializeDisks.ps1’

Install MySQL using chocolatey, you can pass along params here if you want something custom, see choco docs for more info.

‘powershell.exe choco install mysql -y’

As the last command here we do the cfn-signal that will report the status back to the stack.

‘cfn-signal.exe -e %ERRORLEVEL% — resource mysqlinstance — stack ‘+this.stackId+’ — region ‘+this.region,

EC2 Instance

The next step is to set up the instance itself using the above configuration.

Lets create a listeners for both the MySQL and RDP ports, which tells the load balancer to accept traffic on these ports. Then add the instance as targets on those listeners on the very same ports.

Deploying and troubleshooting

If you look at the complete example you will see that I put the instance, as well as the other resources that relies on the instance inside an if sentence. Then you are able to add and remove the instance as many times you want without destroying the whole stack, this is just to save time when troubleshooting this.

If you set deployInstance to false the instance will not be deployed, but adding true will deploy it. If you want to redeploy it just set it to false after it’s deployed and do a cdk deploy and it will be removed etc.

I also suggest you set ignoreFailures to true in the beginning so the instance is not terminated before you can figure out the problem.

As the network load balancer listener is not ready from the start I would suggest having a jumpbox in the same vpc that you can use to connect to the instance using rdp.

You would also need the acmekey.pem key on your computer to be able to decrypt the local admin password.

When you have the source, that you want to deploy ready, save the file and do

npm run build && cdk deploy

If you go to CloudFormation in the AWS console of the account you are deploying to you will eventually see the stack being deployed. Go to resources and wait for the instance id of the new instance to appear. Go into that and you will see it under EC2 in the console. Get its ip address from there as well as do a right click, security, Get Windows password and upload the acmekey.pem file to get the password.

Log in using username Administrator and the decrypted password. Do it from the jumpbox or other computer that can reach the instance. You have to create an ingress rule on the instances security group stating that its allowed to connect using RDP.

On an instance deployed in this way there is a folder called c:\cfn which holds things relevant to the cfn-signal deployment.

The appBootstrapScript.ps1 is here as well as a log folder that holds the cfn-init log.

When you are inside the new instance start a powershell window and do a

cd c:\cfn\logGet-Content cfn-init.log -wait

Here you will see the init commands being run as well as the output and status for them.

If something fails, fix it based on this output until the instance reports the success status back to the stack. When you see the success signal in this file you will see it as an event in the Cloudformation stack in the AWS console.

When that happens you can set ignoreFailures back to false so that the instance’s status interacts with the stack.

Complete code example

The complete gist can be cloned with this: https://gist.github.com/f0b9e3d7aa932ece8ff4ce5c9191fd74.git

--

--