BuildKit은 Dockerfile 빌더를 넘어, LLB·프런트엔드·솔버/캐시로 구성된 범용 플러그형 빌드 프레임워크다. OCI 이미지뿐 아니라 tarball, 로컬 디렉터리, 패키지 등 DAG로 표현 가능한 산출물을 만들 수 있으며, 커스텀 프런트엔드로 YAML 스펙에서 Alpine APK 패키지를 생성하는 예시를 통해 그 가능성을 보여준다.
대부분의 사람들은 BuildKit을 매일 사용하면서도 그것을 의식하지 못한다. docker build를 실행할 때 그 뒤에서 동작하는 엔진이 바로 BuildKit이다. 하지만 BuildKit을 “Dockerfile을 빌드하는 그거” 정도로 축소해 버리는 건, LLVM을 “C를 컴파일하는 그거”라고 부르는 것과 같다. 아키텍처의 규모를 한 자릿수 이상 과소평가하는 셈이다.
BuildKit은 범용적이며 플러그형인 빌드 프레임워크다. OCI 이미지를 만들 수 있는 것은 물론이고, tarball, 로컬 디렉터리, APK 패키지, RPM, 혹은 파일시스템 연산의 방향성 비순환 그래프(DAG)로 설명할 수 있는 어떤 것이든 만들어낼 수 있다. Dockerfile은 그저 하나의 프런트엔드일 뿐이다. 여러분은 직접 프런트엔드를 만들 수도 있다.
BuildKit의 설계는 깔끔하며, 레이어를 한 번만 제대로 보면 놀랄 만큼 이해하기 쉽다. 핵심 개념은 세 가지다.
BuildKit의 중심에는 LLB(Low-Level Build definition)가 있다. 이를 빌드 시스템의 LLVM IR이라고 생각해도 된다. LLB는 파일시스템 연산의 DAG를 기술하는 바이너리 프로토콜(protobuf)이다. 예를 들어 명령 실행, 파일 복사, 파일시스템 마운트 같은 동작들이 포함된다. 또한 콘텐츠 주소 지정(content-addressable) 방식이라서, 동일한 연산은 동일한 해시를 만들어내며, 이 덕분에 공격적인 캐싱이 가능하다.
Dockerfile을 작성하면 Dockerfile 프런트엔드가 이를 파싱해 LLB를 생성한다. 하지만 BuildKit은 입력이 Dockerfile이어야 한다는 전제를 전혀 갖고 있지 않다. 유효한 LLB를 만들어낼 수 있는 어떤 프로그램이든 BuildKit을 구동할 수 있다.
**프런트엔드(frontend)**는 BuildKit이 실행하는 컨테이너 이미지로, 여러분의 빌드 정의(Dockerfile, YAML, JSON, HCL 등 무엇이든)를 LLB로 변환해 준다. 프런트엔드는 BuildKit Gateway API를 통해 빌드 컨텍스트와 빌드 파일을 전달받고, 직렬화된 LLB 그래프를 반환한다.
여기서 핵심 통찰은 이거다. 빌드 언어는 BuildKit에 내장되어 있지 않다. 플러그 가능한 레이어다. YAML 스펙, TOML 설정, 혹은 커스텀 DSL을 읽는 프런트엔드를 작성하면, BuildKit은 Dockerfile을 실행하듯 동일한 방식으로 이를 실행한다.
사실 이 메커니즘은 이미 한 번쯤 봤을 것이다. Dockerfile 맨 위의 # syntax= 지시어는 BuildKit이 어떤 프런트엔드 이미지를 사용할지 알려준다. # syntax=docker/dockerfile:1은 그냥 기본값일 뿐이다. 여러분은 이를 어떤 이미지로든 지정할 수 있다.
**솔버(solver)**는 LLB 그래프를 받아 실행한다. DAG의 각 정점(vertex)은 콘텐츠 주소 지정이므로, 동일한 입력으로 특정 단계를 이미 빌드한 적이 있다면 BuildKit은 그 단계를 통째로 건너뛴다. 이것이 BuildKit이 빠른 이유다. 예전 Docker 빌더처럼 레이어를 선형으로만 캐싱하는 것이 아니라, 그래프 전체에서 연산 단위로 캐싱하며, 독립적인 브랜치는 병렬로 실행할 수 있다.
캐시는 로컬, 인라인(이미지 안에 임베드), 또는 원격(레지스트리)으로 둘 수 있다. 덕분에 BuildKit 빌드는 CI 러너 간에도 재현 가능하고 공유 가능해진다.
┌─────────────┐ ┌──────────┐ ┌────────┐ ┌─────────┐ ┌────────────┐
│ Your spec │───▶│ Frontend │───▶│ LLB │───▶│ Solver │───▶│ Output │
│ (YAML, HCL, │ │ (Gateway │ │ (DAG) │ │ (cache, │ │ (image, │
│ Dockerfile)│ │ client) │ │ │ │ execute)│ │ tarball, │
└─────────────┘ └──────────┘ └────────┘ └─────────┘ │ local dir) │
└────────────┘
BuildKit의 --output 플래그에서 이것이 실용적으로 드러난다. 결과를 다음과 같이 내보내도록(export) 지정할 수 있다.
type=image — 레지스트리에 푸시( docker build 의 기본값)type=local,dest=./out — 최종 파일시스템을 로컬 디렉터리에 덤프type=tar,dest=./out.tar — tarball로 내보내기type=oci — OCI 이미지 tarball로 내보내기비(非) 이미지 용도에서 가장 흥미로운 것은 type=local 출력이다. 빌드는 컴파일된 바이너리, 패키지, 문서 등 무엇이든 만들 수 있고, BuildKit은 그 결과를 디스크로 덤프해 준다. 컨테이너 이미지가 필요 없다.
Earthly, Dagger, Depot 같은 프로젝트들은 모두 BuildKit의 LLB 위에 구축되어 있다. 검증된 패턴이다.
이를 더 구체적으로 보여주기 위해, 나는 apkbuild를 만들었다. YAML 스펙을 읽고 Alpine APK 패키지를 생성하는 커스텀 BuildKit 프런트엔드다. Dockerfile은 전혀 관여하지 않는다. 소스 컴파일부터 APK 패키징까지의 전체 파이프라인이 LLB 연산을 사용해 BuildKit 내부에서 실행된다. Chainguard의 melange의 더미 버전 같은 것으로 생각하면 된다.
익숙함 때문에 YAML을 선택했지만, 프런트엔드가 파싱할 수만 있다면(JSON, TOML, 커스텀 DSL 등) 스펙은 무엇이든 가능하다.
내 패키지 YAML 스펙은 이렇게 생겼다:
name: hello
version: "1.0.0"
epoch: "0"
url: https://example.com/hello
license: MIT
description: Minimal CMake APK demo
sources:
app:
context: {}
build:
source_dir: hello
이게 전부다. Dockerfile도 없다. 셸 스크립트도 없다. BuildKit은 커스텀 프런트엔드를 통해 이 스펙을 읽고 .apk 파일을 만들어낸다.
프런트엔드 이미지를 빌드한다:
docker build -t tuananh/apkbuild -f Dockerfile .
그다음 이를 사용해 APK 패키지를 빌드한다:
cd example
docker buildx build \
-f spec.yml \
--build-arg BUILDKIT_SYNTAX=tuananh/apkbuild \
--output type=local,dest=./out \
.
아래처럼 out 폴더에서 APK 패키지를 확인할 수 있을 것이다.

