홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

OCI ARM64 서버 통합기: 빌드 RAM 병목을 GitHub Actions로 분리했습니다

정기창·2026년 7월 3일

저는 OCI(Oracle Cloud)의 Always Free 티어로 ARM64 인스턴스 두 대를 굴리고 있었습니다. 하나는 여러 앱을 함께 올려둔 메인 박스였고, 다른 하나는 별도의 서비스를 하나 띄워둔 박스였습니다. 둘 다 공짜였지만 관리 대상이 둘이라는 사실이 늘 마음에 걸려, 결국 한 대로 합치기로 했습니다. 그런데 그 과정에서 예상하지 못한 벽을 만났습니다.

무료로 굴리던 ARM 서버 2대, 1대로 합치고 싶었습니다

Always Free는 총 4 OCPU와 24GB 램을 인스턴스에 나눠 쓰게 해줍니다. 그렇다면 굳이 두 대로 쪼갤 이유가 없다는 생각이 들었습니다. 하나로 합치면 영구적으로 $0을 유지하면서 신경 쓸 서버도 한 대로 줄어드니까요. 마침 옮겨야 할 쪽은 API 서버와 관리형 DB, 프론트엔드/nginx로만 이루어진 stateless 서비스라 로컬 볼륨이 없었습니다. 옮길 상태랄 게 없어, 통합 조건으로는 나쁘지 않았습니다.

런타임은 여유로운데, 빌드하는 순간 죽었습니다

막상 합치려 하니, 걸림돌은 런타임 메모리가 아니라 빌드 메모리 피크였습니다. 기존 구조에서는 Coolify가 각 OCI 박스에서 Docker 이미지를 직접 빌드했는데, 앱이 몇 개 안 되니 그럭저럭 버텼습니다. 하지만 두 대의 앱을 한 대로 몰면, 여러 앱의 빌드가 동시에 피크를 치면서 이미 돌고 있는 런타임과 메모리를 다투게 됩니다.

빌드 순간의 요구는 만만치 않았습니다. 어떤 앱은 Node heap을 3GB 가까이 열어두고, 다른 앱은 2GB, 거기에 프론트엔드 번들러(Vite/esbuild) 빌드까지 겹칩니다. 런타임만 보면 단일 박스로 충분했지만, "빌드하는 그 순간"에는 이야기가 전혀 달랐습니다. 런타임은 여유로운데 빌드하다 죽는 구조였던 셈입니다. 처음에는 "램이 24GB나 되는데 왜 죽지?" 싶었지만, 평균이 아니라 순간 피크가 문제라는 걸 인정하고 나서야 진짜 장애물이 보이기 시작했습니다.

빌드를 박스 밖으로 — 빌드와 실행을 분리하기로 했습니다

결론은 의외로 단순했습니다. 박스에서 빌드를 아예 없애는 것입니다. 이미지 빌드는 GitHub Actions에서 하고, 완성된 이미지는 GHCR(GitHub Container Registry)에 push합니다. OCI 박스의 Coolify는 그 이미지를 pull해서 run만 하는 pull-only로 역할이 바뀝니다.

이렇게 하면 박스의 빌드 RAM 피크가 통째로 사라집니다. 남는 것은 런타임 부하뿐이고, 그건 통합된 단일 박스가 충분히 감당할 수 있습니다. 다시 말해 빌드/실행 분리는 편의 기능이 아니라 통합의 선행 전제 조건이었습니다.

arm64라는 함정: exec format error와 QEMU의 6시간

사실 이 글을 쓰게 된 진짜 이유는 여기서부터입니다. OCI Ampere A1은 arm64 아키텍처라, CI는 반드시 linux/arm64 이미지를 빌드해야 합니다. 습관대로 x86(amd64) 이미지를 만들어 arm64 박스에서 pull하면, 컨테이너는 시작하자마자 이렇게 죽습니다.

exec /docker-entrypoint.sh: exec format error

CPU 명령어 집합이 다르니 어찌 보면 당연한 결과지만, 막상 이 에러를 마주하면 잠깐 당황하게 됩니다.

QEMU 크로스빌드에서 6시간을 태웠습니다

처음에는 가장 흔한 방법을 택했습니다. ubuntu-latest(x86 러너)에 docker/setup-qemu-action을 얹어 QEMU로 크로스빌드하는 방식입니다. 문서상으로는 어떤 조합에서든 arm64 이미지를 만들 수 있으니, 이게 정답인 줄 알았습니다.

애석하게도 QEMU 에뮬레이션 아래에서 프론트엔드의 Vite/esbuild 빌드가 비결정적으로 hang에 빠졌습니다. 부끄럽지만 저는 어느 PR의 두 번째 run이 6시간 타임아웃으로 취소되는 것을 지켜보고 나서야 이 방식을 접었습니다. 에뮬레이션 위에서 무거운 번들러를 돌리는 건, 설령 되더라도 신뢰할 수 없는 길이었습니다.

네이티브 arm64 러너로 갈아탔습니다

근본 해결은 뜻밖에 간단했습니다. GitHub이 제공하는 네이티브 arm64 러너(runs-on: ubuntu-24.04-arm)로 바꾸는 것이었습니다. 에뮬레이션 없이 linux/arm64를 직접 빌드하니 hang은 근본적으로 사라졌고, 속도도 5~10배 빨라졌습니다. 돌이켜 생각해보면 처음부터 에뮬레이션이라는 우회로를 의심했어야 했습니다. 다만 arm64 러너를 쓸 수 없는 환경이라면, QEMU로 되돌리되 timeout-minutes로 hang을 방어하는 폴백도 열어둘 만합니다.

