Docker Compose로 개발 환경 구성: .env 기반 설정 관리와 서비스 설정 파일 분리 전략
배경¶
XGEN 2.0 개발 초기에는 모든 서비스 설정이 하나의 services.yaml에 담겨 있었다. K3s 환경 기준으로 작성된 파일이라 호스트가 쿠버네티스 서비스 이름(xgen-core.xgen.svc.cluster.local)으로 하드코딩되어 있었다.
로컬 개발 환경에서 개발자들이 이 파일을 직접 수정해서 localhost:8002로 바꾸고, K3s 배포 전에 다시 원래대로 돌려놓는 과정이 반복됐다. 설정을 되돌리는 걸 잊고 커밋하면 배포가 깨지는 사고가 생겼다.
환경별로 설정 파일을 분리하고, Docker Compose 환경에서는 .env 파일로 민감 정보를 관리하는 구조로 개편했다.
설정 파일 3분할 구조¶
xgen-backend-gateway/config/
├── config.yml # 환경별 오버라이드 (APP_SITE 분기)
├── services.yaml # K3s 기본값 (기존)
├── services.local.yaml # 로컬 개발 (localhost:800x)
└── services.docker.yaml # Docker Compose (컨테이너명 기준)
게이트웨이는 시작 시 APP_SITE 환경변수를 읽어서 어떤 설정 파일을 로드할지 결정한다.
APP_SITE=local → services.local.yaml
APP_SITE=docker → services.docker.yaml
APP_SITE 없음 → services.yaml (K3s 기본)
services.local.yaml¶
로컬에서 각 서비스를 직접 실행할 때 쓰는 설정이다. 모든 호스트가 localhost다.
services:
xgen-core:
host: http://localhost:8002
modules: [admin, auth, config, llm, data, session-station]
retrieval-service:
host: http://localhost:8003
modules: [retrieval, documents, folder, embedding, data-processor, storage]
xgen-model-service:
host: http://localhost:8004
modules: [inference, model, management]
xgen-workflow-service:
host: http://localhost:8001
modules: [workflow, agent]
services.docker.yaml¶
Docker Compose 환경에서는 컨테이너명으로 서비스를 찾는다. 같은 Docker 네트워크 안에 있으면 컨테이너명이 DNS 역할을 한다.
services:
xgen-core:
host: http://xgen-core:8000
modules: [admin, auth, config, llm, data, session-station]
retrieval-service:
host: http://xgen-documents:8000
modules: [retrieval, documents, folder, embedding, data-processor, storage]
xgen-model-service:
host: http://xgen-model:8000
modules: [inference, model, management]
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
${DATABASE_URL} 형식으로 환경변수를 참조한다. 이 값은 런타임에 실제 환경변수로 치환된다.
Rust에서 ${VAR} 확장 처리¶
YAML 파서는 기본적으로 ${DATABASE_URL} 같은 형식을 그냥 문자열로 읽는다. 환경변수 확장은 직접 구현해야 했다.
// src/config.rs
fn expand_yaml_value(raw: &str) -> String {
let unquoted = raw.trim().trim_matches('"').trim_matches('\'');
if unquoted.starts_with("${") && unquoted.ends_with("}") {
let key = &unquoted[2..unquoted.len() - 1];
std::env::var(key).unwrap_or_default()
} else {
unquoted.to_string()
}
}
YAML 값을 읽을 때 이 함수를 거치면 ${DATABASE_URL} → postgresql://ailab:ailab123@postgresql:5432/plateerag으로 치환된다. 환경변수가 없으면 빈 문자열을 반환한다.
config.yml 환경별 오버라이드¶
# xgen-backend-gateway/config/config.yml
default:
DOCS_PAGE: false
LOG_LEVEL: info
local:
development:
DOCS_PAGE: true
DATABASE_URL: postgresql://ailab:ailab123@localhost:5432/plateerag_dev
REDIS_URL: redis://localhost:6379
docker:
development:
DOCS_PAGE: true
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
external:
minio: http://minio:9000
qdrant: http://qdrant:6333
k8s:
production:
DOCS_PAGE: false
DATABASE_URL: ${DATABASE_URL}
local 환경에서는 DB URL을 직접 하드코딩(개발용 비밀번호라 무방)하고, docker와 k8s 환경에서는 환경변수로 주입받는다.
Docker Compose 구성¶
.env.example¶
# xgen-infra/compose/.env.example
GITLAB_TOKEN=your_gitlab_token_here
DOCKER_REGISTRY=docker.x2bee.com/xgen/main
IMAGE_TAG=latest-amd64
DATA_PATH=./data
# 포트
XGEN_BACKEND_GATEWAY_PORT=8080
# DB
POSTGRES_DB=plateerag
POSTGRES_USER=ailab
POSTGRES_PASSWORD=ailab123
# Redis
REDIS_PASSWORD=redis_secure_password123!
# GPU 설정
GPU_TYPE=nvidia # nvidia | amd | cpu
.env.example은 git에 커밋한다. 실제 .env는 .gitignore에 추가해서 비밀번호가 레포에 들어가지 않도록 했다.
docker-compose.yml 게이트웨이 서비스¶
xgen-backend-gateway:
image: ${DOCKER_REGISTRY}/xgen-backend-gateway:${IMAGE_TAG}
container_name: xgen-backend-gateway
ports:
- "${XGEN_BACKEND_GATEWAY_PORT:-8080}:8080"
environment:
APP_SITE: "docker"
APP_ENV: ${ENV:-development}
DATABASE_URL: >-
postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgresql:5432/${POSTGRES_DB}
REDIS_URL: "redis://:${REDIS_PASSWORD}@redis:6379"
SERVICES_CONFIG_FILE: /app/config/services.docker.yaml
volumes:
- ../xgen-backend-gateway/config/config.yml:/app/config/config.yml:ro
- ../xgen-backend-gateway/config/services.docker.yaml:/app/config/services.docker.yaml:ro
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_healthy
networks:
- xgen-network
몇 가지 설계 결정이 있다.
볼륨 마운트로 설정 파일 주입: 설정 파일을 이미지에 구워 넣지 않고 볼륨으로 마운트했다. 설정만 바꿀 때 이미지를 다시 빌드할 필요가 없다. :ro(read-only) 옵션으로 컨테이너가 설정 파일을 수정하지 못하도록 했다.
depends_on condition: service_started(기본값)가 아닌 service_healthy를 사용했다. PostgreSQL과 Redis가 실제로 접속 가능한 상태가 된 후에 게이트웨이가 시작된다. 그렇지 않으면 시작 직후 DB 연결 실패로 컨테이너가 재시작되는 루프가 생긴다.
포트 기본값: ${XGEN_BACKEND_GATEWAY_PORT:-8080} 형식으로 .env에 값이 없을 때 기본값을 지정했다.
개발 환경 DB 분리¶
로컬 개발용 DB를 운영 DB와 완전히 분리했다. DB 이름부터 다르게 해서 실수로 운영 데이터를 건드리는 일이 없도록 했다.
# services.local.yaml
DATABASE_URL: postgresql://ailab:dev_password123@localhost:5432/plateerag_dev
REDIS_URL: redis://localhost:6379 # 로컬 Redis는 인증 없음
# services.docker.yaml (Docker Compose)
DATABASE_URL: ${DATABASE_URL} # → postgresql://ailab:ailab123@postgresql:5432/plateerag
REDIS_URL: ${REDIS_URL} # → redis://:redis_secure_password123!@redis:6379
로컬은 인증 없이 빠르게 개발하고, Docker Compose 환경은 운영과 동일한 인증 설정을 적용했다.
전체 실행 흐름¶
# 1. .env 파일 준비
cd xgen-infra/compose
cp .env.example .env
vim .env # 비밀번호 설정
# 2. 전체 스택 실행
docker compose up -d
# 3. 게이트웨이 로그 확인
docker logs xgen-backend-gateway -f
# 로그 예시:
# [INFO] APP_SITE=docker, loading services.docker.yaml
# [INFO] Database connected: postgresql://...@postgresql:5432/plateerag
# [INFO] Redis connected: redis://:***@redis:6379
# [INFO] Server started on 0.0.0.0:8080
삽질: services.docker.yaml 경로 오류¶
초기 구성에서 볼륨 마운트 경로가 잘못됐었다.
# 잘못된 경로
volumes:
- ./config/services.docker.yaml:/app/config/services.docker.yaml:ro
# 올바른 경로 (docker-compose.yml이 compose/ 디렉토리에 있고,
# services.docker.yaml은 ../xgen-backend-gateway/config/에 있음)
volumes:
- ../xgen-backend-gateway/config/services.docker.yaml:/app/config/services.docker.yaml:ro
docker compose up 실행 후 게이트웨이 컨테이너가 시작됐지만 서비스 라우팅이 전혀 안 됐다. 로그를 보니 설정 파일을 읽지 못해서 빈 서비스 목록으로 시작한 것이었다. 볼륨 마운트 경로의 상대 경로 기준이 docker-compose.yml 위치임을 확인하고 수정했다.
결과¶
- 환경별 설정 파일 분리로 "로컬에서 수정 후 되돌리기" 실수 제거
.env파일 기반으로 민감 정보 관리, git 히스토리에 비밀번호 노출 없음- Rust의
expand_yaml_value함수로 YAML 내${VAR}환경변수 확장 지원 - Docker Compose
depends_on: condition: service_healthy로 서비스 기동 순서 보장
개발 환경 구성은 "한 번만 설정하면 끝"이 아니라 팀이 늘어나고 환경이 다양해질수록 계속 정비해야 한다. 환경별 설정 파일 분리와 .env 기반 관리는 이 과정에서 가장 효과적인 패턴이었다.
관련 글
- XGEN K3s 인프라 완전 해부 (1) — 전체 구조와 컨테이너 빌드 전략
BuildKitDevOpsDocker - K3s 위에 AI 플랫폼 올리기: 인프라 설계부터 배포까지
ArgoCDDevOpsDocker Compose - 인프라 모노레포 디렉토리 구조 설계: dockerfiles/compose/k3s 분리 전략
ArgoCDDevOpsDocker - XGEN K3s 인프라 완전 해부 (2) — Kubernetes 핵심 오브젝트와 스케일링 전략
DeploymentDevOpsHPA - XGEN K3s 인프라 완전 해부 (4) — CI/CD 파이프라인: Jenkins 빌드에서 ArgoCD 배포까지
App of AppsArgoCDCI/CD