GitOps 이전까지 쓸 배포 파이프라인과 Portal 모드 Chart를 추가한다

GitOps 저장소도 ArgoCD Application도 아직 없어, 그때까지 이 저장소가
push 방식 파이프라인(.gitea/workflows/)을 임시로 소유한다. 무엇을
포기하는지와 넘길 때 할 일은 deploy/README.md에 적었다.

Chart는 portal과 bundles 두 배포 모델을 모두 렌더링한다. ADR-0013이
ADR-0007을 대체했으므로 운영은 portal이 기준이지만, bundles 경로를
언제 삭제할지는 아직 정하지 않았다.

- .gitea/workflows/ci.yaml, deploy-openshift.yaml
- deploy/ci/render-manifests.sh, deploy/examples/
- Chart: mode 분기, selectedDeployment/tier helper, imagePullSecrets,
  toolService.apiKeySecret 참조
- extension-points.md에 미결 항목 9~12 추가

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:02:36 +09:00
parent b79a10a451
commit d1d93f7dc9
19 changed files with 944 additions and 150 deletions

82
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,82 @@
name: CI
# main push와 PR에서 같은 검증을 돌린다. 이 워크플로는 배포하지 않는다.
on:
push:
branches:
- main
pull_request:
env:
# 빌드·렌더링 도구는 러너에 설치하지 않고 컨테이너로 가져온다.
# 러너에 JDK나 helm이 깔려 있는지에 파이프라인이 의존하지 않게 하기 위해서다.
# 러너가 host 모드(docker가 러너 호스트에서 직접 도는 구성)라는 전제다.
# 현재 .gitea/workflows/deploy.yaml이 호스트의 docker compose 스크립트를 부르므로 그 전제가 성립한다.
JDK_IMAGE: eclipse-temurin:21-jdk
HELM_IMAGE: alpine/helm:3.14.4
jobs:
verify:
runs-on: ubuntu
steps:
- uses: actions/checkout@v4
# 컨테이너를 러너 사용자로 돌린다. root로 돌면 build/ 산출물이 root 소유가 되어
# 다음 실행의 checkout이 그 디렉터리를 지우지 못한다.
# ideaFormatCheck는 IDEA_FORMATTER가 없으면 경고만 남기고 건너뛴다.
# 들여쓰기·줄바꿈은 CI에서 검증되지 않는다는 뜻이다(README).
- name: Gradle check
run: |
docker run --rm \
--user "$(id -u):$(id -g)" \
-v "$PWD":/workspace -w /workspace \
-e GRADLE_USER_HOME=/workspace/.gradle/ci-home \
"$JDK_IMAGE" ./gradlew check --no-daemon
# HelmDeploymentContractTest는 values와 template의 정적 규칙만 본다.
# helper 오류와 조건 분기 실수는 실제로 렌더링해야 드러난다.
- name: Helm lint and template
run: |
docker run --rm \
--user "$(id -u):$(id -g)" \
-v "$PWD":/workspace -w /workspace \
--entrypoint sh \
"$HELM_IMAGE" deploy/ci/render-manifests.sh
# GitOps 저장소가 생기기 전까지, 이 산출물이 "무엇이 배포되는가"를 확인할 수 있는 유일한 형태다.
- name: Upload rendered manifests
uses: actions/upload-artifact@v3
with:
name: rendered-manifests
path: build/rendered
image:
runs-on: ubuntu
needs: verify
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t "ax-hub-mcp-server:${{ github.sha }}" .
# registry가 아직 확정되지 않았으면 빌드까지만 하고 멈춘다.
# 없는 secret 때문에 파이프라인 전체가 실패로 보이는 것을 막는다.
# TODO: 사내 registry 확정 시 저장소 secret에 REGISTRY_HOST·REGISTRY_USER·REGISTRY_PASSWORD·IMAGE_REPOSITORY를 등록한다.
- name: Push image
env:
REGISTRY_HOST: ${{ secrets.REGISTRY_HOST }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
IMAGE_REPOSITORY: ${{ secrets.IMAGE_REPOSITORY }}
IMAGE_TAG: ${{ github.sha }}
run: |
if [ -z "$REGISTRY_HOST" ] || [ -z "$IMAGE_REPOSITORY" ]; then
echo "REGISTRY_HOST 또는 IMAGE_REPOSITORY secret이 없어 push를 건너뛴다."
echo "이미지는 러너 로컬에만 있다: ax-hub-mcp-server:$IMAGE_TAG"
exit 0
fi
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
docker tag "ax-hub-mcp-server:$IMAGE_TAG" "$IMAGE_REPOSITORY:$IMAGE_TAG"
docker push "$IMAGE_REPOSITORY:$IMAGE_TAG"
echo "배포에 쓸 tag: $IMAGE_TAG"

View File

@@ -0,0 +1,114 @@
name: Deploy to OpenShift
# GitOps 저장소와 ArgoCD Application이 아직 없어 CD를 push 방식으로 돌린다.
# 파이프라인이 클러스터에 직접 helm upgrade를 건다.
#
# 이 방식의 대가를 숨기지 않는다.
# - 클러스터의 실제 상태가 저장소와 자동으로 맞춰지지 않는다. 누가 oc edit으로 고치면 그대로 남는다.
# - 배포 이력이 Helm release history에만 남는다. git revert로 되돌릴 수 없다.
# - 파이프라인이 클러스터 자격증명을 들고 있어야 한다.
#
# 그래서 자동 트리거를 두지 않고 사람이 값을 확인하고 실행한다.
# GitOps 저장소가 준비되면 이 워크플로를 삭제하고, ArgoCD Application이 이 Chart를 당겨 가게 한다.
# 그때까지의 인수인계 형태는 CI가 올리는 rendered-manifests 아티팩트다.
on:
workflow_dispatch:
inputs:
environment:
description: 배포 환경 (dev | test | prod)
required: true
default: dev
mode:
description: 배포 모델 (portal | bundles)
required: true
default: portal
deploymentKey:
description: mode=bundles일 때 설치할 배포 key. portal이면 비워 둔다
required: false
default: ""
imageTag:
description: 배포할 이미지 tag. CI가 push한 commit sha를 그대로 넣는다
required: true
env:
HELM_IMAGE: alpine/helm:3.14.4
jobs:
deploy:
runs-on: ubuntu
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
ENVIRONMENT: ${{ github.event.inputs.environment }}
MODE: ${{ github.event.inputs.mode }}
DEPLOYMENT_KEY: ${{ github.event.inputs.deploymentKey }}
IMAGE_TAG: ${{ github.event.inputs.imageTag }}
IMAGE_REPOSITORY: ${{ secrets.IMAGE_REPOSITORY }}
OCP_SERVER: ${{ secrets.OCP_SERVER }}
OCP_TOKEN: ${{ secrets.OCP_TOKEN }}
# 사내 CA가 서명한 API 인증서일 때 PEM 전체를 넣는다. 비어 있으면 러너의 신뢰 저장소를 쓴다.
OCP_CA_CERT: ${{ secrets.OCP_CA_CERT }}
OCP_NAMESPACE_DEV: ${{ secrets.OCP_NAMESPACE_DEV }}
OCP_NAMESPACE_TEST: ${{ secrets.OCP_NAMESPACE_TEST }}
OCP_NAMESPACE_PROD: ${{ secrets.OCP_NAMESPACE_PROD }}
run: |
set -eu
CA_FILE=.ocp-ca.crt
trap 'rm -f "$CA_FILE"' EXIT
case "$ENVIRONMENT" in
dev) NAMESPACE=$OCP_NAMESPACE_DEV ;;
test) NAMESPACE=$OCP_NAMESPACE_TEST ;;
prod) NAMESPACE=$OCP_NAMESPACE_PROD ;;
*) echo "environment는 dev|test|prod여야 한다: $ENVIRONMENT" >&2; exit 1 ;;
esac
for required in OCP_SERVER OCP_TOKEN IMAGE_REPOSITORY; do
eval "value=\${$required}"
if [ -z "$value" ]; then
echo "$required secret이 없다. 플랫폼 담당자에게 발급받아 저장소 secret에 등록한다." >&2
exit 1
fi
done
if [ -z "$NAMESPACE" ]; then
echo "$ENVIRONMENT namespace secret이 없다." >&2
exit 1
fi
# release 이름은 mode가 정한다. portal은 배포가 하나이고, bundles는 배포 key마다 하나다.
if [ "$MODE" = "portal" ]; then
RELEASE=axhub-mcp
EXTRA=""
else
if [ -z "$DEPLOYMENT_KEY" ]; then
echo "mode=bundles에는 deploymentKey가 필요하다." >&2
exit 1
fi
RELEASE="$DEPLOYMENT_KEY-mcp"
EXTRA="--set deploymentKey=$DEPLOYMENT_KEY"
fi
# 사내 CA를 신뢰시키는 정상 경로다. TLS 검증을 끄는 스위치는 두지 않는다.
if [ -n "$OCP_CA_CERT" ]; then
printf '%s\n' "$OCP_CA_CERT" > "$CA_FILE"
EXTRA="$EXTRA --kube-ca-file /workspace/$CA_FILE"
fi
# token은 helm 인자로 들어간다. 컨테이너는 명령마다 새로 뜨고 바로 사라지지만,
# 러너를 여러 팀이 공유하게 되면 kubeconfig 파일 방식으로 바꾼다.
# --atomic: 실패하면 직전 revision으로 되돌린다. 반쯤 배포된 상태로 두지 않는다.
docker run --rm \
-v "$PWD":/workspace -w /workspace \
"$HELM_IMAGE" upgrade --install "$RELEASE" deploy/helm/mcp-server \
-f "deploy/helm/mcp-server/values-$ENVIRONMENT.yaml" \
--namespace "$NAMESPACE" \
--set "mode=$MODE" \
$EXTRA \
--set "image.repository=$IMAGE_REPOSITORY" \
--set "image.tag=$IMAGE_TAG" \
--kube-apiserver "$OCP_SERVER" \
--kube-token "$OCP_TOKEN" \
--atomic --timeout 10m

View File

