วิธีสร้าง Custom multi-arch container image สำหรับใช้ใน CI/CD Pipeline

ในโลกของ CI/CD ที่รวดเร็ว ทีม DevOps จำเป็นต้องมีเครื่องมือที่ยืดหยุ่นและมีประสิทธิภาพ การสร้าง custom container image ที่รองรับหลายสถาปัตยกรรม (multi-arch) เป็นกุญแจสำคัญในการปรับปรุงขั้นตอนการทำงานให้คล่องตัวและปลอดภัยยิ่งขึ้น ในบทความนี้ เราจะมาเจาะลึกวิธีการสร้าง multi-arch container image สำหรับ Bitbucket Pipeline บน AWS พร้อมตัวอย่างและคำอธิบาย Dockerfile อย่างละเอียด

Nont Banditwong
5 min readSep 24, 2024

ทำไมต้องสร้าง Custom Container Image

CI/CD platform ยอดนิยมอย่าง GitLab CI, GitHub Actions และ Bitbucket Pipeline มักใช้งานผ่าน container ซึ่งบางครั้งอาจไม่ตอบโจทย์ทุกความต้องการ การสร้าง custom image ช่วยให้:

  • รวมเครื่องมือที่จำเป็น: เพิ่มเครื่องมือเฉพาะ เช่น AWS CLI, notation, oras หรือ jq เข้าไปใน image ได้โดยตรง
  • เพิ่มความปลอดภัย: หลีกเลี่ยงการใช้ image จากแหล่งที่ไม่น่าเชื่อถือ
  • ควบคุมเวอร์ชัน: กำหนดเวอร์ชันของเครื่องมือต่างๆ ได้เอง

เจาะลึก Dockerfile

ในตัวอย่างนี้ เราจะสร้าง image ที่มี AWS CLI, notation, oras OCI registry client และ jq สำหรับใช้งานใน Bitbucket Pipeline บน AWS Infrastructure

Dockerfile นี้สร้าง image ที่รองรับทั้งสถาปัตยกรรม amd64 และ arm64

# --- Installer Stage (Multi-Arch) ---
FROM --platform=$BUILDPLATFORM public.ecr.aws/amazonlinux/amazonlinux:2023 AS installer

# Define architecture-specific variables
ARG TARGETOS TARGETARCH

ARG AWSCLI_FILE="awscliv2.zip"
ARG SIGNER_BINARY_FILE="aws-signer-notation-cli.rpm"
ARG ORAS_FILE="oras_linux.tar.gz"

ARG AWSCLI_LINK_AMD64="https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip"
ARG SIGNER_BINARY_LINK_AMD64="https://d2hvyiie56hcat.cloudfront.net/linux/amd64/installer/rpm/latest/aws-signer-notation-cli_amd64.rpm"
ARG ORAS_LINK_AMD64="https://github.com/oras-project/oras/releases/download/v1.2.0/oras_1.2.0_linux_amd64.tar.gz"

ARG AWSCLI_LINK_ARM64="https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip"
ARG SIGNER_BINARY_LINK_ARM64="https://d2hvyiie56hcat.cloudfront.net/linux/arm64/installer/rpm/latest/aws-signer-notation-cli_arm64.rpm"
ARG ORAS_LINK_ARM64="https://github.com/oras-project/oras/releases/download/v1.2.0/oras_1.2.0_linux_arm64.tar.gz"

RUN yum update -y && \
yum install -y unzip less groff jq tar gzip && \
# Conditional download and installation based on architecture
if [ "$TARGETARCH" == "amd64" ]; then \
curl $AWSCLI_LINK_AMD64 -o $AWSCLI_FILE && \
curl $SIGNER_BINARY_LINK_AMD64 -o $SIGNER_BINARY_FILE && \
curl -L $ORAS_LINK_AMD64 -o $ORAS_FILE && \
unzip $AWSCLI_FILE && \
./aws/install --bin-dir /aws-cli-bin/ && \
yum install -y $SIGNER_BINARY_FILE && \
tar -xzf $ORAS_FILE; \
elif [ "$TARGETARCH" == "arm64" ]; then \
curl $AWSCLI_LINK_ARM64 -o $AWSCLI_FILE && \
curl $SIGNER_BINARY_LINK_ARM64 -o $SIGNER_BINARY_FILE && \
curl -L $ORAS_LINK_AMD64 -o $ORAS_FILE && \
unzip $AWSCLI_FILE && \
./aws/install --bin-dir /aws-cli-bin/ && \
yum install -y $SIGNER_BINARY_FILE && \
tar -xzf $ORAS_FILE; \
fi && \
yum clean all

