How to Build and Push a Docker Image to ECR with Terraform
The build/push of docker images to ECR using Terraform can be accomplished using the null provider. Depending on the version of Terraform you have running, a more favourable (or “modern”) alternative would be to use providers/kreuzwerker.
With that said, the null provider
can make for a good fallback option. This article provides a step-by-step demo showing how to use this provider to build/push an image to ECR.
Prerequisites
If you choose to run the code samples using your own AWS account, be mindful that this will incur a cost. ECR is not part of the free-tier, however, the image being pushed is only a couple of BYTES in size, which should keep costs low. Alternatively, you could explore using localstack.
The following sample values are used throughout the demo. These will need to be changed to reflect values matching your environment.
- AWS account id:
111111111111
- AWS CLI profile name: demo_terraform
Below is a sample profile
$HOME/.aws/config
[profile demo_terraform]
region = us-east-1
output = json
aws_access_key_id = XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
aws_secret_access_key = YYYYYYYYYYYYYYYYYYYYYYYYYYYYYY
3. Repo named demo needs to be created in your default ECR registry
For the demo, 111111111111.dkr.ecr.us-east-1.amazonaws.com/demo
is used.
You can create the repo using the command:
$ aws --profile demo_terraform ecr create-repository --repository-name demo
Create Terraform and Docker folders/files
Create Demo Project Folder Structure
$ mkdir -p $HOME/demo/docker-src
For the purposes of the demo, a very small image based off scratch will be used.
Create Docker Image files
- Dockerfile
$HOME/demo/docker-src/Dockerfile
FROM scratch
COPY docker-src/demofile /
CMD ["/demofile"]
- Since ECR doesn’t seem to support images with empty layers, we’ll create a file to copy over to the image
- Create
$HOME/demo/docker-src/demofile
with contents as below
demofile
Create Terraform main.tf
Create the file shown below $HOME/demo/main.tf
and be sure to update the following to match your account:
aws_account = "111111111111"
aws_region = "us-east-1"
$HOME/demo/main.tf
terraform {
required_version = ">= 0.12"
required_providers {
aws = {
source = "hashicorp/aws"
}
}
}
provider "aws" {
region = local.aws_region
profile = local.aws_profile
}
locals {
//////////////////////////////////////////////////////////////////////////////////////////////
///////////// Substitute below values to match your AWS account, region & profile //////////////////////////////////////////////////////////////////////////////////////////////
aws_account = "111111111111" # AWS account
aws_region = "us-east-1" # AWS region
aws_profile = "demo_terraform" # AWS profile
/////////////////////////////////////////////////////////////////////////////////////////////
ecr_reg = "${local.aws_account}.dkr.ecr.${local.aws_region}.amazonaws.com" # ECR docker registry URI
ecr_repo = "demo" # ECR repo name
image_tag = "latest" # image tag
dkr_img_src_path = "${path.module}/docker-src"
dkr_img_src_sha256 = sha256(join("", [for f in fileset(".", "${local.dkr_img_src_path}/**") : file(f)]))
dkr_build_cmd = <<-EOT
docker build -t ${local.ecr_reg}/${local.ecr_repo}:${local.image_tag} \
-f ${local.dkr_img_src_path}/Dockerfile .
aws --profile ${local.aws_profile} ecr get-login-password --region ${local.aws_region} | \
docker login --username AWS --password-stdin ${local.ecr_reg}
docker push ${local.ecr_reg}/${local.ecr_repo}:${local.image_tag}
EOT
}
variable "force_image_rebuild" {
type = bool
default = false
}
# local-exec for build and push of docker image
resource "null_resource" "build_push_dkr_img" {
triggers = {
detect_docker_source_changes = var.force_image_rebuild == true ? timestamp() : local.dkr_img_src_sha256
}
provisioner "local-exec" {
command = local.dkr_build_cmd
}
}
output "trigged_by" {
value = null_resource.build_push_dkr_img.triggers
}
Final Directory Structure
The final project structure and contents should resemble the following:
$HOME/demo
|
├── docker-src
│ ├── Dockerfile
│ └── demofile
├── main.tf
Overview of main.tf Logic
The key local variables defined within main.tf are as follows:
...
locals {
...
...
dkr_img_src_path = "${path.module}/docker-src"
dkr_img_src_sha256 = sha256(join("", [for f in fileset(".", "${local.dkr_img_src_path}/**") : file(f)]))
dkr_build_cmd = <<-EOT
docker build -t ${local.ecr_reg}/${local.ecr_repo}:${local.image_tag} \
-f ${local.dkr_img_src_path}/Dockerfile .
aws --profile ${local.aws_profile} ecr get-login-password --region ${local.aws_region} | \
docker login --username AWS --password-stdin ${local.ecr_reg}
docker push ${local.ecr_reg}/${local.ecr_repo}:${local.image_tag}
EOT
dkr_img_src_path
: This is the path to where all docker build artifacts are located, and resolves to$HOME/demo/docker-src/
dkr_img_src_sha256
: This variable concatenates (joins) the contents of all files at the above path (recursively) and calculates their combined SHA256 hash.
Why are we doing this? Remember that thenull_resource
operator will do nothing unless it is "triggered". The sha256 hash is our trigger. In other words, when this hash changes value, the image should be built/refreshed and pusheddkr_build_cmd
: shell commands (heredoc) which are sent to the null_resource provider to build and push the image to ECR
You will also notice an additional variable (more on why this variable has been included later on):
variable "force_image_rebuild" {
type = bool
default = false
}
Terraform Init/Plan/Apply
Now we can run some commands and make some observations.
Initialise Terraform
$ cd $HOME/demo
$ terraform init
output:
Initializing the backend...
Initializing provider plugins...
- Finding latest version of hashicorp/aws...
- Finding latest version of hashicorp/null...
- Installing hashicorp/null v3.
...
...
Generate Plan
Before we run a build, we’ll see what the plans looks like:
$ terraform plan
output:
...
...
Terraform will perform the following actions:
# null_resource.build_push_dkr_img will be created
+ resource "null_resource" "build_push_dkr_img" {
+ id = (known after apply)
+ triggers = {
+ "detect_docker_source_changes" = "c028c0608dc3639236b8d379e8994fbcff3cfe4d70997aa7cabab362c5860afd"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
...
From the output of an initial plan, we can see the null_resource
was triggered by the sha256 hash of the joined/concatenated contents of the docker artifacts.
Apply the Plan
Appy the plan to build and push to ECR:
$ terraform apply --auto-approve
Output :
null_resource.build_push_dkr_img: Creating...
null_resource.build_push_dkr_img: Provisioning with 'local-exec'...
null_resource.build_push_dkr_img (local-exec): Executing: ["/bin/sh" "-c" "docker build -t 111111111111.dkr.ecr.us-east-1.amazonaws.com/demo:latest \\\n -f ./docker-src/Dockerfile .\n\naws --profile demo_terraform ecr get-login-password --region us-east-1 | \\\n docker login --username AWS --password-stdin 111111111111.dkr.ecr.us-east-1.amazonaws.com\n\ndocker push 111111111111.dkr.ecr.us-east-1.amazonaws.com/demo:latest\n"]
null_resource.build_push_dkr_img (local-exec): #1 [internal] load build definition from Dockerfile
null_resource.build_push_dkr_img (local-exec): #1 transferring dockerfile: 95B done
null_resource.build_push_dkr_img (local-exec): #1 DONE 0.0s
null_resource.build_push_dkr_img (local-exec): #2 [internal] load .dockerignore
null_resource.build_push_dkr_img (local-exec): #2 transferring context: 2B done
null_resource.build_push_dkr_img (local-exec): #2 DONE 0.0s
null_resource.build_push_dkr_img (local-exec): #3 [internal] load build context
null_resource.build_push_dkr_img (local-exec): #3 transferring context: 70B done
null_resource.build_push_dkr_img (local-exec): #3 DONE 0.0s
null_resource.build_push_dkr_img (local-exec): #4 [1/1] COPY docker-src/demofile /
null_resource.build_push_dkr_img (local-exec): #4 CACHED
null_resource.build_push_dkr_img (local-exec): #5 exporting to image
null_resource.build_push_dkr_img (local-exec): #5 exporting layers done
null_resource.build_push_dkr_img (local-exec): #5 writing image sha256:03fd9c7b6c755b862c2a9fdf6bdba018c8ecc72a3a0b594fe23ca510d7422458 done
null_resource.build_push_dkr_img (local-exec): #5 naming to 111111111111.dkr.ecr.us-east-1.amazonaws.com/demo:latest done
null_resource.build_push_dkr_img (local-exec): #5 DONE 0.0s
null_resource.build_push_dkr_img (local-exec): WARNING! Your password will be stored unencrypted in /home/lts/.docker/config.json.
null_resource.build_push_dkr_img (local-exec): Configure a credential helper to remove this warning. See
null_resource.build_push_dkr_img (local-exec): https://docs.docker.com/engine/reference/commandline/login/#credentials-store
null_resource.build_push_dkr_img (local-exec): Login Succeeded
null_resource.build_push_dkr_img (local-exec): The push refers to repository [111111111111.dkr.ecr.us-east-1.amazonaws.com/demo]
null_resource.build_push_dkr_img (local-exec): 2fe8976e3c80: Preparing
null_resource.build_push_dkr_img (local-exec): 2fe8976e3c80: Layer already exists
null_resource.build_push_dkr_img (local-exec): latest: digest: sha256:8d17d369bb7831b67f42dd7861e15eaa26b68e079377d38c29884d467b09f223 size: 523
null_resource.build_push_dkr_img: Creation complete after 8s [id=2414129855178391836]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
trigged_by = {
"detect_docker_source_changes" = "c028c0608dc3639236b8d379e8994fbcff3cfe4d70997aa7cabab362c5860afd"
}
As expected, the image is built and pushed to ECR.
- What happens if we run the apply again without making any changes to files within the docker artifacts directory (
$HOME/demo/docker-src
)?
$ terraform plan
output:
null_resource.build_push_dkr_img: Refreshing state... [id=2414129855178391836]
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
Outputs:
trigged_by = {
"detect_docker_source_changes" = "c028c0608dc3639236b8d379e8994fbcff3cfe4d70997aa7cabab362c5860afd"
}
SHA256 hash is the same, i.e. no changes have been made to the docker image artifacts since last apply. Terraform takes no action.
2. What happens if we change a docker build artifact?
- Let’s make a change to a file within the docker artifacts directory
$HOME/demo/docker-src
- We’ll change the contents of
$HOME/demo/docker-src/demofile
from :
demofile
to
demofile has changed
- Now run terraform plan
$ terraform plan
output:
Terraform will perform the following actions:
# null_resource.build_push_dkr_img must be replaced
-/+ resource "null_resource" "build_push_dkr_img" {
~ id = "2414129855178391836" -> (known after apply)
~ triggers = { # forces replacement
~ "detect_docker_source_changes" = "c028c0608dc3639236b8d379e8994fbcff3cfe4d70997aa7cabab362c5860afd" -> "da45847cea6180ae387eca5373cb0e0d40afacf180ad6b5b5c1fe43dfa7ac1e7"
}
}
Plan: 1 to add, 0 to change, 1 to destroy.
Changes to Outputs:
~ trigged_by = {
~ "detect_docker_source_changes" = "c028c0608dc3639236b8d379e8994fbcff3cfe4d70997aa7cabab362c5860afd" -> "da45847cea6180ae387eca5373cb0e0d40afacf180ad6b5b5c1fe43dfa7ac1e7"
}
- A rebuild of the image is expected as a result of the update we made to the contents of
$HOME/demo/docker-src/demofile
. The change in contents has changed the value of the calculated sha256 hash
from:
c028c0608dc3639236b8d379e8994fbcff3cfe4d70997aa7cabab362c5860afd
to
da45847cea6180ae387eca5373cb0e0d40afacf180ad6b5b5c1fe43dfa7ac1e7
- Apply the plan and examine the output to confirm the image is rebuilt and pushed
$ terraform apply --auto-approve
3. What if we wanted to build/push the image regardless of whether or not the docker build artifacts have changed?
- Getting back to the variable mentioned earlier
force_image_rebuild
:
variable "force_image_rebuild" {
type = bool
default = false
}
- As we can see, the default value is “false”.
- The trigger defined in the
null resource
is as follows:
triggers = {
detect_docker_source_changes = var.force_image_rebuild == true ? timestamp() : local.dkr_img_src_sha256
}
- By overriding the default value of variable
force_image_rebuild
fromfalse
totrue
, we are effectively changing thenull resource
trigger from the sha256 hash calculation totimestamp()
- Terraform would now build/push the image regardless of whether or not contents of
$HOME/demo/docker-src
have changed - To test if this works, run the following:
$ terraform apply -var "force_image_rebuild=true" --auto-approve
Terraform should build/push the image, with message in the output log that includes:
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
Outputs:
trigged_by = {
"detect_docker_source_changes" = "2023-mm-ddT06:29:28Z"
}