Sử dụng Packer để tạo AWS Windows AMI


Lời mở đầu

Nếu các bạn đã từng làm qua AWS, và đặc biệt là làm về EC2, thì không thế không nhắc đến AMI. Vậy AMI là gì?

AMI (viết tắt của Amazon Machine Image), hiểu đơn giản chính là một template dựng sẵn của một hệ điều hành nào đó, có thể dùng nó để build nên một EC2 Instance.

Khi các bạn tạo một máy Instance trên EC2, thì lúc đó bạn đã sử dụng AMI rồi đấy, đó là các AMI có sẵn trên AWS, do aws hoặc ai đó tạo ra.

Tuy nhiên, bạn lại muốn tạo một AMI riêng cho chính mình, dựa trên các AMI có sẵn, thêm hoặc bớt một số tính năng, thành phần thì phải làm như thế nào? Bạn có thể lên Console, tạo một Instance, sau đó thay đổi các thành phần bạn mong muốn, sau đó convert qua AMI là được.

Tuy nhiên cách đó quá thủ công, bạn muốn mọi thứ phải Automation. Và Packer sinh ra để làm việc đó.

Tìm hiểu về Packer

Packer là gì?

Packer là một công cụ để tạo ra các images trên nhiều platform khác nhau, bao gồm AWS, GCP, Azure, Digital Ocean,.. hay thậm chí là cả VMWare và Docker.

Packer template

Packer sử dụng JSON Template, để khai báo các cấu hình cho việc xây dựng một image cụ thể nào đó. Ví dụ một Packer JSON template đơn giản:

{
  "variables": {
    "aws_access_key": "",
    "aws_secret_key": ""
  },
  "builders": [
    {
      "type": "amazon-ebs",
      "access_key": "{{user `aws_access_key`}}",
      "secret_key": "{{user `aws_secret_key`}}",
      "region": "us-east-1",
      "source_ami_filter": {
        "filters": {
          "virtualization-type": "hvm",
          "name": "ubuntu/images/*ubuntu-xenial-16.04-amd64-server-*",
          "root-device-type": "ebs"
        },
        "owners": ["099720109477"],
        "most_recent": true
      },
      "instance_type": "t2.micro",
      "ssh_username": "ubuntu",
      "ami_name": "packer-example {{timestamp}}"
    }
  ],
  "provisioners": [
    {
      "type": "shell",
      "inline": ["echo foo"]
    }
  ]
}

Trong đó, lưu ý các thông tin sau:

  • variables: khai báo các biến có thể sử dụng trong template này. Các biến này có thể được gọi thông qua cấu trúc: {{ user `variable_name` }}.
  • builders: chứa các thông tin cơ bản để build một image
    • type: loại image bạn muốn tạo, ở đây là aws-ebs
    • access_key, secret_key: các key cho việc authentication, tuy nhiên Packer có thể lấy các key này thông qua biến môi trường, nên không bắt buộc bạn phải khai báo trong tin này trong packer.
    • region: bạn muốn image này được tạo ra ở region nào.
    • source_ami_filter: vì có rất nhiều public AMI có sẵn, nên bạn phải lọc ra bạn muốn sử dụng chính xác image nào.
      • filters: các thông tin bạn muốn lọc ra, có thể là AMI này có virtualization-type là gì? name của nó theo định dạng ra sao? root-device-type như thế nào? …
      • owers: bạn nên cung cấp tên hoặc ID của owers để có được image chính xác nhất.
      • most_recent: khai bán giá trị này bằng true để luôn luôn lấy AMI version mới nhất.
    • instance_type: khi bạn dùng Packer, nó sẽ tạo ra một EC2 Instance để có thể cài đặt những thành phần bạn mong muốn. Giá trị này tương ứng với loại instance bạn muốn sử dụng cho việc này.
    • ssh_username: Packer có thể tự tạo tạm thời một cặp key-pair để có thể truy cập đến Instance từ xa, vì vậy nên khai báo thêm giá trị này để Packer có thể access được. Giá trị này nên username default của AMI gốc nhé.
    • ami_name: với quá nhiều Input kể trên, thì tất nhiên bạn phải khai báo thêm một Output là ami_name để đặt tên cho AMI của bạn rồi 😉
  • provisioners: chứa các cài đặt bạn muốn thêm vào AMI hiện có.
    • type: cách mà bạn provision, có thể bằng shell, scripts, file, hoặc các tools config,… Bạn có thể tìm hiểu thêm nhiều loại type khác nhau ở đây.
    • inline: ở đây mình sẽ gõ lệnh trực tiếp vào shell của Instance luôn nên mình khai báo inline.

