개발 노트

지속·공유·이동 — stateless 에이전트의 공유 가능한 상태, 우리는 이렇게 설계했다

OpenAB Connect는 첫날부터 에이전트의 실행 환경을 stateless로 설계했습니다. uid 1000, sudo 없음, 읽기 전용 root 파일 시스템, 세션과 함께 버려지는 워크스페이스 — 이것은 의도적입니다. 에이전트는 폭주하고 셸은 속아 넘어가므로, 환경은 그 안의 것을 신뢰할 수 없다고 가정해야 합니다. 하지만 모순은 금방 드러났습니다. 실제 작업에는 상태가 필요합니다. 한나절을 들여 키운 워크스페이스, 여러 에이전트가 함께 쓰는 API 키, 새 환경으로 옮겨 다시 돌리고 싶은 데이터 — 어느 것도 세션과 생사를 같이해서는 안 됩니다. 이번 업데이트는 '실행 환경은 무상태'와 '작업에는 상태가 필요하다' 사이에, 공유 가능한 상태라는 층을 어떻게 설계했는가에 대한 이야기입니다.

먼저 선을 긋자 — 무엇이 stateless여야 하고, 무엇은 아니어야 하는가

에이전트의 실행 환경은 stateless여야 합니다. 각 세션은 버릴 수 있는 샌드박스이고, 망가지면 버리고 깨끗한 것을 새로 열면 됩니다. 하지만 /workspace 안의 데이터는 다른 이야기입니다. 이 둘을 묶어 버리는 것은 가장 저지르기 쉽고 가장 아픈 실수입니다. 그 에이전트 하나를 정리하려고 연결을 닫았을 뿐인데, 세 시간치 산출물이 함께 사라집니다.

그래서 첫 번째 결정: 워크스페이스의 수명 주기는 세션의 수명 주기와 독립적이어야 합니다. 이번 버전에서는 배포 시 셋 중 하나를 고를 수 있습니다 — 일회용(emptyDir), 새 지속 디스크 생성(PVC), 기존 디스크 마운트. 앞의 둘은 원래 있었고, 핵심은 세 번째입니다. 이것이 '데이터'를 여러 세션이 번갈아 쓸 수 있는 일급 객체로 만들기 때문입니다.

'빌린 것'은 '가진 것'이 아니다 — 오삭제를 막는 한 줄의 선

기존 디스크의 마운트를 허용하는 순간, 위험이 하나 나타납니다. 연결을 제거할 때, 그 디스크를 지워도 되는가?

우리의 답은 하나의 단단한 규칙입니다. 이 앱이 만든 것만 이 앱이 지운다. 여러분이 마운트하라고 지정한 것은 절대 지우지 않는다. 미리 만들어 둔 디스크, 혹은 다른 에이전트가 함께 쓰고 있는 디스크는 이 연결에게 '빌린 것'이지 '가진 것'이 아닙니다 — 연결을 제거하면 pod만 정리되고, 디스크는 원래 그대로 남습니다.

당연하게 들리지만, 제대로 하려면 소유권이 증거에 근거해야 합니다. 추측으로는 안 됩니다. 우리가 빠졌던 구멍은 이렇습니다. 초기의 정리 로직은 '기록에 디스크 이름이 있다'는 것만으로 지웠습니다. 스스로 만든 디스크만 기록되던 시절에는 안전했지만 — 기존 디스크의 마운트를 여는 순간, 같은 로직이 사용자가 지우라고 허락한 적 없는 디스크를 지우러 갑니다. 수정된 원칙은 이렇습니다. 소유권은 배포 시점의 실제 증거로 결정한다(이건 정말로, 이번에, 내가 만든 것인가?). 이름이 우연히 맞아떨어진다고 추론하지 않는다. 관례로 짐작한 이름은 증명되지 않은 소유권이고, 증명되지 않았으면 지우지 않습니다.

공유 — 세션만이 아니라 에이전트도 넘어서

OpenAB Connect의 Kubernetes 관리 패널: context macmini-orbstack, namespace openab-pty. 공유 volume claims에 kiro-shared-pvc와 kiroshared-pvc2(Bound, 10Gi, local-path)가 나열되고 아래에 New PVC / Delete / Copy 버튼, 공유 secrets에는 oab-kiro-shared-secrets(key KIRO_API_KEY)

