
Farm 오픈소스 공개: 고통 없는 프리뷰 환경
Farm 오픈소스 공개: 고통 없는 프리뷰 환경
Farm이 단일 VM의 셀프 호스팅 스크립트에서 Docker와 Kubernetes 기반 프리뷰 환경 오케스트레이터로 성장한 과정, 그리고 코드를 공개한 이유.

풀 리퀘스트마다 테스트만 통과시키는 것으로는 부족합니다. 디자이너에게, 매니저에게, 옆 팀에게 실제로 동작하는 모습을 보여주고 싶어집니다. 모두가 UI를 직접 눌러 보며 기뻐하기도 하고, 버그를 찾아내 아쉬워하기도 하도록 말입니다. 별로 어려울 것 없어 보입니다. 환경을 하나 띄우고 링크를 공유하면 그만이니까요. 하지만 풀 리퀘스트가 하루에 수십 개씩 쏟아지고 팀도 여럿이라면, '그냥 환경 하나 띄우기'는 어엿한 인프라 프로젝트로 변합니다.
안녕하세요! 저는 Yandex Cloud의 시니어 프론트엔드 개발자 Mikhail Golbakh입니다. 벌써 몇 년째 팀과 함께 Farm을 만들어 오고 있습니다. Farm은 풀 리퀘스트마다 프리뷰 환경을 띄워 주는 서비스입니다. 이 글에서는 Farm이 가상 머신 한 대 위의 단순한 셀프 호스티드 스크립트에서 Docker와 Kubernetes 기반의 오케스트레이터로 성장해 온 과정과, 결국 저희가 코드를 오픈소스로 공개하게 된 이유를 이야기해 보겠습니다.
Farm의 첫 버전이 나오기까지
Yandex에는 수많은 프론트엔드 팀이 있고, 이들이 하루에도 수백 개의 풀 리퀘스트를 만들어 냅니다. 풀 리퀘스트마다 수많은 테스트와 각종 CI 검사가 실행됩니다. 유닛 테스트, 타입 체크, 린터는 격리된 가상 머신에서 아무 문제 없이 돌아갑니다.
하지만 브랜치에서 온전한 애플리케이션을 통째로 띄워야 한다면 어떻게 해야 할까요? 이 질문에서 프론트엔드 개발자가 늘 마주치는 과제가 나옵니다. 바로 풀 리퀘스트마다 '베타'를 띄우는 일입니다. 가장 먼저 떠오르는 방법은 프로덕션을 본뜬 전용 dev 환경을 하나 마련해 개발자들이 나눠 쓰는 것입니다. 초기 단계의 작은 팀들이 흔히 택하는 방식이지만, 곧 확장성 문제에 부딪힙니다. 환경 하나를 더는 나눠 쓸 수 없게 되고, 그 위에서 e2e 테스트를 돌리기도 어렵습니다. 테스트 안정성에 의문이 생길 게 뻔하니까요.
전통적으로 각 팀은 dev 환경 배포 프로세스를 저마다 직접 구축하고, 여기에 상당한 노력과 유지보수 리소스를 들입니다. 저희도 예외는 아니어서 자체 해결책을 만들었습니다. 다만 처음부터 인프라가 서로 조금씩 달라도 수십 개 팀으로 확장할 수 있는 공용 기술을 목표로 설계했습니다.
2018년, Yandex Cloud 프론트엔드 팀은 Farm의 첫 버전을 만들었습니다. 본질적으로는 단순한 셀프 호스티드 서비스였습니다. 리포지토리 브랜치에서 코드를 내려받아 대상 애플리케이션의 빌드 명령을 실행하고, VM에서 프로세스를 시작한 뒤 전용 도메인으로 들어오는 트래픽을 라우팅해 주는 것이었죠. 심지어 명령어 목록 자체가 코드에 하드코딩되어 있었습니다. Farm은 무엇보다 Node.js 웹 서비스용 공통 컴포넌트 위에 구축된 저희 자체 프로젝트들을 겨냥해 만들어졌기 때문입니다. 그렇게 Yandex 색채가 묻어나는, 일종의 간소화된 배포 시스템이 탄생했습니다.
여러 해에 걸쳐 Farm 서비스는 진화했습니다. 프로젝트별로 분리된 빌드·시작 구성을 다룰 수 있게 되었고, 제대로 된 UI와 데이터베이스, 그리고 Yandex 인프라와의 연동을 간소화해 주는 수많은 부가 코드를 갖추게 되었습니다. 하지만 서비스의 본질은 변하지 않았습니다. 여전히 다음 과제들을 해결하고 있었습니다:
-
풀 리퀘스트에서 코드를 내려받아 프로젝트를 빌드하는 일.
-
실행 중인 애플리케이션으로 트래픽을 라우팅하는 일.
-
배포하기 쉽고 Yandex의 CI 프로세스에 쉽게 편입할 수 있는 단일 인프라 컴포넌트로 해결책을 통합하는 일.
동작 방식
Farm은 작은 Node.js 애플리케이션이며, 단순화한 동작 흐름은 다음과 같습니다:
-
Farm이 생성 요청을 받습니다. 요청은 CI 태스크에서 올 수도 있고, 사용자가 UI의 폼을 통해 직접 보낼 수도 있습니다.
-
새 애플리케이션 인스턴스가 queued 상태로 데이터베이스에 기록되며 빌드 큐에 들어갑니다.
-
Farm이 큐를 처리하면서 대상 리포지토리와 브랜치에서 코드를 내려받고, 구성을 읽은 뒤 빌드를 시작합니다.
-
애플리케이션 프로세스가 시작됩니다. 이 프로세스는
<instance_dir>/dist/server.sock경로에 소켓을 열도록 기대됩니다. -
Farm이 — 정확히는 이 시점부터 특수한 구성을 가진 nginx가 — 고유 도메인으로 트래픽을 라우팅하기 시작합니다. 예를 들어 d008838956420e285cee652262dd5d18b2ed2a2a.farm.example.com 같은 식입니다.