BUILDKIT_SYNTAX는 기본 Dockerfile 파서 대신 우리가 만든 커스텀 프런트엔드를 사용하라고 BuildKit에 알려준다. --output type=local은 결과로 생성된 .apk 파일들을 ./out으로 덤프한다. 이미지는 생성되지 않는다. 레지스트리도 관여하지 않는다.
BuildKit은 콘텐츠 주소 지정, 병렬화, 캐싱이 가능한 빌드 엔진을 공짜로 제공한다. 캐싱, 병렬성, 재현성을 다시 발명할 필요가 없다. 여러분은 스펙을 LLB로 번역하는 프런트엔드만 작성하면 되고, 나머지는 BuildKit이 처리한다.
이건 장난감 데모를 넘어서는 이야기다. Dagger는 CI/CD 파이프라인을 실행하는 엔진으로 LLB를 사용한다. Earthly는 Earthfile을 LLB로 컴파일한다. 이 패턴은 대규모 환경에서도 검증되어 있다.
코드를 컴파일하고, 산출물을 만들고, 여러 단계의 빌드를 오케스트레이션해야 하는 도구를 만든다면, 실행 백엔드로 BuildKit을 고려해 보라. Dockerfile은 기본 프런트엔드일 뿐이다. 진짜 힘은 그 아래에 있는 엔진에 있다.