Packer CLI

Packer hỗ trợ nhiều commands để có thể thao tác dễ dàng. Trong đó, kể đến các commands thông dụng sau:

  • packer validate <packer_template>: kiểm tra xem template của bạn có lỗi gì hay không.
  • packer build <packer_template>: build AMI bằng command này.
  • packer inspect <packer_template>: show ra các thông tin cơ bản của template này, lệnh này nên có trong pipeline chạy packer để có logs khi cần thiết.

Build Windows AMI thông qua Packer

Mục tiêu

Với packer template đơn giản ở trên, bạn có thể hoàn toàn build được một Linux AMI (cụ thể là Ubuntu). Việc thêm hay bớt các thành phần trong AMI chỉ là cách bạn config provisioners như thế nào mà thôi.

Ở bài này, mình sẽ thử dùng Packer để build một Windows AMI, có khác đôi chút so với Linux đấy 😛

Yêu cầu về AMI

Ở đây, Windows AMI được tạo ra của mình sẽ là Windows Server 2019, trong đó cài sẵn cho mình Google ChromeJDK bản mới nhất, từ phía người dùng thì họ có thể Remote Desktop vào được Instance được tạo từ AMI này và User Account Control (UAC) cũng nên được loại bỏ

Code Structure

windows-ami
├─ aws-windows.json
├─ scripts
│  ├─ disable_uac.ps1
│  ├─ enable_rdp.ps1
│  ├─ install_chrome.ps1
│  ├─ install_jdk.ps1
│  └─ user_data.ps1
└─ variables.json

Tạo các scripts để cài đặt các phần mềm trong AMI

Mình sẽ tạo một vài scripts (dùng powershell) để cài đặt và cấu hình theo yêu cầu

Install latest Google Chrome:

$Installer = "$env:temp\chrome_installer.msi"
$url = 'https://dl.google.com/tag/s/dl/chrome/install/googlechromestandaloneenterprise64.msi'
Write-Host "Downloading Google Chrome from $url"
Invoke-WebRequest -Uri $url -OutFile $Installer -UseBasicParsing
Write-Host "Downloaded Google Chrome"
Write-Host "Installing Google Chrome..."
Start-Process msiexec.exe -Wait -ArgumentList "/I $Installer /quiet"
Write-Host "Installed Google Chrome..."
Remove-Item -Path $Installer

Install latest JDK: vì rất khó có thể download được các bản cài đặt JDK thông qua command line, nên mình sẽ lưu các file JDK cài đặt lên một host nào đó (ở đây mình dùng AWS S3), sau đó script này sẽ download bản này về. Mình cấu hình version theo biến môi trường có trong Packer template nhé.

$Installer = "$env:temp\jdk.exe"
Write-Host "Downloading JDK from $env:java_binary_url"
Invoke-Webrequest $env:java_binary_url -OutFile $Installer -UseBasicParsing
Write-Host "Downloaded JDK installer"
Write-Host "Installing JDK..."
Start-Process $Installer '/s REBOOT=0 SPONSORS=0 AUTO_UPDATE=0 ADDLOCAL="ToolsFeature,SourceFeature,PublicjreFeature" INSTALLDIR=C:\java\jdk /INSTALLDIRPUBJRE=C:\java\jre' -wait
[Environment]::SetEnvironmentVariable("PATH", $env:Path + ";C:\java\jdk\bin", [EnvironmentVariableTarget]::Machine)
[Environment]::SetEnvironmentVariable("JAVA_HOME", "C:\java\jdk", [EnvironmentVariableTarget]::Machine)
[Environment]::SetEnvironmentVariable("JRE_HOME", "C:\java\jre", [EnvironmentVariableTarget]::Machine)
Write-Host "Installed JDK"
Remove-Item -Path $Installer
C:\java\jdk\bin\java -version

