GitHub Actions로 ARM64 플랫폼 빌드 및 배포
무료 ARM architecture 인스턴스를 사용하려고 Oracle Cloud 회원가입을 했다. ’Arm 기반 Ampere A1 코어 및 24GB 메모리’라니 후발주자라지만 너무 퍼주는 거 아냐? 남 걱정해 주긴 이르다. 여분 인스턴스가 없다고 안 만들어진다. 유료 서비스를 사용 안 하더라도 결제 카드를 등록해 놓으면 된다는 팁을 봤다. 등록하니 바로 만들어진다. 지갑을 열어 돈은 안 꺼내더라도 지갑을 열어놓은 고객만 대우해 주는구나. 뭐 그래도 무료 체험으로 이런 고사양일 줘서 땡큐.
Heroku 서비스를 사용 중인 Tbot-800.ex가 생각났다. 지금까지 유료 서비스를 썼으면 그동안 무료 서비스를 사용한 은혜는 다 갚았다. 이제 옮기자 싶었다. Tbot-800.ex는 CPU 같은 거라도 달려 있으면 돌아가는 아주 가벼운 프로그램이다. 이런 프로그램이 사용하기에 아주 과분하지만 ARM architecture 대상 빌드 및 배포 연습하기에 좋은 프로젝트다. 언어는 Elixir이고 빌드 및 배포는 GitHub Actions를 사용했다.
요약 by human
- 무료 ARM 아키텍처 인스턴스를 사용하려고 Oracle Cloud 가입
- Heroku 서비스를 사용 중인 Tbot-800 프로젝트를 옮겼다
- 사용 언어는 Elixir, GitHub Actions를 사용해 빌드 및 배포
- Mix release로 배포 바이너리 빌드
- scp-action를 사용해 빌드 결과물 배포
- QEMU를 사용해 Docker를 ARM으로 실행할 수 있게 세팅
- GitHub-hosted ARM64 runner가 preview 상태여서
Mix release로 배포 바이너리 빌드
Elixir 언어 표준 배포 바이너리를 만드는 방법인 Mix release를 사용한다. self-contained라서 편리하다. 즉, 실행에 필요한 Erlang, Elixir 런타임을 모두 탑재하고 있어서 실행하려고 다른 걸 설치할 필요가 없다.
배포 바이너리를 만드는 script/release
스크립트를 짰다.
#!/bin/sh
set -e
cd "$(dirname "$0")/.."
script/bootstrap
MIX_ENV=prod mix release --overwrite
_build/prod/rel/[APP_NAME]
디렉터리에 빌드 결과물이 나온다. ohyecloudy/template-elixir - github.com 템플릿 저장소에도 스크립트를 추가했다.
scp-action
을 사용해 빌드 결과물 배포
빌드한 애플리케이션을 실행할 타겟 머신에 배포해야 한다. SCP(secure copy protocol)를 사용해서 복사할 계획이다. appleboy/scp-action GitHub Actions를 사용했다. GitHub Actions workflow에 사용할 수 있는 액션을 GitHub 저장소로 관리할 수 있게 한 걸 보면 GitHub이 설계를 정말 잘한 것 같다. GitHub 서비스를 강화할 수 있는 보완재(complement) 역할을 하기도 하기 때문이다.
SCP를 사용하려면 private key 보관이 필요하다. GitHub이 제공하는 저장소 환경 secrets 저장 기능을 사용했다. 보안을 위해 배포 디렉터리에 파일을 쓸 수 있는 권한을 가진 linux 계정을 별도로 생성했다.
- name: Deploy via scp
uses: appleboy/scp-action@v0.1.7
with:
username: $
host: $
key: $
source: _build/prod/rel/APPNAME/*
target: $
strip_components: 4
scp 프로그램을 사용해 봤다면 용도를 알 수 있는 필드에 값을 채우면 된다. strip_components
옵션을 사용해 복사할 때, 앞의 path를 제거할 수 있다. 이 옵션을 사용하지 않으면 target
을 ~/app
디렉터리로 설정했을 때, ~/app/_build_prod/rel/APPNAME
디렉터리를 만들어서 빌드 결과물을 복사한다. 난 ~/app
디렉터리에 바로 복사하는 걸 원하는데 말이다. 뒤에 /
문자 붙여보고 /*
붙여보고 난리를 떨었다. 다 통하지 않는다. strip_components
옵션만이 우리를 도와줄 수 있다. 예전에 분명 tar –strip-components 옵션을 몰라서 똑같이 /
문자 붙여보고 /*
붙여보고 난리를 떨었는데 말이다. 정말 잘 안 외워지는 옵션이다.
여기까지 했다면 아키텍처에 상관없이 바이너리 빌드를 만들고 배포할 수 있다. 이제 ARM architecture 바이너리를 어떻게 하면 만들 수 있는지 알아보자.
QEMU를 사용해 ARM 아키텍처 지원 Docker를 세팅
Elixir에서 ARM 아키텍처에서 실행할 수 있는 빌드 바이너리를 어떻게 만들까? 잠깐만! BEAM(Erlang virtual machine)이 다 해주는 거 아냐? VM이 있는데 왜 아키텍처 타령? C 코드를 호출하는 NIF(Native Implemented Function)를 사용하는 라이브러리가 하나도 없다면 맞는 말이다. 하지만 알게 모르게 NIF를 사용하는 라이브러리가 의존성 그물에 하나는 걸려들기 마련이다. 의존성을 빌드하는 과정에서 C 코드를 빌드한다. 그래서 배포 타겟 아키텍처와 같은 아키텍처 머신에서 빌드를 해줘야 한다. 게다가 위에서 Mix release가 실행에 필요한 Elixir, Erlang 런타임을 아키텍처에 맞게 배포하기 위해서도 아키텍처를 맞춰 줄 필요가 있다.
GitHub-hosted ARM64 runner가 있긴 있다. 글을 쓰는 에는 public preview 상태라서 x64 runner에 QEMU를 세팅해서 ARM architecture로 Docker를 실행할 수 있게 세팅한다.
- name: Set up QEMU for ARM64 emulation
uses: docker/setup-qemu-action@v2
with:
platforms: arm64
이제 Docker로 ARM 아키텍쳐를 에뮬레이션하는 컨테이너를 실행할 수 있다.
Asdf .toolversion
파일에서 docker image reference 문자열 만들기
Asdf GitHub actions으로 Erlang과 Elixir를 세팅할 때와 다르게 사용할 Docker image reference를 만들어서 사용해야 한다. 준비한 ARM 아키텍처를 에뮬레이션하는 Docker를 사용해서 빌드할 계획이기 때문이다. 대신 버전을 직접 정의하지 않고 Asdf에서 사용하는 .toolversion
파일을 읽어서 Docker image reference를 만들자. Erlang과 Elixir 버전을 한 곳에서만 관리하고 싶다.
아래는 .toolversion
파일 내용이다.
erlang 27.2.2
elixir 1.17.3-otp-27
Elixir 버전에 Erlang 버전과 라이브러리를 통칭하는 otp-27
문자열이 있다. 그래서 Erlang은 파싱하지 않고 Elixir 버전만 파싱하면 된다.
#!/bin/bash
set -e
cd "$(dirname "$0")/.."
if [ ! -f .tool-versions ]; then
echo "? Error: .tool-versions file not found!" >&2
exit 1
fi
ELIXIR_VERSION=$(awk '/^elixir / {print $2}' .tool-versions)
if [ -z "$ELIXIR_VERSION" ]; then
echo "? Error: Elixir version not found in .tool-versions!" >&2
exit 1
fi
DOCKER_IMAGE="elixir:${ELIXIR_VERSION}"
echo "$DOCKER_IMAGE"
script/build_docker_image_reference 스크립트 파일을 만들었다. 이 스크립트는 Elixir 버전이 1.17.3-otp-27
이라면 elixir:1.17.3-otp-27
를 리턴한다.
ARM architecture로 빌드와 테스트
위에서 QEMU를 사용해 Docker에서 ARM architecture를 에뮬레이션할 수 있게 세팅했다. Docker에서 사용할 이미지 레퍼런스 문자열도 만들었다. 실제로 사용해 빌드할 차례다.
Docker 이미지 레퍼런스 문자열을 만드는 step을 분리해서 정의한다.
- name: Build elixir docker image reference
id: build_docker_image_reference
run: |
IMAGE_TAG=$(script/build_docker_image_reference)
echo "Docker image: $IMAGE_TAG"
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
다음 step에 만든 문자열을 어떻게 전달할 수 있을까? GITHUB_ENV 환경 변수를 사용하면 된다. 참고로 secret 정보는 GITHUB_OUTPUT 환경 변수를 사용한다. IMAGE=TAG
환경 변수에 Docker 이미지 레퍼런스를 저장한다.
이제 script/release
스크립트를 실행해서 ARM 아키텍처 타겟 바이너리를 만든다.
- name: Release for ARM64
run: |
docker run --rm --platform linux/arm64 \
-e USE_GLOBAL_ELIXIR=${USE_GLOBAL_ELIXIR}\
-v $PWD:/app -w /app \
$IMAGE_TAG \
sh -c "script/release"
-v $PWD:/app -w /app
옵션으로 현재 작업 디렉터리를 Docker 컨테이너의 작업 디렉터리와 일치시킨다. Docker 컨테이너 안에서 script/release
스크립트를 실행하기 때문에 ARM 아키텍처 타겟으로 배포 바이너리를 만들게 된다. 실행하고 나면 _build/prod/rel/APPNAME
디렉터리에 결과물이 저장된다. 작업 디렉터리를 Docker 컨테이너의 볼륨으로 사용했기 때문에 Docker 컨테이너에서 복사하는 과정을 생략할 수 있다.
ARM architecture로 배포하니깐 테스트도 해보자.
- name: Run tests for ARM64
run: |
docker run --rm --platform linux/arm64 \
-v $PWD:/app -w /app \
$IMAGE_TAG \
sh -c "script/cibuild"
script/release
대신 script/cibuild
스크립트를 호출하면 된다. 실행 스크립트는 ohyecloudy/template-elixir GitHub 프로젝트를 참고한다.
QEMU과 Erlang JIT 불화설
위의 모든 과정이 순조롭게 흘러간 건 아니다. Docker로 ARM 아키텍처 타겟 빌드가 원하는 대로 되지 않아 시간을 좀 썼다.
==> mime
Compiling 1 file (.ex)
Generated mime app
==> jason
Compiling 10 files (.ex)
Segmentation fault (core dumped)
Error: Process completed with exit code 139.
애꿎은 jason 라이브러리를 의심하게 만드는 원인을 알 수 없는 빌드 에러가 발생했다. Erlang, Elixir 버전을 바꿔봐도 똑같다. 이제 포럼 검색을 할 차례다.
There have been issues in the past with qemu and the Erlang JIT, though they usually manifest earlier. Try setting ERL_FLAGS=“+JMsingle true” and see if that helps.
Multi-platform docker image build fails - #4 by anuarsaeed - Questions / Help…
둘이 왜 사이가 안 좋대? 아무튼 ERL_FLAGS="+JMsingle true"
옵션으로 해결할 수 있었다. 이걸 스크립트에 넣어서 QEMU 환경이면 빌드할 때, 해당 옵션으로 넣어주면 좋겠다고 생각했다. /proc/cpuinfo
, /proc/sys/kernel/osrelease
파일에서 QEMU 환경 검출을 해보려고 했지만 제대로 식별이 안 된다.
그래서 docker run
인자로 넘겨서 해결했다.
- name: Release for ARM64
run: |
docker run --rm --platform linux/arm64 \
-e USE_GLOBAL_ELIXIR=${USE_GLOBAL_ELIXIR}\
-v $PWD:/app -w /app \
$IMAGE_TAG \
sh -c "ERL_FLAGS=\"+JMsingle true\" script/release"
- name: Run tests for ARM64
run: |
docker run --rm --platform linux/arm64 \
-v $PWD:/app -w /app \
$IMAGE_TAG \
sh -c "ERL_FLAGS=\"+JMsingle true\" script/cibuild"
마치며
전체 흐름은 다음과 같다. QEMU를 사용해 ARM architecture 지원 Docker를 세팅한다. ARM 아키텍처 Docker 컨테이너에서 Mix release를 호출하는 script/release
스크립트를 호출해서 ARM 타겟 배포 바이너리를 만든다. appleboy/scp-action을 사용해서 ARM 인스턴스에 빌드 결과물을 복사한다. GitHub ARM64 runner가 preview 딱지를 떼면 QEMU 세팅을 할 필요가 없어져서 더 간단해진다.
ARM architecture 지원이 어렵지 않아서 애용할 것 같다. 클라우드 인스턴스 가격이 x86보단 싸니깐.
GitHub actions workflow 주요 스크립트
name: Elixir CI
on:
push:
branches: ["*"] # adapt branch for project
pull_request:
branches: ["*"] # adapt branch for project
workflow_dispatch:
env:
MIX_ENV: test
USE_GLOBAL_ELIXIR: true
DATABASE_NAME: mydb
DATABASE_USERNAME: postgres
DATABASE_HOSTNAME: localhost
DATABASE_PASSWORD: postgres
DATABASE_PORT: 5432
permissions:
contents: read
jobs:
test-arm64:
runs-on: ubuntu-latest
name: Test ARM64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU for ARM64 emulation
uses: docker/setup-qemu-action@v2
with:
platforms: arm64
- name: Build elixir docker image reference
id: build_docker_image_reference
run: |
IMAGE_TAG=$(script/build_docker_image_reference)
echo "Docker image: $IMAGE_TAG"
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
- name: Run tests for ARM64
run: |
docker run --rm --platform linux/arm64 \
-v $PWD:/app -w /app \
$IMAGE_TAG \
sh -c "ERL_FLAGS=\"+JMsingle true\" script/cibuild"
deploy-arm64:
runs-on: ubuntu-latest
name: Deploy ARM64
needs: test-arm64
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU for ARM64 emulation
uses: docker/setup-qemu-action@v2
with:
platforms: arm64
- name: Build elixir docker image reference
id: build_docker_image_reference
run: |
IMAGE_TAG=$(script/build_docker_image_reference)
echo "Docker image: $IMAGE_TAG"
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
- name: Release for ARM64
run: |
docker run --rm --platform linux/arm64 \
-e USE_GLOBAL_ELIXIR=${USE_GLOBAL_ELIXIR}\
-v $PWD:/app -w /app \
$IMAGE_TAG \
sh -c "ERL_FLAGS=\"+JMsingle true\" script/release"
- name: Deploy via scp
uses: appleboy/scp-action@v0.1.7
with:
username: $
host: $
key: $
source: _build/prod/rel/APPNAME/*
target: $
strip_components: 4
주요 스크립트가 눈에 들어오게 하려고 cache와 clean 같은 step은 제거했다. 전체 스크립트는 ohyecloudy/template-elixir GitHub 프로젝트에서 볼 수 있다.
links
- What is Docker? - Docker Docs - docs.docker.com(archive)
- About GitHub-hosted runners - GitHub Docs - docs.github.com(archive)
- About workflows - GitHub Docs - docs.github.com(archive)
- Workflow commands for GitHub Actions - GitHub Docs - docs.github.com(archive)
- GitHub Actions에서 비밀 사용 - GitHub Docs - docs.github.com(archive)
- GitHub Actions 설명서 - GitHub Docs - docs.github.com(archive)
- The Elixir programming language - elixir-lang.org(archive)
- Multi-platform docker image build fails - #4 by anuarsaeed - Questions / Help…(archive)
- BEAM (Erlang virtual machine) - en.wikipedia.org(archive)
- appleboy/scp-action - github.com
- ohyecloudy/template-elixir - github.com
- ohyecloudy/template-elixir/blob/main/.github/workflows/elixir.yaml - github.com
- ohyecloudy/template-elixir/blob/main/script/build_docker_image_reference - gi…
- qemu/qemu - github.com
- mix release — Mix v1.18.1 - hexdocs.pm(archive)
- #TIL asdf .tool-versions 파일로 GitHub Actions에서 erlang, elixir 버전 셋업 - dev diar…(archive)
- 보완재(complement)의 개념으로 본 구글 크롬 OS - 지극히 당연한 구글의 행보 - exp cabinet - ohyecloudy.com(archive)
- Elixir 프로젝트를 Heroku에 배포하기 - ohyecloudy’s pnotes - ohyecloudy.com(archive)
- GitHub repository templates 사용 후기 - ohyecloudy’s pnotes - ohyecloudy.com(archive)
- 10년 전에 Clojure로 짠 트위터 인용봇을 Elixir로 재작성한 후기 - ohyecloudy’s pnotes - ohyecloudy…(archive)
- Index - Erlang/OTP - erlang.org(archive)
- NIFs — Erlang System Documentation v27.3.4 - erlang.org(archive)