본문 바로가기
AI Development/Workflow

Codex Agent Server 인프라 설계 — PostgreSQL / Gitea / Coding Agent의 3-daemon 분리

by JLearn 2026. 9. 2.

Ubuntu 24.04 한 대에서 PostgreSQL, Gitea, Codex 기반 Coding Agent를 함께 운영하더라도 모든 서비스를 하나의 Docker daemon에 넣을 필요는 없습니다.

이 글에서는 다음과 같이 세 개의 Linux service account와 세 개의 Rootless Docker daemon을 사용하여 Docker control plane을 분리하고, Codex Agent Server만 Ubuntu native systemd service로 실행하는 구조를 정리합니다.

  • postgres-svc: PostgreSQL 전용 Rootless Docker daemon
  • gitea-svc: Gitea 전용 Rootless Docker daemon
  • codex-agentd: Ticket별 Worker를 실행하는 Rootless Docker daemon과 native Agent Server

Coding Agent는 Gitea Issue의 codex-ready label을 시작점으로 작업 브랜치를 만들고, 코드를 수정·검증한 뒤 Pull Request를 생성합니다. 사람의 승인 이후에는 Agent Server가 Gitea API를 사용해 dev branch까지 merge하는 것을 범위로 합니다.

Rootless Docker는 Docker daemon과 container를 일반 사용자 권한의 user namespace 안에서 실행하여 daemon 자체가 root 권한으로 동작하는 구조를 피합니다. 다만 여러 Rootless daemon을 같은 Ubuntu host에 배치해도 Linux kernel, CPU, RAM, disk, host 장애까지 서로 분리되는 것은 아닙니다.


전체 인프라 구조

최종 구조는 다음과 같습니다.

Ubuntu 24.04
│
├─ postgres-svc
│   └─ Rootless Docker daemon A
│       └─ PostgreSQL Compose
│           └─ postgres:18
│               ├─ database: gitea       / role: gitea
│               └─ database: codex_agent / role: codex_agent
│
├─ gitea-svc
│   └─ Rootless Docker daemon B
│       └─ Gitea Compose
│           └─ docker.gitea.com/gitea:1.27.3-rootless
│
└─ codex-agentd
    ├─ Go Agent Server
    │   ├─ native systemd service
    │   ├─ HTTP router: go-chi v5
    │   ├─ Gitea webhook / REST API
    │   ├─ Compose workflow orchestration
    │   └─ approved agent PR -> dev merge
    │
    └─ Rootless Docker daemon C
        └─ Ticket별 Docker Compose project
            ├─ workspace-init
            ├─ git-prepare
            ├─ codex
            ├─ validation
            ├─ git-finalize
            └─ workspace-read

PostgreSQL과 Gitea는 각각 별도의 Rootless Docker daemon에서 실행합니다.

Agent Server 자체는 container가 아니라 Ubuntu의 native systemd service로 실행하고, Worker만 codex-agentd 계정의 Rootless Docker daemon에서 실행합니다.

이 구조의 핵심은 서비스 실행 위치의 분리보다 Docker API와 container lifecycle의 제어 경계를 분리하는 것입니다.


Dockerfile, Compose, Agent Server의 책임

Worker runtime은 Dockerfile, Compose, Agent Server의 세 계층으로 나누어 관리합니다.

Dockerfile
    │
    └─ codex-agent:local image
        ├─ Git
        ├─ Codex CLI
        ├─ Go
        ├─ JDK
        └─ worker scripts

compose.yaml
    ├─ service별 credential
    ├─ workspace volume
    ├─ CPU / memory / PID 제한
    └─ service별 network 정책

Go Agent Server
    ├─ service 실행 순서
    ├─ stage timeout
    ├─ 실패 처리
    ├─ Pull Request 생성
    ├─ 사람 승인 webhook 처리
    └─ dev merge

Dockerfile의 역할

Dockerfile은 Worker가 실행되는 실행 환경 자체를 정의합니다.

예를 들어 codex-agent:local image에는 다음과 같은 도구를 넣을 수 있습니다.

Git
Codex CLI
Go toolchain
JDK
rsync
validation scripts
worker scripts

Dockerfile은 "어떤 프로그램이 설치되어 있는가"를 정의하며, Ticket workflow의 실행 순서까지 담당하지 않습니다.

Compose의 역할

Compose는 같은 Worker image를 사용하더라도 각 단계에 필요한 환경과 권한을 다르게 구성하기 위해 사용합니다.