Enable Remote Desktop:

Write-Host "Enabling Remote Desktop..."
Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -name "fDenyTSConnections" -value 1
Write-Host "Enabled Remote Desktop..."

Disable UAC:

Write-Host "Disabling UAC..."
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
Write-Host "Disabled UAC..."

Khác với các Linux AMIs, nó được cài sẵn SSH Server để Packer có thể access được từ xa. Tuy nhiên Windows AMIs thì lại không. Nên bắt buộc bạn phải khai báo thêm user_data_file, đế Windows AMI sẽ đọc cấu hình này trước khi chạy các Provisioners. Và đây là scripts của user_data_file mình cần, mục đích của nó là sẽ cài đặt và cấu hình OpenSSH Server.

<powershell>
# Version and download URL
$openSSHVersion = "7.6.1.0p1-Beta"
$openSSHURL = "https://github.com/PowerShell/Win32-OpenSSH/releases/download/v$openSSHVersion/OpenSSH-Win64.zip"

Set-ExecutionPolicy Unrestricted

# GitHub became TLS 1.2 only on Feb 22, 2018
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12;

# Function to unzip an archive to a given destination
Add-Type -AssemblyName System.IO.Compression.FileSystem
Function Unzip
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, Position=0)]
        [string] $ZipFile,
        [Parameter(Mandatory=$true, Position=1)]
        [string] $OutPath
    )

    [System.IO.Compression.ZipFile]::ExtractToDirectory($zipFile, $outPath)
}

# Set various known paths
$openSSHZip = Join-Path $env:TEMP 'OpenSSH.zip'
$openSSHInstallDir = Join-Path $env:ProgramFiles 'OpenSSH'
$openSSHInstallScript = Join-Path $openSSHInstallDir 'install-sshd.ps1'
$openSSHDownloadKeyScript = Join-Path $openSSHInstallDir 'download-key-pair.ps1'
$openSSHDaemon = Join-Path $openSSHInstallDir 'sshd.exe'
$openSSHDaemonConfig = [io.path]::combine($env:ProgramData, 'ssh', 'sshd_config')

# Download and unpack the binary distribution of OpenSSH
Invoke-WebRequest -Uri $openSSHURL `
    -OutFile $openSSHZip `
    -ErrorAction Stop

Unzip -ZipFile $openSSHZip `
    -OutPath "$env:TEMP" `
    -ErrorAction Stop

Remove-Item $openSSHZip `
    -ErrorAction SilentlyContinue

# Move into Program Files
Move-Item -Path (Join-Path $env:TEMP 'OpenSSH-Win64') `
    -Destination $openSSHInstallDir `
    -ErrorAction Stop

# Run the install script, terminate if it fails
& Powershell.exe -ExecutionPolicy Bypass -File $openSSHInstallScript
if ($LASTEXITCODE -ne 0) {
	throw("Failed to install OpenSSH Server")
}

# Add a firewall rule to allow inbound SSH connections to sshd.exe
New-NetFirewallRule -Name sshd `
    -DisplayName "OpenSSH Server (sshd)" `
    -Group "Remote Access" `
    -Description "Allow access via TCP port 22 to the OpenSSH Daemon" `
    -Enabled True `
    -Direction Inbound `
    -Protocol TCP `
    -LocalPort 22 `
    -Program "$openSSHDaemon" `
    -Action Allow `
    -ErrorAction Stop

# Ensure sshd automatically starts on boot
Set-Service sshd -StartupType Automatic `
    -ErrorAction Stop

