An Amazon Machine Image (AMI) is an AWS resource we use to deploy Amazon EC2(Elastic Compute Cloud) instances. Ideally, an AMI contains the Operating System(Windows, Linux etc.) and a list of customization made on it to meet specific requirements. It helps to quickly bootstrap one or more EC2 instances with similar configuration.
As per Amazon documentation, An AMI includes the following:
- One or more EBS snapshots, or, for instance-store-backed AMIs, a template for the root volume of the instance (for example, an operating system, an application server, and applications).
- Launch permissions that control which AWS accounts can use the AMI to launch instances.
- A block device mapping that specifies the volumes to attach to the instance when it’s launched.
In my previous post I discussed Linux AMI creation process using Packer and Powershell and provided a script to automate this process.
Today I will discuss the process of creating Windows Operating System AMI. I will be using Amazon provided Windows 2019 base image as source and customize it with a small list of changes to personalize the AMI as per my requirements. Following script is a sample I am using to customize AMI. It will be executed through provisioner in Packer. Feel free to add your customization based on your requirements. I named my script as configAMI.ps1. This script will be referred in provisioner JSON file. Save it in the same directory along with the main Packer build script provided later in this post.
configAMI.ps1
Function DisableWindowsErrReport()
{
Set-ItemProperty -path "HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting" -Name Disabled -value 1 -type DWord -Force
}
Function SetVisualAffectReg()
{
Set-ItemProperty -path "HKLM:\Software\Microsoft\Windows\CurrentVersion\Explorer\VisualEffects" -Name VisualFXSetting -value 2 -type DWord -Force
}
Function SetStandardReg()
{
Set-ItemProperty -path HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\Kerberos\Parameters -Name MaxTokenSize -value 48000 -type DWord -Force
#Disabling RC4 Cipher through Registry
$RC4RegPath = "SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Ciphers"
@('RC4 40/128','RC4 56/128','RC4 128/128') | %{$key = (Get-Item HKLM:\).OpenSubKey($RC4RegPath, $true).CreateSubKey($_);$key.SetValue('Enabled', 0, 'DWord');$key.close()}
# Setting Security Settings
new-item -Name Explorer -path "HKLM:\Software\Policies\Microsoft\Windows" -type Directory -Force
set-itemproperty "HKLM:\Software\Policies\Microsoft\Windows\Explorer" -Name NoAutoplayfornonVolume -type DWord -Value 0x00000001 -Force
new-item -Name Personalization -path "HKLM:\Software\Policies\Microsoft\Windows" -type Directory -Force
set-itemproperty "HKLM:\Software\Policies\Microsoft\Windows\Personalization" -Name NoLockScreenCamera -type DWord -Value 0x00000001 -Force
set-itemproperty "HKLM:\Software\Policies\Microsoft\Windows\Personalization" -Name NoLockScreenSlideshow -type DWord -Value 0x00000001 -Force
set-itemproperty "HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer" -Name NoAutorun -type DWord -Value 0x00000001 -Force
set-itemproperty "HKLM:\System\CurrentControlSet\Control\Lsa\MSV1_0" -Name NTLMMinClientSec -type DWord -Value 0x20080000 -Force
set-itemproperty "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services" -Name MinEncryptionLevel -type DWord -Value 0x00000003 -Force
set-itemproperty "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services" -Name fDisableCdm -type DWord -Value 0x00000001 -Force
set-itemproperty "HKLM:\System\CurrentControlSet\Services\LanManServer\Parameters" -Name EnableSecuritySignature -type DWord -Value 0x00000001 -Force
# Limiting firewall log sizes
Set-NetFirewallProfile -name domain -LogMaxSizeKilobytes 32767 -LogAllowed false -LogBlocked true
Set-NetFirewallProfile -name private -LogMaxSizeKilobytes 32767 -LogAllowed false -LogBlocked true
Set-NetFirewallProfile -name public -LogMaxSizeKilobytes 32767 -LogAllowed false -LogBlocked true
# Disabling Dynamic DNS Update
Set-ItemProperty -path HKLM:\system\currentcontrolset\services\tcpip\parameters -Name DisableDynamicUpdate -Type DWORD -value 1
}
#Call Functions to set up Windows System as per standard
DisableWindowsErrReport
SetVisualAffectReg
SetStandardReg
I am also using a User Data script to configure WinRM on the temporary packer builder EC2 instance along with a Self-Signed Certificate to allow WinRM over HTTPs. Copy following script block and store it as userdata.txt file in the same directory as above. I will refer this script in Builder JSON block in the Template.
userdata.txt
<powershell>
# Disable User Access Controller
New-ItemProperty -Path HKLM:Software\Microsoft\Windows\CurrentVersion\Policies\System -Name EnableLUA -PropertyType DWord -Value 0 -Force
New-ItemProperty -Path HKLM:Software\Microsoft\Windows\CurrentVersion\Policies\System -Name ConsentPromptBehaviorAdmin -PropertyType DWord -Value 0 -Force
Set-ExecutionPolicy Unrestricted -Scope LocalMachine -Force -ErrorAction Ignore
netsh advfirewall firewall set rule name="Windows Remote Management (HTTP-In)" new enable=yes action=block
# Delete any existing WinRM listeners
Remove-Item -Path WSMan:\Localhost\listener\listener* -Recurse
$Cert = New-SelfSignedCertificate -CertstoreLocation Cert:\LocalMachine\My -DnsName "PackerBuilder"
New-Item -Path WSMan:\LocalHost\Listener -Transport HTTPS -Address * -CertificateThumbPrint $Cert.Thumbprint -Force
# Create a new WinRM listener and configure
cmd.exe /c winrm quickconfig -q
cmd.exe /c winrm set "winrm/config" '@{MaxTimeoutms="1800000"}'
cmd.exe /c winrm set "winrm/config/winrs" '@{MaxMemoryPerShellMB="1024"}'
cmd.exe /c winrm set "winrm/config/service" '@{AllowUnencrypted="true"}'
cmd.exe /c winrm set "winrm/config/client" '@{AllowUnencrypted="true"}'
cmd.exe /c winrm set "winrm/config/service/auth" '@{Basic="true"}'
cmd.exe /c winrm set "winrm/config/client/auth" '@{Basic="true"}'
cmd.exe /c winrm set "winrm/config/service/auth" '@{CredSSP="true"}'
cmd.exe /c winrm set "winrm/config/listener?Address=*+Transport=HTTPS" "@{Port=`"5986`";Hostname=`"PackerBilder`";CertificateThumbprint=`"$($Cert.Thumbprint)`"}"
cmd.exe /c netsh advfirewall firewall set rule group="remote administration" new enable=yes
cmd.exe /c netsh firewall add portopening TCP 5986 "Port 5986"
cmd.exe /c net stop winrm
cmd.exe /c sc config winrm start= auto
cmd.exe /c net start winrm
</powershell>
This process requires a set of credential(Access Key and Secret Access Key) with required role assignments to perform the Packer builder tasks. So, make sure you have those ready. Refer IAM role for EC2 document while setting up your permission.
You also, need to install and import AWSPowershell Module on your system. You can skip this if you already have done it!
Next important task is to decide how you are going to refer those access key and secret key in your script. I am running this script from a Windows system and set up Environment variables for those secrets. I will refer them in the script. You can follow the same process or you may use a file with secrets stored or AWS profile or some alternate method. It is absolutely on your convenience and security practice that your organization allows.
So, to set those secrets as persistent environment variables run following commands in Powershell console :
[System.Environment]::SetEnvironmentVariable('AWS_ACCESS_KEY_ID', 'AKIAMYSAMPLEACCESSKEYEXAMPLE',[System.EnvironmentVariableTarget]::Machine)
[System.Environment]::SetEnvironmentVariable('AWS_SECRET_ACCESS_KEY', 'myyyyyyyyyyyysecrettttttttttttttttt',[System.EnvironmentVariableTarget]::Machine)
AWS supports a list of standard Environment variables. Refer following document to learn more :
Environment Variables To Configure the AWS CLI
As, Packer will deploy a temporary EC2 instance to create image, you need to provide a VPC Id, Subnet Id and Security Group Id. If you are running behind a proxy, update your script to authenticate through it. Your Security Group must allow WinRM(port 5985),WinRM over HTTPs(port 5986) to the EC2 instance as Packer will use WinRM to connect the temporary instance to configure it and run scripts to set your customization defined in provisioner.
With that let’s start scripting.
First, as my previous posts, I will use chocolatey to install packer on my Windows system. So, let’s set that :
try{
$checkChoco = choco --version
}
catch{
Write-Host "ERROR : $_" -Foregroundcolor Yellow
}
if([String]::IsNullOrEmpty($checkChoco)){
Set-ExecutionPolicy Bypass -Scope Process -Force
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))
}
Similarly, I will check if Packer is already there in my system. If not, will install using choco :
try{
$checkPacker = packer --version
}
catch{
Write-Host "ERROR : $_" -Foregroundcolor Yellow
}
try{
if([String]::IsNullOrEmpty($checkPacker)){
choco install packer
}
}
catch{
Write-Host "ERROR : $_" -Foregroundcolor Yellow
Exit
}
Now, I am defining a name for the new AMI and then checking if an AMI with same name already exists in my AWS account. If I already have one, add some extra random characters to make it unique :
$amiName = "$($amiNamePrefix)-CentOs-$((Get-Date).ToString("dd-MMM-yyyy"))"
$awsAccount = Get-STSCallerIdentity | Select-Object Account -ExpandProperty Account
$amiDetails = Get-EC2Image -AccessKey $accessKey -SecretKey $secretKey -Region $region -Filter @{ Name="name"; Values=$amiName} -Owner $awsAccount -ErrorAction SilentlyContinue
if(!([String]::IsNullOrEmpty($amiDetails))){
$amiName = "$($amiName)-$(-join ((65..90) + (97..122) | Get-Random -Count 6 | ForEach-Object {[char]$_}))"
}
Next, we will generate the Template JSON file with all required information for Packer to create the AMI for us. It includes variables, builders and provisioners. Note, I assigned empty string to access key and secret key variables as those will be user defined variables coming from the build command parameters. You still can assign default values instead of nothing if you want. Rest of items are quite self-explanatory.
As mentioned above, update provisioner inline script with your own stuff or create a separate bash script and refer here. JSON template creation script block is there in the full script. Following script block will call Packer build process with parameters :
Try{
powershell packer build -var "access_key=$($accessKey)" -var "secret_key=$($secretKey)" $jsonTemplateFile
}
Catch
{
Write-Host "ERROR : $_" -Foregroundcolor Red
Exit
}
}
Finally, I am checking if the AMI has been created successfully and then cleaning the JSON file.
Try{
Write-Host "Deleting Template JSON file"
if(Test-Path -Path $jsonTemplateFile){
Remove-Item -Path $jsonTemplateFile -Force
}
}
Catch{
Write-Host "ERROR : $_" -Foregroundcolor Red
}
==================================================
$amiDetails = Get-EC2Image -AccessKey $accessKey -SecretKey $secretKey -Region $region -Filter @{ Name="name"; Values=$amiName} -Owner $awsAccount -ErrorAction SilentlyContinue
if(!([String]::IsNullOrEmpty($amiDetails))){
Write-Host "New Image $($amiName)($($amiDetails.ImageId)) created successfully!" -ForegroundColor Green
}
else{
Write-Host "ERROR : Something went wrong! Please try again!" -ForegroundColor Red
}
Here is the complete script with all required statements and functions we discussed above:
Param(
[Parameter(Mandatory=$true)]
[String]$amiNamePrefix,
[Parameter(Mandatory=$false)]
[ValidateSet("eu-north-1","ap-south-1","eu-west-3","eu-west-2","eu-west-1","ap-northeast-2","ap-northeast-1","sa-east-1","ca-central-1","ap-southeast-1","ap-southeast-2","eu-central-1","us-east-1","us-east-2","us-west-1","us-west-2")]
[String]$region = "us-east-2",
[Parameter(Mandatory=$false)]
[String]$vpcId,
[Parameter(Mandatory=$true)]
[String]$subnetId,
[Parameter(Mandatory=$true)]
[String]$securityGroupId
)
# Function to create template definition json file for Packer
Function Create-JsonTemplate()
{
Try{
$input = @"
{
"variables": {
"access_key": "",
"secret_key": ""
},
"builders": [{
"type": "amazon-ebs",
"access_key": "{{user ``access_key``}}",
"secret_key": "{{user ``secret_key``}}",
"ami_name": "$($amiName)",
"vpc_id": "$($vpcId)",
"subnet_id": "$($subnetId)",
"security_group_id":"$($securityGroupId)",
"region": "$($region)",
"source_ami_filter": {
"filters": {
"virtualization-type": "hvm",
"name": "Windows_Server-2019-English-Full-Base-*",
"root-device-type": "ebs"
},
"owners": ["amazon"],
"most_recent": true
},
"instance_type": "t2.medium",
"ami_description": "Windows 2019 AMI for Avengers Team",
"disable_stop_instance": "false",
"communicator": "winrm",
"winrm_username": "Administrator",
"winrm_timeout": "20m",
"winrm_insecure": true,
"winrm_use_ssl": true,
"launch_block_device_mappings": [
{
"device_name": "/dev/sda1",
"volume_type": "gp2",
"delete_on_termination": true
}
],
"user_data_file":"./userdata.txt",
"run_tags": {
"Name": "Packer Builder",
"OsType": "Windows",
"CreationDate": "$((Get-Date).ToString("dd-MMM-yyyy"))",
"Owner": "Avengers"
},
"tags": {
"CreationDate": "$((Get-Date).ToString("dd-MMM-yyyy"))",
"OsType": "Windows",
"Owner": "Avengers",
"Name": "$($amiName)"
}
}],
"provisioners": [
{
"type": "powershell",
"script": "./configAMI.ps1"
},
{
"type": "windows-restart",
"restart_timeout": "10m"
},
{
"type": "powershell",
"inline": [
"C:\\ProgramData\\Amazon\\EC2-Windows\\Launch\\Scripts\\InitializeInstance.ps1 -Schedule",
"C:\\ProgramData\\Amazon\\EC2-Windows\\Launch\\Scripts\\SysprepInstance.ps1 -NoShutdown"
]
}
]
}
"@
$input | Out-File $jsonTemplateFile -Encoding ascii
}
Catch
{
Write-Host "ERROR : $_" -Foregroundcolor Red
Exit
}
}
# Function to create Image using Packer
Function Create-Image(){
Try{
powershell packer build -var "access_key=$($accessKey)" -var "secret_key=$($secretKey)" $jsonTemplateFile
}
Catch
{
Write-Host "ERROR : $_" -Foregroundcolor Red
Exit
}
}
# Function to remove resources created during the image creation process
Function Cleanup-Resources(){
Try{
Write-Host "Deleting Template JSON file"
if(Test-Path -Path $jsonTemplateFile){
Remove-Item -Path $jsonTemplateFile -Force
}
}
Catch{
Write-Host "ERROR : $_" -Foregroundcolor Red
}
}
# Check AWS Secrets are configured as Environment Variable. If not either set those or use alternate method to pass those secrets.
# If you use a different method, like user Profile, File or any other method comment out below script block.
if((Test-Path -Path Env:\AWS_ACCESS_KEY_ID) -and (Test-Path -Path Env:\AWS_SECRET_ACCESS_KEY)){
$accessKey = $Env:AWS_ACCESS_KEY_ID
$secretKey = $Env:AWS_SECRET_ACCESS_KEY
}
else{
Write-Host "ERROR : AWS Credentials are not set as Environment Variables. Either set those or use different method to pass those values.If you use alternate method.Comment this Block" -ForegroundColor Yellow
Exit
}
# Check if passed VPCId exists in the AWS Account, if not Exit
$vpcInfo = Get-EC2Vpc -VpcId $vpcId -Region $region -AccessKey $accessKey -SecretKey $secretKey
if([String]::IsNullOrEmpty($vpcInfo)){
Write-Host "ERROR : Incorrect VPC ID Supplied. Exiting.."
Exit
}
# Check if passed SubnetId exists in the AWS Account, if not Exit
$subnetInfo = Get-EC2Subnet -SubnetId $subnetId -Region $region -AccessKey $accessKey -SecretKey $secretKey
if([String]::IsNullOrEmpty($subnetInfo)){
Write-Host "ERROR : Incorrect Subnet ID Supplied. Exiting.."
Exit
}
# Check if passed SecurityGroupId exists in the AWS Account, if not Exit
$sgInfo = Get-EC2SecurityGroup -GroupId $securityGroupId -Region $region -AccessKey $accessKey -SecretKey $secretKey
if([String]::IsNullOrEmpty($sgInfo)){
Write-Host "ERROR : Incorrect Subnet ID Supplied. Exiting.."
Exit
}
$jsonTemplateFile = "$($env:Temp)\image-template.json"
if(Test-Path $jsonTemplateFile){Remove-Item -Path $jsonTemplateFile -Force}
# Check if chocolatey is already installed on local system. If not, install.
try{
$checkChoco = choco --version
}
catch{
Write-Host "ERROR : $_" -Foregroundcolor Yellow
}
if([String]::IsNullOrEmpty($checkChoco)){
Set-ExecutionPolicy Bypass -Scope Process -Force
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))
}
# Check if Packer is already installed.If not, install using chocolatey
try{
$checkPacker = packer --version
}
catch{
Write-Host "ERROR : $_" -Foregroundcolor Yellow
}
try{
if([String]::IsNullOrEmpty($checkPacker)){
choco install packer
}
}
catch{
Write-Host "ERROR : $_" -Foregroundcolor Yellow
Exit
}
# Generate AMI Name. Check if an AMI already exists with that name. If so, suffix name with random letters
$amiName = "$($amiNamePrefix)-Windows2019-$((Get-Date).ToString("dd-MMM-yyyy"))"
$awsAccount = Get-STSCallerIdentity | Select-Object Account -ExpandProperty Account
$amiDetails = Get-EC2Image -AccessKey $accessKey -SecretKey $secretKey -Region $region -Filter @{ Name="name"; Values=$amiName} -Owner $awsAccount -ErrorAction SilentlyContinue
if(!([String]::IsNullOrEmpty($amiDetails))){
$amiName = "$($amiName)-$(-join ((65..90) + (97..122) | Get-Random -Count 6 | ForEach-Object {[char]$_}))"
}
Create-JsonTemplate
Create-Image
$amiDetails = Get-EC2Image -AccessKey $accessKey -SecretKey $secretKey -Region $region -Filter @{ Name="name"; Values=$amiName} -Owner $awsAccount -ErrorAction SilentlyContinue
if(!([String]::IsNullOrEmpty($amiDetails))){
Write-Host "New Image $($amiName)($($amiDetails.ImageId)) created successfully!" -ForegroundColor Green
}
else{
Write-Host "ERROR : Something went wrong! Please try again!" -ForegroundColor Red
}
Cleanup-Resources
Note, if you come across any error while running packer build process, you can enable detailed logging to get helpful information about the error. To enable detailed logging you can set environment PACKER_LOG variable and set it’s value to 1. Use following script block to set it:
[System.Environment]::SetEnvironmentVariable('PACKER_LOG', 1,[System.EnvironmentVariableTarget]::Machine)
Now, when you execute this script, you will see detailed execution logs displayed on powershell console as shown below:
Finally, the AMI is ready :
Let me know if this post is helpful!