예를 들어 다음 요소를 service별로 다르게 지정할 수 있습니다.

services:
  git-prepare:
    environment:
      GITEA_TOKEN: ${GITEA_TOKEN}

  codex:
    environment:
      OPENAI_API_KEY: ${OPENAI_API_KEY}

  validation:
    network_mode: "none"

또한 CPU, memory, PID, volume, working directory 같은 runtime 제한도 Compose에서 관리할 수 있습니다.

Compose는 이 구조에서 workflow engine 역할을 하지 않습니다.

Agent Server가 필요한 service를 순서대로 호출합니다.

docker compose \
  -p <project-name> \
  run --rm --no-deps -T <service>

각 옵션의 의미는 다음과 같습니다.

옵션 역할
-p <project-name> Compose project name 지정
run 특정 service를 일회성 container로 실행
--rm 종료된 container 자동 삭제
--no-deps 연결된 dependency service를 자동 시작하지 않음
-T pseudo-TTY를 할당하지 않음

Agent Server의 역할

Agent Server가 실제 workflow를 제어합니다.

예를 들어 하나의 Ticket에 대해 다음 순서를 실행할 수 있습니다.

workspace-init
    ↓
git-prepare
    ↓
codex
    ↓
validation
    ↓
git-finalize
    ↓
Pull Request 생성

각 stage의 timeout, exit code 판정, 실패 처리, 재실행 정책 같은 orchestration은 Compose가 아니라 Agent Server의 책임으로 둡니다.


Ticket별 Compose project 격리

Agent Server는 Ticket마다 서로 다른 Compose project name을 사용합니다.

예를 들어 Issue 번호가 123이라면 다음처럼 만들 수 있습니다.

codex-issue-123

실행 예:

docker compose \
  -p codex-issue-123 \
  run --rm --no-deps -T codex

Compose가 기본적으로 생성하는 container, network, volume 등의 리소스 이름에는 project name이 사용됩니다.

따라서 Ticket별 project name을 다르게 하면 다음과 같은 namespace를 구성할 수 있습니다.

codex-issue-123_...
codex-issue-124_...
codex-issue-125_...

이를 통해 Ticket A의 workspace volume과 Ticket B의 workspace volume을 구분할 수 있습니다.

external: true를 사용하거나 name:으로 리소스 이름을 직접 고정한 volume/network는 Compose project name의 일반적인 namespace 규칙을 우회할 수 있습니다. Ticket별 격리를 목적으로 하는 리소스에는 이런 설정을 신중하게 사용해야 합니다.


Rootless Docker daemon을 세 개로 분리하는 이유

Docker Rootless mode에서는 Docker daemon과 container가 일반 사용자의 user namespace 안에서 실행됩니다.

각 service account가 자기 Rootless Docker daemon을 가지도록 구성하면 Docker API socket도 사용자별로 분리됩니다.

postgres-svc
└─ /run/user/<postgres-svc-uid>/docker.sock

gitea-svc
└─ /run/user/<gitea-svc-uid>/docker.sock

codex-agentd
└─ /run/user/<codex-agentd-uid>/docker.sock

즉 다음 세 socket은 서로 다른 Docker daemon을 가리킵니다.

gitea-svc Docker socket
    ≠
postgres-svc Docker socket
    ≠
codex-agentd Docker socket

Docker의 container, image, volume, network와 daemon data는 각 daemon에 속합니다.

Rootless Docker의 기본 data directory도 사용자별로 다음과 같이 분리됩니다.

~/.local/share/docker

따라서 세 service account를 사용하면 기본적으로 서로 다른 home directory 아래에 Docker data가 저장됩니다.

Agent Server가 제어할 수 있는 범위

Agent Server의 DOCKER_HOSTcodex-agentd 전용 socket만 가리키게 합니다.

DOCKER_HOST=unix:///run/user/<codex-agentd-uid>/docker.sock

Agent Server가 이 socket을 이용해 Docker API를 제어하더라도 같은 API를 사용해 postgres-svcgitea-svc daemon의 container를 직접 stop, remove, exec할 수는 없습니다.

Agent Server
    │
    │ DOCKER_HOST
    ▼
codex-agentd Docker daemon
    │
    └─ Ticket Worker containers

PostgreSQL과 Gitea는 별도 Docker daemon에 있으므로 Agent Worker의 Docker control plane에서 분리됩니다.

분리되지 않는 영역