# --- Final Stage ---
FROM public.ecr.aws/amazonlinux/amazonlinux:2023

RUN yum update -y && \
yum install -y jq && \
yum clean all

COPY --from=installer /usr/local/aws-cli/ /usr/local/aws-cli/
COPY --from=installer /aws-cli-bin/ /usr/local/bin/
COPY --from=installer /usr/local/bin/notation /usr/local/bin/notation
COPY --from=installer /oras /usr/local/bin/oras
COPY --from=installer /root/.config /root/.config

WORKDIR /aws
ENTRYPOINT ["/usr/local/bin/aws"]

Dockerfile นี้สร้าง image ของ Docker ที่มี AWS CLI (Command Line Interface), AWS Signer binary, jq command-line JSON processor, และ ORAS (OCI Registry as Storage) ติดตั้งอยู่ ซึ่ง image นี้ถูกออกแบบมาให้รองรับหลายสถาปัตยกรรม (multi-architecture) โดยเฉพาะ amd64 และ arm64

Installer Stage (Multi-Arch)

FROM --platform=$BUILDPLATFORM public.ecr.aws/amazonlinux/amazonlinux:2023 AS installer

สร้าง stage ชื่อ “installer” โดยใช้ image amazonlinux:2023 เป็น base image

--platform=$BUILDPLATFORM ทำให้ Docker สร้าง image สำหรับสถาปัตยกรรมที่ตรงกับเครื่องที่ใช้สร้าง

ARG ...

กำหนดตัวแปรต่าง ๆ ที่จะใช้ใน Dockerfile เช่น ชื่อไฟล์, URL สำหรับดาวน์โหลด, และตัวแปรเฉพาะสถาปัตยกรรม

RUN ...

ทำการอัปเดตและติดตั้ง package ที่จำเป็น เช่น unzip, less, groff, jq, tar, gzip

ใน Dockerfile เดียวนี้สามารถใช้สร้างได้ทั้ง container image บน สถาปัตยกรรม amd64 และ arm64 โดยตรวจสอบสถาปัตยกรรม (TARGETARCH) และดาวน์โหลด/ติดตั้งเครื่องมือที่เกี่ยวข้องตามสถาปัตยกรรมนั้น ๆ โดยใน block ของแต่ละสถาปัตยกรรมจะมีการทำงานดังต่อไปนี้

  • ดาวน์โหลดไฟล์ AWS CLI, AWS Signer binary, และ ORAS
  • unzip ไฟล์ AWS CLI และติดตั้ง
  • ติดตั้ง AWS Signer binary
  • แตกไฟล์ ORAS
  • ลบไฟล์ที่ไม่จำเป็นออกเพื่อลดขนาด image

Final Stage

  • FROM public.ecr.aws/amazonlinux/amazonlinux:2023

สร้าง stage สุดท้ายโดยใช้ amazonlinux:2023 เป็น base image อีกครั้ง

  • RUN ...

อัปเดตและติดตั้ง jq และลบไฟล์ที่ไม่จำเป็นออก

  • COPY --from=installer ...

คัดลอกไฟล์และโฟลเดอร์ที่ติดตั้งไว้ใน stage “installer” ไปยัง stage สุดท้าย คัดลอก AWS CLI, AWS Signer binary, ORAS, และไฟล์ configuration บางส่วน

  • WORKDIR /aws

ตั้งค่า working directory ภายใน container เป็น /was

  • ENTRYPOINT ["/usr/local/bin/aws"]

กำหนดให้ /usr/local/bin/aws (AWS CLI) เป็นคำสั่งเริ่มต้นเมื่อ container เริ่มทำงาน

Docker manifest

ในขั้นตอนสุดท้ายคือการสร้าง container image ในแต่ละสถาปัตยกรรมแล้วนำ image ที่สร้างมาในแต่ละสถาปัตยกรรมรวมี้เข้าด้วยกันเป็น Docker manifest เดียว ทำให้ง่ายต่อการดึง (pull) และใช้งาน image ที่ถูกต้องโดยอัตโนมัติตามสถาปัตยกรรมของระบบที่ใช้ในการ pull

การสร้าง container image จะทำผ่าน Bitbucket Pipeline โดยการสร้าง container image ในแต่ละสถาปัตยกรรมจะใช้วิธีแบบ native โดยไม่ผ่าน emulator อย่าง buildx เนื่องจากทำงานค่อนข้างช้า และบางครั้ง buildx ทำงานใน runner ไม่เสร็จจน Job ตายไป

การสร้าง multi-arch container image ด้วย Bitbucket pipeline ต้องทำการเตรียม Bitbucket runner ตาม สถาปัตยกรรมของ container ที่ต้องการสร้างเช่นในกรณีตัวอย่างนีคือสถาปัตยกรรม amd64 และ arm64 อย่างละตัว