다이어그램에서 볼 수 있듯이, 로드 밸런싱 책임은 Farm에 지우지 않고 nginx를 사용했습니다. 이 접근은 앞으로도 유지됩니다. 트래픽 프록시를 직접 유지보수하는 일은 저희가 굳이 떠안고 싶지 않은 만만치 않은 과제이기 때문입니다. Git과 함께 Yandex의 자체 내부 버전 관리 시스템인 Arc도 지원했습니다.
빌드와 시작 자체는 Farm이 명령어를 실행하는 게 전부인 단순한 방식이었습니다. 모든 것이 더할 나위 없이 투박하고 직설적이었습니다:
# 빌드
npm ci
npm run build
# 실행
npm run start
전체 그림을 위해 farm.json 구성 예시도 살펴보겠습니다:
{
"preview-generator": {
"build": ["npm ci", "npm run build"],
"start": {
"command": "npm",
"args": "run start"
},
"env": {
"APP_BUILDER_CDN": "false",
"IS_FARM": "true"
},
"instances": [
{
"name": "preprod",
"urlTemplate": "https://{hash}.farm.example.com",
"env": {
"APP_INSTALLATION": "russia",
"APP_ENV": "preprod"
}
},
{
"name": "prod",
"urlTemplate": "https://{hash}.farm.example.com",
"env": {
"APP_INSTALLATION": "russia",
"APP_ENV": "prod"
}
}
]
}
}
그 결과물은 그야말로 든든한 '일꾼'이었습니다. 도입하려는 팀이 할 일은 가상 머신을 준비하고, Farm 자체와 프로젝트들의 구성을 기술하고, Farm을 띄운 다음, 풀 리퀘스트용 CI 프로세스를 설정하는 것이 전부였습니다. 어렵지 않게 들리죠? 하지만…
성장과 새로운 도전 과제
Farm은 이런 모습으로 꽤 오랫동안 지내며 주어진 과제를 무난히 해결해 왔습니다. 처음에는 여러 설치본을 유지보수하느라 힘을 낭비하지 않도록, 여러 프론트엔드 팀이 Farm 하나를 같이 쓰는 공용 구성으로 운영하기도 했습니다. 하지만 팀 규모가 커지고 팀 수도 늘어나는 동안 Farm은 VM 한 대 안에서만 동작할 수 있었습니다. 시간이 지나자 가장 강력한 사양으로도 감당이 되지 않았습니다.
빌드 몇 개를 병렬로 돌리면 머신의 리소스가 전부 소진되었고, 인스턴스는 큐에서 한참을 기다리기 일쑤였습니다. 그래서 공용 Farm을 더 작은 Farm들로, 때로는 프로젝트별로 하나씩 쪼개는 과정이 자연스럽게 시작되었습니다.
당연하게도 이런 분리는 문제를 해결한 게 아니라 미뤄 둔 것에 불과했습니다. 컴퓨팅 리소스는 여전히 바닥났고, 새로 합류한 팀들은 Farm을 지원하고 유지보수하는 데 추가 노력을 들여야 했습니다. 예를 들어 VM의 디스크가 꽤 주기적으로 가득 차서 불필요한 파일을 손으로 정리해야 했습니다. 그러니까 첫 번째 문제는 관리도 확장도 안 되는 제한된 리소스입니다. 이 점을 기억해 둡시다.
또 다른 문제는 Farm이 내부적으로 Knex 같은 추상화 없이 SQLite를 데이터베이스로 그대로 사용했다는 점입니다. 이는 벤더 종속을 낳았고, 마이그레이션 메커니즘이 없어서 스키마가 바뀔 때마다 데이터를 몽땅 지워야 했습니다. Farm이 팀별로 수많은 설치본으로 나뉘어 있던 상황에서, 이는 곧 아무도 버전을 올리고 싶어 하지 않는다는 뜻이기도 했습니다. 뭐가 깨질지 모르는 데다 십중팔구 데이터까지 날려야 할 테니까요. 흔히 말하듯 '잘 돌아가면 건드리지 마라'였습니다.
게다가 Farm은 Node.js 애플리케이션만 실행할 수 있었고, 모든 프로세스는 아무런 격리 없이 호스트에서 직접 돌아갔습니다. 격리가 없으니 애플리케이션은 트래픽을 받을 때 익숙한 자기 포트를 그냥 쓸 수도 없었습니다. Farm이 포트 관리를 하지 않았기 때문입니다. 그래서 통신에는 유닉스 소켓이 사용되었고, 애플리케이션은 이 조금 이상한 계약을 따라야만 했습니다.
정리하면, 저희가 해결해야 할 문제는 다음과 같았습니다:
-
관리와 확장이 불가능한 제한된 리소스.
-
데이터베이스 마이그레이션의 부재와 SQLite에 대한 강한 의존.
-
인스턴스 격리의 부재, 그리고 유닉스 소켓을 여는 Node.js 애플리케이션이어야 한다는 제약.
-
지원과 운영의 부담. 애초에 저희가 원했던 건 팀의 삶을 편하게 만드는 것이지 골칫거리를 더하는 게 아니었으니까요(이 추가 문제는 잠시 접어 두고, 글 끝부분에서 다시 다루겠습니다).
문제를 해결한 방법
경험 많은 엔지니어라면 이런 과제를 보고 십중팔구 Farm을 k8s 클러스터 위로 옮기자고 제안할 것입니다. 저희도 서서히 같은 생각에 이르렀습니다. 애플리케이션의 빌드와 배포를 클러스터의 컴퓨팅 자원 위 별도 파드로 옮기고, 자동 확장과 리소스 관리에는 클러스터의 기능을 활용하자는 것이었습니다. 애플리케이션 자체는 누구에게나 익숙한 docker 컨테이너로 전달됩니다. 우리 업계에서는 더없이 명확하고 단순한 계약이죠. 여기에 더해, 애플리케이션을 예전 방식대로 프로세스로 실행하는 능력도 유지해야 했습니다. 작은 팀들이 k8s 솔루션으로 힘겹게 마이그레이션하지 않아도 되도록 말입니다.
Farm을 그냥 버리고 CI에서 k8s 클러스터로 애플리케이션을 직접 배포하는 방식으로 갈아탈 수는 없었습니다. 이유는 다음과 같습니다:
-
Farm은 이미 기존의 모든 프로세스와 CI에 깊이 뿌리내리고 있었습니다. 팀들에게 완전히 새로운 솔루션으로 옮기라고 강요하는 건 말이 되지 않았습니다.
-
자세히 들여다보면 Farm은 애플리케이션의 빌드와 시작만 하는 게 아닙니다. (nginx를 통해서이긴 하지만) 라우팅을 책임지고, 사용하지 않는 인스턴스를 중지·삭제하는 등 훨씬 많은 일을 합니다.
-
편리한 UI의 존재도 무척 중요합니다. 프론트엔드뿐 아니라 백엔드 엔지니어도 UI에서 애플리케이션 인스턴스를 띄워 다양한 기능을 테스트할 수 있으니까요.
실제로 k8s 지원을 추가하는 일은 처음 생각보다 어려웠습니다. 초기 버전부터 애플리케이션 인스턴스를 다루는 로직이 코드베이스 곳곳에 흩어져 있었고, 세월이 흐르며 그 안에 수많은 세부 사항과 우아한 임시방편들이 쌓여 있었습니다. 그 옆에 또 하나의 구현을 끼워 넣기란 쉽지 않았습니다.
대대적인 리팩토링
첫 단계로 Farm 코드의 대대적인 리팩토링에 착수했습니다. 우선 인스턴스를 다루는 로직을 전용 프로바이더 클래스로 캡슐화해야 했습니다. 프로바이더는 말하자면 Farm의 백엔드로, 애플리케이션의 빌드와 배포 전략을 정의하는 역할입니다. 이렇게 하면 당분간 레거시 방식과 k8s를 나란히 지원할 수 있고, 이후에는 단일 인터페이스를 통해 새로운 구현을 추가할 수 있습니다.
코드를 분해한 끝에 프로바이더에 특화된 메서드를 모두 추려내고, 이를 기술하는 추상 클래스를 준비할 수 있었습니다. 조금 단순화하면 결국 이런 코드가 되었습니다:
class BaseFarmProvider {
startup(): Promise<void>;
buildInstance(
generateData: GenerateInstanceData,
observer: SubscriptionObserver<InstanceObservableEmitValue>,
): Promise<void>;
stopBuilder(hash: string): Promise<void>;
startInstance(instance: Instance): Promise<void>;
stopInstance(hash: string): Promise<void>;
restartInstance(instance: Instance): Promise<void>;
deleteInstance(hash: string): Promise<void>;
getInstanceStatus(instance: Instance): Promise<InstanceProviderStatus>;
getInstances(): Promise<Array<InstanceProviderInfo>>;
getInstanceLogs(params: {
hash: string;
stdout?: LogParams;
stderr?: LogParams;
}): Promise<{stdout?: string; stderr?: string}>;
}
비슷한 시기에 데이터베이스 접근도 모두 추상화해 Knex로 옮겼습니다. 앞으로 더 강력하고 영속적인 다른 DBMS로 전환할 수 있게 하기 위해서입니다. 덤으로 데이터베이스 스키마 변경을 한결 쉽게 만들어 주는 값싼 마이그레이션 메커니즘도 얻었습니다.
Kubernetes 기반의 새로운 구성
새 구성에서 Farm은 클러스터 안에 배포되어 컨트롤러로 동작합니다. k8s API에 연결해 클러스터 리소스를 관리하기 시작합니다. 빌드용 파드와 애플리케이션용 파드를 띄우고, 그 밖의 리소스를 만들고 지우는 식입니다.
이 구성을 이해하려면 두 가지 핵심 프로세스만 살펴보면 충분합니다. 애플리케이션 인스턴스의 빌드와 배포입니다.
빌드는 여러 단계로 이루어집니다:
-
사용자 또는 CI가 빌드를 시작합니다(buildInstance 메서드).
-
브랜치에서 대상 애플리케이션의 Farm 구성을 가져옵니다(리포지토리 루트의 farm.json 파일).
-
빌더 파드가 시작됩니다. 이 파드는 브랜치에서 코드를 내려받아 docker 이미지를 빌드하고(기본값은 Dockerfile.farm), 미리 지정된 레지스트리에 푸시합니다.
-
빌더 파드의 모든 로그는 실시간으로 스트리밍되어 디버깅을 위해 UI에 표시됩니다.
-
빌드가 끝나면 Farm이 빌더 파드를 삭제합니다.