# Set the default login shell for SSH connections to Powershell
New-Item -Path HKLM:\SOFTWARE\OpenSSH -Force
New-ItemProperty -Path HKLM:\SOFTWARE\OpenSSH `
    -Name DefaultShell `
    -Value "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" `
    -ErrorAction Stop

$keyDownloadScript = @'
# Download the instance key pair and authorize Administrator logins using it
$openSSHAdminUser = 'c:\ProgramData\ssh'
$openSSHAuthorizedKeys = Join-Path $openSSHAdminUser 'authorized_keys'
If (-Not (Test-Path $openSSHAdminUser)) {
    New-Item -Path $openSSHAdminUser -Type Directory
}
$keyUrl = "http://169.254.169.254/latest/meta-data/public-keys/0/openssh-key"
$keyReq = [System.Net.WebRequest]::Create($keyUrl)
$keyResp = $keyReq.GetResponse()
$keyRespStream = $keyResp.GetResponseStream()
    $streamReader = New-Object System.IO.StreamReader $keyRespStream
$keyMaterial = $streamReader.ReadToEnd()
$keyMaterial | Out-File -Append -FilePath $openSSHAuthorizedKeys -Encoding ASCII
# Ensure access control on authorized_keys meets the requirements
$acl = Get-ACL -Path $openSSHAuthorizedKeys
$acl.SetAccessRuleProtection($True, $True)
Set-Acl -Path $openSSHAuthorizedKeys -AclObject $acl
$acl = Get-ACL -Path $openSSHAuthorizedKeys
$ar = New-Object System.Security.AccessControl.FileSystemAccessRule( `
	"NT Authority\Authenticated Users", "ReadAndExecute", "Allow")
$acl.RemoveAccessRule($ar)
$ar = New-Object System.Security.AccessControl.FileSystemAccessRule( `
	"BUILTIN\Administrators", "FullControl", "Allow")
$acl.RemoveAccessRule($ar)
$ar = New-Object System.Security.AccessControl.FileSystemAccessRule( `
	"BUILTIN\Users", "FullControl", "Allow")
$acl.RemoveAccessRule($ar)
Set-Acl -Path $openSSHAuthorizedKeys -AclObject $acl
Disable-ScheduledTask -TaskName "Download Key Pair"
$sshdConfigContent = @"
# Modified sshd_config, created by Packer provisioner
PasswordAuthentication yes
PubKeyAuthentication yes
PidFile __PROGRAMDATA__/ssh/logs/sshd.pid
AuthorizedKeysFile __PROGRAMDATA__/ssh/authorized_keys
AllowUsers Administrator
Subsystem       sftp    sftp-server.exe
"@
Set-Content -Path C:\ProgramData\ssh\sshd_config `
    -Value $sshdConfigContent
'@
$keyDownloadScript | Out-File $openSSHDownloadKeyScript

# Create Task - Ensure the name matches the verbatim version above
$taskName = "Download Key Pair"
$principal = New-ScheduledTaskPrincipal `
    -UserID "NT AUTHORITY\SYSTEM" `
    -LogonType ServiceAccount `
    -RunLevel Highest
