콘텐츠로 이동
Study Notekagent 실습

7. Backend walking skeleton

결론부터
우리 backend는 관리할 때 Kubernetes API의 CRD를 사용하고, 호출할 때 kagent controller의 A2A endpoint를 사용하는 두 개의 명시적 port를 가져야 한다
이 장에서 처음 나오는 말4개
walking skeleton
기능은 작지만 등록·배포·상태·호출까지 실제 production 경로를 관통하는 최소 구현이다.
management path
backend가 Kubernetes API에 Agent resource를 쓰고 status를 읽는 관리 경로다.
invocation path
backend가 Grant를 통과한 사용자 요청을 controller의 A2A endpoint로 중계하는 호출 경로다.
field manager
server-side apply에서 어느 주체가 manifest의 필드를 소유하는지 기록하는 이름이다.

앞 장까지는 사람이 kubectl과 kagent CLI를 사용했다. 이 장부터는 우리 Node/TypeScript backend가 같은 일을 직접 한다. 아직 포털 DB나 OIDC는 만들지 않고 두 adapter port가 실제 cluster에서 통하는지만 검증한다.

Node backend의 관리 경로는 Kubernetes API로, 호출 경로는 kagent controller 8083의 A2A route로 갈라지는 구조

Agent Pod의 임의 Service나 dashboard 내부 API를 찾지 않는다. kagent가 문서화한 외부 호출 표면은 kagent-controller Service의 8083 포트와 /api/a2a/{namespace}/{agent-name}/ route다.

  • backend code가 backend-reader를 server-side apply하고 같은 code를 다시 실행해도 resource가 하나다.
  • Accepted=True, Ready=True를 Kubernetes API 응답에서 확인한다.
  • Agent Card를 읽은 뒤 raw A2A message/send로 tool을 사용하는 답을 받는다.
  • 관리 경로와 호출 경로의 URL·credential·실패를 따로 기록한다.

Node.js 20 이상과 현재 kind context를 확인한다. @kubernetes/client-node는 cluster Kubernetes version과 호환되는 release를 골라 lockfile에 고정한다. 이 덱을 검토한 시점의 1.4.0은 Kubernetes 1.34를 추적하며 이전 minor와의 호환 범위는 공식 표에서 확인한다.

터미널 창
node --version
kubectl config current-context
kubectl version

기대 context는 정확히 kind-kagent-lab이다. 임시 project를 만들고 의존성을 lock한다.

터미널 창
mkdir -p /tmp/kagent-backend-probe
cd /tmp/kagent-backend-probe
npm init -y
npm install @kubernetes/[email protected]
npm install --save-dev tsx
npm ls @kubernetes/client-node tsx

관리와 호출을 한 code로 연결하기

섹션 제목: “관리와 호출을 한 code로 연결하기”

/tmp/kagent-backend-probe/probe.ts를 만들고 다음 code를 넣는다. production domain object를 흉내 내려고 manifest 생성과 A2A 호출 함수를 분리했다. 실습 code라 현재 kubeconfig를 읽지만, 아래에서 in-cluster ServiceAccount로 바꿀 지점을 분명히 한다.

import { randomUUID } from 'node:crypto';
import {
KubeConfig,
KubernetesObjectApi,
PatchStrategy,
type KubernetesObject,
} from '@kubernetes/client-node';
const namespace = 'kagent';
const agentName = 'backend-reader';
const a2aBaseUrl = process.env.KAGENT_A2A_URL ??
`http://localhost:8083/api/a2a/${namespace}/${agentName}/`;
const kc = new KubeConfig();
kc.loadFromDefault(); // production Pod에서는 loadFromCluster()
if (kc.getCurrentContext() !== 'kind-kagent-lab') {
throw new Error(`refusing context: ${kc.getCurrentContext()}`);
}
const objects = KubernetesObjectApi.makeApiClient(kc);
function buildManifest(): KubernetesObject {
return {
apiVersion: 'kagent.dev/v1alpha2',
kind: 'Agent',
metadata: {
name: agentName,
namespace,
labels: {
'study.upggu.com/managed-by': 'backend-probe',
},
},
spec: {
description: 'Read-only Agent managed by the backend walking skeleton.',
type: 'Declarative',
declarative: {
runtime: 'go',
modelConfig: 'default-model-config',
systemMessage: [
'You are a read-only Kubernetes assistant.',
'Use a tool for current cluster facts and never mutate resources.',
].join('\n'),
tools: [{
type: 'McpServer',
mcpServer: {
apiGroup: 'kagent.dev',
kind: 'RemoteMCPServer',
name: 'kagent-tool-server',
toolNames: [
'k8s_get_available_api_resources',
'k8s_get_resources',
],
},
}],
},
},
};
}
async function applyAgent() {
return objects.patch(
buildManifest(),
undefined,
undefined,
'kagent-lab-backend',
false,
PatchStrategy.ServerSideApply,
);
}
async function waitReady(timeoutMs = 180_000) {
const ref = {
apiVersion: 'kagent.dev/v1alpha2',
kind: 'Agent',
metadata: { name: agentName, namespace },
};
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const current = await objects.read(ref) as KubernetesObject & {
status?: { conditions?: Array<{ type: string; status: string; reason?: string }> };
};
const conditions = current.status?.conditions ?? [];
const accepted = conditions.find((item) => item.type === 'Accepted');
const ready = conditions.find((item) => item.type === 'Ready');
if (accepted?.status === 'False') {
throw new Error(`Agent rejected: ${accepted.reason ?? 'unknown reason'}`);
}
if (ready?.status === 'True') return current;
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
throw new Error('Agent Ready timeout');
}
async function readAgentCard() {
const response = await fetch(new URL('.well-known/agent.json', a2aBaseUrl));
if (!response.ok) throw new Error(`Agent Card HTTP ${response.status}`);
return response.json() as Promise<{ name: string; capabilities?: { streaming?: boolean } }>;
}
async function invoke(text: string) {
const requestId = randomUUID();
const response = await fetch(a2aBaseUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: requestId,
method: 'message/send',
params: {
id: requestId,
message: {
role: 'user',
parts: [{ kind: 'text', text }],
},
},
}),
signal: AbortSignal.timeout(120_000),
});
if (!response.ok) throw new Error(`A2A HTTP ${response.status}`);
const payload = await response.json() as {
error?: { code: number; message: string };
result?: { status?: { state?: string }; artifacts?: Array<{
parts?: Array<{ kind?: string; text?: string }>;
}> };
};
if (payload.error) throw new Error(`A2A ${payload.error.code}: ${payload.error.message}`);
return payload.result;
}
await applyAgent();
const ready = await waitReady();
const card = await readAgentCard();
const result = await invoke(
'List the Services in the kagent namespace. Use a tool and cite the evidence.',
);
console.log(JSON.stringify({
agentUid: ready.metadata?.uid,
cardName: card.name,
streaming: card.capabilities?.streaming,
taskState: result?.status?.state,
artifacts: result?.artifacts,
}, null, 2));