빌드의 결과물은 아티팩트, 즉 레지스트리에 푸시된 애플리케이션 이미지입니다. 언제든 배포하고 시작할 수 있는 상태죠. 덤으로 Docker를 통한 빌드 캐싱도 얻었는데, CI 속도를 높이는 데 아주 유용합니다.
배포는 빌드 직후 같은 buildInstance 메서드 안에서 시작됩니다. 이미지가 푸시된 시점부터 이어서 살펴보겠습니다:
-
이전 단계에서 만들어진 이미지를 사용하는 Deployment가 클러스터에 생성됩니다.
-
Farm 구성에 지정한 포트로 Service가 생성되고, 인스턴스 ID 기반 도메인을 가진 Ingress도 함께 생성됩니다.
-
이제 새로운 선수가 경기에 등장합니다 — Ingress NGINX Controller입니다. 모든 라우팅을 담당하며 애플리케이션을 외부에서 접근할 수 있게 만들어 줍니다. 여기서도 Farm은 트래픽 프록시를 시스템의 다른 컴포넌트에 맡기는, 자신다운 방식을 고수합니다.

기술적으로는 라우팅에 다른 어떤 Ingress 컨트롤러라도 쓸 수 있습니다. 필요하다면 클라우드 ALB 같은 것까지도요. 저희는 동작이 단순하고 구성 업데이트가 빠르다는 이유로 Ingress NGINX Controller를 선택했습니다.
대략 이런 과정을 거쳐, 접속해서 테스트할 준비가 끝난 동작하는 애플리케이션 인스턴스를 얻게 됩니다. 여기에 더해 Farm은 리소스가 중복되지 않고 모든 연산이 멱등하도록 관리하는데, 이는 안정적인 운영에 매우 중요합니다.
순수 Docker를 사용한 더 단순한 구성
Farm의 k8s 지원 아이디어가 나오자, 꽤 자연스러운 제안이 뒤따랐습니다. 인스턴스를 그냥 가상 머신의 Docker에서 실행하는 방식도 지원하면 어떨까? 그렇게 하면 인스턴스 격리 문제가 해결되고, Node.js 애플리케이션이어야 한다는 제약과 포트 제약이 사라지며, Farm에 또 하나의 동작 모드가 생깁니다. 이번에는 k8s 클러스터가 없는 작은 팀들을 위한 모드죠. 나중에는 평범한 Node.js 프로세스로 실행하던 옛 방식을 완전히 걷어낼 수도 있고, 그러면 산더미 같은 레거시 코드를 지울 수 있어 유지보수가 크게 단순해집니다.
결국 어떻게 동작할까요? Farm은 소켓으로 Docker Engine에 연결해 스스로 빌더 역할을 합니다. 여기에는 별도의 빌드 파드가 없습니다. 그리고 바로 여기서 k8s 구성과의 첫 번째 중요한 차이가 나옵니다. 인스턴스 이미지는 같은 데몬에서 로컬로 빌드되고, 그대로 거기에 남습니다. 레지스트리에 푸시할 필요가 없습니다. 레지스트리는 기껏해야 빌드 중에 베이스 이미지를 끌어올 때나 쓸모가 있을 뿐입니다. 눈에 띄게 단순해지는데, 클러스터가 없는 작은 팀에 필요한 게 바로 이것입니다.
저희는 두 가지 동작 모드를 설계했습니다:
-
docker_container — Farm 자체가 호스트의 소켓을 마운트한 컨테이너로 실행되어, 같은 데몬 위의 '이웃' 컨테이너들을 지휘합니다.
-
vm — Farm이 자체 Docker Engine을 갖춘 VM에서 직접 동작합니다.
Farm과 모든 인스턴스는 공유 Docker 네트워크(기본값은 farm)에 참여하고, 각 애플리케이션 인스턴스는 이름만 봐도 정체를 알 수 있는 farm-docker-<hash> 컨테이너를 갖게 됩니다. 나중에 트래픽을 라우팅할 때가 오면 바로 이 이름으로 컨테이너를 찾습니다.
k8s 구성에서와 마찬가지로 빌드와 시작은 같은 buildInstance 메서드 안에 함께 삽니다. 다만 별도의 파드로 나뉘지 않을 뿐입니다:
-
사용자 또는 CI가 빌드를 시작합니다(buildInstance 메서드).
-
브랜치에서 코드를 내려받고 farm.json에서 Farm 구성을 읽습니다. Dockerfile 경로와 빌드·실행용 변수들이 여기서 나옵니다.
-
Farm이 로컬에서 docker 이미지를 빌드하고
farm-docker-<hash>태그를 붙입니다. 그 과정에서 모든 빌드 로그를 디버깅용으로 UI에 스트리밍합니다. -
완성된 이미지로 farm 네트워크 안에 컨테이너를 만들어 시작합니다. 환경 변수가 전달되고, 원한다면 시작 명령을 재정의할 수도 있습니다.

