콘텐츠로 이동
Study NoteAgent 배포 플랫폼

10. 사내 frontend·backend와 연결하기

결론부터
자체 포털을 만든다는 것은 UI를 새로 그리는 일이 아니라 catalog·권한·이력의 소유권을 우리 DB로 가져오고 kagent는 실행만 맡기는 일이다
이 장에서 처음 나오는 말4개
SSAServer-Side Apply
전체 manifest를 보내면 API server가 field 소유자를 추적하며 병합하는 apply 방식이다.
ServiceAccount
Pod 안의 workload가 Kubernetes API를 호출할 때 쓰는 신원이다.
informer
resource 변경을 watch하며 로컬 캐시를 최신으로 유지하는 클라이언트 패턴이다.
A2AAgent-to-Agent
Agent를 외부에서 호출하고 응답을 stream으로 받는 표준 protocol이다.

8~9장이 kagent가 무엇인지였다면 이 장은 이미 온프렘 Kubernetes에 올려 둔 우리 frontend·backend가 kagent와 어디서 만나는가다. 사내 상황을 다음으로 가정한다.

  • frontend와 backend(Node/TypeScript)가 같은 온프렘 cluster에 이미 떠 있다.
  • 사용자·부서의 단일 원본은 Keycloak 또는 Entra ID다.
  • 사용자는 포털에서 Agent를 만들고, 다른 부서 사람과 공유한다.
  • 사용자에게 kubectl이나 kubeconfig를 주지 않는다.

kagent dashboard는 8장에서 봤듯 controller의 내부 API를 통해 resource-derived 상태와 chat을 보여 주는 운영 도구다. 자체 포털을 만들면 역할이 이렇게 갈린다.

kagent dashboard사내 포털
대상플랫폼 팀전체 임직원
보는 범위cluster의 모든 Agent내가 권한을 가진 Agent
쓰는 곳설치 검증, 장애 진단, resource 상태 확인생성·공유·호출·감사
배포내부망에서 운영자에게만 노출사내 표준 인증 뒤

dashboard를 없앨 필요는 없다. 다만 최종 사용자에게 열지 않는다. dashboard는 Agent별 권한을 판단하지 않으므로, 링크를 아는 사람이 다른 부서 Agent를 보고 호출할 수 있다.

통합 지점을 무엇으로 잡을 것인가

섹션 제목: “통합 지점을 무엇으로 잡을 것인가”

backend가 kagent에 말을 거는 방법은 목적에 따라 둘이다. dashboard 내부 API와 CLI shell은 어느 쪽의 계약도 아니다.

목적계약쓰지 않을 것
배포·수정·삭제·상태 관찰Kubernetes API의 CRD schema·statusdashboard 내부 HTTP API, CLI shell
Agent Card·task·stream 호출kagent-controller:8083의 /api/a2a/{namespace}/{agent-name}/Agent Pod의 임의 Service, CLI shell

관리 경로에 CRD를 고르면 Agent 배포가 Kubernetes resource 조작이 되므로 RBAC·감사 로그·GitOps 같은 기존 온프렘 운영 자산을 그대로 쓴다. 호출 경로는 A2A JSON-RPC·stream·task 상태를 우리 invoke contract로 번역한다. 두 client의 credential·timeout·retry·관측값도 분리한다.

일반 Agent와 MCP는 전용 workload를 만든다

섹션 제목: “일반 Agent와 MCP는 전용 workload를 만든다”

먼저 관리·배포 경로다. 가운데 CR은 API server에 저장되는 선언과 상태이고 Pod가 아니다.

Portal Backend Pod가 Kubernetes API의 Agent·MCP resource를 관리하고 kagent와 kmcp controller가 각각 Agent와 MCP server workload를 만드는 관리·배포 구조

kagent controller는 Agent CR을 watch해 Agent engine의 Deployment·Service를 만든다. backend는 이 controller Pod를 관리 API처럼 쓰지 않고 Kubernetes API로 CR을 apply·list/watch한다.

