CI/CD 시스템을 구축하는 게 얼마나 어려울까?
출처: Dev.to
CI/CD 시스템을 만드는 것이 얼마나 어려울까?
그 질문이 내 머릿속에 오래 남아 있었고, 결국 직접 만들게 되었다. 누군가 시킨 것이 아니다. 시장의 빈틈을 발견해서도 아니다. 그저 그 질문이 사라지지 않았기 때문이다.
계기가 된 것은 Concourse CI였다. 꽤 오래 사용해 왔는데, 내가 가장 마음에 들어 하는 점은 리소스 추상화—외부와 통신하기 위해 따라야 하는 인터페이스: 새 버전을 확인하고, 가져오고, 다시 푸시한다. Go 개발자로서 이런 깔끔한 인터페이스는 큰 매력이다. 파이프라인에 있는 모든 것은 그 계약을 구현하는 무언가일 뿐이다.
하지만 운영 오버헤드는 상당했다. 또한 GitHub Actions가 제공하지 못하는 맞춤형 환경이 필요한 내 사이드 프로젝트(게임 및 오픈소스 도구)에도 CI가 필요했다. 그래서 PikoCI를 만들기 시작했다.
수평 확장이 가능한 단일 바이너리
./pikoci server \
--db-system mem \
--pubsub-system mem \
--run-worker \
--pipeline-config pipeline.hcl
이 한 줄이 완전한 CI/CD 시스템이다:
- 기본은 메모리—파일도 없고 외부 서비스도 없다.
--db-system sqlite로 영속성을 추가한다.- NATS 로 분산 워커를 추가하고 다른 머신에서 워커를 실행한다.
- 파이프라인 설정은 절대 변하지 않는다.
네 가지 플러그인 가능한 추상화
PikoCI는 HCL에 정의하고 URL 로 가져올 수 있는 네 가지 개념을 제공한다:
- 리소스 타입 – 무언가의 변화를 감시하고 가져오는 방법.
- 러너 타입 – 작업이 실행되는 위치.
- 시크릿 타입 – 자격 증명이 어디서 오는지.
- 서비스 타입 – 작업과 함께 실행되는 프로세스.
각각은 동일한 패턴을 따른다: 타입을 한 번 정의하고, 파라미터로 인스턴스화한다.
1. 리소스 타입
resource_type "git" {
source = "pikoci://git" # built‑in
}
resource "git" "my-app" {
params {
url = "https://github.com/org/app"
name = "app"
}
check_interval = "@every 1m"
}
2. 러너 타입
docker 와 exec 러너는 기본 제공이므로 선언할 필요가 없다.
아래는 Docker 러너가 내부적으로 어떻게 생겼는지 보여준다(직접 정의하고 싶을 때 참고).
# 이것이 pikoci://docker 의 모습이다
# 직접 정의해서 커스터마이즈하거나 교체할 수 있다
runner_type "docker" {
run {
path = "docker"
args = [
"run", "--rm",
"-v", "$WORKDIR:/workdir",
"-w", "/workdir",
"$image",
"/bin/sh", "-ec", "$cmd",
]
}
}
Docker 러너를 사용하는 예시 잡:
job "test" {
get "git" "my-app" { trigger = true }
task "run-tests" {
run "docker" {
image = "golang:1.25"
cmd = "cd app && make test"
args = ["-v", "/cache/go:/root/go/pkg/mod"]
}
}
}
3. 시크릿 타입
secret_type "vault" {
source = "pikoci://vault"
}
variable "db_password" {
secret "vault" {
path = "secret/data/db"
key = "password"
}
}
시크릿은 변수에 바인딩되며 파이프라인 어디서든 참조할 수 있다.
4. 서비스 타입
서비스는 작업과 함께 실행되며, 잡이 시작되기 전에 시작하고 끝난 뒤에 중지된다. 결과와 무관하게 보장된다. 내가 가장 자랑스러워하는 기능이다:
service_type "postgres" {
# source = "pikoci://postgres" — 혹은 인라인 정의
start "exec" {
path = "/bin/sh"
args = ["-ec", "docker run -d --name db -p 5432:5432 postgres:16"]
}
ready_check "exec" {
path = "/bin/sh"
args = ["-ec", "pg_isready -h localhost"]
timeout = "30s"
}
stop "exec" {
path = "/bin/sh"
args = ["-ec", "docker rm -f db"]
}
}
잡에서 서비스 사용하기:
job "integration" {
service "postgres" {}
get "git" "my-app" { trigger = true }
task "test" {
run "exec" {
path = "make"
args = ["integration-test"]
}
}
}
Docker‑in‑Docker도 없고, CI 옆에 docker‑compose도 없다. 서비스는 잡이 성공하든 실패하든 무조건 중지된다.
네 가지 타입 모두 URL 로 가져올 수 있다. 기본 제공 타입은 pikoci:// 를 사용하고, 직접 호스팅하는 어떤 것이든 같은 메커니즘으로 참조한다. Kubernetes 나 Azure 에서 작업을 실행하는 러너가 필요하면? 한 번 구현하고, 어디에든 호스팅한 뒤 URL 로 지정하면 된다.
네 가지 추상화를 모두 활용한 전체 파이프라인
resource_type "git" {
source = "pikoci://git"
}
resource "git" "my-app" {
params {
url = "https://github.com/org/app"
name = "app"
}
check_interval = "@every 1m"
}
secret_type "vault" {
source = "pikoci://vault"
}
variable "db_password" {
secret "vault" {
path = "secret/data/db"
key = "password"
}
}
service_type "postgresql" {
source = "pikoci://postgresql"
}
job "test" {
get "git" "my-app" { trigger = true }
service "postgresql" {
version = "17"
port = "5432"
password = var.db_password
}
task "run-tests" {
run "docker" {
image = "golang:1.25"
cmd = "cd app && make integration-test"
args = ["-v", "/cache/go:/root/go/pkg/mod"]
}
}
}
- git 리소스가 변화를 감시한다.
- Vault 시크릿이 Postgres 비밀번호를 제공한다.
- Postgres 가 서비스로 시작된다.
- 작업은 Docker 안에서 실행된다.
네 가지 추상화가 모두 들어간 하나의 파이프라인이다.
로컬에서 파이프라인 실행하기
pikoci run --pipeline-config pipeline.hcl --job test
노트북 하나만 있으면 어떤 잡도 실행 가능 — 서버가 필요 없다.
리소스를 로컬 경로로 오버라이드하고, --var 로 시크릿을 주입한다. CI 에서 쓰던 파이프라인을 그대로 노트북에서도 돌릴 수 있다.
분산 워커
워커는 서버에 직접 연결되지 않는다; 큐를 구독한다. 따라서 워커는 다음과 같은 환경에서도 가능하다:
- NAT 뒤에 있을 때
- 노트북에서
- 다른 데이터센터에 있을 때
- 완전히 일시적인 경우
서버는 워커가 어디에 있는지 알 필요가 없다. 분산 워커를 추가하는 일은 매우 간단하다—네트워크가 큐에 접근할 수 있는 어디서든 워커를 실행하면 자동으로 동작한다.
PikoCI 스스로를 배포한다
파이프라인은 PR 체크, 목 테스트, 그리고 여섯 가지 데이터베이스·큐 백엔