지속만으로는 부족합니다. OpenAI 키 하나, 여러 에이전트가 모두 읽는 설정 하나 — 에이전트를 열 때마다 다시 입력하고 각자 따로 저장할 것이 아닙니다. 그래서 이번 버전에는 Kubernetes 관리 패널을 추가했습니다. 여기서 만든 PVC와 Secret은 공유이며 어떤 연결로부터도 독립입니다 — 특정 연결에 속하지 않고, 어느 연결을 제거해도 건드려지지 않으며, 오직 패널에서 수동으로 지울 때만 사라집니다.

Secret 쪽은 한 가지 원칙을 특히 지켰습니다. 평문은 이 앱을 거치지 않는다. 배포 시 공유 Secret의 특정 key를 에이전트의 환경 변수로 직접 지정할 수 있는데 — 여기에 쓰는 것은 Kubernetes의 secretKeyRef라서, 값은 처음부터 끝까지 클러스터 안에 머뭅니다. 앱은 이름만 옮기고 내용은 만지지 않습니다. '키를 입력란에 붙여 넣는 것'과는 보안 등급이 다릅니다.

이동 — 오염이 아니라 복사

상태를 공유할 수 있게 되면 다음 요구는 자연스럽게 따라옵니다. 기존 디스크를 출발점 삼아 복사본을 하나 만들어 새 에이전트에게 주고 싶다, 단 원본은 건드리지 않고.

여기서 우리는 Kubernetes의 CSI clone을 일부러 쓰지 않았습니다. 하부 스토리지 드라이버의 지원이 필요한데, 많은 환경(개발에서 흔히 쓰는 local-path 포함)에는 그것이 아예 없기 때문입니다. 우리가 쓴 것은 더 소박하지만 어디서나 도는 방법입니다. 아주 짧게 사는 job을 하나 띄워, 원본(읽기 전용)과 대상을 동시에 마운트하고, cp -a로 내용을 복사한 뒤, 스스로 사라집니다. 이 job은 엄격하게 하드닝되어 있습니다(비 root, 모든 capability 제거, 읽기 전용 root fs, service-account 토큰 없음, 원본은 읽기 전용). 여러분의 데이터를 만지는 코드이므로, 신뢰할 수 없는 코드를 다루는 방식으로 다뤄야 하기 때문입니다.

정직한 경계 하나. 이 길로는 놀고 있는 디스크만 복사할 수 있습니다. 지금 어떤 pod가 쓰고 있는 디스크(RWO — 한 번에 하나만 마운트 가능)는, job이 타임아웃까지 매달리게 두는 대신 선택할 수 없게 하고 '사용 중'이라고 분명히 표시하는 쪽을 택했습니다. '사용 중'인 디스크를 방해하지 않고 복사하려면 정말로 CSI 스냅숏이 필요합니다 — 그것은 미래에 남겨 둔 한 걸음입니다.

그 모순으로 돌아가서

'stateless 에이전트'와 '공유 가능한 상태'는 충돌하는 것처럼 들리지만, 사실 그렇지 않습니다. stateless인 것은 실행 환경입니다 — 그 층은 버릴 수 있고, 신뢰하지 않고, 언제든 폐기할 수 있기를 바랍니다. 상태를 갖는 것은 작업 그 자체입니다 — 그 층은 지속되고, 공유되고, 옮겨질 수 있기를 바랍니다. 이번 업데이트가 한 일은 이 두 층을 깨끗하게 분리한 것입니다. 샌드박스는 계속 일회용으로 두면서, 데이터에는 어떤 단일 세션보다 오래 살고, 명시적으로 소유되거나 명시적으로 빌려지며, 복사해 옮길 수 있는 집을 줍니다.

앱을 닫아도 에이전트는 계속 돕니다. 연결을 제거해도 데이터는 남습니다. 같은 신념의 두 얼굴입니다. 짧아야 할 것은 짧게 — 그리고 지속되어야 할 것에는, 따져 물어도 견디는 소유권 모델을.