@@ -52,8 +52,9 @@ $env:MCP_LOCAL_TOOL_REGISTRY_FILE='file:C:/path/local-tools.json'
## 공개 계약 ## 공개 계약
- 공개 endpoint: `POST https://{global.mcpHost}{deployments.<key>.publicPath}` - 공개 endpoint: `POST https://{global.mcpHost}/mcp/{routeKey}`
- 예: `https://mcp-dev.apps.example.internal/mcp/processing-critical` - 예: `https://mcp-dev.apps.example.internal/mcp/cus`
- route key는 URI에서만 결정된다. route 없는 `/mcp` 호출은 거부한다
- OpenShift Route는 공개 path로 MCP Service만 선택하고, 컨테이너가 같은 path를 직접 처리 - OpenShift Route는 공개 path로 MCP Service만 선택하고, 컨테이너가 같은 path를 직접 처리
- Method: `initialize`, `notifications/initialized`, `tools/list`, `tools/call` - Method: `initialize`, `notifications/initialized`, `tools/list`, `tools/call`
- Response: 항상 단일 `application/json` JSON-RPC response - Response: 항상 단일 `application/json` JSON-RPC response
@@ -70,9 +71,9 @@ Agent Builder는 공개 URL마다 별도 MCP로 등록하고 initialize한다. U
| 환경 | Tool 원천 | Redis | | 환경 | Tool 원천 | Redis |
|---|---|---| |---|---|---|
| `local` | local JSON fixture | 사용 안 함 | | `local` | local JSON fixture | 사용 안 함 |
| 운영(`ocp`) | 이 배포가 보는 Tool Service 매니페스트를 주기적으로 pull | 성공 snapshot 공유와 warm start에만 사용 | | 운영(`ocp`) | Portal registry가 알려 준 route별 Tool Service 매니페스트를 주기적으로 pull | 성공 snapshot 공유와 Portal registry fallback에 사용 |
요청 경로의 `tools/list``tools/call`은 in-memory snapshot만 읽는다. 운영 refresh는 bundle별 last-good을 유지하고, 모든 bundle에 사용 가능한 성공본이 있을 때만 aggregate를 교체한다. 조회 실패만으로 Tool을 제거하지 않으며 정상 매니페스트에서 삭제가 확인될 때만 반영한다. 코드는 bundle N개 병합을 지원하지만 **운영 배포의 bundle은 항상 하나**([ADR-0007](docs/decisions/ADR-0007-one-mcp-per-tool-service.md)). 요청 경로의 `tools/list``tools/call`은 in-memory snapshot만 읽는다. 운영 refresh는 bundle별 last-good을 유지하고, 그 route의 모든 bundle에 사용 가능한 성공본이 있을 때만 route의 aggregate를 교체한다. 조회 실패만으로 Tool을 제거하지 않으며 정상 매니페스트에서 삭제가 확인될 때만 반영한다. **한 route에는 Tool Service가 여럿 붙을 수 있고, 병합 단위는 route**([ADR-0013](docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)).
Tool 실행 주소는 local `_meta.endpoint` 또는 운영 `baseEndpoint` 설정에서만 정한다. Agent Builder의 `arguments`와 Tool Service 매니페스트는 호출 대상을 바꿀 수 없다. Tool 실행 주소는 local `_meta.endpoint` 또는 운영 `baseEndpoint` 설정에서만 정한다. Agent Builder의 `arguments`와 Tool Service 매니페스트는 호출 대상을 바꿀 수 없다.
@@ -123,31 +124,19 @@ MDC는 사용하지 않는다. 로그에는 `guid`와 `x-request-id`만 남기
readiness는 첫 Tool discovery 시도가 끝나고 usable in-memory snapshot이 있을 때만 UP이다. 원천 장애 중에도 readiness는 첫 Tool discovery 시도가 끝나고 usable in-memory snapshot이 있을 때만 UP이다. 원천 장애 중에도
기존 memory 또는 Redis last-good이 있으면 서비스를 유지하고, 아무 성공본도 없으면 트래픽을 받지 않는다. 기존 memory 또는 Redis last-good이 있으면 서비스를 유지하고, 아무 성공본도 없으면 트래픽을 받지 않는다.
**MCP 배포 하나는 Tool Service 하나만 본**([ADR-0007](docs/decisions/ADR-0007-one-mcp-per-tool-service.md)). 대상을 늘리는 방법은 bundle 목록을 늘리는 것이 아니라 배포를 하나 더 만드는 것이다. 외부에서는 같은 host의 고유 path로 각 배포를 노출하고 컨테이너가 그 path를 그대로 처리한다([ADR-0009](docs/decisions/ADR-0009-container-handles-public-mcp-path.md)). 배포는 업무 × 중요도 등급으로 나뉘며, 등급이 replica 수와 PodDisruptionBudget을 정한다. **route↔Tool Service 매핑의 원천은 Portal이**([ADR-0013](docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)). 배포 하나가 N개 route를 서비스하고, route key는 `/mcp/{routeKey}` URI에서만 결정된다. 매핑이 바뀌어도 재배포하지 않는다. 외부에서는 같은 host의 path로 route를 구분하고 컨테이너가 그 path를 그대로 처리한다([ADR-0009](docs/decisions/ADR-0009-container-handles-public-mcp-path.md)).
배포 정의는 [Helm Chart](deploy/helm/mcp-server/) 하나뿐이다. 배포 토폴로지는 `values.yaml`이, 환경 차이는 `values-{dev,test,prod}.yaml`이 소유한다. 설치할 배포 하나는 `--set`으로 고른다. 배포 정의는 [Helm Chart](deploy/helm/mcp-server/) 하나뿐이다. Chart는 배포 모델 둘을 `mode`로 고른다. `portal`이 현재 애플리케이션이 실제로 도는 경로이고, `bundles`는 ADR-0013이 대체한 1:1 구성([ADR-0007](docs/decisions/ADR-0007-one-mcp-per-tool-service.md))이다.
```bash ```bash
helm upgrade --install processing-critical-mcp deploy/helm/mcp-server -f deploy/helm/mcp-server/values-dev.yaml --set deploymentKey=processing-critical -n <namespace> helm upgrade --install axhub-mcp deploy/helm/mcp-server -f deploy/helm/mcp-server/values-dev.yaml -n <namespace>
``` ```
**MCP Server와 Tool Service는 같은 namespace에 배포한다.** 그래서 values에는 Tool Service의 이름만 적고 주소는 template이 조립한다. 환경마다 URL을 반복해 적지 않으므로 오타로 엉뚱한 곳을 호출할 수 없다. 배포 토폴로지는 `values.yaml`이, 환경 차이는 `values-{dev,test,prod}.yaml`이 소유한다. 두 모드의 차이, 등급별 가용성, 확정 전 임시값은 [deploy/README.md](deploy/README.md)가 정본이다.
```yaml 평문 manifest가 필요하면 `deploy/ci/render-manifests.sh``helm template`으로 만든다. 별도 YAML을 저장소에 두지 않는다 — 두 벌은 반드시 어긋난다.
deployments:
processing-critical:
name: processing-critical-mcp
service: processing-critical-tools # ← 이름만. 주소는 template이 만든다
namePrefix: "processing." # ← 업무 단위. 등급을 넣지 않는다
tier: critical
publicPath: /mcp/processing-critical # ← 같은 환경 host 안에서 유일
```
배포가 10개든 20개든 파일 수는 늘지 않는다. 자세한 사용법은 [deploy/README.md](deploy/README.md)에 있다. 빌드·이미지·배포 실행 방식은 원래 사내 표준 CI/CD가 담당한다. GitOps 저장소가 준비되기 전까지만 `.gitea/workflows/`가 임시로 그 역할을 하며, 그 방식이 무엇을 포기하는지와 넘길 때 할 일은 [deploy/README.md](deploy/README.md#gitops-저장소가-없는-동안의-우회)에 적었다. 배포 시 알아야 할 앱 제약도 같은 문서에 정리했다.
평문 manifest가 필요하면 `helm template`으로 만든다. 별도 YAML을 저장소에 두지 않는다 — 두 벌은 반드시 어긋난다.
빌드·이미지·배포 실행 방식은 사내 표준 CI/CD가 담당하며 이 저장소가 정하지 않는다. 배포 시 알아야 할 앱 제약은 [deploy/README.md](deploy/README.md)에 정리했다.
## 문서 R&R ## 문서 R&R

View File

@@ -1,68 +1,72 @@
# 배포 정의 # 배포 정의
이 디렉터리는 **배포될 대상**을 정의한다. 빌드·이미지·배포 실행 방식은 사내 표준 CI/CD가 담당하며 이 디렉터리는 **배포될 대상**과, 그것을 클러스터에 올리는 **임시 경로**를 정의한다.
이 저장소가 정하지 않는다. 빌드·배포 실행 방식의 정본은 원래 사내 표준 CI/CD이며 이 저장소가 정하지 않는다.
지금 여기 파이프라인이 있는 이유는 [아래](#gitops-저장소가-없는-동안의-우회)에 적었다.
## Helm Chart ## Helm Chart
[helm/mcp-server/](helm/mcp-server/)가 유일한 배포 정의다. values는 두 축으로 나뉜다. [helm/mcp-server/](helm/mcp-server/)가 유일한 배포 정의다.
### 배포 모델이 두 가지다
| mode | 무엇이 route↔Tool Service 매핑을 소유하는가 | 근거 |
|---|---|---|
| `portal` (기본값) | **Portal.** 배포 하나가 N개 route를 서비스하고 route key는 `/mcp/{routeKey}` URI에서만 온다 | [ADR-0013](../docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md) |
| `bundles` | **배포 정의.** 배포 하나가 Tool Service 하나만 보고 매핑을 배포 시점에 못박는다 | [ADR-0007](../docs/decisions/ADR-0007-one-mcp-per-tool-service.md) |
**현재 애플리케이션이 실제로 도는 경로는 `portal`이다.** `bundles`는 ADR-0013이 대체했지만 코드 경로가
남아 있어 1:1 검증과 격리 배포에 쓸 수 있다. 어느 쪽을 운영에 쓸지는 아직 확정되지 않았고
[extension-points.md](../docs/extension-points.md)에서 관리한다.
`mode`를 바꾸면 ConfigMap의 Tool 원천이 통째로 바뀐다. 값 하나로 배포 성격이 달라지므로
설치 명령에 항상 명시한다.
### values는 두 축으로 나뉜다
| 파일 | 소유하는 것 | | 파일 | 소유하는 것 |
|---|---| |---|---|
| `values.yaml` | **배포 토폴로지.** 어떤 MCP가 어떤 Tool Service를 보는가, 공개 path, 가용성 등급 | | `values.yaml` | **배포 토폴로지.** mode, portal 배포 정의, bundles 배포 목록, 등급 기준 |
| `values-{dev,test,prod}.yaml` | **환경 차이.** namespace, 이미지, 공개 host·허용 CIDR, 등급별 replica·PDB, 리소스 | | `values-{dev,test,prod}.yaml` | **환경 차이.** namespace, 공개 host·허용 CIDR, Portal registry 주소, 등급별 replica·PDB, 리소스 |
설치할 때 두 번째 축을 `-f`로, 첫 번째 축에서 고를 배포 하나를 `--set deploymentKey=`로 지정한다. 환경 파일은 토폴로지를 갖지 않는다. `HelmDeploymentContractTest`가 그 경계를 고정한다.
```bash ```bash
helm upgrade --install processing-critical-mcp helm/mcp-server -f helm/mcp-server/values-dev.yaml --set deploymentKey=processing-critical -n <namespace> # portal 모드. 배포가 하나이므로 deploymentKey가 없다.
helm upgrade --install axhub-mcp helm/mcp-server -f helm/mcp-server/values-dev.yaml -n <namespace>
# bundles 모드. 설치할 배포 하나를 반드시 고른다.
helm upgrade --install processing-critical-mcp helm/mcp-server -f helm/mcp-server/values-dev.yaml \
--set mode=bundles --set deploymentKey=processing-critical -n <namespace>
``` ```
`deploymentKey`에는 기본값이 없다. 지정을 빠뜨리면 렌더링 단계에서 멈춘다. `deploymentKey`에는 기본값이 없다. `bundles`에서 지정을 빠뜨리면 렌더링 단계에서 멈춘다.
엉뚱한 배포가 조용히 설치되는 것보다 낫다. 엉뚱한 배포가 조용히 설치되는 것보다 낫다. 반대로 `portal`에서 `deploymentKey`를 주면 역시 멈춘다.
route를 배포 정의에 적기 시작하면 Portal을 원천으로 둔 이유가 사라지기 때문이다.
### 공 host와 배포별 path ### 공 host와 path
[ADR-0009](../docs/decisions/ADR-0009-container-handles-public-mcp-path.md)에 따라 한 환경은 하나의 공개 host를 사용하고, 한 환경은 하나의 공개 host를 사용한다([ADR-0009](../docs/decisions/ADR-0009-container-handles-public-mcp-path.md)).
각 Helm release는 고유 path의 OpenShift Route를 만든다. Route는 Service만 선택하고 공개 path를 그대로 Route는 Service만 선택하고 공개 path를 그대로 전달하며, 컨테이너가 같은 path를 직접 처리한다.
전달하며, 컨테이너가 같은 path를 직접 처리한다.
`portal` 모드에서 Route path는 `/mcp` 하나다. OpenShift Route의 path는 prefix 매칭이므로
`/mcp/{routeKey}` 전체가 이 Route로 들어오고, route 구분은 컨테이너가 한다.
```text
https://mcp-dev.apps.example.internal/mcp/cus -> axhub-mcp:8080/mcp/cus
https://mcp-dev.apps.example.internal/mcp/sal -> axhub-mcp:8080/mcp/sal
```
`bundles` 모드에서는 Route가 배포마다 하나씩 생기고 path가 배포별로 다르다.
```text ```text
https://mcp-dev.apps.example.internal/mcp/processing-critical -> processing-critical-mcp:8080/mcp/processing-critical https://mcp-dev.apps.example.internal/mcp/processing-critical -> processing-critical-mcp:8080/mcp/processing-critical
https://mcp-dev.apps.example.internal/mcp/information-standard -> information-standard-mcp:8080/mcp/information-standard
``` ```
공개 URL은 각각 독립된 MCP다. Agent Builder는 URL별로 등록하고 initialize하며, 한 Route나 MCP Pod의 두 경우 모두 공개 URL은 각각 독립된 MCP다. Agent Builder는 URL별로 등록하고 initialize한다.
장애가 다른 path의 Deployment로 전파되지 않는다.
### MCP 하나는 Tool Service 하나만 본다
[ADR-0007](../docs/decisions/ADR-0007-one-mcp-per-tool-service.md)의 결정이다. 대상을 늘리는 방법은
bundle 목록을 늘리는 것이 아니라 **배포를 하나 더 만드는 것**이다.
```yaml
deployments:
processing-critical:
name: processing-critical-mcp
service: processing-critical-tools # ← 이름만. 주소는 template이 만든다
namePrefix: "processing." # ← 업무 단위. 등급을 넣지 않는다
tier: critical
publicPath: /mcp/processing-critical # ← 환경 host 안에서 유일
```
배포가 10개든 20개든 **파일 수는 늘지 않는다.** 전체 매핑을 한 화면에서 검토할 수 있고,
`--set`으로 고르는 값 하나만 배포마다 달라진다.
MCP Server와 Tool Service는 같은 namespace에 배포하므로 values에는 서비스 이름만 적고
주소는 template이 조립한다. 환경마다 URL을 반복하지 않으므로 오타로 다른 대상을 호출할 수 없다.
`identity``{배포 이름}-{global.env}`로 template이 조립한다. 현재 Redis cache 구현이 이 값을
사용하지만, key namespace와 공유 정책은 아직 확정되지 않았다.
### 가용성 등급 ### 가용성 등급
배포를 업무 × 중요도로 나누는 목적은 **중요 등급에만 비용을 쓰기 위해서**다.
```yaml ```yaml
tiers: tiers:
critical: { replicas: 3, podDisruptionBudget: true, spreadAcrossNodes: true } critical: { replicas: 3, podDisruptionBudget: true, spreadAcrossNodes: true }
@@ -71,27 +75,74 @@ tiers:
test와 prod의 `critical`은 **replica 2 이상, PodDisruptionBudget, 노드 분산 설정이 필수**다. replica가 test와 prod의 `critical`은 **replica 2 이상, PodDisruptionBudget, 노드 분산 설정이 필수**다. replica가
1이면 rolling update 중 반드시 공백이 생기고, PDB가 없으면 노드 drain이 마지막 Pod을 내릴 수 있다. 1이면 rolling update 중 반드시 공백이 생기고, PDB가 없으면 노드 drain이 마지막 Pod을 내릴 수 있다.
`HelmDeploymentContractTest`는 values와 template의 정적 규칙을 검사한다. dev는 배포마다 Pod 1개로 dev는 배포마다 Pod 1개로 운영하므로 이 검사 대상이 아니다.
운영하므로 이 검사 대상이 아니다.
정적 테스트는 Helm 렌더러를 실행하지 않는다. 실제 배포 파이프라인은 사용하는 환경과 등급별로 **`portal` 모드에서 등급별 물리 분리는 성립하지 않는다.** 배포가 하나이므로 전 route가 같은
`helm lint``helm template`을 실행해 병합된 values와 생성 YAML을 확인해야 한다. 프로세스·같은 replica set을 공유한다([ADR-0013](../docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md) 전제 2).
`tiers`는 그 하나의 배포에 어떤 가용성 기준을 적용할지만 정한다.
### 렌더링 검증
`HelmDeploymentContractTest`는 values와 template의 **정적 규칙**만 본다. helper 오류, 조건 분기 실수,
들여쓰기는 실제로 렌더링해야 드러난다. 두 검사는 서로를 대신하지 못한다.
```bash ```bash
helm lint helm/mcp-server -f helm/mcp-server/values-prod.yaml --set deploymentKey=processing-critical deploy/ci/render-manifests.sh # 환경 × 모드 전 조합 lint + template
helm template processing-critical-mcp helm/mcp-server -f helm/mcp-server/values-prod.yaml --set deploymentKey=processing-critical
helm template processing-standard-mcp helm/mcp-server -f helm/mcp-server/values-prod.yaml --set deploymentKey=processing-standard
``` ```
**나누는 것만으로 가용성이 생기지는 않는다.** 같은 노드 배치, namespace 쿼터, 공통 Redis·클러스터 CI가 매 push에서 같은 스크립트를 돌리고 결과를 `rendered-manifests` 아티팩트로 올린다.
장애는 분할로 막히지 않는다. 남은 작업은 [extension-points.md](../docs/extension-points.md)의
"운영 적용 전 필수 보완"에서 관리한다.
### dev에서 MCP에 연결되지 않을 때 ## GitOps 저장소가 없는 동안의 우회
Chart를 어디에 둘지, 배포를 무엇이 실행할지는 아직 확정되지 않았다. GitOps 저장소도 ArgoCD
Application도 없다. 그동안 파이프라인을 멈춰 두지 않기 위해 아래 형태로 돌린다.
| 파일 | 트리거 | 하는 일 |
|---|---|---|
| `.gitea/workflows/ci.yaml` | main push, PR | `gradlew check`, Chart lint·template, 이미지 빌드·push |
| `.gitea/workflows/deploy-openshift.yaml` | 수동 실행 | 고른 환경·모드로 `helm upgrade --install` |
| `.gitea/workflows/deploy.yaml` | main push | 기존 VM docker compose 배포 |
### 이 방식이 무엇을 포기하는가
숨기지 않고 적는다. GitOps로 넘어가는 판단의 근거가 되기 때문이다.
- **클러스터 상태가 저장소와 자동으로 맞춰지지 않는다.** 누가 `oc edit`으로 고치면 그대로 남는다.
- **배포 이력이 Helm release history에만 남는다.** `git revert`로 되돌릴 수 없고 `helm rollback`을 써야 한다.
- **파이프라인이 클러스터 자격증명을 들고 있어야 한다.** 러너를 신뢰 경계 안에 두어야 한다.
- **어떤 이미지가 어느 환경에 떠 있는지 저장소만 봐서는 모른다.** 수동 실행 이력을 봐야 한다.
이 때문에 OpenShift 배포에는 자동 트리거를 두지 않았다. 사람이 환경·모드·이미지 tag를 확인하고 실행한다.
### GitOps 저장소가 생기면
1. `deploy-openshift.yaml`을 삭제한다. 클러스터 자격증명 secret도 회수한다.
2. ArgoCD Application이 이 Chart를 참조하게 하거나, Chart 자체를 배포 저장소로 옮긴다.
3. CI의 `rendered-manifests` 아티팩트가 **인수인계 형태**다. 그 시점에 무엇이 배포되고 있었는지가
거기 그대로 있으므로, 옮긴 뒤 diff로 대조한다.
4. `deploy.yaml`의 VM compose 배포를 계속 쓸지 결정한다. 스크립트가 저장소 밖(러너의
`/home/ubuntu/apps/prd-dap-gateway/deploy.sh`)에 있어 이 저장소가 내용을 모른다.
### 아직 필요한 secret
확정 전까지 CI는 이미지 빌드까지만 하고 push를 건너뛴다. 없는 secret 때문에 파이프라인 전체가
실패로 보이지 않게 하기 위해서다.
| secret | 쓰는 곳 | 없으면 |
|---|---|---|
| `REGISTRY_HOST`·`REGISTRY_USER`·`REGISTRY_PASSWORD`·`IMAGE_REPOSITORY` | CI 이미지 push | push 건너뜀 |
| `OCP_SERVER`·`OCP_TOKEN` | OpenShift 배포 | 배포 실패 |
| `OCP_CA_CERT` | API 인증서를 사내 CA가 서명했을 때 | 러너의 신뢰 저장소를 쓴다. 사내 CA면 TLS 검증 실패 |
| `OCP_NAMESPACE_DEV`·`OCP_NAMESPACE_TEST`·`OCP_NAMESPACE_PROD` | OpenShift 배포 | 배포 실패 |
## dev에서 MCP에 연결되지 않을 때
**먼저 Tool Service가 떠 있는지 확인한다.** readiness가 usable snapshot을 요구하므로, Tool Service가 **먼저 Tool Service가 떠 있는지 확인한다.** readiness가 usable snapshot을 요구하므로, Tool Service가
없으면 MCP Pod은 Ready가 되지 못하고 Service endpoint에서 빠진다. dev는 배포마다 Pod 1개라 없으면 MCP Pod은 Ready가 되지 못하고 Service endpoint에서 빠진다.
그 순간 그 MCP로는 아예 연결되지 않는다. "MCP가 죽었다"가 아니라 "읽을 Tool이 없다"는 뜻이다. "MCP가 죽었다"가 아니라 "읽을 Tool이 없다"는 뜻이다.
`portal` 모드에서는 Portal registry 조회부터 확인한다. registry를 못 읽으면 route 자체가 등록되지 않아
`/mcp/{routeKey}` 호출이 route key 검증에서 거부된다.
```bash ```bash
kubectl get pod -l app=<배포 이름> # 0/1 Ready이면 이 경우다 kubectl get pod -l app=<배포 이름> # 0/1 Ready이면 이 경우다
@@ -99,14 +150,15 @@ kubectl describe pod <pod> # Readiness probe 실패 사유
kubectl port-forward <pod> 9090:9090 # /actuator/toolBundles로 bundle 상태 확인 kubectl port-forward <pod> 9090:9090 # /actuator/toolBundles로 bundle 상태 확인
``` ```
Tool Service가 뜨면 다음 refresh 주기(기본 30초) 안에 스스로 Ready가 된다. 재기동할 필요가 없다. Tool Service가 뜨면 다음 refresh 주기 안에 스스로 Ready가 된다. 재기동할 필요가 없다.
`/actuator/toolBundles`는 management 포트라 NetworkPolicy가 관제 namespace로 제한하므로, `/actuator/toolBundles`는 management 포트라 NetworkPolicy가 관제 namespace로 제한하므로,
개발자는 위처럼 `port-forward`로 본다. 개발자는 위처럼 `port-forward`로 본다.
## 확정 전 임시값 ## 확정 전 임시값
`values.yaml`의 Tool Service 이름·이미지 경로와 `values-{env}.yaml`의 namespace·공개 host·Route 허용 CIDR은 자리표시자다. `values.yaml` 이미지 경로와 Tool Service 이름, `values-{env}.yaml`의 namespace·공개 host·Route 허용
각 파일의 `TODO` 주석을 참고해 확정 시 교체하고, 존재하지 않는 배포는 `deployments`에서 삭제한다. CIDR·Portal registry 주소는 자리표시자다. 각 파일의 `TODO` 주석을 참고해 확정 시 교체하고,
존재하지 않는 배포는 `deployments`에서 삭제한다.
## 배포 시 알아야 할 앱 제약 ## 배포 시 알아야 할 앱 제약
@@ -119,14 +171,14 @@ Tool Service가 뜨면 다음 refresh 주기(기본 30초) 안에 스스로 Read
| `terminationGracePeriodSeconds`는 Spring drain보다 길어야 한다 | [architecture.md의 요청 시간 예산](../docs/architecture.md#요청-시간-예산) | | `terminationGracePeriodSeconds`는 Spring drain보다 길어야 한다 | [architecture.md의 요청 시간 예산](../docs/architecture.md#요청-시간-예산) |
| **NetworkPolicy는 필수다. 비활성화 스위치를 두지 않았다** | [ADR-0006](../docs/decisions/ADR-0006-no-authentication-in-mcp.md) | | **NetworkPolicy는 필수다. 비활성화 스위치를 두지 않았다** | [ADR-0006](../docs/decisions/ADR-0006-no-authentication-in-mcp.md) |
| 공개 path는 Route와 컨테이너 endpoint가 동일하게 사용한다 | [ADR-0009](../docs/decisions/ADR-0009-container-handles-public-mcp-path.md) | | 공개 path는 Route와 컨테이너 endpoint가 동일하게 사용한다 | [ADR-0009](../docs/decisions/ADR-0009-container-handles-public-mcp-path.md) |
| route↔Tool Service 매핑의 원천은 Portal이다 | [ADR-0013](../docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md) |
| Portal 응답 모양과 실패 처리 | [Portal-MCP 계약 v0.1](../docs/contracts/portal-mcp/protocol-v0.1-registry.md) |
| Redis key·TTL·공유 정책은 확정 전이다 | [extension-points.md](../docs/extension-points.md#운영-적용-전-필수-보완) | | Redis key·TTL·공유 정책은 확정 전이다 | [extension-points.md](../docs/extension-points.md#운영-적용-전-필수-보완) |
| bundle은 정확히 하나다 | [ADR-0007](../docs/decisions/ADR-0007-one-mcp-per-tool-service.md) |
Route IP allowlist와 NetworkPolicy는 특히 중요하다. 이 서버는 인증·인가를 하지 않으므로 `/mcp` Route IP allowlist와 NetworkPolicy는 특히 중요하다. 이 서버는 인증·인가를 하지 않으므로 `/mcp`
도달할 수 있다는 것이 곧 인가다. Route는 Agent Builder 고정 egress CIDR만 받고, NetworkPolicy는 Route 도달할 수 있다는 것이 곧 인가다. Route는 Agent Builder 고정 egress CIDR만 받고, NetworkPolicy는 Route
backend인 ingress controller와 명시한 Agent Builder namespace만 업무 포트에 허용한다. backend인 ingress controller와 명시한 Agent Builder namespace만 업무 포트에 허용한다.
## 미확정 항목 `portal` 모드는 여기에 하나를 더한다. **MCP는 Portal registry가 준 주소를 그대로 호출한다.** Portal이
신뢰 경계 안에 있다는 전제가 깨지면 MCP의 outbound 대상이 통째로 바뀐다([ADR-0013](../docs/decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md) 전제 4).
배포 정의를 이 저장소가 어디까지 소유하는지, namespace·registry 명명 규칙은 아직 확정되지 않았다. egress 제한은 아직 없으며 [extension-points.md](../docs/extension-points.md#운영-적용-전-필수-보완)에서 관리한다.
[docs/extension-points.md](../docs/extension-points.md)에서 관리한다.

View File

@@ -0,0 +1,59 @@
#!/bin/sh
# Chart를 실제로 렌더링해 배포될 YAML을 만든다.
#
# 존재 이유가 둘이다.
# 1. 검증. HelmDeploymentContractTest는 values와 template의 정적 규칙만 본다.
# helper 오류, 잘못된 들여쓰기, 조건 분기 실수는 렌더링해야 드러난다.
# 2. 인수인계. GitOps 저장소가 아직 없으므로 여기서 나온 YAML이 "지금 무엇이 배포되는가"의
# 유일한 확인 가능한 형태다. 저장소가 생기면 이 산출물을 그대로 옮기면 된다.
#
# helm 바이너리가 PATH에 있어야 한다. CI는 helm 컨테이너 안에서 이 스크립트를 실행한다.
set -eu
CHART=deploy/helm/mcp-server
OUT=${OUT_DIR:-build/rendered}
# values.yaml의 deployments에서 배포 key 목록을 뽑는다.
# 목록의 정본은 values.yaml 하나이며 여기에 복사해 두지 않는다.
deployment_keys() {
sed -n '/^deployments:/,/^[a-z]/p' "$CHART/values.yaml" |
sed -n 's/^ \([a-z0-9-]*\):$/\1/p'
}
rm -rf "$OUT"
mkdir -p "$OUT"
keys=$(deployment_keys)
if [ -z "$keys" ]; then
echo "values.yaml의 deployments에서 배포 key를 찾지 못했다. 형식이 바뀌었는지 확인한다." >&2
exit 1
fi
for env in dev test prod; do
values="$CHART/values-$env.yaml"
namespace="ax-hub-$env"
# portal 모드. 배포 하나가 전 route를 서비스한다(ADR-0013).
echo "== lint $env / portal"
helm lint "$CHART" -f "$values"
echo "== render $env / portal"
helm template axhub-mcp "$CHART" -f "$values" \
--namespace "$namespace" \
>"$OUT/$env-portal.yaml"
# bundles 모드. 배포마다 Tool Service 하나(ADR-0007).
# 토폴로지 전체를 돌려야 등급별 replica·PDB·노드 분산 분기가 모두 렌더링된다.
for key in $keys; do
echo "== lint $env / bundles / $key"
helm lint "$CHART" -f "$values" --set mode=bundles --set "deploymentKey=$key"
echo "== render $env / bundles / $key"
helm template "$key-mcp" "$CHART" -f "$values" \
--set mode=bundles --set "deploymentKey=$key" \
--namespace "$namespace" \
>"$OUT/$env-bundles-$key.yaml"
done
done
echo
echo "렌더링 결과: $OUT"
ls -1 "$OUT"

View File

@@ -0,0 +1,277 @@
# AX HUB MCP 서버 개발계 수동 배포 샘플
#
# 주의:
# - 이 파일은 Helm template이 아니라, AA와 값을 협의한 뒤 수동으로 적용할 Raw OpenShift YAML 샘플이다.
# - "{{대문자_이름}}"은 확정되지 않은 값이다. 모든 자리표시자를 실제 값으로 교체한 뒤 적용한다.
# - 비밀번호와 API Key를 담는 Secret 및 그 참조는 현재 사용하지 않으므로 포함하지 않았다.
# - Harbor 인증이 필요하면 AA가 별도로 ServiceAccount에 image pull secret을 연결해야 한다.
# - 이 파일은 임시 수동 배포용 예제이며, 배포 정의의 정본은 deploy/helm/mcp-server Chart다.
#
# AA와 협의할 값:
# - {{DEV_NAMESPACE}}: MCP 서버를 배포할 개발계 namespace
# - {{HARBOR_IMAGE_REPOSITORY}}: Harbor project를 포함한 이미지 경로. 예: harbor.example/axhub/axhub-mcp
# - {{IMAGE_TAG}}: AA가 Podman으로 만들어 Push한 이미지 tag
# - {{DEV_MCP_HOST}}: 개발계 OpenShift Route host
# - {{AGENT_BUILDER_EGRESS_CIDR}}: Route 접근을 허용할 Agent Builder의 고정 egress CIDR
# - {{AGENT_BUILDER_NAMESPACE}}: Agent Builder Pod이 있는 namespace
# - {{DEV_PORTAL_REGISTRY_URL}}: 개발계 Portal registry API 주소
# - {{REDIS_SERVICE_HOST}}: 개발계 Redis Service host 또는 FQDN
# - {{CONFIG_VERSION}}: ConfigMap을 바꿀 때마다 증가시키는 값. 예: 1, 2, 3
#
# 적용 전 자리표시자 확인 예시(PowerShell):
# Get-Content .\deploy\examples\axhub-mcp-dev-manual.template.yaml |
# Where-Object { $_ -notmatch '^\s*#' } |
# Select-String -Pattern '\{\{[A-Z0-9_]+\}\}'
#
# 적용 예시:
# oc apply -f .\deploy\examples\axhub-mcp-dev-manual.template.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: axhub-mcp-config
namespace: "{{DEV_NAMESPACE}}"
labels:
app: axhub-mcp
app.kubernetes.io/name: axhub-mcp
app.kubernetes.io/instance: axhub-mcp-dev
app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub
ax-hub/mode: portal
ax-hub/tier: critical
data:
# SPRING_PROFILES_ACTIVE=dev이므로 파일명도 application-dev.yml이어야 한다.
application-dev.yml: |
management:
server:
port: 9090
health:
redis:
enabled: false
mcp:
# 환경별 Redis key가 서로 겹치지 않도록 개발계 identity를 고정한다.
identity: axhub-mcp-dev
# Route가 경로를 변경하지 않고 그대로 전달하므로 Route path와 같아야 한다.
endpoint-path: "/mcp"
registry:
refresh-interval-seconds: 30
refresh-jitter-seconds: 5
discovery:
enabled: true
redis:
enabled: true
# Portal 조회 실패 시 사용하는 Redis fallback key다. Portal과 같은 key인지 확인한다.
portal-registry-key: "axhub:mcp:portal-registry"
portal:
enabled: true
registry-url: "{{DEV_PORTAL_REGISTRY_URL}}"
refresh-interval-seconds: 60
# Portal이 route와 Tool Server 주소를 제공하므로 정적 bundle은 두지 않는다.
bundles: []
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: axhub-mcp
namespace: "{{DEV_NAMESPACE}}"
labels:
app: axhub-mcp
app.kubernetes.io/name: axhub-mcp
app.kubernetes.io/instance: axhub-mcp-dev
app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub
ax-hub/mode: portal
ax-hub/tier: critical
spec:
# 개발계 임시 테스트이므로 Pod 한 개로 구성한다.
replicas: 1
selector:
matchLabels:
app: axhub-mcp
template:
metadata:
labels:
app: axhub-mcp
app.kubernetes.io/name: axhub-mcp
app.kubernetes.io/instance: axhub-mcp-dev
app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub
ax-hub/mode: portal
ax-hub/tier: critical
annotations:
# Raw YAML은 Helm checksum을 자동 생성하지 못한다. ConfigMap 변경 시 이 값을 올리면 Pod이 재기동된다.
ax-hub/config-version: "{{CONFIG_VERSION}}"
spec:
terminationGracePeriodSeconds: 45
containers:
- name: mcp-server
image: "{{HARBOR_IMAGE_REPOSITORY}}:{{IMAGE_TAG}}"
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
protocol: TCP
- name: management
containerPort: 9090
protocol: TCP
env:
- name: SPRING_PROFILES_ACTIVE
value: dev
# ConfigMap의 application-dev.yml을 JAR 내부 설정보다 우선 적용한다.
- name: SPRING_CONFIG_ADDITIONAL_LOCATION
value: file:/opt/app/config/
- name: REDIS_HOST
value: "{{REDIS_SERVICE_HOST}}"
- name: REDIS_PORT
value: "16379"
- name: MANAGEMENT_SERVER_PORT
value: "9090"
volumeMounts:
- name: config
mountPath: /opt/app/config
readOnly: true
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: management
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: management
initialDelaySeconds: 20
periodSeconds: 20
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
volumes:
- name: config
configMap:
name: axhub-mcp-config
---
apiVersion: v1
kind: Service
metadata:
name: axhub-mcp
namespace: "{{DEV_NAMESPACE}}"
labels:
app: axhub-mcp
app.kubernetes.io/name: axhub-mcp
app.kubernetes.io/instance: axhub-mcp-dev
app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub
ax-hub/mode: portal
ax-hub/tier: critical
spec:
type: ClusterIP
selector:
app: axhub-mcp
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
---
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: axhub-mcp
namespace: "{{DEV_NAMESPACE}}"
labels:
app: axhub-mcp
app.kubernetes.io/name: axhub-mcp
app.kubernetes.io/instance: axhub-mcp-dev
app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub
ax-hub/mode: portal
ax-hub/tier: critical
annotations:
haproxy.router.openshift.io/timeout: 300s
# 이 서버는 자체 인증을 하지 않으므로 반드시 실제 Agent Builder 고정 egress CIDR로 제한한다.
haproxy.router.openshift.io/ip_allowlist: "{{AGENT_BUILDER_EGRESS_CIDR}}"
spec:
host: "{{DEV_MCP_HOST}}"
# /mcp/{routeKey} 요청도 prefix match로 이 Route에 들어온다. rewrite는 사용하지 않는다.
path: /mcp
to:
kind: Service
name: axhub-mcp
weight: 100
port:
targetPort: http
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
wildcardPolicy: None
---
# MCP 서버는 자체 인증·인가를 하지 않으므로 NetworkPolicy를 제거하면 안 된다.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: axhub-mcp-ingress
namespace: "{{DEV_NAMESPACE}}"
labels:
app: axhub-mcp
app.kubernetes.io/name: axhub-mcp
app.kubernetes.io/instance: axhub-mcp-dev
app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub
ax-hub/mode: portal
ax-hub/tier: critical
spec:
podSelector:
matchLabels:
app: axhub-mcp
policyTypes:
- Ingress
ingress:
# OpenShift Route를 통과한 요청을 8080 포트로 허용한다.
- from:
- namespaceSelector:
matchLabels:
policy-group.network.openshift.io/ingress: ""
ports:
- protocol: TCP
port: 8080
# 같은 클러스터 안에서 Agent Builder가 직접 호출하는 경우만 8080 포트로 허용한다.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: "{{AGENT_BUILDER_NAMESPACE}}"
ports:
- protocol: TCP
port: 8080
# Actuator management 포트는 OpenShift 관제 namespace에서만 접근하도록 제한한다.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: openshift-monitoring
ports:
- protocol: TCP
port: 9090

Binary file not shown.

View File

@@ -4,6 +4,7 @@ description: AX HUB MCP Server - Agent Builder와 Tool Service 사이의 statele
type: application type: application
# Chart 자체의 버전. 애플리케이션 버전과 따로 올린다. # Chart 자체의 버전. 애플리케이션 버전과 따로 올린다.
version: 0.1.0 # 0.2.0에서 배포 모델이 두 가지(portal·bundles)가 되어 values 구조가 바뀌었다.
version: 0.2.0
# 기본 이미지 tag. 배포 시 values의 image.tag가 덮어쓴다. # 기본 이미지 tag. 배포 시 values의 image.tag가 덮어쓴다.
appVersion: "0.1.0" appVersion: "0.1.0"

View File

@@ -2,8 +2,27 @@
, . , .
"nil pointer" . "nil pointer" .
template . template .
required의 .
host와 CIDR이 . .
mode에 . portal route Portal이 (ADR-0013)
deploymentKey가 registryUrl이 . bundles .
*/}} */}}
{{- define "mcp-server.validate" -}} {{- define "mcp-server.validate" -}}
{{- if not (has .Values.mode (list "portal" "bundles")) -}}
{{- fail (printf "mode는 portal 또는 bundles여야 한다: %v" .Values.mode) -}}
{{- end -}}
{{- if eq .Values.mode "portal" -}}
{{- $_ := required "mode=portal이면 portal.deployment.name을 지정해야 한다." .Values.portal.deployment.name -}}
{{- $_ = required "mode=portal이면 portal.registryUrl에 Portal registry 주소를 지정해야 한다. 환경별 values-{env}.yaml이 소유한다." .Values.portal.registryUrl -}}
{{- if not (index .Values.tiers .Values.portal.deployment.tier) -}}
{{- fail (printf "values.yaml의 tiers에 '%s' 등급이 없다." .Values.portal.deployment.tier) -}}
{{- end -}}
{{- if .Values.deploymentKey -}}
{{- fail "mode=portal에서는 deploymentKey를 쓰지 않는다. route는 /mcp/{routeKey} URI에서만 결정된다(ADR-0013)." -}}
{{- end -}}
{{- else -}}
{{- $key := required "deploymentKey를 지정해야 한다. 예: --set deploymentKey=processing-critical" .Values.deploymentKey -}} {{- $key := required "deploymentKey를 지정해야 한다. 예: --set deploymentKey=processing-critical" .Values.deploymentKey -}}
{{- $deployment := index .Values.deployments $key -}} {{- $deployment := index .Values.deployments $key -}}
{{- if not $deployment -}} {{- if not $deployment -}}
@@ -12,20 +31,42 @@
{{- if not (index .Values.tiers $deployment.tier) -}} {{- if not (index .Values.tiers $deployment.tier) -}}
{{- fail (printf "values.yaml의 tiers에 '%s' 등급이 없다. deployments의 tier와 tiers의 key가 어긋났다." $deployment.tier) -}} {{- fail (printf "values.yaml의 tiers에 '%s' 등급이 없다. deployments의 tier와 tiers의 key가 어긋났다." $deployment.tier) -}}
{{- end -}} {{- end -}}
{{- required "global.mcpHost에 환경별 공개 MCP host를 지정해야 한다." .Values.global.mcpHost -}} {{- end -}}
{{- required "route.sourceAllowlist에 Agent Builder의 고정 egress CIDR을 지정해야 한다." .Values.route.sourceAllowlist -}} {{- $_ := required "global.mcpHost에 환경별 공개 MCP host를 지정해야 한다." .Values.global.mcpHost -}}
{{- $publicPath := required (printf "deployments.%s.publicPath를 지정해야 한다." $key) $deployment.publicPath -}} {{- $_ = required "route.sourceAllowlist에 Agent Builder의 고정 egress CIDR을 지정해야 한다." .Values.route.sourceAllowlist -}}
{{- if not (regexMatch "^/mcp/[a-z0-9-]+$" $publicPath) -}} {{- $publicPath := required "선택된 배포의 publicPath를 지정해야 한다." (include "mcp-server.selectedDeployment" . | fromYaml).publicPath -}}
{{- fail (printf "deployments.%s.publicPath는 /mcp/<영문 소문자·숫자·하이픈> 형식이어야 한다: %s" $key $publicPath) -}} {{- if not (regexMatch "^/mcp(/[a-z0-9-]+)?$" $publicPath) -}}
{{- fail (printf "publicPath는 /mcp 또는 /mcp/<영문 소문자·숫자·하이픈> 형식이어야 한다: %s" $publicPath) -}}
{{- end -}} {{- end -}}
{{- end -}} {{- end -}}
{{/* {{/*
. namespace에 MCP . dict로 . mode가 ,
template은 (name·tier·publicPath) .
`include ... | fromYaml` . Helm helper는 .
. validate가 helper를 .
*/}}
{{- define "mcp-server.selectedDeployment" -}}
{{- if eq .Values.mode "portal" -}}
{{ toYaml .Values.portal.deployment }}
{{- else -}}
{{ toYaml (index .Values.deployments .Values.deploymentKey) }}
{{- end -}}
{{- end -}}
{{/*
. namespace에 MCP .
*/}} */}}
{{- define "mcp-server.name" -}} {{- define "mcp-server.name" -}}
{{- include "mcp-server.validate" . -}} {{- include "mcp-server.validate" . -}}
{{- (index .Values.deployments .Values.deploymentKey).name -}} {{- (include "mcp-server.selectedDeployment" . | fromYaml).name -}}
{{- end -}}
{{/*
.
*/}}
{{- define "mcp-server.tier" -}}
{{- (include "mcp-server.selectedDeployment" . | fromYaml).tier -}}
{{- end -}} {{- end -}}
{{/* {{/*
@@ -38,12 +79,12 @@ Redis key namespace가 되는 식별자.
{{- end -}} {{- end -}}
{{/* {{/*
MCP가 Tool Service의 host:port. MCP가 Tool Service의 host:port. bundles .
MCP와 Tool Service는 namespace이므로 FQDN이 . MCP와 Tool Service는 namespace이므로 FQDN이 .
( v0.2 §1). . ( v0.2 §1). .
portal Portal registry가 helper를 .
*/}} */}}
{{- define "mcp-server.toolServiceHost" -}} {{- define "mcp-server.toolServiceHost" -}}
{{- include "mcp-server.validate" . -}}
{{- $deployment := index .Values.deployments .Values.deploymentKey -}} {{- $deployment := index .Values.deployments .Values.deploymentKey -}}
{{- printf "%s.%s.svc.cluster.local:%v" $deployment.service .Release.Namespace .Values.toolService.port -}} {{- printf "%s.%s.svc.cluster.local:%v" $deployment.service .Release.Namespace .Values.toolService.port -}}
{{- end -}} {{- end -}}
@@ -54,7 +95,8 @@ app.kubernetes.io/name: {{ include "mcp-server.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: mcp-server app.kubernetes.io/component: mcp-server
app.kubernetes.io/part-of: ax-hub app.kubernetes.io/part-of: ax-hub
ax-hub/tier: {{ (index .Values.deployments .Values.deploymentKey).tier }} ax-hub/mode: {{ .Values.mode }}
ax-hub/tier: {{ include "mcp-server.tier" . }}
{{- end -}} {{- end -}}
{{- define "mcp-server.selectorLabels" -}} {{- define "mcp-server.selectorLabels" -}}

View File

@@ -1,9 +1,8 @@
# 배포별로 달라지는 설정만 담는다. # 배포별로 달라지는 설정만 담는다.
# 환경과 무관한 기본값(timeout, 상한, management 포트 등)은 jar 안의 application-ocp.yml이 소유하고, # 환경과 무관한 기본값(timeout, 상한, management 포트 등)은 jar 안의 application-ocp.yml이 소유하고,
# 이 파일이 같은 이름으로 덮어써 identity와 bundle만 배포 시점에 결정한다. # 이 파일이 같은 이름으로 덮어써 identity와 Tool 원천만 배포 시점에 결정한다.
{{- include "mcp-server.validate" . }} {{- include "mcp-server.validate" . }}
{{- $deployment := index .Values.deployments .Values.deploymentKey }} {{- $deployment := include "mcp-server.selectedDeployment" . | fromYaml }}
{{- $toolServiceHost := include "mcp-server.toolServiceHost" . }}
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
metadata: metadata:
@@ -19,19 +18,41 @@ data:
endpoint-path: {{ $deployment.publicPath | quote }} endpoint-path: {{ $deployment.publicPath | quote }}
registry: registry:
refreshIntervalSeconds: {{ .Values.mcp.refreshIntervalSeconds }} refresh-interval-seconds: {{ .Values.mcp.refreshIntervalSeconds }}
refreshJitterSeconds: {{ .Values.mcp.refreshJitterSeconds }} refresh-jitter-seconds: {{ .Values.mcp.refreshJitterSeconds }}
discovery: discovery:
# 운영 profile은 Tool Service 매니페스트만 원천으로 쓴다. # 운영 profile은 Tool Service 매니페스트만 원천으로 쓴다.
enabled: true enabled: true
redis:
# Portal 조회가 실패한 cold start에서만 읽는 fallback key다.
# 포털이 쓰는 key와 반드시 같아야 한다.
portal-registry-key: {{ .Values.portal.registryRedisKey | quote }}
{{- if eq .Values.mode "portal" }}
# route↔Tool Service 매핑의 원천은 Portal이다(ADR-0013).
# 배포 하나가 N개 route를 서비스하고, route key는 /mcp/{routeKey} URI에서만 결정된다.
# 매핑이 바뀌어도 이 ConfigMap을 고치지 않는다. 그것이 Portal을 원천으로 둔 이유다.
portal:
enabled: true
registry-url: {{ .Values.portal.registryUrl | quote }}
refresh-interval-seconds: {{ .Values.portal.refreshIntervalSeconds }}
# Portal이 주소를 소유하므로 정적 bundle을 선언하지 않는다.
bundles: []
{{- else }}
portal:
enabled: false
# MCP 배포 하나는 Tool Service 하나만 본다(ADR-0007). # MCP 배포 하나는 Tool Service 하나만 본다(ADR-0007).
# 이 목록은 항상 한 항목이며, 늘리려면 배포를 하나 더 만든다. # 이 목록은 항상 한 항목이며, 늘리려면 배포를 하나 더 만든다.
# 주소는 여기서 조립한다. values에 URL을 적기 시작하면 오타가 라우팅 사고가 된다. # 주소는 여기서 조립한다. values에 URL을 적기 시작하면 오타가 라우팅 사고가 된다.
bundles: bundles:
- id: {{ .Values.deploymentKey | quote }} - id: {{ .Values.deploymentKey | quote }}
namePrefix: {{ $deployment.namePrefix | quote }} namePrefix: {{ $deployment.namePrefix | quote }}
manifestUrl: http://{{ $toolServiceHost }}{{ .Values.toolService.manifestPath }} manifestUrl: http://{{ include "mcp-server.toolServiceHost" . }}{{ .Values.toolService.manifestPath }}
baseEndpoint: http://{{ $toolServiceHost }}{{ .Values.toolService.basePath }} baseEndpoint: http://{{ include "mcp-server.toolServiceHost" . }}{{ .Values.toolService.basePath }}
enabled: true enabled: true
{{- end }}

View File

@@ -1,5 +1,5 @@
{{- include "mcp-server.validate" . }} {{- include "mcp-server.validate" . }}
{{- $tier := index .Values.tiers (index .Values.deployments .Values.deploymentKey).tier }} {{- $tier := index .Values.tiers (include "mcp-server.tier" .) }}
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -17,12 +17,17 @@ spec:
labels: labels:
{{- include "mcp-server.labels" . | nindent 8 }} {{- include "mcp-server.labels" . | nindent 8 }}
annotations: annotations:
# ConfigMap이 바뀌면 Pod을 다시 굴린다. 이게 없으면 bundle 설정을 고쳐도 # ConfigMap이 바뀌면 Pod을 다시 굴린다. 이게 없으면 설정을 고쳐도
# 기존 Pod이 옛 설정으로 계속 돌아 배포한 줄 알고 넘어가게 된다. # 기존 Pod이 옛 설정으로 계속 돌아 배포한 줄 알고 넘어가게 된다.
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
spec: spec:
# 진행 중인 tools/call이 잘려 부작용만 남는 것을 줄인다. # 진행 중인 tools/call이 잘려 부작용만 남는 것을 줄인다.
terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }}
{{- with .Values.image.pullSecrets }}
# 사내 registry가 인증을 요구할 때만 지정한다. 비워 두면 렌더링되지 않는다.
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if $tier.spreadAcrossNodes }} {{- if $tier.spreadAcrossNodes }}
affinity: affinity:
podAntiAffinity: podAntiAffinity:
@@ -57,10 +62,20 @@ spec:
value: {{ .Values.redis.port | quote }} value: {{ .Values.redis.port | quote }}
- name: MANAGEMENT_SERVER_PORT - name: MANAGEMENT_SERVER_PORT
value: {{ .Values.ports.management | quote }} value: {{ .Values.ports.management | quote }}
{{- if .Values.toolService.apiKeySecret.name }}
# Tool Service 호출용 API key. 값은 Secret이 소유하고 Chart는 이름만 안다.
- name: TOOL_SERVER_API_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.toolService.apiKeySecret.name }}
key: {{ .Values.toolService.apiKeySecret.key }}
{{- end }}
volumeMounts: volumeMounts:
- name: config - name: config
mountPath: /opt/app/config mountPath: /opt/app/config
readOnly: true readOnly: true
# readiness는 첫 Tool 조회가 끝나고 usable snapshot이 있을 때만 UP이다.
# 원천이 늦게 뜨는 환경에서 Pod을 죽이지 않도록 liveness에는 그 조건이 들어가지 않는다.
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /actuator/health/readiness path: /actuator/health/readiness

