PhotoGram 하나만 올려놓고 볼 때는 PaaS의 경계가 지금보다 훨씬 단순해 보였다.
서비스가 어느 서버에서 실행되는지 알고, 그 서버로 요청을 보내면 된다. 실제 workload가 하나뿐이면 이 구조가 잘못됐다는 신호도 거의 없다.
문제는 두 번째 workload를 붙이면서 시작됐다.
Ghost를 같은 PaaS와 server-ops, Gateway 계약 위에 올리는 과정에서 단일 workload에서는 지나갔던 결함들이 한꺼번에 드러났다. tenant bootstrap이 두 번째 project owner를 만들 수 없는 문제, 실제 host의 네트워크 출력 형식을 잘못 가정한 코드, SELinux 처리의 blind spot, Cloudflare에서 TLS가 끝난 뒤 upstream으로 전달되는 scheme 문제까지 있었다.
각각은 별개의 버그였다. 하지만 실제 서비스를 하나 더 붙여보니 공통된 질문이 남았다.
PaaS가 결정해야 하는 것과, 외부 요청을 전달하는 구성요소가 결정해야 하는 것을 어디까지 분리할 것인가?
이 질문을 제대로 나누지 않으면 PRIMARY라는 단어 하나가 너무 많은 의미를 가지게 된다.
서버가 바뀌는 것과 서비스의 정체성이 바뀌는 것은 다르다
처음 구조를 단순화하면 public route는 이렇게 생각하기 쉽다.
service.sorune.org
↓
현재 서비스가 실행 중인 서버
한 대에서는 충분하다.
하지만 Node가 늘어나면 바로 문제가 생긴다. 현재 workload가 Node A에 있다가 Node B로 이동할 수 있고, PaaS의 reconciliation authority 자체도 다른 Node로 넘어갈 수 있다. 이때 public hostname까지 물리 서버와 같이 움직이게 만들면 내부 placement 변화가 외부 ingress 설정으로 새어 나온다.
내가 유지하고 싶었던 것은 반대였다.
service.sorune.org
↓
stable public entrypoint
↓
현재 accepted workload binding
사용자가 보는 서비스의 이름은 그대로 두고, 내부에서 어떤 Node가 역할을 맡는지만 바뀌어야 한다.
그래서 public identity와 physical node identity를 분리했다.
이 결정은 겉으로 보면 routing 구성 하나를 추가한 것처럼 보이지만, 실제로는 이후의 handover와 rollback 기준을 정하는 바닥이 됐다.
PaaS는 “어디가 정답인가”를 결정하고 Gateway는 그 정답으로 보낸다
현재 구조에서 PaaS가 소유하는 것은 서비스의 의미에 가깝다.
- 어떤 product가 존재하는가
- 어떤 revision을 배포하려는가
- 어느 Node가 candidate인가
- 현재 accepted placement는 무엇인가
- 어떤 route가 존재해야 하는가
- promotion과 rollback은 어떤 상태인가
반면 public request를 실제로 전달하는 일은 네트워크 경계의 문제다.
Internet
↓
Cloudflare DNS / Tunnel
↓
Gateway
↓
accepted PRIMARY binding
↓
Workload
여기서 Gateway는 현재 binding을 소비한다. 스스로 “더 건강해 보이는 서버”를 골라 PRIMARY를 선출하지 않는다.
health check만 보면 자동 선택이 편해 보일 수 있다. 하지만 실제 promotion에는 health 외에도 revision, acceptance, rollback 가능성, 운영자의 명시적 결정 같은 정보가 들어간다.
그 판단을 Gateway가 가져가면 routing component가 product authority까지 소유하게 된다.
그래서 둘의 관계를 다음처럼 정리했다.
PaaS는 현재 어떤 상태가 정답인지 결정한다. Gateway는 그 정답을 public traffic에 적용한다.
같은 프로세스에 구현될 수 있는지는 부차적인 문제다. 중요한 것은 한쪽의 failure나 변경이 다른 쪽의 의미까지 바꾸지 않게 하는 것이다.
Cloudflare Tunnel에는 PRIMARY의 의미를 넣지 않았다
외부 ingress에는 Cloudflare Tunnel을 사용한다.
Tunnel 자체는 편리하다. 홈 네트워크에서 public port를 직접 열지 않고도 hostname을 내부 endpoint에 연결할 수 있다. 하지만 그 편리함 때문에 PRIMARY 상태까지 provider 설정에 넣으면 promotion의 의미가 외부 서비스에 종속된다.
예를 들어 이런 순서를 core contract로 삼고 싶지는 않았다.
promotion
→ DNS/Tunnel 변경
→ propagation 대기
→ 새 서버가 PRIMARY로 간주됨
내부에서 이미 accepted된 placement가 있는데 public cutover의 정확성이 DNS propagation이나 provider mutation에 좌우되면 두 state machine이 하나로 엉킨다.
그래서 Cloudflare가 알아야 하는 정보는 최소화했다.
Cloudflare
→ stable Gateway entrypoint
Gateway
→ current accepted binding
Cloudflare HA나 Tunnel replica는 여전히 중요한 운영 문제다. 다만 그것은 ingress availability 축이고 workload placement 축은 아니다.
실제로 더 헷갈렸던 것은 PRIMARY가 두 종류였다는 점이다
설계를 정리하면서 한 번 더 분리해야 했던 것이 있다.
“PRIMARY를 다른 Node로 옮긴다”는 문장이 두 가지 전혀 다른 작업을 가리키고 있었다.
첫 번째는 control-plane handover다.
BEFORE
PaaS PRIMARY = Node A
PhotoGram runtime = BC250
Ghost runtime = BC250
AFTER
PaaS PRIMARY = Node B
PhotoGram runtime = BC250
Ghost runtime = BC250
여기서 움직이는 것은 reconciliation authority다.
PhotoGram과 Ghost가 그대로 BC250에서 실행되고 있다면 오히려 그게 성공 조건에 가깝다. control plane을 옮겼다고 application을 재배포하면 두 책임이 다시 섞인다.
두 번째는 workload placement 이동이다.
이쪽은 실제 runtime이 움직인다.
내가 acceptance 대상으로 잡은 왕복은 다음이었다.
BC250 LIVE
→ Ubuntu TESTBED
→ BC250 LIVE
Ubuntu에 candidate를 올렸다는 사실만으로 public traffic이 따라가면 안 된다. 먼저 health와 acceptance를 확인하고, 명시적으로 promotion된 binding만 Gateway가 사용해야 한다.
그래서 당시 launch gate에는 세 상태를 의도적으로 분리해 두었다.
DEPLOYED
ACCEPTED PRIMARY
PUBLIC CUTOVER
세 단계가 우연히 동시에 일어날 수는 있어도 같은 상태는 아니다.
이 차이가 있어야 candidate 배포가 실패했을 때 기존 LIVE를 그대로 유지할 수 있고, promotion 이후 문제가 생겼을 때 어느 수준까지 rollback해야 하는지도 설명할 수 있다.
두 번째 workload가 unit test보다 더 많은 것을 알려줬다
이 설계를 문서만 보고 만들었다면 아마 훨씬 깔끔한 이야기가 됐을 것이다.
실제로는 Ghost를 붙이면서 꽤 지저분한 문제들이 나왔다.
예를 들면 Nginx가 upstream에 외부 HTTPS 의미를 제대로 전달하지 못하는 문제가 있었다. generic installer에서는 실제 SELinux 동작이 예상한 로그 형태로 보이지 않는 경우가 있었고, tenant bootstrap의 authorization model도 두 번째 project에서 막혔다.
이런 문제는 모두 “PaaS의 큰 아키텍처”와 직접 관련 없어 보인다.
하지만 실제 host와 실제 workload, 실제 public request를 연결하지 않았다면 발견하기 어려웠다는 점에서는 같은 종류의 문제였다.
real workload
→ actual host
→ actual gateway
→ public request
이 경로를 한 번 더 통과할 때마다 추상적인 contract 안에 숨어 있던 가정이 하나씩 드러났다.
그래서 v1의 마지막 단계도 기능을 더 추가하는 일이 아니라 이동과 복구를 실제로 증명하는 일로 잡았다.
브라우저에서도 같은 경계를 적용했다
비슷한 문제가 운영 UI에도 있었다.
브라우저가 PaaS API뿐 아니라 server-ops와 Security Guard endpoint를 각각 직접 알고 있으면 처음에는 구현이 간단하다.
하지만 그렇게 되면 client가 내부 topology를 소유한다.
Browser
├─ PaaS
├─ server-ops
└─ Security Guard
향후 Electron이나 다른 client를 만들면 같은 내부 구조와 credential 문제를 또 가져가야 한다.
그래서 client-facing boundary도 PaaS 쪽으로 모았다.
Browser / Electron / CLI
↓
Gateway
↓
PaaS client-facing API
↙ ↘
server-ops Security Guard
여기서 중요한 점은 PaaS가 server-ops나 Security Guard의 authority를 흡수한 것이 아니라는 것이다.
client가 내부 subsystem을 직접 알 필요가 없게 만들었을 뿐, 실제 infrastructure observation과 security decision의 소유권은 각 subsystem에 남겨두었다.
이 구분은 내가 이 시스템에서 계속 지키려는 방식과도 맞았다. 호출 경로가 한곳으로 모였다고 책임까지 한곳으로 합쳐지는 것은 아니다.
내가 확인하려 했던 것은 “이동 가능”보다 “무엇이 안 움직여야 하는가”였다
2026년 9월 15일 시점에 PaaS v1의 남은 launch gate는 기능 목록이 아니었다.
control-plane handover에서는 다음을 보고 싶었다.
- durable product state가 이어지는가
- provider가 새 authority에 정상적으로 재연결되는가
- split-brain이 생기지 않는가
- 기존 workload와 public route가 불필요하게 재생성되지 않는가
- reverse handover가 가능한가
workload mobility에서는 다른 것을 본다.
- Ubuntu에 candidate를 올려도 public impact가 없는가
- acceptance 후에만 binding이 바뀌는가
- BC250으로 되돌아올 수 있는가
- 실패 시 기존 LIVE를 유지하거나 rollback할 수 있는가
- restart/reboot 뒤에도 accepted state가 복구되는가
둘 다 “Node가 바뀌었다”는 표현으로 묶을 수 있지만 acceptance 기준은 전혀 다르다.
이걸 분리하고 나서야 무엇을 테스트해야 하는지도 명확해졌다.
작은 홈 서버에서도 결국 같은 문제를 만났다
처음에는 내가 관리하는 몇 대의 장비를 편하게 운영하려고 시작한 PaaS였다.
그런데 실제 서비스를 두 개 올리고, control plane을 다른 Node로 넘기고, workload를 testbed로 이동시키려 하자 결국 흔히 보던 단어들이 그대로 등장했다.
control plane, data plane, identity, placement, promotion, rollback.
규모가 작다고 이런 문제가 사라지는 것은 아니었다. 오히려 노드와 workload 수가 적어서 어떤 상태가 실제로 움직였고 어떤 상태는 그대로여야 하는지 더 쉽게 볼 수 있었다.
현재 설계에서 public hostname은 서비스의 정체성이다. Node는 실행 위치이고, PRIMARY는 특정 시점에 accepted된 역할이다.
이 세 가지를 분리한 뒤에야 “PRIMARY가 바뀌어도 도메인은 바뀌지 않는다”는 문장이 단순한 routing 설정이 아니라 시스템 전체의 경계를 설명하는 문장이 됐다.