force=false가 중요한 이유는 다른 manager가 소유한 필드를 조용히 빼앗지 않기 위해서다. 충돌은 adapter가 사용자에게 보여 줄 실패이지, 처음부터 강제 소유권 이전으로 숨길 일이 아니다.

  1. 별도 terminal에서 controller의 A2A port를 연다.

    터미널 창
    kubectl -n kagent port-forward svc/kagent-controller 8083:8083
  2. project terminal에서 probe를 두 번 실행한다.

    터미널 창
    cd /tmp/kagent-backend-probe
    KAGENT_A2A_URL="http://localhost:8083/api/a2a/kagent/backend-reader/" \
    npx tsx probe.ts
    KAGENT_A2A_URL="http://localhost:8083/api/a2a/kagent/backend-reader/" \
    npx tsx probe.ts
  3. resource가 하나이고 field manager가 남았는지 확인한다.

    터미널 창
    kubectl -n kagent get agent backend-reader
    kubectl -n kagent get agent backend-reader -o yaml

두 실행의 agentUid가 같아야 한다. metadata.managedFields에는 kagent-lab-backend가 보이고, task state는 완료 상태이며 artifact에 Service 조회 결과가 있어야 한다. 답변 내용은 cluster 시점에 따라 달라지므로 문자열을 golden value로 고정하지 않는다.

실제 backend로 옮길 때 바뀌는 것

섹션 제목: “실제 backend로 옮길 때 바뀌는 것”
실습 probe온프렘 backend
loadFromDefault()와 개인 kubeconfigloadFromCluster()와 전용 ServiceAccount
localhost:8083 port-forwardhttp://kagent-controller.kagent.svc.cluster.local:8083 또는 승인된 gateway
code 안의 한 manifestDB의 immutable AgentVersion에서 deterministic manifest 생성
바로 invokeOIDC token 검증 → Grant → session ownership 검사 뒤 invoke
stdoutdeployment status·A2A event·effective config hash를 DB와 trace에 기록
단일 process pollinformer/watch 재연결과 여러 replica의 writer 조정

CRD schema와 A2A payload가 domain code 전체로 새지 않게 KagentAdapter 내부에 가둔다. 다음 장에서는 이 backend가 가져야 할 Kubernetes 권한과, 사용자가 controller 8083에 직접 붙어 Grant를 우회하지 못하게 하는 두 번째 문을 검증한다.

증상확인
SSA가 403현재 kubeconfig 사용자 권한. 다음 장에서는 backend ServiceAccount Role로 축소
SSA가 409managedFields의 다른 manager와 충돌 필드. 무조건 force=true로 넘기지 않음
Ready timeoutAgent condition·생성된 Pod·model/MCP dependency
Card가 404controller port-forward와 namespace·Agent 이름·trailing slash
A2A가 JSON-RPC errorerror.code·error.message, controller와 Agent log
task는 완료됐지만 tool evidence가 없음allowlist·MCP 상태와 artifact/event를 분리해 확인
  • Node/TypeScript code가 backend-reader를 SSA로 만들고 Ready를 기다렸다.
  • 같은 code를 두 번 실행해도 UID가 같은 resource 하나만 남았다.
  • controller A2A route에 raw message/send를 보내 artifact를 받았다.
  • production에서 바꿀 kubeconfig·ServiceAccount·URL·Grant·DB 경계를 표시했다.
  • backend-reader는 다음 장과 Substrate 비교를 위해 남겨 둔다.