View File

@@ -1,10 +1,10 @@
{{- include "mcp-server.validate" . }} {{- include "mcp-server.validate" . }}
{{- $tier := index .Values.tiers (index .Values.deployments .Values.deploymentKey).tier }} {{- $tier := index .Values.tiers (include "mcp-server.tier" .) }}
{{- if $tier.podDisruptionBudget }} {{- if $tier.podDisruptionBudget }}
# 중요 등급 배포가 자발적 중단(노드 drain, 클러스터 업그레이드) 중에도 최소 1개를 남기게 한다. # 중요 등급 배포가 자발적 중단(노드 drain, 클러스터 업그레이드) 중에도 최소 1개를 남기게 한다.
# #
# replica를 2 이상으로 올려도 PDB가 없으면 노드 drain이 두 Pod을 한꺼번에 내릴 수 있다. # replica를 2 이상으로 올려도 PDB가 없으면 노드 drain이 두 Pod을 한꺼번에 내릴 수 있다.
# 등급을 나눈 목적이 "중요 Tool은 다운이 없어야 한다"이므로 이 둘은 함께 가야 한다(ADR-0007). # 등급을 나눈 목적이 "중요 Tool은 다운이 없어야 한다"이므로 이 둘은 함께 가야 한다.
# #
# NetworkPolicy와 달리 조건이 붙는다. 저쪽은 인가의 전제라 끌 수 없지만 이것은 가용성 정책이고, # NetworkPolicy와 달리 조건이 붙는다. 저쪽은 인가의 전제라 끌 수 없지만 이것은 가용성 정책이고,
# replica 1인 dev에서는 PDB가 오히려 노드 drain을 영구히 막는다. # replica 1인 dev에서는 PDB가 오히려 노드 drain을 영구히 막는다.

