OCI ARM64 서버 통합기: 빌드 RAM 병목을 GitHub Actions로 분리했습니다
저는 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시간짜리 타임아웃을 지나서야 배웠습니다.
관련 글
Coolify HEALTHCHECK 버그를 발견하고 오픈소스 PR을 올리기까지
NestJS Worker 배포 중 Coolify의 HEALTHCHECK 감지 버그를 발견했습니다. Dockerfile 전체에서 문자열을 검색하는 로직이 multi-stage target을 고려하지 않는 문제였고, 소스 분석부터 PR 제출까지의 과정을 정리했습니다.
SaaS 블로그 플랫폼을 Coolify로 배포하며 마주친 실전 문제들
글력(SaaS 블로그 플랫폼)을 Coolify에 처음 배포하면서 겪은 메모리 최적화, OG 이미지 문제, 봇 스캔 방어, Redis 연결, CORS까지. 실전에서 하나씩 해결한 과정을 정리했습니다.
사람 손 없이 PR→머지→배포: AI 에이전트 자동 연쇄를 설계하며 배운 것
코드 리뷰·머지 게이트·배포는 다 자동화했는데, 사슬의 진입점만 수동 호출로 남아 있었습니다. PR 생성 직후 자동 트리거, 보고가 아닌 GitHub를 직접 조회하는 머지 게이트, 배포 보류 게이팅까지 — 자동 연쇄를 직접 설계하며 겪은 시행착오를 정리했습니다.