워크플로를 어떻게 설계했나

빌드를 박스 밖으로 내보내는 이상, 워크플로(build-arm64.yml)도 신중하게 설계해야 했습니다. 핵심 판단은 트리거와 concurrency, 러너에 담겨 있습니다.

on:
  pull_request:
    branches: [main]        # arm64 빌드 성공 여부만 검증, push는 안 함
  push:
    branches: [main]        # 이미지를 GHCR에 push
  workflow_dispatch:

concurrency:
  group: build-arm64-${{ github.ref }}
  cancel-in-progress: false  # main에선 커밋마다 sha 이미지를 빠짐없이 남김

jobs:
  build:
    runs-on: ubuntu-24.04-arm   # 네이티브 arm64 — QEMU 없이 직접 빌드

트리거는 역할을 나눴습니다. main을 향한 pull_request에서는 arm64 빌드가 성공하는지 검증만 하고, 실제 GHCR push는 push(main)에서만 일어납니다. 검증과 배포를 떼어놓으니 리뷰 단계에서 빌드가 깨지는지를 미리 걸러낼 수 있었습니다.

태그는 두 갈래입니다. main에 한해 latest를 갱신하고, 동시에 sha-<커밋 전체 sha> 태그를 immutable하게 남겨 언제든 특정 커밋으로 되돌릴 수 있게 했습니다. cancel-in-progress를 main에서만 끈(false) 것도 같은 맥락입니다. 각 커밋의 sha 이미지를 빠짐없이 남겨야 롤백이 완전해지기 때문입니다. PR·브랜치에서는 최신 커밋만 의미가 있으니 이 옵션을 켭니다.

매트릭스는 backend와 frontend를 두 잡으로 병렬 실행하되, 한쪽이 실패해도 다른 쪽 결과는 봐야 하니 fail-fast: false로 두었습니다. 프론트엔드 쪽은 nginx에 envsubst로 백엔드 URL을 런타임 주입하는 변형을 썼습니다. 권한은 contents: read와 packages: write만 남겼습니다.

이건 일회성 스크립트가 아니었습니다

작업을 마치고 가장 오래 남은 생각은, 이 워크플로가 한 번 쓰고 버리는 마이그레이션 스크립트가 아니라는 점이었습니다. 통합이 끝난 단일 박스는 빌드하는 순간 OOM으로 죽으므로, 구조적으로 다시는 스스로 빌드할 수 없습니다. 그러니 앞으로 모든 배포는 계속 GitHub Actions에서 빌드되어야 합니다. PR로 한 번 심어둔 워크플로가 main에 push할 때마다 도는 것은, 걷어내야 할 군더더기가 아니라 의도된 설계이자 영구적인 전제입니다.

물론 대가가 없지는 않습니다. 저장소가 private이라 매 트리거마다 backend와 frontend 두 잡이 GitHub Actions 실행 분(minute)을 무료 할당량에서 갉아먹습니다. 빌드가 박스 밖으로 나가면서 배포 경로에 GHCR pull 한 단계도 더 붙었습니다.

그럼에도 남는 장사였다는 생각이 듭니다. 서버 한 대와 영구적인 $0을 얻는 대신, 빌드를 클라우드에 위임하고 약간의 실행 분과 pull 한 스텝을 내준 셈이니까요. 결국 무료 티어를 알뜰하게 굴린다는 것은, 자원이 부족한 곳(박스의 빌드 RAM)에서 남는 곳(CI)으로 부하를 옮기는 일이었습니다. 그 단순한 사실을 저는 exec format error와 6시간짜리 타임아웃을 지나서야 배웠습니다.

OCIARM64GitHub ActionsGHCRCoolifyDocker셀프호스팅빌드/실행 분리

관련 글

Coolify HEALTHCHECK 버그를 발견하고 오픈소스 PR을 올리기까지

NestJS Worker 배포 중 Coolify의 HEALTHCHECK 감지 버그를 발견했습니다. Dockerfile 전체에서 문자열을 검색하는 로직이 multi-stage target을 고려하지 않는 문제였고, 소스 분석부터 PR 제출까지의 과정을 정리했습니다.

관련도 89%

SaaS 블로그 플랫폼을 Coolify로 배포하며 마주친 실전 문제들

글력(SaaS 블로그 플랫폼)을 Coolify에 처음 배포하면서 겪은 메모리 최적화, OG 이미지 문제, 봇 스캔 방어, Redis 연결, CORS까지. 실전에서 하나씩 해결한 과정을 정리했습니다.

관련도 89%

사람 손 없이 PR→머지→배포: AI 에이전트 자동 연쇄를 설계하며 배운 것

코드 리뷰·머지 게이트·배포는 다 자동화했는데, 사슬의 진입점만 수동 호출로 남아 있었습니다. PR 생성 직후 자동 트리거, 보고가 아닌 GitHub를 직접 조회하는 머지 게이트, 배포 보류 게이팅까지 — 자동 연쇄를 직접 설계하며 겪은 시행착오를 정리했습니다.

관련도 89%