트래픽 라우팅
여기서도 Farm은 프록시 역할을 다시 nginx에 맡깁니다. 인스턴스 컨테이너는 호스트에 어떤 포트도 공개하지 않습니다. farm Docker 네트워크 안에서 farm-docker-<hash>라는 이름으로 접근할 수 있고, 이 이름은 Docker의 내장 DNS가 해석해 줍니다.
남은 질문은 그 이름을 해석할 수 있는 nginx가 어디에 있느냐인데, 이는 모드에 따라 달라집니다:
-
docker_container 모드에서는 호스트에서 온 요청이 Farm 컨테이너로 들어가고, farm 네트워크에 물려 있는 Farm의 내부 nginx가 트래픽을 인스턴스까지 곧장 전달합니다.
-
vm 모드에서는 Farm의 nginx가 호스트에서 직접 돌기 때문에 컨테이너 이름을 볼 수 없습니다. 그래서 요청을 별도의 Docker Proxy 컨테이너(같은 nginx를 farm 네트워크 안에서 띄운 것)로 프록시하고, 이 컨테이너가 트래픽을 올바른 인스턴스에 넘겨줍니다.

이렇게 해서 클러스터 없이 Docker만 있는 평범한 가상 머신에서도 동작하는 인스턴스를 얻게 됩니다. 코드는 이미지로 빌드되고, 이미지는 컨테이너가 되며, nginx가 그 컨테이너까지 트래픽을 전달합니다.
그 과정에서 격리 문제와 그 이상한 유닉스 소켓 계약도 함께 해결했습니다. 이제 애플리케이션은 평범한 docker 이미지로 제공되고, Farm의 내부 사정은 전혀 모른 채 그저 자기 포트에서 요청을 기다리면 됩니다.
Farm의 진화와 오픈소스 공개
프로바이더 추상화, 다양한 빌드·실행 방식 지원, Knex로의 전환까지 모든 개선을 거치고 나니 Farm은 이미 Yandex 특유의 요소가 큰 비중을 차지하지 않는, 성숙하고 어엿한 제품처럼 보였습니다. Farm은 어느새 자기 밑에서 돌아가는 기술들과 강하게 결합하지 않은 오케스트레이터가 되어 있었습니다.
이제는 Heroku 계열의 간소화된 배포 시스템을, 다만 셀프 호스티드 서비스 형태로 닮아 있었습니다. 그래서 더 야심 찬 계획이 생겼습니다. Farm의 코드를 저희 오픈소스 프로젝트 Gravity UI의 일부로 공개해서, 회사 안에서만이 아니라 커뮤니티 전체에 도움이 되게 하자는 것이었습니다.
하지만 코드를 공개하자는 이 아름다운 아이디어에는 한 가지 걸림돌이 있었습니다. Farm이 한층 추상적이고 깔끔해졌다고는 해도 Yandex 고유의 요소가 여전히 많이 남아 있었습니다. 내부 인증, Arc 지원, 저희 이슈 트래커로의 상태 게시, 텔레메트리, 그 밖에 내부 인프라에 묶여 있는 자잘한 것들 말입니다. 이것들을 전부 공개하는 건 좋은 생각이 아니고, 어차피 커뮤니티에는 쓸모도 없습니다. 그렇다고 그냥 잘라낼 수도 없었습니다. 매일 수십 개 팀이 사용하는 저희 자체 설치본이 바로 그 요소들 위에서 돌아가고 있었으니까요.
그래서 과제는 이렇게 정리되었습니다. Yandex 고유의 것들을 전부 밖으로 빼내고 Farm에서 구성 가능한 코어를 추출하되, 코어의 코드를 건드리지 않고도 그 고유 요소들을 바깥에서 다시 꽂아 넣을 수 있게 하자. 본질적으로는 세상에 공개할 준비가 된 것과 내부에만 남을 것 사이에 경계선을 하나 더 긋는 일이었습니다.
결국 저희는 Farm을 물리적으로 두 개의 코드베이스로 분리했습니다.
-
공개 코어: 모든 오케스트레이션, Docker와 k8s 프로바이더, Git 지원, Knex 기반 데이터베이스, API, UI, 빌드 큐, 헬스체크 — 한마디로 Farm을 제품으로 만들어 주는 모든 것입니다. 코어는 Yandex에 대해 아무것도 모르며, 있는 그대로 안심하고 공개할 수 있습니다.
-
그 위에 사내 전용 요소들을 전부 얹어 주는 얇은 확장 레이어.
둘을 이어 주는 접착제는 플러그인 레지스트리입니다. 코어가 스스로 기동하면서 단일 진입점을 제공하고, 확장 레이어는 이 진입점을 통해 잘 정의된 인터페이스에 맞춰 자기 구현을 등록합니다. 그 결과 Yandex 관련 부분 전체가 이제 작은 모듈 하나에 모여 살며 선언적으로 연결됩니다:
initCoreExtension(async () => {
// 인스턴스를 실행하는 자체 방식
coreRegistry.farmProviders.plugIn('process', {
constructor: ({internalApi, config}) => new ProcessFarmProvider(internalApi, config),
});
// 자체 버전 관리 시스템
coreRegistry.vcs.plugIn('arc', {constructor: () => new ArcVcs()});
// 자체 웹훅 액션 — 예를 들어 트래커에 상태 게시
coreRegistry.webhookActions.plugIn('tracker', new TrackerWebhookAction());
// 자체 인증
coreRegistry.authProviders.plugIn('internal', {constructor: () => new InternalAuthProvider()});
// ...그 외 텔레메트리, CSP 도메인, UI 메뉴 항목
});
레지스트리는 저마다 자체 인터페이스를 가진 개별 확장 지점들의 집합으로 구성되어 있고, 달라질 수 있는 모든 것이 이 지점들을 통해 노출됩니다:
-
프로바이더(farmProviders) — 어떻게, 어디에 배포할지.
-
버전 관리 시스템(vcs) — 공개된 git, 내부용 arc.
-
인증(authProviders) — 사용자를 UI와 API에 어떻게 들여보낼지.
-
웹훅 액션(webhookActions) — CI 이벤트에 대한 응답으로 무엇을 할지. 예를 들어 트래커에 상태를 다시 게시하는 일.
-
farm.json 스키마(farmJsonConfig) — 특정 프로바이더의 필요에 맞춘 애플리케이션 구성의 커스텀 필드.
-
UI와 보안(uiConfiguration, cspDirectives) — 메뉴 항목과 CSP의 신뢰할 수 있는 도메인 목록.
핵심은 코어 입장에서 이 모든 구현이 동등하다는 점입니다. 코어에게 arc는 git과 다를 게 없고, process는 docker나 k8s와 다를 게 없습니다. 덕분에 내부 사정을 신경 쓰지 않고 코어를 공개할 수 있고, 저희 설치본은 코어를 확장 레이어와 함께 빌드하기만 하면 예전처럼 계속 동작합니다.
Farm을 k8s에 도입하기
긴 여정을 지나 마침내 저희 Farm 설치본들을 k8s로 옮기는 단계에 이르렀습니다. 저희가 택한 전략은 이렇습니다. 가장 큰 설치본을 먼저 Kubernetes 위로 옮기고, 모든 게 잘 동작하면 나머지 프로젝트들을 각자의 로컬 Farm에서 하나의 공용 설치본으로 옮겨 와 예전의 공용 구성을 부활시키는 것입니다. 바로 이 접근이 글 서두에서 짚었던 유지보수 복잡성 문제를 해결해 줍니다. 공용 클러스터가 부하에 따라 자동으로 확장되니, 팀들은 다시금 자기 설치본을 운영하고 그 리소스를 챙기는 일에 신경 쓰지 않아도 됩니다.
인프라를 구축할 때 저희는 자사 서비스와 클라우드 상품을 자유롭게 활용할 수 있습니다. 그래서 Yandex Managed Service for Kubernetes®로 클러스터를 만들고, 모든 리소스를 하나도 빠짐없이 Terraform으로 기술했습니다. 프로젝트마다 시크릿 관리를 분리하고 리소스 하나하나에 원자적인 접근 권한도 설정했습니다. 그 결과물이 재사용 가능한 Terraform 모듈인데, 이 모듈만 있으면 Farm의 k8s 설치본을 꽤 손쉽게 배포할 수 있습니다.
단 하루 만에 현재 진행 중이던 모든 프로젝트를 Farm의 k8s 설치본으로 무사히 옮겼습니다. 원래의 계약과 구성이 그대로 유지되었기 때문에 크게 어렵지 않았습니다. 새 Farm은 꽤 좋은 모습을 보여 주었고, 다른 프로젝트들도 하나둘 옮겨 오기 시작했습니다. 그러다 머지않아 새로운 문제가 드러났습니다. 클러스터가 RAM과 CPU 기준으로는 확장되는데도 디스크 공간은 여전히 계속 차올랐습니다. 매일 수많은 이미지가 빌드되어 저장 공간을 차지했기 때문입니다.
이번에는 문제를 정공법으로 풀었습니다. 클리너 메커니즘을 추가한 것입니다:
-
Docker에서는 이렇게 동작합니다. 사용하지 않는 이미지를 언제 삭제할지 cron 표현식으로 시각을 지정합니다.
-
k8s에서는 이 메커니즘이 네이티브 CronJob 리소스 위에 구축되어 있습니다. CronJob이 노드마다 파드를 띄우고, 이 파드들이 마찬가지로 오래된 이미지를 삭제합니다.
저희는 팀들의 반응, 즉 피드백과 발생한 문제 수, 메인테이너에게 들어온 문의 수를 근거로 k8s 기반 Farm 실험을 성공이라고 결론지었습니다.
현재 Farm이 제공하는 기능
이 여정을 거치며 Farm은 단일 스택용 '일꾼'에서 성숙한 프리뷰 환경 오케스트레이터로 성장했습니다. 지금 무엇을 할 수 있는지 간단히 정리하면 다음과 같습니다:
-
브랜치로부터의 빌드와 실행. 웹훅이나 UI에서 트리거되면 Farm이 코드를 가져와 애플리케이션을 빌드하고, 전용 URL을 가진 격리된 인스턴스를 띄웁니다.
-
복수의 프로바이더. 수평 확장이 필요한 대규모 설치본에는 k8s를, 클러스터 없는 가상 머신 한 대에는 docker를 사용합니다. 애플리케이션 계약은 모든 프로바이더에서 동일합니다.
-
플러그인 레지스트리를 갖춘 구성 가능한 코어. 프로바이더, 버전 관리 시스템, 인증, 웹훅 액션이 단일 레지스트리를 통해 연결되므로 코어를 바꾸지 않고도 Farm을 확장할 수 있습니다.
-
스케줄러와 빌드 큐. 생성 요청은 큐에 쌓이고, 스케줄러가 동시 빌드 수와 실행 중인 인스턴스 수 제한을 지키며 큐를 처리합니다.
-
라이프사이클 관리. UI에서의 자동 인스턴스 시작은 물론, 유휴 인스턴스를 타임아웃에 따라 중지하고 삭제합니다.
-
헬스체크. Farm은 인스턴스 상태를 모니터링하고 최신 상태를 UI와 API에 반영합니다. Docker와 프로세스 모드에서는 자체 가용성 검사로, Kubernetes에서는 네이티브 liveness/readiness 프로브로 확인합니다.
-
환경 변수 전달. 유연한 환경 설정을 지원합니다. 빌드 타임·런타임 변수, 생성 시점에 재정의할 수 없는 보호 변수, 그리고 Docker에서는 호스트나 컨테이너의 변수 상속까지 가능합니다.
-
마이그레이션을 갖춘 영속적 데이터베이스. SQLite, PostgreSQL 또는 다른 DBMS 위에서 동작하는 Knex.
-
UI와 API. 인스턴스를 실행하고 디버깅하는 웹 인터페이스, 그리고 CI 연동을 위한 HTTP API.
-
디스크 공간 클리너. Docker와 k8s에서 사용하지 않는 이미지를 정기적으로 정리합니다.
앞으로의 계획
이제 계획 이야기를 해 보겠습니다. Farm에서 개선하고 싶은 것은 아직 많습니다. 현재 시점의 주요 방향은 다음과 같습니다:
-
k8s 프로바이더에서 Ingress를 대체할 Gateway API로의 전환.
-
해시 대신 사람이 읽을 수 있는 이름으로 인스턴스에 접근할 수 있게 하는 별칭(alias) 지원.
-
본격적인 권한 관리 — 사용자, 역할, 접근 권한으로 이루어진 체계.
-
프로젝트별로 분리된 쿼터.
다른 오픈소스 프로젝트와 마찬가지로 문서와 예제도 계속 다듬어 나가겠습니다. Farm을 더 쉽고 편하게 쓸 수 있게 하고, 외부 사용자 수가 빠르게 늘어나도록 돕기 위해서입니다. 언젠가 Farm이 업계에서 프리뷰 환경 하면 떠오르는 도구 중 하나가 되면 좋겠습니다. 물론 어떻게 될지는 두고 봐야겠지만요.
사용해 보는 방법
저희 리포지토리에 들러 README와 관련 문서를 살펴보세요. Docker Engine이 설치되어 있다면 로컬 환경에서 자유롭게 Farm을 띄워 볼 수 있습니다. 어떤 피드백이든 감사히 받겠습니다. 이슈를 등록하고, PR을 보내고, 질문을 던지고, 추가 기능이 필요하다면 기능 요청도 가져와 주세요!
읽어 주셔서 감사합니다! 저희 프로젝트가 마음에 드셨다면 별표를 주시면 기쁘겠습니다! Yandex Cloud 팀의 최신 소식이 궁금하시다면 저희 채널에도 들러 주세요.

Mikhail Golbakh
Yandex Cloud 시니어 프론트엔드 개발자