View File

@@ -1,5 +1,7 @@
{{- include "mcp-server.validate" . }} {{- include "mcp-server.validate" . }}
{{- $deployment := index .Values.deployments .Values.deploymentKey }} {{- $deployment := include "mcp-server.selectedDeployment" . | fromYaml }}
# OpenShift Route의 path는 prefix 매칭이다. portal 모드에서 path가 "/mcp"이면
# /mcp/{routeKey} 전체가 이 Route 하나로 들어오고, route 구분은 컨테이너가 한다(ADR-0013).
apiVersion: route.openshift.io/v1 apiVersion: route.openshift.io/v1
kind: Route kind: Route
metadata: metadata:

View File

@@ -1,10 +1,10 @@
# dev 환경. 배포마다 Pod 1개로 구성한다. # dev 환경. 배포마다 Pod 1개로 구성한다.
# #
# dev에서는 중요 등급도 replica 1이다. rolling update 중 수십 초 공백이 생기지만 # dev에서는 중요 등급도 replica 1이다. rolling update 중 수십 초 공백이 생기지만
# dev는 가용성 목표 대상이 아니다. 중요 등급의 replica 하한과 PDB는 prod에서만 강제하며 # dev는 가용성 목표 대상이 아니다. 중요 등급의 replica 하한과 PDB는 test·prod에서만 강제하며
# HelmDeploymentContractTest가 그 사실을 고정한다. # HelmDeploymentContractTest가 그 사실을 고정한다.
# #
# 어느 배포를 설치할지는 이 파일이 정하지 않는다. --set deploymentKey=<key>로 고른다. # 이 파일은 배포 토폴로지를 소유하지 않는다. mode와 deployments는 values.yaml 한 곳에 있다.
# TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다. # TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다.
global: global:
@@ -12,6 +12,11 @@ global:
agentBuilderNamespace: ax-hub-agentbuilder-dev agentBuilderNamespace: ax-hub-agentbuilder-dev
mcpHost: mcp-dev.apps.example.internal mcpHost: mcp-dev.apps.example.internal
portal:
# TODO: 실제 dev Portal registry 주소로 확정한다. deploy/docker-compose.yml의 mock과 같은 응답을 준다.
registryUrl: https://axhub.devjun.net/api/portal/registry
refreshIntervalSeconds: 60
route: route:
# TODO: Agent Builder의 실제 고정 egress CIDR로 교체한다. # TODO: Agent Builder의 실제 고정 egress CIDR로 교체한다.
sourceAllowlist: 192.0.2.0/24 sourceAllowlist: 192.0.2.0/24