Docker daemon을 세 개로 나누더라도 물리적인 host는 하나입니다.

Ubuntu host
├─ Linux kernel        공유
├─ CPU                 공유
├─ RAM                 공유
├─ physical disk       공유
└─ host 장애            공유

따라서 이 구조는 VM이나 별도 물리 서버처럼 완전한 장애 격리를 제공하지 않습니다.

목적은 다음과 같이 이해하는 것이 정확합니다.

Docker API / container lifecycle 격리 강화
                    ≠
물리 host 장애 격리

서로 다른 Docker daemon 사이의 네트워크

Docker bridge network는 해당 Docker daemon이 관리하는 network입니다.

따라서 daemon A의 container와 daemon B의 container를 같은 user-defined bridge에 직접 연결하는 방식은 사용할 수 없습니다.

다음과 같은 Docker DNS 이름을 daemon을 넘어 사용하지 않습니다.

Gitea container
    │
    └─ platform-postgres:5432   X

platform-postgres가 PostgreSQL daemon의 Compose service name이라 하더라도 Gitea daemon의 Docker DNS에서는 알 수 없습니다.

PostgreSQL 접속 경로

대신 PostgreSQL container의 포트를 Ubuntu host의 명시적인 private/internal IP에 publish합니다.

Gitea container
Docker daemon B
      │
      │ TCP
      ▼
HOST_PRIVATE_IP:5432
      │
      ▼
PostgreSQL container
Docker daemon A

Gitea는 PostgreSQL을 일반 TCP server처럼 사용합니다.

예를 들어 PostgreSQL Compose에서 host의 특정 private IP에만 bind하도록 구성할 수 있습니다.

ports:
  - "192.168.10.10:5432:5432"

여기서 192.168.10.10은 예시이며 실제 서버의 internal/private IP를 사용해야 합니다.

PostgreSQL도 해당 접속을 허용하도록 listen_addresses, pg_hba.conf, host firewall을 함께 제한해야 합니다.

127.0.0.1을 사용하지 않는 이유

Gitea container 안의 다음 주소는 Ubuntu host가 아닙니다.

127.0.0.1

container 내부의 127.0.0.1은 그 container 자신의 loopback을 의미합니다.

따라서 Gitea 설정을 다음처럼 작성하면 별도 daemon에 있는 PostgreSQL로 연결되지 않습니다.

127.0.0.1:5432

이 구조에서는 명시적으로 준비한 host private endpoint를 사용합니다.

HOST_PRIVATE_IP:5432

Native Agent Server가 향후 codex_agent database를 사용할 때도 같은 PostgreSQL endpoint를 사용할 수 있습니다.


Worker credential과 Git 경계

모든 Worker container에 모든 credential을 전달하지 않습니다.

각 단계에 실제로 필요한 credential만 제공합니다.

service Gitea token OpenAI key network
workspace-init X X X
git-prepare O X O
codex X O O
validation X X X
git-finalize O X O
workspace-read X X X

이 구조에서는 Codex가 Gitea token을 직접 받을 필요가 없습니다.

git-prepare
    └─ Gitea credential 사용

codex
    └─ OpenAI credential 사용

validation
    └─ 외부 network 차단

git-finalize
    └─ Gitea credential 사용

credential 노출 범위를 stage 단위로 줄이면 Codex가 수정한 코드나 validation process가 불필요한 token에 접근하는 것을 줄일 수 있습니다.

Codex가 사용한 .git metadata를 재사용하지 않기

Codex가 작업한 directory는 다음과 같습니다.

/workspace/repo

이 directory에는 Codex가 작업 중 사용한 .git metadata도 존재할 수 있습니다.

최종 commit 단계에서는 이를 그대로 신뢰하지 않고 fresh clone을 준비합니다.

/workspace/repo      <- Codex가 수정한 working tree

/workspace/final     <- git-finalize가 만든 fresh clone
        ↑
        │
        └─ working-tree only rsync

개념적인 흐름은 다음과 같습니다.

fresh clone
    ↓
기존 working tree 파일만 rsync
    ↓
변경 확인
    ↓
새 commit 생성
    ↓
agent branch push

이때 .git directory는 rsync 대상에서 제외합니다.

예를 들면 다음과 같은 방식입니다.

rsync -a --delete \
  --exclude='.git/' \
  /workspace/repo/ \
  /workspace/final/