MCP는 두 갈래다. RemoteMCPServer는 이미 떠 있는 endpoint를 가리키므로 그 CR 자체가 Pod를 만들지 않는다. 반면 kmcp의 MCPServer는 kmcp controller가 Deployment·Service와 MCP server Pod로 번역한다. 포털에 보여 줄 Agent·tool 목록은 이 runtime inventory를 그대로 노출하지 않고, DB의 Publication·Grant·승인된 tool 목록과 합쳐 만든다.

이미 배포된 Agent를 호출할 때는 Kubernetes API를 지나지 않는다. controller는 A2A 입구와 route를 맡고, 대화 loop와 실제 MCP tool call은 Agent engine Pod에서 실행된다. 따라서 backend는 호출할 때만 controller Service를 지나며, Agent Pod나 MCP Pod의 임의 Service를 제품 계약으로 직접 호출하지 않는다.

SandboxAgent와 AgentHarness는 Agent Substrate로 간다

섹션 제목: “SandboxAgent와 AgentHarness는 Agent Substrate로 간다”

SandboxAgent와 AgentHarness는 일반 Agent처럼 Agent별 Deployment Pod를 만들지 않는다. kagent controller가 Agent Substrate에 actor 실행을 요청하면, Substrate가 공유 WorkerPool의 gVisor worker Pod에 actor를 배치하고 idle 상태를 object storage에 snapshot한다.

Portal Backend가 SandboxAgent와 AgentHarness CR을 관리하고 kagent controller와 Agent Substrate가 공유 WorkerPool에 두 종류의 actor를 배치해 snapshot storage로 suspend하는 구조

SandboxAgent는 일반 Agent와 비슷한 spec의 Go Declarative Agent를 sandbox actor로 실행한다. AgentHarness는 별도 kind이며 OpenClaw·Hermes 같은 coding agent의 장기 실행 환경을 만든다. kagent는 harness마다 ActorTemplate을 만들고 첫 chat 때 shared actor를 시작하며, A2A 요청을 actor 안의 acp-shim에 ACP로 bridge한다. 둘 다 Agent별 상주 Pod 대신 WorkerPool Pod를 공유하지만 actor state와 snapshot은 분리된다.

포털에는 성격이 다른 두 흐름이 있다. 1장에서 나눈 그대로다.

사용자가 포털에서 Agent를 만들면 backend가 ACL을 검사하고 DB에 저장한 뒤 CRD를 apply하고, controller가 만든 status를 다시 DB에 반영하는 시퀀스

핵심은 두 가지다. 원본은 우리 DB에 먼저 저장하고, CR은 그 원본을 번역해 만든다. 그리고 사용자가 보낸 값을 그대로 apply하지 않는다 — 9장의 필드 구분표대로 namespace·security context·allowedNamespaces는 서버가 채운다.

controller A2A route로 Agent를 호출할 때

섹션 제목: “controller A2A route로 Agent를 호출할 때”
사용자의 호출이 backend의 Grant 검사를 거쳐 Agent의 A2A endpoint로 가고 응답 stream이 되돌아오며 감사 기록이 남는 시퀀스

호출 경로가 backend를 지나야 하는 이유는 세 가지다 — 권한 검사, 감사 기록, 그리고 tool 승인 같은 대화 중 이벤트를 우리 UI 규격으로 번역하는 일이다.

관리에는 공식 @kubernetes/client-node, 호출에는 controller A2A client를 둔다. 두 client를 한 class의 숨은 분기로 합치지 말고 deploy/status port와 invoke port로 나눈다.

backend Pod에 ServiceAccount를 붙이고 loadFromCluster()로 자격을 읽는다. 사용자 개인의 Kubernetes 권한을 쓰지 않는다. 사용자는 애초에 cluster 신원이 없고, 권한 판단은 backend가 DB의 Grant로 한다.