View File

@@ -1,16 +1,14 @@
# prod 환경. # prod 환경.
# #
# replica는 배포 하나가 받는 트래픽 기준으로 잡는다. 업무 × 등급으로 나뉘어 있으므로 # replica는 배포가 받는 트래픽 기준으로 잡는다.
# 배포 하나가 받는 몫은 전체를 하나로 묶었을 때의 일부다. 등급별 기준은 아래가 정본이다. # 매니페스트 조회 부하 = replica 수 × (route에 붙은 Tool Service 수) / 주기다.
# # portal 모드는 한 배포가 전 route를 서비스하므로 route가 늘면 이 값이 함께 는다(ADR-0013 전제 3).
# 조회 부하 = replica 수 / 주기. 1:1이라 bundle 수는 항상 1이다(ADR-0007).
# 중요 등급 3 replica / 30초 = 배포당 초당 0.1회. Tool Service 한 대가 받는 몫이 그대로 이 값이다.
# #
# 중요 등급은 replica 2 이상과 PodDisruptionBudget이 필수다. # 중요 등급은 replica 2 이상과 PodDisruptionBudget이 필수다.
# 1이면 rolling update 중 반드시 공백이 생기고, PDB가 없으면 노드 drain이 마지막 Pod을 내린다. # 1이면 rolling update 중 반드시 공백이 생기고, PDB가 없으면 노드 drain이 마지막 Pod을 내린다.
# HelmDeploymentContractTest가 replica·PDB·노드 분산 values를 정적으로 검사한다. # HelmDeploymentContractTest가 replica·PDB·노드 분산 values를 정적으로 검사한다.
# #
# 어느 배포를 설치할지는 이 파일이 정하지 않는다. --set deploymentKey=<key>로 고른다. # 이 파일은 배포 토폴로지를 소유하지 않는다. mode와 deployments는 values.yaml 한 곳에 있다.
# TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다. # TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다.
global: global:
@@ -18,6 +16,11 @@ global:
agentBuilderNamespace: ax-hub-agentbuilder-prod agentBuilderNamespace: ax-hub-agentbuilder-prod
mcpHost: mcp.apps.example.internal mcpHost: mcp.apps.example.internal
portal:
# TODO: 실제 운영 Portal registry 주소로 교체한다.
registryUrl: https://axhub.apps.example.internal/api/portal/registry
refreshIntervalSeconds: 300
route: route:
# TODO: Agent Builder의 실제 고정 egress CIDR로 교체한다. # TODO: Agent Builder의 실제 고정 egress CIDR로 교체한다.
sourceAllowlist: 192.0.2.0/24 sourceAllowlist: 192.0.2.0/24