이 구조의 목적은 Codex가 작업 과정에서 사용한 Git metadata와 최종 push에 사용되는 Git metadata의 신뢰 경계를 분리하는 것입니다.


Gitea Issue에서 dev merge까지의 책임 경계

Coding Agent Server의 범위는 dev branch merge까지입니다.

Issue + codex-ready
        ↓
agent/issue-N branch 생성
        ↓
코드 수정
        ↓
validation
        ↓
agent/issue-N push
        ↓
PR: agent/issue-N -> dev
        ↓
사람 review / approve
        ↓
approval webhook
        ↓
Agent Server merge API
        ↓
dev merge

사람 승인 이후의 merge

Agent Server는 사람이 승인했다는 webhook을 받은 후 Gitea의 Pull Request merge API를 호출합니다.

API endpoint는 다음 형태입니다.

POST /api/v1/repos/{owner}/{repo}/pulls/{index}/merge

이 구조에서는 강제로 branch protection을 우회하지 않도록 force_mergefalse로 둡니다.

예를 들면 요청 의도는 다음과 같습니다.

{
  "do": "merge",
  "force_merge": false,
  "merge_when_checks_succeed": true
}

merge_when_checks_succeed=true는 필수 check가 아직 끝나지 않은 경우 check 성공 이후 merge하도록 요청하는 데 사용할 수 있습니다.

다만 실제 merge 가능 여부는 repository와 protected branch에 설정된 정책의 영향을 받습니다.

따라서 Agent Server가 "승인 webhook을 받았다"는 이유만으로 자체적으로 protected branch 정책을 우회하지 않도록 구성합니다.

Agent Server
    │
    └─ merge 요청
          │
          ▼
Gitea protected branch / check 정책
          │
          └─ 최종 merge 가능 여부 판단

실제 webhook event 이름과 payload 필드는 Gitea에서 사용 중인 버전의 webhook payload를 기준으로 Agent Server가 검증해야 합니다. 단순히 외부에서 전달된 PR 번호와 "approved" 문자열만 신뢰하지 않고 repository, PR, reviewer, current head commit 등의 상태를 Gitea API에서 다시 확인하는 편이 안전합니다.

dev 이후의 승격

다음 단계는 Coding Agent의 책임에서 제외합니다.

dev -> stg
    └─ 사람 PR / QA / merge / 배포

stg -> main
    └─ 사람 PR / merge / 배포

즉 Agent Server의 자동화 범위는 다음과 같습니다.

Issue
  ↓
Coding Agent
  ↓
Pull Request
  ↓
사람 승인
  ↓
dev merge

운영 승격은 사람이 통제합니다.


PostgreSQL 데이터 경계

하나의 PostgreSQL cluster 안에서 Gitea와 Agent Server용 database와 role을 분리합니다.

PostgreSQL cluster
│
├─ database: gitea
│   └─ owner / role: gitea
│
└─ database: codex_agent
    └─ owner / role: codex_agent

이를 표로 정리하면 다음과 같습니다.

database owner/role 용도
gitea gitea Gitea application data
codex_agent codex_agent Agent Server durable state

Gitea가 codex_agent database를 사용할 이유는 없으며, Agent Server 역시 Gitea 내부 table에 직접 접근하지 않습니다.

애플리케이션 간 연동은 Gitea REST API와 webhook을 사용합니다.

Agent Server
    │
    ├─ Gitea REST API
    └─ Gitea webhook

PostgreSQL
    ├─ gitea DB
    └─ codex_agent DB

MVP의 상태 저장 방식

현재 MVP에서는 다음 상태가 process memory에 있습니다.

issue active lock
webhook dedup

따라서 Agent Server process가 재시작되면 이 상태는 유지되지 않습니다.

codex_agent database는 향후 다음과 같은 durable state를 저장하기 위한 인프라로 준비해 둡니다.

ticket workflow state
stage execution result
webhook delivery dedup
active issue lock
PR / branch relation
retry state
audit data

즉 현재 구조는 다음 단계에 있습니다.

현재 MVP
    └─ runtime state: process memory

향후
    └─ durable state: codex_agent database

Agent Server systemd 서비스와 상태 확인

Agent Server는 Ubuntu native systemd service로 실행합니다.

systemd
└─ codex-agent-server.service
    └─ /opt/codex-agent/bin/agent-server

Container로 실행하지 않는 이유는 Agent Server 자체가 host service로서 webhook을 받고, 지정된 Rootless Docker daemon을 orchestration하는 역할을 하기 때문입니다.

