왜 "내 컴퓨터에선 됐는데"가 반복되는가
빌드가 개발자 로컬, CI 러너, 배포 파이프라인에서 각각 다른 결과를 내는 근본 원인은 환경의 암묵적 상태다. 시스템에 깔린 컴파일러 버전, 로컬 캐시, 환경변수, OS 라이브러리가 빌드 산출물에 조용히 스며든다. Makefile은 무엇을 실행할지는 정의하지만 어떤 환경에서 실행할지는 보장하지 못한다. Dockerfile은 환경을 격리하지만 다단계 빌드·병렬 처리·테스트 오케스트레이션에는 표현력이 부족하다. Earthly는 이 둘의 경계에 위치해, 모든 빌드 단계를 컨테이너 안에서 실행하도록 강제한다.
Earthly의 핵심 아이디어
Earthly는 Earthfile이라는 단일 파일에 Dockerfile 문법과 Makefile의 타깃 개념을 합쳤다. 모든 RUN은 격리된 컨테이너에서 돌고, 각 타깃의 결과물은 명시적으로 SAVE ARTIFACT 또는 SAVE IMAGE로만 밖으로 나온다. 즉 "선언하지 않은 것은 새어나가지 않는다". BuildKit을 백엔드로 쓰기 때문에 레이어 캐싱과 병렬 실행이 기본이다.
VERSION 0.8
FROM python:3.12-slim
WORKDIR /app
deps:
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
SAVE ARTIFACT /usr/local/lib/python3.12/site-packages venv
build:
FROM +deps
COPY src/ src/
RUN python -m compileall src/
SAVE ARTIFACT src /app/src
test:
FROM +build
COPY tests/ tests/
RUN pytest -q tests/
docker:
FROM +build
ENTRYPOINT ["python", "src/main.py"]
SAVE IMAGE myapp:latest
타깃 참조로 캐시를 재사용한다
위 예시에서 test와 docker는 모두 FROM +build로 시작한다. Earthly는 build 타깃을 한 번만 실행하고 결과를 공유한다. requirements.txt가 바뀌지 않으면 deps 레이어는 캐시에서 즉시 나온다. 이 구조 덕분에 로컬에서 earthly +test로 돌린 것과 CI에서 돌린 것이 같은 컨테이너 정의를 공유한다.
도구 대비 위치
| 도구 | 환경 격리 | 다단계 오케스트레이션 | 캐시 세분화 |
|---|---|---|---|
| Makefile | 없음 | 수동 | 파일 타임스탬프 |
| Dockerfile | 강함 | 제한적 | 레이어 단위 |
| Earthly | 강함 | 타깃 그래프 | 레이어 + 아티팩트 |
CI에 붙이기
CI 통합은 단순하다. earthly 바이너리를 설치하고 타깃 하나를 호출하면 된다. 아래는 GitHub Actions 예시다. 원격 캐시(--remote-cache)를 레지스트리에 두면 러너가 바뀌어도 이전 레이어를 내려받아 재사용한다.
name: build
on: [push]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: earthly/actions-setup@v1
with:
version: v0.8.0
- name: Run tests and build image
run: |
earthly --ci \
--remote-cache=ghcr.io/org/myapp:cache \
+test +docker
재현성을 실제로 보장하려면
Earthly가 환경을 격리해줘도 재현성이 저절로 완성되지는 않는다. 세 가지를 직접 챙겨야 한다. 첫째, 베이스 이미지를 python:3.12-slim처럼 태그로만 고정하지 말고 다이제스트(@sha256:...)까지 고정한다. 태그는 언제든 갱신될 수 있다. 둘째, 의존성은 락 파일로 버전을 못 박는다(pip은 requirements.txt에 해시 포함, npm은 package-lock.json). 셋째, 네트워크 접근이 필요한 RUN은 최소화한다. 빌드 중 외부 API를 호출하면 그 순간의 응답이 산출물에 섞여 재현성이 깨진다.
주의점과 한계
실무에서 마주치는 함정 몇 가지를 정리한다.
- 캐시 무효화 범위:
COPY . .처럼 전체를 복사하면 사소한 파일 변경에도 하위 레이어가 전부 재실행된다. 자주 바뀌는 소스와 안정적인 의존성 복사를 분리하라. - 빌드 시각·난수: 타임스탬프나 무작위 값을 산출물에 넣으면 바이트 단위 재현이 깨진다. 필요하면
SOURCE_DATE_EPOCH같은 고정 값을 주입한다. - Docker 데몬 의존: Earthly는 BuildKit 데몬을 띄운다. 데몬 없는 순수 CI 환경에서는
--buildkit-host설정이나 원격 러너가 필요하다. - 플랫폼 차이: arm64 로컬과 amd64 CI가 섞이면 결과가 달라질 수 있다.
--platform을 명시해 타깃 아키텍처를 통일하라.
정리하면 Earthly는 "빌드 로직을 컨테이너 안으로 밀어넣는" 규율을 강제하는 도구다. 도구가 격리를 보장하는 만큼, 남은 재현성의 책임—버전 고정과 부작용 제거—은 여전히 엔지니어의 몫이다.