View File

@@ -1,9 +1,9 @@
# test 환경. 운영계에 앞서 중요 등급의 가용성 설정을 검증하는 단계다. # test 환경. 운영계에 앞서 중요 등급의 가용성 설정을 검증하는 단계다.
# #
# 중요 등급을 prod와 같은 방식(replica 2 + PDB)으로 먼저 검증하는 자리다. # 중요 등급을 prod와 같은 방식(replica 2 + PDB + 노드 분산)으로 먼저 검증하는 자리다.
# 여기서 확인하지 않으면 prod 배포 때 처음 겪게 된다. # 여기서 확인하지 않으면 prod 배포 때 처음 겪게 된다.
# #
# 어느 배포를 설치할지는 이 파일이 정하지 않는다. --set deploymentKey=<key>로 고른다. # 이 파일은 배포 토폴로지를 소유하지 않는다. mode와 deployments는 values.yaml 한 곳에 있다.
# TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다. # TODO: namespace가 확정되면 agentBuilderNamespace를 교체한다.
global: global:
@@ -11,6 +11,11 @@ global:
agentBuilderNamespace: ax-hub-agentbuilder-test agentBuilderNamespace: ax-hub-agentbuilder-test
mcpHost: mcp-test.apps.example.internal mcpHost: mcp-test.apps.example.internal
portal:
# TODO: 실제 test Portal registry 주소로 교체한다.
registryUrl: https://axhub-test.apps.example.internal/api/portal/registry
refreshIntervalSeconds: 300
route: route:
# TODO: Agent Builder의 실제 고정 egress CIDR로 교체한다. # TODO: Agent Builder의 실제 고정 egress CIDR로 교체한다.
sourceAllowlist: 192.0.2.0/24 sourceAllowlist: 192.0.2.0/24