Job ของ Bitbucket pipeline จะทำงานบน runner ในแต่ละสถาปัตกรรมโดยดูจาก label เช่นในตัวอย่างใช้ label my-amd64-runner สำหรับสถาปัตยกรรมแบบ amd64 และ my-arm64-runner สำหรับสถาปัตยกรรมแบบ arm64

bitbucket-pipelines.yml เพื่อใช้ในการสร้าง container image

# You can specify a custom docker image from Docker Hub as your build environment.
image: public.ecr.aws/aws-cli/aws-cli

definitions:
services:
docker: # Define a custom docker daemon - can only be used with a self-hosted runner
image: public.ecr.aws/docker/library/docker:dind
memory: 2048

pipelines:
default:
- parallel:
- step:
name: Container build AMD64
oidc: true
runs-on:
- linux
- self.hosted
- my-amd64-runner
services:
- docker
script:
- export TAG=$(date +%Y%m%d) # Format: YYYYMMDD
- export CI_REGISTRY_IMAGE="$CI_ECR_URI/cicd/aws-cli"
# Retrieve an authentication token and authenticate your Docker client to the registr
- export AWS_REGION=$CI_AWS_DEFAULT_REGION
- export AWS_ROLE_ARN=$CI_AWS_OIDC_ROLE_ARN
- export AWS_WEB_IDENTITY_TOKEN_FILE=$(pwd)/web-identity-token
- echo $BITBUCKET_STEP_OIDC_TOKEN > $(pwd)/web-identity-token
# Login ECR container registry
- export $(printf "AWS_ECR_LOGIN_PASSWORD=%s" $(aws ecr get-login-password --region $CI_AWS_DEFAULT_REGION))
- echo $AWS_ECR_LOGIN_PASSWORD | docker login --username AWS --password-stdin $CI_ECR_URI
# Build and Push container image
- docker build --platform linux/amd64 --pull --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$TAG-amd64 .
- docker push $CI_REGISTRY_IMAGE --all-tags

- step:
name: Container build ARM64
oidc: true
runs-on:
- linux.arm64
- self.hosted
- my-arm64-runner
services:
- docker
script:
- export TAG=$(date +%Y%m%d) # Format: YYYYMMDD
- export CI_REGISTRY_IMAGE="$CI_ECR_URI/cicd/aws-cli"
# Retrieve an authentication token and authenticate your Docker client to the registr
- export AWS_REGION=$CI_AWS_DEFAULT_REGION
- export AWS_ROLE_ARN=$CI_AWS_OIDC_ROLE_ARN
- export AWS_WEB_IDENTITY_TOKEN_FILE=$(pwd)/web-identity-token
- echo $BITBUCKET_STEP_OIDC_TOKEN > $(pwd)/web-identity-token
# Login ECR container registry
- export $(printf "AWS_ECR_LOGIN_PASSWORD=%s" $(aws ecr get-login-password --region $CI_AWS_DEFAULT_REGION))
- echo $AWS_ECR_LOGIN_PASSWORD | docker login --username AWS --password-stdin $CI_ECR_URI
# Build and Push container image
- docker build --platform linux/arm64 --pull --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$TAG-arm64 .
- docker push $CI_REGISTRY_IMAGE --all-tags

- step:
name: Manifest
oidc: true
runs-on:
- linux
- self.hosted
- my-amd64-runner
services:
- docker
script:
- export TAG=$(date +%Y%m%d) # Format: YYYYMMDD
- export CI_REGISTRY_IMAGE="$CI_ECR_URI/cicd/aws-cli"
# Retrieve an authentication token and authenticate your Docker client to the registr
- export AWS_REGION=$CI_AWS_DEFAULT_REGION
- export AWS_ROLE_ARN=$CI_AWS_OIDC_ROLE_ARN
- export AWS_WEB_IDENTITY_TOKEN_FILE=$(pwd)/web-identity-token
- echo $BITBUCKET_STEP_OIDC_TOKEN > $(pwd)/web-identity-token
# Login ECR container registry
- export $(printf "AWS_ECR_LOGIN_PASSWORD=%s" $(aws ecr get-login-password --region $CI_AWS_DEFAULT_REGION))
- echo $AWS_ECR_LOGIN_PASSWORD | docker login --username AWS --password-stdin $CI_ECR_URI
- docker pull $CI_REGISTRY_IMAGE:$TAG-arm64
- docker pull $CI_REGISTRY_IMAGE:$TAG-amd64
- docker manifest create $CI_REGISTRY_IMAGE:$TAG $CI_REGISTRY_IMAGE:$TAG-amd64 $CI_REGISTRY_IMAGE:$TAG-arm64
- docker manifest annotate $CI_REGISTRY_IMAGE:$TAG $CI_REGISTRY_IMAGE:$TAG-amd64 --os linux --arch amd64
- docker manifest annotate $CI_REGISTRY_IMAGE:$TAG $CI_REGISTRY_IMAGE:$TAG-arm64 --os linux --arch arm64
- docker manifest push $CI_REGISTRY_IMAGE:$TAG