Docker daemon 연결

Agent Server가 사용하는 Docker endpoint는 오직 codex-agentd 계정의 Rootless Docker socket입니다.

DOCKER_HOST=unix:///run/user/<codex-agentd-uid>/docker.sock

Rootless Docker의 기본 socket 경로는 일반적으로 다음 형태입니다.

/run/user/<UID>/docker.sock

Agent Server의 systemd environment에서도 이 값을 명시적으로 고정하는 것이 좋습니다.

예:

[Service]
Environment=DOCKER_HOST=unix:///run/user/<codex-agentd-uid>/docker.sock

liveness

다음 endpoint는 Agent Server HTTP process가 응답 가능한지 확인합니다.

GET /healthz

예를 들어 단순한 HTTP liveness 용도로 사용할 수 있습니다.

200 OK

/healthz에서 PostgreSQL, Gitea, Docker 같은 외부 dependency까지 모두 검사하면 외부 장애가 Agent Server process 자체의 liveness 장애로 해석될 수 있으므로, liveness는 가볍게 유지하는 편이 좋습니다.

readiness

다음 endpoint는 실제 workflow를 받을 준비가 되었는지 확인합니다.

GET /readyz

현재 MVP의 readiness 항목은 다음과 같습니다.

Agent 전용 Rootless Docker daemon
    └─ docker info

Gitea
    └─ GET /api/v1/version

예를 들면 다음과 같은 상태를 확인합니다.

Agent Server HTTP process   OK
Docker daemon               OK
Gitea API                   OK
--------------------------------
ready                       true

향후 Agent Server가 codex_agent database를 runtime state 저장소로 사용하게 되면 PostgreSQL connectivity도 readiness 항목으로 추가할 수 있습니다.


이 구조에서 얻는 경계

전체 설계의 핵심을 정리하면 다음과 같습니다.

경계 분리 방식
PostgreSQL Docker API postgres-svc Rootless daemon
Gitea Docker API gitea-svc Rootless daemon
Agent Worker Docker API codex-agentd Rootless daemon
Ticket workspace Compose project name + volume
OpenAI credential codex stage만 전달
Gitea credential Git 관련 stage만 전달
validation network network 차단
Codex Git metadata git-finalize에서 fresh clone으로 분리
Gitea DB / Agent DB 별도 database + role
dev 이후 승격 사람의 PR / QA / merge 영역

이 구조는 하나의 Ubuntu host를 사용하면서도 서비스별 control plane과 Worker credential의 blast radius를 줄이는 데 목적이 있습니다.

다만 다음 요소는 여전히 host 차원의 공통 장애 영역입니다.

Linux kernel
CPU
RAM
physical storage
host network
host power / reboot / 장애

따라서 고가용성이나 물리적 장애 격리가 필요하다면 PostgreSQL, Gitea, Agent Worker를 별도 VM이나 별도 host로 분리하는 다음 단계의 설계를 고려해야 합니다.


결론

PostgreSQL, Gitea, Coding Agent Worker를 하나의 Docker daemon에 넣으면 구성은 단순하지만 Agent Server가 사용하는 Docker socket의 권한 범위가 커집니다.

세 개의 service account와 세 개의 Rootless Docker daemon으로 나누면 다음과 같이 control plane을 분리할 수 있습니다.

postgres-svc
    └─ PostgreSQL만 제어

gitea-svc
    └─ Gitea만 제어

codex-agentd
    └─ Ticket Worker만 제어

Agent Server는 Ubuntu native systemd service로 두고 자신의 Rootless Docker socket만 사용합니다.

Worker workflow는 Dockerfile이나 Compose에 숨기지 않고 Agent Server가 명시적으로 orchestration합니다.

Dockerfile
    = 실행 image

Compose
    = stage별 runtime / credential / resource 경계

Agent Server
    = workflow와 상태 전이

Git에서도 Codex가 사용한 .git metadata를 final push에 그대로 사용하지 않고 fresh clone과 working-tree 복사를 통해 경계를 둡니다.

최종적으로 Coding Agent의 자동화 범위는 dev merge에서 종료합니다.

Issue
  -> code
  -> validation
  -> Pull Request
  -> human approval
  -> dev merge

dev -> stg -> main 승격은 사람이 담당하도록 남겨 두면 AI Coding Agent의 자동화 범위와 실제 배포 책임을 명확하게 나눌 수 있습니다.


참고 자료

반응형

댓글