View File

@@ -1,36 +1,48 @@
# 환경 공통 기본값과 배포 토폴로지. 환경별 차이는 values-{env}.yaml이 덮어쓴다. # 환경 공통 기본값과 배포 토폴로지. 환경별 차이는 values-{env}.yaml이 덮어쓴다.
# #
# 이 Chart의 설계 원칙: # 이 Chart의 설계 원칙:
# 1. MCP 배포 하나는 Tool Service 하나만 본다(ADR-0007). # 1. 배포 모델이 두 가지다. mode가 그 축을 고른다.
# bundle 목록은 항상 한 항목이며 template이 만든다. # portal — route↔Tool Service 매핑의 원천이 Portal이다(ADR-0013). 배포 하나가 N route를
# 2. 배포 대상 전체를 아래 deployments 한 곳에 적는다. # 서비스하고 route key는 /mcp/{routeKey} URI에서만 온다. 현재 애플리케이션 코드의 경로다.
# 설치할 때 --set deploymentKey=<key>로 하나를 고른다. # bundles — 배포 하나가 Tool Service 하나만 보고 매핑을 배포 시점에 못박는다(ADR-0007).
# 배포가 10개든 20개든 파일 수가 늘지 않고, 전체 매핑을 한 화면에서 검토할 수 있다. # ADR-0013이 대체했지만 코드 경로가 남아 있어 1:1 검증·격리 배포에 쓸 수 있다.
# 3. 환경 축(namespace·이미지·등급별 replica)과 배포 축(어느 Tool Service를 보는가)을 섞지 않는다. # 2. 환경 축(namespace·이미지·등급별 replica)과 배포 축(무엇을 보는가)을 섞지 않는다.
# values-{env}.yaml에는 deployments가 없고, deployments에는 환경 정보가 없다. # values-{env}.yaml에는 deployments가 없고, deployments에는 환경 정보가 없다.
# 4. identity는 "{배포 이름}-{global.env}"로 조립한다. # 3. identity는 "{배포 이름}-{global.env}"로 조립한다.
# Redis key namespace이므로 환경끼리 겹치면 서로 Tool snapshot을 덮어쓴다. # Redis key namespace이므로 환경끼리 겹치면 서로 Tool snapshot을 덮어쓴다.
# 사람이 손으로 적지 않게 해 실수를 구조적으로 막는다. # 사람이 손으로 적지 않게 해 실수를 구조적으로 막는다.
# 5. 외부에서는 환경별 한 host 아래 publicPath로 구분한다. Route는 Service만 선택하고 # 4. 공개 path는 Route와 컨테이너가 동일하게 사용하고 rewrite하지 않는다(ADR-0009).
# 컨테이너가 같은 path를 직접 처리하므로 Registry의 1:1 경계는 바뀌지 않는다(ADR-0009).
# 설치할 배포를 고르는 key. 반드시 --set으로 지정한다. # 배포 모델. portal | bundles
# 기본값을 portal로 둔 이유는 현재 애플리케이션이 실제로 도는 경로이기 때문이다.
mode: portal
# ---------------------------------------------------------------------------
# mode=portal 축
# ---------------------------------------------------------------------------
portal:
# 이 환경에 설치되는 단일 MCP 배포. route가 늘어도 배포는 늘지 않는다.
deployment:
name: axhub-mcp
tier: critical
# route key는 이 path 아래 URI segment에서 온다. 여기에 routeKey를 적지 않는다.
publicPath: /mcp
# Portal registry 조회 주소. 환경마다 다르므로 values-{env}.yaml이 소유한다.
# 기본값을 두지 않는 이유는, 빠뜨린 설치가 조용히 성공하는 것보다 렌더링 실패가 낫기 때문이다.
registryUrl: ""
refreshIntervalSeconds: 300
# Portal 조회가 실패한 cold start에서만 읽는 Redis fallback key.
# 포털이 registry를 써 넣는 key와 반드시 같아야 한다.
registryRedisKey: axhub:mcp:portal-registry
# ---------------------------------------------------------------------------
# mode=bundles 축
# ---------------------------------------------------------------------------
# 설치할 배포를 고르는 key. mode=bundles일 때 반드시 --set으로 지정한다.
# 기본값을 두지 않는 이유는, 지정을 빠뜨렸을 때 엉뚱한 배포가 조용히 설치되는 것보다 # 기본값을 두지 않는 이유는, 지정을 빠뜨렸을 때 엉뚱한 배포가 조용히 설치되는 것보다
# 렌더링 실패가 낫기 때문이다. # 렌더링 실패가 낫기 때문이다.
deploymentKey: "" deploymentKey: ""
global:
# 배포 환경. identity 접미사와 NetworkPolicy 판단에 쓰인다.
env: dev
# Agent Builder가 있는 namespace. Route를 우회한 Pod 직접 호출을 이 namespace로 제한한다.
# MCP와 Tool Service는 같은 namespace이므로 여기 적지 않는다.
# TODO: 실제 namespace 확정 시 교체한다.
agentBuilderNamespace: ax-hub-agentbuilder-dev
# Actuator management 포트에 접근할 관제 namespace.
monitoringNamespace: openshift-monitoring
# 환경별 공개 MCP host. 실제 OpenShift apps domain으로 교체한다.
mcpHost: mcp-dev.apps.example.internal
# 배포 대상 전체. map의 key가 곧 bundle id가 된다. # 배포 대상 전체. map의 key가 곧 bundle id가 된다.
# #
# name Deployment/Service/ConfigMap/NetworkPolicy 이름. 같은 namespace에서 유일해야 한다 # name Deployment/Service/ConfigMap/NetworkPolicy 이름. 같은 namespace에서 유일해야 한다
@@ -39,10 +51,6 @@ global:
# tier 가용성 등급. 아래 tiers의 key여야 한다 # tier 가용성 등급. 아래 tiers의 key여야 한다
# publicPath Agent Builder가 등록할 외부 MCP path. 전체 topology에서 유일해야 한다 # publicPath Agent Builder가 등록할 외부 MCP path. 전체 topology에서 유일해야 한다
# #
# 같은 업무의 두 등급이 같은 namePrefix를 공유하는 것은 의도된 구성이다(ADR-0007).
# 등급을 이름에 넣으면 Tool 재분류가 Tool name 변경이 되어 Agent Builder 재등록을 부른다.
# 그 안에서 Tool 이름이 겹치지 않게 하는 것은 Tool Service 책임이다.
#
# TODO: Tool 목록이 확정되면 실제 Tool Service 이름으로 교체하고, 없는 배포는 삭제한다. # TODO: Tool 목록이 확정되면 실제 Tool Service 이름으로 교체하고, 없는 배포는 삭제한다.
deployments: deployments:
processing-critical: processing-critical:
@@ -94,6 +102,20 @@ deployments:
tier: standard tier: standard
publicPath: /mcp/hr-standard publicPath: /mcp/hr-standard
# ---------------------------------------------------------------------------
# 모드 공통
# ---------------------------------------------------------------------------
global:
# 배포 환경. identity 접미사와 NetworkPolicy 판단에 쓰인다.
env: dev
# Agent Builder가 있는 namespace. Route를 우회한 Pod 직접 호출을 이 namespace로 제한한다.
# TODO: 실제 namespace 확정 시 교체한다.
agentBuilderNamespace: ax-hub-agentbuilder-dev
# Actuator management 포트에 접근할 관제 namespace.
monitoringNamespace: openshift-monitoring
# 환경별 공개 MCP host. 실제 OpenShift apps domain으로 교체한다.
mcpHost: mcp-dev.apps.example.internal
# OpenShift Router와 MCP 컨테이너가 같은 publicPath를 사용한다. rewrite하지 않는다. # OpenShift Router와 MCP 컨테이너가 같은 publicPath를 사용한다. rewrite하지 않는다.
route: route:
# Agent Builder 최대 대기 시간과 맞춘 공개 HTTP 연결 timeout이다. # Agent Builder 최대 대기 시간과 맞춘 공개 HTTP 연결 timeout이다.
@@ -103,9 +125,9 @@ route:
# 등급별 가용성 기준. 환경별 values가 덮어쓴다. # 등급별 가용성 기준. 환경별 values가 덮어쓴다.
# #
# 배포를 등급으로 나누는 목적이 여기에 있다. 나뉘어 있어야 중요 등급에만 비용을 쓸 수 있다.
# 다만 나누는 것만으로 가용성이 생기지는 않는다. 같은 노드 배치, namespace 쿼터, # 다만 나누는 것만으로 가용성이 생기지는 않는다. 같은 노드 배치, namespace 쿼터,
# 공통 Redis·클러스터 장애는 분할로 막히지 않는다(ADR-0007). # 공통 Redis·클러스터 장애는 분할로 막히지 않는다.
# portal 모드에서는 배포가 하나이므로 등급별 물리 분리가 성립하지 않는다(ADR-0013 전제 2).
tiers: tiers:
critical: critical:
replicas: 2 replicas: 2
@@ -120,21 +142,28 @@ tiers:
image: image:
# TODO: 사내 컨테이너 registry 경로 확정 시 교체한다. # TODO: 사내 컨테이너 registry 경로 확정 시 교체한다.
# CI가 --set image.tag=<commit sha>로 덮어쓴다.
repository: image-registry.openshift-image-registry.svc:5000/ax-hub/ax-hub-mcp-server repository: image-registry.openshift-image-registry.svc:5000/ax-hub/ax-hub-mcp-server
tag: "0.1.0" tag: "0.1.0"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
# 사내 registry가 인증을 요구할 때만 채운다. 예: [{name: harbor-pull}]
pullSecrets: []
mcp: mcp:
# Tool Service 매니페스트 조회 주기(초). # Tool Service 매니페스트 조회 주기(초).
# 1:1이라 bundle 수가 항상 1이므로 조회 부하는 (replica 수 / 주기)다.
refreshIntervalSeconds: 30 refreshIntervalSeconds: 30
refreshJitterSeconds: 5 refreshJitterSeconds: 5
toolService: toolService:
# MCP와 같은 namespace에 있으므로 서비스 이름 + 아래 값으로 주소가 완성된다. # bundles 모드에서만 쓴다. MCP와 Tool Service가 같은 namespace라는 전제다.
port: 8080 port: 8080
manifestPath: /tool-manifest manifestPath: /tool-manifest
basePath: /mcp basePath: /mcp
# Tool Service 호출용 API key를 담은 Secret. name이 비어 있으면 환경변수를 주입하지 않고
# 애플리케이션 기본값을 쓴다. 운영에서는 반드시 채운다.
apiKeySecret:
name: ""
key: tool-server-api-key
redis: redis:
host: redis host: redis