import { KubeConfig, KubernetesObjectApi, makeInformer } from '@kubernetes/client-node';
const kc = new KubeConfig();
kc.loadFromCluster();
const objects = KubernetesObjectApi.makeApiClient(kc);

ServiceAccount에는 kagent.dev API group의 agents·modelconfigs·remotemcpservers에 대한 권한만 준다. 대상 namespace도 목록으로 제한한다. SandboxAgent나 AgentHarness publication을 실제로 열 때만 별도 policy와 함께 sandboxagents·agentharnesses 권한을 추가한다. backend가 탈취됐을 때의 blast radius가 여기서 정해진다.

CR 생성·수정은 create/update 분기 대신 server-side apply 하나로 통일한다. 같은 요청을 여러 번 보내도 같은 결과가 되고, fieldManager를 지정하면 우리가 소유한 필드가 무엇인지 API server가 추적한다. 누군가 dashboard에서 손대도 다음 apply가 우리 상태로 되돌린다.

const FIELD_MANAGER = 'agent-portal';
// manifest는 사용자 입력이 아니라 DB의 AgentVersion에서 생성한다
const manifest = buildAgentManifest(agentVersion, target);
// apply는 Content-Type: application/apply-patch+yaml 과 fieldManager로 보낸다.
// 정확한 인자 형태는 설치한 client-node 버전의 example을 따른다.
await applyResource(objects, manifest, FIELD_MANAGER);

CR 이름은 사용자가 붙인 표시 이름이 아니라 우리 ID에서 결정적으로 만든 이름을 쓴다. 표시 이름은 바뀌고 한글·공백이 들어가지만 Kubernetes 이름은 DNS 규칙을 지켜야 하고 바뀌면 안 되기 때문이다. 반대 방향 조회를 위해 deploymentId label을 함께 넣고, DB에는 Deployment.providerRef로 cluster·namespace·이름·UID를 기록한다.

읽기 — informer로 status를 받는다

섹션 제목: “읽기 — informer로 status를 받는다”

배포 상태를 사용자 요청마다 Kubernetes API에 물어보면 API server가 포털 트래픽을 그대로 받는다. informer로 watch해 두고 status 변경을 DB에 반영한 뒤, 포털 화면은 DB만 읽게 한다.

const path = '/apis/kagent.dev/v1alpha2/namespaces/agents-hr/agents';
const informer = makeInformer(kc, path, listFn);
informer.on('update', (obj) => syncDeploymentStatus(obj));
informer.on('error', (err) => scheduleRestart(err));

informer는 끊긴다. error 핸들러에서 재시작을 예약하지 않으면 조용히 죽고, 화면의 상태가 영원히 Pending에 머문다. backend replica가 여러 개면 어느 replica가 DB에 쓸지도 정해야 한다.

in-cluster URL은 controller Service DNS와 Agent의 namespace·resource name으로 결정한다.

const url = new URL(
`/api/a2a/${namespace}/${agentName}/`,
'http://kagent-controller.kagent.svc.cluster.local:8083',
);
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(toA2AMessageSend(platformInvocation)),
signal: AbortSignal.timeout(120_000),
});
const result = normalizeA2AResponse(await response.json());

실제 adapter는 먼저 /.well-known/agent.json을 검증하고 request ID·session owner·timeout·stream reconnect를 기록한다. Agent가 SandboxAgent로 바뀌어도 이 URL shape와 응답 정규화는 같아야 한다. 실행 가능한 server-side apply·condition wait·raw A2A 예제는 kagent-lab 7장에서 Node/TypeScript로 관통한다.

가장 자주 틀리는 부분이다. Kubernetes를 데이터베이스로 쓰지 않는다.

데이터원본이유
Agent 정체성·소유자·설명우리 DBprovider를 바꿔도 남아야 한다
version 이력우리 DBCR을 덮어쓰면 이전 version 조사가 불가능
knowledge source·version·binding우리 DBsource revision·ACL·index lineage가 runtime보다 오래 남아야 한다
부서·개인 권한(Grant)우리 DBkagent에 대응 개념이 없다
승인 이력·감사 event우리 DB보존 기간이 cluster 수명과 다르다
실행 중인 Agent 상태Kubernetescontroller가 만드는 사실
Pod·Service·conditionKubernetes진단용
대화 이력결정 필요아래 참조

