빌드는 CI에서, 실행은 서버에서: GHCR pull-only 배포
앞선 글에서, 작은 ARM 서버의 진짜 병목이 빌드 RAM이라는 걸 확인했습니다. 그 결론의 실체가 되는 배포 구조, 즉 빌드는 서버 밖에서 하고 서버는 실행만 하는 pull-only 방식을 이번에 따로 정리해 두려 합니다.
왜 빌드와 실행을 나누는가
작은 서버의 리소스를 들여다보면, 실행할 때와 빌드할 때의 메모리 프로파일이 전혀 다릅니다. 앱이 떠서 요청을 처리하는 동안의 상주 메모리는 작은 서버에도 넉넉히 들어갑니다. 그런데 같은 서버에서 이미지를 빌드하는 순간, 번들러와 컴파일러가 동시에 메모리를 끌어올려 순간 피크가 몇 배로 튑니다. 이 피크가 서버 용량을 넘으면 OOM으로 빌드가 죽습니다.
그래서 결론은 단순합니다. 순간적으로 메모리를 많이 쓰는 빌드는 일회성으로 자원을 빌릴 수 있는 CI에 맡기고, 서버는 그 결과물(완성된 이미지)을 받아 실행만 하게 하는 것입니다.
파이프라인의 모양
git push
└─ GitHub Actions: 이미지 빌드 (arm64)
└─ GHCR(레지스트리)에 push
└─ 서버: 이미지를 pull → run (빌드 없음)기존에는 서버가 코드를 받아 직접 docker build를 돌렸다면, 이제는 서버가 하는 일이 docker pull과 실행뿐입니다. 빌드의 무거운 부분이 통째로 서버 밖으로 빠지는 셈입니다.
compose를 build에서 image로
서버에서 빌드하던 docker-compose는 대개 build: 지시로 소스에서 이미지를 만듭니다. pull-only로 바꾸려면 이걸 미리 만들어 둔 이미지를 가리키는 image:로 교체하면 됩니다.
# before — 서버에서 빌드
services:
frontend:
build: { context: ., dockerfile: packages/frontend/Dockerfile }
# after — 레지스트리에서 pull
services:
frontend:
image: ghcr.io/OWNER/app-frontend:latestprivate 이미지라면: 서버에 자격증명을 심어야 한다
레지스트리의 이미지가 private이면, 서버가 아무 인증 없이 pull할 수 없습니다. 서버에서 한 번 로그인해 두면, 그 자격증명이 저장돼 이후 pull에 자동으로 쓰입니다.
# 서버에서 GHCR 로그인 (read 권한 토큰 사용)
# 비밀번호는 인자로 넘기지 말고 표준입력으로 — 셸 히스토리 노출 방지
echo "$TOKEN" | docker login ghcr.io -u OWNER --password-stdin한 가지 주의할 점이 있습니다. 서버의 오케스트레이터(예: Coolify 같은 self-hosted PaaS)가 root 권한으로 이미지를 pull하는 경우, 일반 사용자로 로그인하면 그 자격증명을 못 읽습니다. 이럴 때는 root의 도커 설정에 로그인이 저장되도록 sudo로 로그인해야 합니다. 저는 이 지점에서 한 번 헤맸습니다.
트레이드오프
깔끔해 보이지만, 이 구조에도 대가는 있습니다.
배포에 별도의 pull 트리거가 필요합니다. 새 이미지를 올려도 서버가 자동으로 최신을 받아오지 않는 경우가 있어, 여기서
:latest태그 캐싱이라는 함정을 따로 만납니다. 이건 뒤에 이어지는 글에서 자세히 다룹니다.빌드 로그가 서버가 아니라 CI에 남습니다. 빌드가 깨지면 서버가 아니라 CI 로그를 봐야 합니다.
레지스트리 인증이라는 관리 지점이 하나 늘어납니다. 토큰 만료나 권한 관리를 신경 써야 합니다.
정리하며
돌이켜 보면, "서버에서 다 한다"는 단순함이 작은 서버에서는 오히려 발목을 잡았습니다. 실행과 빌드는 자원 성격이 다르니, 성격이 다른 일은 다른 곳에서 처리하는 게 자연스럽다는 생각이 들었습니다.
빌드는 CI에서 일회성으로, 실행은 서버에서 상시로. 그 사이를 레지스트리가 잇습니다. 이 구조가 자리 잡으면 서버는 훨씬 가벼워지고, 여러 서비스를 한 대에 얹을 여유도 생깁니다. 다음 글에서는 이 구조의 첫 관문인 arm64 이미지 빌드에서 만난 함정을 짚겠습니다.
관련 글
OCI ARM64 서버 통합기: 빌드 RAM 병목을 GitHub Actions로 분리했습니다
무료 OCI ARM64 서버 2대를 1대로 합치려다, 진짜 병목이 런타임이 아니라 '빌드 RAM 피크'라는 걸 알게 됐습니다. 박스에서 빌드를 걷어내고 GitHub Actions 네이티브 arm64 러너로 빌드해 GHCR에 올리는 '빌드/실행 분리'로 통합을 해낸 회고입니다.
Coolify HEALTHCHECK 버그를 발견하고 오픈소스 PR을 올리기까지
NestJS Worker 배포 중 Coolify의 HEALTHCHECK 감지 버그를 발견했습니다. Dockerfile 전체에서 문자열을 검색하는 로직이 multi-stage target을 고려하지 않는 문제였고, 소스 분석부터 PR 제출까지의 과정을 정리했습니다.
SaaS 블로그 플랫폼을 Coolify로 배포하며 마주친 실전 문제들
글력(SaaS 블로그 플랫폼)을 Coolify에 처음 배포하면서 겪은 메모리 최적화, OG 이미지 문제, 봇 스캔 방어, Redis 연결, CORS까지. 실전에서 하나씩 해결한 과정을 정리했습니다.