code ด้านบนเป็น Bitbucket Pipeline ที่สร้าง Docker image สำหรับสถาปัตยกรรม amd64 และ arm64 โดย Job ที่ใช้สร้างจะทำงานพร้อมกัน จากที่ระบุ parallel step ของ Job Container build AMD64 และ Container build ARM64

Bitbucket Pipeline

จากนั้นจึงรวมเข้าด้วยกันเป็น Docker manifest เดียว ใน Job Manifest โดยอธิบายการทำงานของ code คร่าวๆดังนี้

  • docker pull $CI_REGISTRY_IMAGE:$TAG-arm64

pull image ที่มีชื่อเก็บในตัวแปร$CI_REGISTRY_IMAGE และ tag $TAG-arm64 จาก registry ที่ระบุในตัวแปร $CI_REGISTRY_IMAGE ของ Bitbucket pipeline ซึ่งคือ URI ของ ECR เช่น <My AWS Account ID>.dkr.ecr.ap-southeast-1.amazonaws.com และ ตัวแปร TAG ได้มาจากวันที่ปัจจุบันตามรูปแบบ YYYYMMDD นอกจากนั้น Image นี้สร้างขึ้นสำหรับสถาปัตยกรรม arm64

  • docker pull $CI_REGISTRY_IMAGE:$TAG-amd64

เหมือน Image ก่อนหน้าแต่สำหรับสถาปัตยกรรม amd64

  • docker manifest create $CI_REGISTRY_IMAGE:$TAG $CI_REGISTRY_IMAGE:$TAG-amd64 $CI_REGISTRY_IMAGE:$TAG-arm64

สร้าง manifest ใหม่ที่มีชื่อ $CI_REGISTRY_IMAGE และ tag $TAG Manifest นี้จะอ้างอิงถึง image ทั้งสองที่ดึงมาในขั้นตอนก่อนหน้า ($CI_REGISTRY_IMAGE:$TAG-amd64 และ $CI_REGISTRY_IMAGE:$TAG-arm64)

  • docker manifest annotate $CI_REGISTRY_IMAGE:$TAG $CI_REGISTRY_IMAGE:$TAG-amd64 --os linux --arch amd64

เพิ่ม metadata ให้กับ manifest เพื่อระบุว่า image $CI_REGISTRY_IMAGE:$TAG-amd64 มีไว้สำหรับระบบปฏิบัติการ Linux และสถาปัตยกรรม amd64

  • docker manifest annotate $CI_REGISTRY_IMAGE:$TAG $CI_REGISTRY_IMAGE:$TAG-arm64 --os linux --arch arm64

เพิ่ม metadata ให้กับ manifest เพื่อระบุว่า image $CI_REGISTRY_IMAGE:$TAG-arm64 มีไว้สำหรับระบบปฏิบัติการ Linux และสถาปัตยกรรม arm64

  • docker manifest push $CI_REGISTRY_IMAGE:$TAG

อัปโหลด (push) manifest ที่สร้างและกำหนดค่าแล้วไปยัง registry ที่ระบุในตัวแปร $CI_REGISTRY_IMAGE

การใช้งาน

เวลาเรียกใช้ หรือ pull image ถ้าอยู่บน environment ที่เป็น amd64 เช่นบน EC2 แล้วใช้คำสั่ง docker pull

docker pull <My AWS Account ID>.dkr.ecr.ap-southeast-1.amazonaws.com/cicd/aws-cli:20240922

Docker จะไป pull image ที่เป็น สถาปัตยกรรม amd64 ซึ่ง image จริงๆคือ <My AWS Account ID>.dkr.ecr.ap-southeast-1.amazonaws.com/cicd/aws-cli:20240922-amd64

เช่นเดียวกัน ถ้าอยู่บน environment ที่เป็น arm64 ก็จะได้ image AWS Account ID>.dkr.ecr.ap-southeast-1.amazonaws.com/cicd/aws-cli:20240922-amd64 กลับมา

--

--

Nont Banditwong

Cloud Engineering Specialist, Software Developer, System Engineer, Photographer and Learner