대화 이력은 기본적으로 kagent engine 쪽 session에 있다. 사내에서 감사·검색·품질 개선에 쓰려면 backend가 stream을 중계하는 김에 우리 DB에도 남기는 편이 낫다. cluster를 재설치하거나 runtime을 교체해도 남고, Langfuse 같은 LLM 관측 도구로 보낼 때도 우리 쪽 trace가 원본이 된다.

여기가 kagent를 그냥 쓰면 안 되는 이유다. 검토 기준인 kagent v0.9.9는 oauth2-proxy 기반 OIDC 인증을 지원하지만, 공식 release note는 application access control이 아직 구현되지 않았다고 명시한다. 로그인한 사용자가 누구인지 아는 것과 Agent별 실행·수정 권한을 판단하는 것은 다르다.

그래서 막아야 할 문이 둘이다.

관리 경로는 backend의 ACL 검사와 ServiceAccount로, 호출 경로는 backend의 Grant 검사와 NetworkPolicy로 막고 사용자의 직접 접근을 차단하는 구조
  • 관리 경로 — Agent 생성·수정·삭제. backend가 DB의 소유권·부서를 검사한 뒤 자기 ServiceAccount로 대행한다. 사용자에게 kubeconfig를 주지 않으므로 우회 경로 자체가 없다.
  • 호출 경로 — 문서화된 A2A 표면은 kagent-controller Service의 8083이다. 사내망에서 이 port에 직접 닿을 수 있으면 backend의 Grant 검사가 무의미해진다. controller Pod의 8083 ingress를 backend·승인된 gateway·운영 UI에서만 허용해야 통제가 완성된다.

namespace는 부서 단위로 나누는 편이 단순하다. ResourceQuota로 부서별 자원을 나누고, NetworkPolicy와 egress 허용 목록을 namespace 단위로 관리하고, 사고 시 격리 범위도 부서로 떨어진다. 사용자마다 namespace를 만드는 방식은 수가 금방 수천이 되고 quota·policy 관리가 어려워진다.

CR을 apply했다고 끝이 아니다. 포털이 감당해야 할 상태 차이가 셋 있다.

상황증상대응
apply는 성공, runtime은 실패DB는 Deployed, Pod는 CrashLoopBackOff이거나 actor restore 실패condition과 Pod·actor·A2A health를 함께 읽어 정규화
cluster에서 직접 수정dashboard·kubectl로 누군가 spec 변경주기적 재조정 job이 DB 기준으로 다시 apply
CR은 있는데 DB에 없음삭제 실패나 수동 생성으로 생긴 고아 resourcelabel 기준으로 조회해 관리자에게 보고

정규화된 상태 값은 backend가 정한다. 사용자에게 CrashLoopBackOff를 그대로 보여 주지 않고, “시작 실패 — 모델 연결을 확인하세요” 같은 판단으로 바꾼다. 이 정규화 규칙은 11장 adapter에 정리한다.

  • dashboard는 운영자 콘솔로 남기고 최종 사용자에게 열지 않는다.
  • 관리 계약은 CRD·status, 호출 계약은 controller A2A다. dashboard 내부 API·CLI·Agent Pod Service는 쓰지 않는다.
  • backend는 in-cluster ServiceAccount로 server-side apply하고 informer로 status를 받아 DB에 반영한다.
  • 정체성·version·Grant·감사는 우리 DB가 원본이고, 실행 상태만 Kubernetes가 원본이다.
  • 관리 경로는 ServiceAccount 대행으로, 호출 경로는 controller 8083 ingress 제한으로 막아야 통제가 완성된다.
  • 부서 단위 namespace가 quota·정책·격리 범위를 함께 정리해 준다.