Pushing Docker Images to AWS ECR

I’ve been using the image registry on GitLab for quite a while now and loved the convenience of having my images living in the same place as my code. However, recently GitLab introduced a soft limit on transfers and that’s cramping my style. I’m moving a lot of my images onto Amazon Elastic Container Registry (ECR). In this post I look at how to get this set up.

Setup Registry on ECR

The first thing that we’ll need to do is get a repository set up on ECR.

  1. Go to the ECR dashboard.
  2. Press .
  3. Choose a name for the repository and press . For the purpose of this post we’ll assume that the repository name is “crawler”.
  4. Select the newly created repository and press . This will give you all of the information that you need for login, tagging and pushing. Take a screenshot of this for future reference.

AWS Permissions

You can use existing AWS credentials. If you don’t have a suitable AWS user for pushing an image to ECR then you’ll need to create one. The quick and easy approach to this is to create an IAM user with programmatic access (I name it “registry”) and AmazonEC2ContainerRegistryFullAccess permissions. These permissions are probably too permissive, but they will work for now.

Add the access key and secret access key to your ~/.aws/credentials file. Let’s call that profile entry “registry” too (although you can choose another, more appropriate name!).

[registry]
aws_access_key_id = AKIAZUYM3M4576QJWSX6
aws_secret_access_key = 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

Test ECR Login

We can immediately test access to the repository by requesting login credentials using the aws CLI. First we’ll set up a couple of environment variables to store the profile name, region and AWS account ID.

AWS_PROFILE=registry
AWS_REGION=us-east-1
AWS_ACCOUNT_ID=999999999999

Now request the credentials.

aws ecr get-login-password --region $AWS_REGION --profile $AWS_PROFILE

If all goes well then the response will be a Base64 encoded string. You can decode that with base64 -d and then pipe into jq. The result should be something like this (the values for the payload and datakey fields have been redacted):

{
  "payload": "...",
  "datakey": "...",
  "version": "2",
  "type": "DATA_KEY",
  "expiration": 1645469652
}

The expiration value indicates (use https://www.epochconverter.com/ to convert to date and time) that the credentials are valid for 12 hours.

Pushing by Hand

Let’s start by taking a look at how to manually push an image to ECR. First we’ll need to connect our local Docker to the repository.

# Store credentials in environment variable.
ECR_LOGIN=$(aws ecr get-login-password --region $AWS_REGION --profile $AWS_PROFILE)
# Docker login to repository.
docker login --username AWS --password $ECR_LOGIN $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com

Create a suitable tag.

# Store image name.
IMAGE=crawler
# Create tag (which bundles image name and account ID).
TAG_LATEST=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE:latest

Let’s see what that looks like.

echo $TAG_LATEST
999999999999.dkr.ecr.us-east-1.amazonaws.com/crawler:latest

It’s a bit clunky, but that’s because it includes not only the name for the image but also the AWS account ID and region.

Now all we need to do is push.

docker push $TAG_LATEST

If you pop over to the repository on ECR you’ll find that it now has an image in it. To use the image, click on the image tag in ECR and copy the image URI.

Pushing with a Makefile

Okay, so now we have established that the whole things works. Cool. But performing each of those steps every time we want to update the image is going to get very tiresome very quickly. Time to roll in some automation. We’ll do this incrementally. First we’ll set up a Makefile which can be used to orchestrate the steps in the process with minimal typing. Then we’ll move the entire workload across onto GitLab CI.

But first the Makefile. Start off by setting up some variables. These mirror the ones that we created in BASH above.

IMAGE = crawler
AWS_PROFILE = registry
AWS_REGION = us-east-1
AWS_ACCOUNT_ID = 999999999999
AWS_SERVER = $(AWS_ACCOUNT_ID).dkr.ecr.$(AWS_REGION).amazonaws.com
TAG_LATEST = $(AWS_SERVER)/$(IMAGE):latest

Now define three targets: build, login and push.

build:
    docker build --cache-from $(TAG_LATEST) -t $(TAG_LATEST) .

login:
    aws ecr get-login-password --region $(AWS_REGION) --profile $(AWS_PROFILE) | \
    docker login --username AWS --password-stdin $(AWS_SERVER)

push: build loging
    docker push $(TAG_LATEST)

The build target will simply update the local image. The login target probably never needs to be run explicitly. However, the push target will run it implicitly. It’ll also run the build target and then push the resulting image onto ECR.

Once the Makefile is in place it’s a simple process to build and push the image.

make build                        # Not strictly necessary (implicit in "push" target).
make push

GitLab

Okay, so the Makefile significantly reduced the amount of work required to get the image onto ECR. But ideally I don’t even want to have to even have to run make. What if the image could be built and transferred to ECR each time I pushed my work to the remote Git repository? Let’s enlist GitLab CI to do precisely that.

There’s a little configuration work required on GitLab to get this working. I set up the following CI environment variables:

  • AWS_ACCOUNT_ID
  • AWS_REGION
  • AWS_ACCESS_KEY_ID and
  • AWS_SECRET_ACCESS_KEY.

The access key and secret access key are those for the IAM user created earlier.

The CI pipeline is configured via the .gitlab-ci.yml file.

stages:
  - build

variables:
  IMAGE_NAME: crawler
  TAG_LATEST: $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_NAME:latest
  TAG_COMMIT: $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA
  DOCKER_TLS_CERTDIR: ""

docker:
  image: docker:stable
  stage: build
  only:
    - master
  services:
    - docker:dind
  before_script:
    - apk add --no-cache python3 py3-pip
    - pip3 install --no-cache-dir awscli
  script:
    - aws ecr get-login-password --region $AWS_REGION |
      docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com
    - docker pull $TAG_LATEST || true
    - docker build --cache-from $TAG_LATEST -t $TAG_COMMIT -t $TAG_LATEST .
    - docker push $TAG_COMMIT
    - docker push $TAG_LATEST

The variables are similar to the ones that we created in the Makefile. I’ve added in another tag now which is linked to the commit SHA. This will mean that, in addition to the latest image being available on ECR, I’ll also have a history of all previous images. The meat of the configuration lies in the script section of the image job. Let’s step through each command:

  1. Login to the Docker registry on ECR. Unlike the corresponding command in the Makefile we’re not using the --profile option here because the corresponding information is picked up from the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.
  2. Pull the most recent version of the image. This will speed up the build process because it will act as a cache and prevent unchanged layers from being rebuilt.
  3. Build the image (using the most recent version of the image as a cache). Add two tags.
  4. Push using the commit tag.
  5. Push using the latest tag.

Conclusion

I’ll be using this as a reference whenever I forget one of the stages in getting this all set up. I hope that it’s useful to you too.