$action = New-ScheduledTaskAction -Execute 'Powershell.exe' `
  -Argument "-NoProfile -File ""$openSSHDownloadKeyScript"""
$trigger =  New-ScheduledTaskTrigger -AtStartup
Register-ScheduledTask -Action $action `
    -Trigger $trigger `
    -Principal $principal `
    -TaskName $taskName `
    -Description $taskName
Disable-ScheduledTask -TaskName $taskName

# Run the install script, terminate if it fails
& Powershell.exe -ExecutionPolicy Bypass -File $openSSHDownloadKeyScript
if ($LASTEXITCODE -ne 0) {
	throw("Failed to download key pair")
}

# Restart to ensure public key authentication works and SSH comes up
Restart-Computer
</powershell>
<runAsLocalSystem>true</runAsLocalSystem>

Tạo file Variables

Bạn hoàn toàn có thể khai báo Variables trong Packer template luôn, tuy nhiên mình khuyến khích các bạn tạo một file Variables.json bên ngoài, để sau này bạn có thể thay đổi các giá variable mà không cần làm thay đổi Packer template. Khi đó, khi chạy Packer CLI thì mình thêm vào parameter -var-file=variables.json.

Ở đây mình thêm vào giá trị java_binary_url để packer download jdk installer từ đây (hiện tại jdk latest version là 8u261).

{
    "region": "ap-southeast-1",
    "ami_regions": "ap-southeast-1",
    "ami_description": "Windows Server 2019 with latest Chrome, JDK",
    "source_ami_name": "Windows_Server-2019-English-Full-Base-*",
    "source_ami_owner": "amazon",
    "dest_ami_name": "THACHANPY-WINDOWS-AMI-{{isotime \"2006-01-02\"}}-{{timestamp}}",
    "instance_type": "t2.micro",
    "subnet_id": "",
    "vpc_id": "",
    "security_group": "",
    "os_version": "AWS Windows_Server-2019",
    "java_binary_url": "https://thachanpy.s3-ap-southeast-1.amazonaws.com/jdk-8u261-windows-x64.exe"
}

Tạo file Packer template

Cuối cùng là mình sẽ tạo ra Packer template, đi đôi với file variable và các scripts đã có sẵn thôi 😛

{
	"builders": [{
		"type": "amazon-ebs",
		"region": "{{ user `region` }}",
		"ami_regions": "{{ user `ami_regions` }}",
        "instance_type": "{{ user `instance_type` }}",
		"communicator": "ssh",
		"ssh_username": "Administrator",
        "source_ami_filter": {
          "filters": {
            "name": "{{ user `source_ami_name`}}"
          },
			"owners": "{{ user `source_ami_owner`}}",
			"most_recent": true
		},
		"encrypt_boot": false,
		"tags": {
			"Name": "{{ user `dest_ami_name`}}",
			"OS_Version": "{{ user `os_version`}}",
			"Base_AMI_Name": "{{ .SourceAMIName }}",
			"Extra": "{{ .SourceAMITags.TagName }}",
			"Timestamp": "{{timestamp}}",
			"Date": "{{isotime \"2006-01-02\"}}",
			"bdc:creator": "packer",
			"bdc:group": "devops",
			"bdc:environment": "devops"
		},
		"vpc_id": "{{ user `vpc_id` }}",
		"subnet_id": "{{ user `subnet_id` }}",
		"security_group_id": "{{ user `security_group` }}",
		"ami_description": "{{ user `ami_description` }}",
		"ami_name": "{{ user `dest_ami_name` }}",
		"user_data_file": "./scripts/user_data.ps1"
    }],
    
    "provisioners": [
		{
			"type": "powershell",
			"scripts": [
				"./scripts/install_chrome.ps1"
			]
		},
		{
			"type": "powershell",
			"environment_vars": [
				"java_binary_url={{ user `java_binary_url` }}"
			],
			"scripts": [
				"./scripts/install_jdk.ps1"
			]
		},
		{
			"type": "powershell",
			"scripts": [
				"./scripts/enable-rdp.ps1",
				"./scripts/disable-uac.ps1"
			]
		},
		{
			"type": "powershell",
			"inline": [
				  "C:\\ProgramData\\Amazon\\EC2-Windows\\Launch\\Scripts\\InitializeInstance.ps1 -Schedule",
				  "C:\\ProgramData\\Amazon\\EC2-Windows\\Launch\\Scripts\\SysprepInstance.ps1"
			]
		}
	]
}   

Bạn lưu ý thêm một số cấu hình sau:

  • communicator: mặc định Packer sẽ dùng communicator là ssh, tuy nhiên ở windows mình có tới 2 communicator có thể dùng được là sshwinrm. Mình khai báo thêm giá trị này để người đọc có thể hiểu rõ mình đang dùng communicator nào.
  • user_data_file: như mình nói ở trên phần scripts, Packer sẽ tự chạy file user_data này trước khi Provisioners.
  • provisioners: vì một số lý do nào đó, mình nên chạy lại AWS Sysprep để có thể thao tác được với AWS Instance một số cấu hình. Ví dụ sau này mình dùng để lấy Windows password.

Build Windows AMI theo template

Việc đơn giản tiếp theo là bạn build AMI thôi :P. Lưu ý thêm là mình đặt các biến AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY ở môi trường rồi nên mình không khai báo vào template nhé.

packer validate -var-file=variables.json aws-windows.json
packer build -var-file=variables.json aws-windows.json

Khi này, Packer sẽ dựng lên một EC2 Instance và các đặt theo cấu hình của mình, và convert từ Insance này qua AMI khi chạy xong.

Đây là build log của mình, không một lỗi gì luôn 😛

...
==> amazon-ebs: Creating snapshot tags
==> amazon-ebs: Terminating the source AWS instance...
==> amazon-ebs: Cleaning up any extra volumes...
==> amazon-ebs: No volumes to clean up, skipping
==> amazon-ebs: Deleting temporary security group...
==> amazon-ebs: Deleting temporary keypair...
Build 'amazon-ebs' finished after 14 minutes 11 seconds.

==> Wait completed after 14 minutes 11 seconds

==> Builds finished. The artifacts of successful builds are:
--> amazon-ebs: AMIs were created:
ap-southeast-1: ami-00c936fe6d241e509

Testing

Một AWS AMI đã tạo ra với ID như log trên, mình sẽ thử dựng 1 con EC2 theo AMI này xem sao nhé.

Đầu tiên là thử kiểm tra AMI có thực sự được tạo ra hay không:

Tạo một EC2 Instance dựa trên AMI này. Bạn lưu ý là tới bước chọn key-pair thì tạo mới hoặc chọn key nào mình có thể access vào được nhé. Tuy không dùng SSH nhưng mình cần key-pair này để tạo password cho Remote Desktop đấy.

Sau đó Launch Instances thôi nào :p

Bạn cần chờ một thời gian để Windows instance này start lên nhé. Và chờ thêm một chút nữa để AWS Sysprep được start, khi đó bạn có thể lấy được password để Remote Desktop vào.

Bạn chuột phải vào Instance, và chọn Get Windows Password, hiện tại thì bạn có thể thấy là chưa thể lấy passoword được.

Và một lát sau, mình thử lại, và đã có thể lấy được Windows password rồi (nhớ upload private key lên nhé). Chọn Decrypt Password thôi nào.

Sau đó một Password sẽ hiện ra, bạn dùng password này để authentication bằng Remote Desktop (với username là Administrator mặc định của Windows AMIs).

Thử remote vào xem nào 😛

Trước mắt là bạn đã thấy icon Chrome rồi đó, việc tiếp theo là test thử JDK có work hay không nữa mà thôi.

Mọi thứ có vẻ ổn rồi.

Tổng kết

Mình đã hướng dẫn cách tạo một Windows AMIs đơn giản trên AWS rồi. Tùy vào nhu cầu của bạn sẽ tùy biến Packer template này theo ý muốn nhé.

Bạn có thể thấy là mình build Windows này một cách tự động, tuy nhiên việc tạo EC2 Instance thì mình lại thủ công. Bài viết sau mình sẽ hướng dẫn cách tạo và test Instance này một cách tự động luôn nhé, mình sẽ dùng Terraform thôi 😛

Cảm ơn các bạn đã theo dõi

5 1 vote
Article Rating
guest
1 Comment
Inline Feedbacks
View all comments
Người lạ ơi
Người lạ ơi
1 year ago

Bài viết rất hay và bổ ích