View File

@@ -54,7 +54,10 @@ MCP와 Tool Service를 1:1로 묶는 결정은 [ADR-0007](decisions/ADR-0007-one
## 플랫폼·DevOps와 확인할 항목 ## 플랫폼·DevOps와 확인할 항목
배포 정의를 이 저장소가 어디까지 소유하는지 확정되지 않았다. 배포 정의를 이 저장소가 어디까지 소유하는지 확정되지 않았다.
현재는 [Helm Chart](../deploy/helm/mcp-server/)만 두고 있으며, 빌드·배포 실행 방식은 정의하지 않는다. GitOps 저장소도 ArgoCD Application도 아직 없어, 그때까지 [Helm Chart](../deploy/helm/mcp-server/)
push 방식 파이프라인(`.gitea/workflows/`)을 이 저장소가 **임시로** 소유한다.
그 방식이 무엇을 포기하는지와 넘길 때 할 일은
[deploy/README.md](../deploy/README.md#gitops-저장소가-없는-동안의-우회)가 정본이다.
1. **Helm Chart를 어디에 두는가.** 앱 저장소인가 배포 전용 저장소인가 1. **Helm Chart를 어디에 두는가.** 앱 저장소인가 배포 전용 저장소인가
2. 환경별 namespace 명명 규칙과 Agent Builder namespace. 2. 환경별 namespace 명명 규칙과 Agent Builder namespace.
@@ -67,6 +70,15 @@ MCP와 Tool Service를 1:1로 묶는 결정은 [ADR-0007](decisions/ADR-0007-one
6. 환경별 실제 `global.mcpHost`, 인증서와 TLS termination 책임 6. 환경별 실제 `global.mcpHost`, 인증서와 TLS termination 책임
7. Agent Builder의 실제 고정 egress CIDR과 Route `ip_allowlist` 7. Agent Builder의 실제 고정 egress CIDR과 Route `ip_allowlist`
8. 대상 OpenShift의 ingress namespace label과 IngressController endpoint publishing 방식이 Chart의 NetworkPolicy 전제와 맞는지 8. 대상 OpenShift의 ingress namespace label과 IngressController endpoint publishing 방식이 Chart의 NetworkPolicy 전제와 맞는지
9. **GitOps 저장소와 ArgoCD Application의 소유 주체와 생성 시점.** 그때까지 파이프라인이 클러스터
자격증명(`OCP_SERVER`·`OCP_TOKEN`)을 들고 있어야 하므로, 러너를 신뢰 경계 안에 두는 것이 전제다
10. **운영 배포 모델을 `portal`로 확정하는가.** Chart는 `portal``bundles`를 모두 렌더링하지만
[ADR-0013](decisions/ADR-0013-portal-owns-route-and-endpoint-registry.md)이
[ADR-0007](decisions/ADR-0007-one-mcp-per-tool-service.md)을 대체했다. `bundles` 경로를 언제 삭제할지
11. 사내 registry 주소·인증 방식과 `imagePullSecrets`에 넣을 Secret 이름
12. `TOOL_SERVER_API_KEY`를 담을 Secret의 소유 주체와 이름. Chart는 `toolService.apiKeySecret`으로 이름만 참조한다
13. VM docker compose 배포(`.gitea/workflows/deploy.yaml`)를 계속 쓸지, 그 `deploy.sh`를 저장소로 가져올지.
현재 스크립트는 러너의 `/home/ubuntu/apps/prd-dap-gateway/`에 있어 이 저장소가 내용을 모른다
## 운영 적용 전 필수 보완 ## 운영 적용 전 필수 보완

View File

@@ -20,7 +20,7 @@ import org.yaml.snakeyaml.Yaml;
* Helm Chart의 배포 토폴로지와 환경별 values를 배포 전에 검증하는 계약 테스트입니다. {@code McpProperties}의 {@code @AssertTrue}는 Pod이 뜬 뒤에야 잘못된 설정을 잡지만, GitOps에서는 그 시점이 이미 배포된 뒤라 * Helm Chart의 배포 토폴로지와 환경별 values를 배포 전에 검증하는 계약 테스트입니다. {@code McpProperties}의 {@code @AssertTrue}는 Pod이 뜬 뒤에야 잘못된 설정을 잡지만, GitOps에서는 그 시점이 이미 배포된 뒤라
* CrashLoopBackOff로 나타납니다. 같은 규칙을 여기서 먼저 적용해 잘못된 values가 머지되는 것을 막습니다. * CrashLoopBackOff로 나타납니다. 같은 규칙을 여기서 먼저 적용해 잘못된 values가 머지되는 것을 막습니다.
* *
* <p>이 테스트가 고정하는 핵심 규칙은 MCP 배포와 Tool Service 1:1 관계, 공개 path의 유일성, Route와 애플리케이션 endpoint의 동일성입니다. 이 규칙들은 애플리케이션 불변식이 아니라 배포 결정이므로 production 코드가 아니라 * <p>이 테스트가 고정하는 핵심 규칙은 두 배포 모델(portal·bundles)의 경계, portal 모드에서 route key가 배포 정의로 새지 않는 것, bundles 모드의 Tool Service 1:1 관계, 공개 path의 유일성, Route와 애플리케이션 endpoint의 동일성입니다. 이 규칙들은 애플리케이션 불변식이 아니라 배포 결정이므로 production 코드가 아니라
* 배포 정의에서 잠급니다. 파일을 읽기만 하며 애플리케이션 context나 helm 바이너리를 필요로 하지 않습니다. * 배포 정의에서 잠급니다. 파일을 읽기만 하며 애플리케이션 context나 helm 바이너리를 필요로 하지 않습니다.
*/ */
class HelmDeploymentContractTest { class HelmDeploymentContractTest {
@@ -302,6 +302,92 @@ class HelmDeploymentContractTest {
.contains("replicas: {{ $tier.replicas }}"); .contains("replicas: {{ $tier.replicas }}");
} }
/**
* Chart가 지원하는 배포 모델만 선언하는지 확인합니다. {@code mode}는 template 전체의 분기 축이므로, 오타가 있으면 렌더링이 통째로 실패하거나 더 나쁘게는 의도하지 않은 모델로 설치됩니다.
*/
@Test
void valuesDeclareASupportedDeploymentMode() throws IOException {
Map<String, Object> values = loadYaml(VALUES);
assertThat(String.valueOf(values.get("mode")))
.withFailMessage("values.yaml의 mode는 portal 또는 bundles여야 합니다: %s", values.get("mode"))
.isIn("portal", "bundles");
}
/**
* portal 모드의 단일 배포가 이름·등급·공개 path를 선언하고, 그 path가 route key를 포함하지 않는지 확인합니다. route key는 {@code /mcp/{routeKey}} URI에서만 결정되므로(ADR-0013) publicPath에 route를 적으면 그 배포는 한 route만
* 받게 되어 Portal이 매핑을 소유하는 의미가 사라집니다.
*/
@Test
void portalDeploymentServesEveryRouteUnderTheBareMcpPath() throws IOException {
Map<String, Object> values = loadYaml(VALUES);
Map<String, Object> deployment = asMap(section(values, "portal").get("deployment"));
assertThat(deployment)
.withFailMessage("portal.deployment에 name/tier/publicPath가 모두 있어야 합니다: %s", deployment)
.containsKeys("name", "tier", "publicPath");
assertThat(String.valueOf(deployment.get("publicPath")))
.withFailMessage("portal.deployment.publicPath에 route key가 들어 있습니다. route는 URI에서만 옵니다(ADR-0013).")
.isEqualTo("/mcp");
assertThat(section(values, "tiers").keySet())
.withFailMessage("portal.deployment의 tier '%s'가 tiers에 없습니다.", deployment.get("tier"))
.contains(String.valueOf(deployment.get("tier")));
}
/**
* Portal registry 주소에 기본값이 없고 환경별 values가 각자 선언하는지 확인합니다. 기본값이 있으면 지정을 빠뜨린 설치가 조용히 성공해 dev가 운영 Portal을 보거나 그 반대가 됩니다.
*/
@ParameterizedTest
@ValueSource(strings = {"dev", "test", "prod"})
void everyEnvironmentDeclaresItsOwnPortalRegistryUrl(String env) throws IOException {
Object shared = section(loadYaml(VALUES), "portal").get("registryUrl");
assertThat(shared == null || String.valueOf(shared).isEmpty())
.withFailMessage("values.yaml이 portal.registryUrl 기본값 '%s'를 갖고 있습니다. 환경별 파일이 소유해야 합니다.", shared)
.isTrue();
String url = String.valueOf(section(loadYaml(environmentValues(env)), "portal").get("registryUrl"));
assertThat(url)
.withFailMessage("values-%s.yaml에 portal.registryUrl이 없습니다. mode=portal 렌더링이 실패합니다.", env)
.isNotBlank()
.doesNotContain("null")
.startsWith("http");
}
/**
* ConfigMap이 두 모델을 모두 만들되 서로 섞이지 않는지 확인합니다. portal 분기는 정적 bundle을 선언하지 않아야 하고, route key를 ConfigMap에 적어서는 안 됩니다. 적는 순간 매핑이 다시 배포 시점으로 고정되어 Portal을 원천으로 둔
* 이유가 사라집니다.
*/
@Test
void configMapKeepsPortalAndBundleSourcesSeparate() throws IOException {
String configMap = Files.readString(CHART.resolve("templates/configmap.yaml"));
assertThat(configMap)
.contains("{{- if eq .Values.mode \"portal\" }}")
.contains("bundles: []")
.contains("registry-url: {{ .Values.portal.registryUrl | quote }}")
.contains("portal-registry-key: {{ .Values.portal.registryRedisKey | quote }}");
assertThat(configMap)
.withFailMessage("ConfigMap이 route key를 직접 적고 있습니다. route는 /mcp/{routeKey} URI에서만 옵니다(ADR-0013).")
.doesNotContain("route-key");
}
/**
* 검사 helper가 확인한 값을 출력하지 않는지 확인합니다. {@code required}의 결과를 변수에 담지 않으면 검사한 host와 CIDR이 렌더링 결과에 그대로 섞여 리소스 이름과 path가 오염됩니다. 정적 values 검사로는 드러나지 않고 렌더링해야 보이는
* 종류의 실수이므로 여기서 형태를 고정합니다.
*/
@Test
void validationHelperDoesNotEmitTheValuesItChecks() throws IOException {
List<String> emitting =
Files.readAllLines(CHART.resolve("templates/_helpers.tpl")).stream()
.map(String::trim)
.filter(line -> line.startsWith("{{- required") || line.startsWith("{{ required"))
.toList();
assertThat(emitting)
.withFailMessage("required 결과를 변수에 담지 않아 검사 값이 렌더링 결과에 출력됩니다: %s", emitting)
.isEmpty();
}
/** /**
* 환경별 values 파일 경로를 만듭니다. * 환경별 values 파일 경로를 만듭니다.
*/ */