더 이상 제공하지 않는 조회 API를 정리하게 됐다. 오래된 클라이언트가 여전히 같은 URL을 호출하기 때문에, 요청을 공용 API 게이트웨이로 보내고 그곳에서 404를 반환하기로 했다. 기존 서비스를 계속 운영하면서 종료된 API의 응답만 처리할 필요는 없었다.
처음에는 게이트웨이에 404 처리 코드와 새 라우트를 추가하고, 기존 서비스의 라우트를 지우는 두 개의 PR이면 충분해 보였다. 그런데 배포 순서를 검토하다가 질문이 생겼다. 같은 호스트와 경로를 가진 라우트가 동시에 존재하면 어느 쪽이 요청을 받는 걸까?
이 질문을 따라가면서 실제 트래픽의 목적지를 바꾸는 작업과 라우트 설정을 관리하는 저장소를 옮기는 작업을 분리했다. 최종적으로는 네 단계로 배포할 수 있도록 PR을 나눴다.
서비스명, 도메인, 네임스페이스와 API 경로는 익명화했다. 예시는 핵심 동작을 설명하도록 단순화했다. 이 글은 코드·설정 검증과 배포 전 PR 분리까지의 기록이며, 운영 전환 완료를 보고하는 글은 아니다.
요청을 받는 게이트웨이와 응답을 만드는 게이트웨이
먼저 두 가지 게이트웨이를 구분할 필요가 있다. Istio ingress는 외부 요청을 받는 Envoy 프록시이고, **api-gateway**는 요청을 처리하는 별도의 HTTP 애플리케이션이다. 여기서 말하는 api-gateway는 Istio의 Gateway 리소스 이름이 아니다.
기존 요청은 다음 경로로 흘렀다.
text클라이언트: https://api.example.com/v1/retired/items ↓ Istio ingress (Envoy) ↓ legacy-service ↓ 기존 조회 응답
변경 후에는 클라이언트의 URL을 유지하면서 ingress 뒤의 목적지를 바꾼다.
text클라이언트: https://api.example.com/v1/retired/items ↓ Istio ingress (Envoy) ↓ api-gateway ↓ HTTP 404, 빈 본문
VirtualService는 이 경로 중간에서 요청을 받아 전달하는 서버가 아니다. Envoy가 요청을 어디로 보낼지 결정할 때 사용할 규칙이다. 기존 규칙을 단순화하면 다음과 같다.
yamlapiVersion: networking.istio.io/v1 kind: VirtualService metadata: name: legacy-api-routes namespace: legacy spec: hosts: - api.example.com gateways: - istio-system/public-ingress http: - name: retired-api match: - uri: prefix: /v1/retired/ route: - destination: host: legacy-service.legacy.svc.cluster.local port: number: 8080
hosts는 클라이언트가 요청하는 호스트이고, destination.host는 실제로 요청을 받을 내부 서비스다. 이 둘을 구분하면 외부 URL을 바꾸지 않고 내부 목적지만 교체한다는 의미가 명확해진다.
Istio 설정은 어느 저장소에 있었나
이 환경에서는 각 서비스 저장소의 Helm values가 라우팅 설정을 관리하고 있었다. 공용 Helm chart가 values를 읽어 VirtualService를 렌더링하고, 배포 과정에서 해당 리소스를 클러스터에 적용하는 구조였다.
text서비스 저장소의 release/values.prod.yaml ↓ Helm chart로 렌더링 Kubernetes의 VirtualService 리소스 ↓ istiod가 설정을 구성·전파 ingress Envoy의 실제 라우트
따라서 게이트웨이 애플리케이션에 404 핸들러를 추가하는 일과 ingress의 목적지를 게이트웨이로 바꾸는 일은 별개다. 핸들러만 바꾸면 ingress는 계속 기존 서비스로 요청을 보낸다. 라우팅을 바꾸려면 Helm values도 수정해야 한다.
Istio 자체의 소스 코드나 설치 저장소를 수정할 필요는 없었다. 규칙의 원본이 서비스 저장소에 있었기 때문에 해당 저장소를 수정하는 것이 맞았다. 이때 values의 키 이름은 공용 chart의 약속이며, Istio 표준 API 필드와는 구분해야 한다.
또한 두 저장소가 배포하는 리소스의 이름과 네임스페이스는 서로 달랐다. 게이트웨이 저장소에서 새 VirtualService를 만든다고 기존 서비스의 리소스가 자동으로 교체되는 구조가 아니었다.
새 규칙을 추가해도 기존 규칙이 덮어써지지 않는다
처음 생각했던 방식으로 게이트웨이를 먼저 배포하면 클러스터에는 다음 두 규칙이 함께 존재한다.
textlegacy-api-routes api.example.com + /v1/retired/ → legacy-service gateway-retired-api-routes api.example.com + /v1/retired/ → api-gateway
Istio는 ingress Gateway에 연결된 여러 VirtualService가 같은 호스트를 사용하면 라우트 규칙을 병합할 수 있다. 하지만 병합된 규칙의 평가 순서를 임의로 기대해서는 안 된다.
하나의 VirtualService 안에서는 http 규칙을 위에서부터 평가한다. 공식 문서의 Routing rule precedence에도 이 순서가 설명돼 있다.
반면 여러 VirtualService를 병합할 때의 문서는 리소스 간 평가 순서를 **“the cross-resource order is UNDEFINED”**라고 명시한다. 새로 배포한 리소스가 기존 리소스보다 우선한다고 보장하지 않는다.
여기서 다루는 병합은 ingress Gateway에 바인딩된 VirtualService에 대한 동작이다. 같은 호스트를 여러 리소스에 선언하는 방식을 sidecar의 mesh 라우팅에도 그대로 적용할 수 있다는 뜻은 아니다.
현재 ingress의 설정을 조사하면 어떤 규칙이 먼저 놓였는지 확인할 수는 있다. 다만 그 관찰 결과를 여러 리소스 사이의 우선순위 계약으로 삼을 수는 없다. 배포 순서에 기대어 기존 서비스의 트래픽이 끊겼다고 판단할 수 없다는 의미다.
그렇다고 기존 규칙부터 지우면 어떨까? 새 규칙이 준비되기 전까지 라우트가 비는 구간이 생길 수 있다. 원하는 것은 게이트웨이가 의도적으로 반환하는 404인데, ingress가 라우트를 찾지 못해서 반환하는 404로 전환 성공을 판단하게 될 수도 있다.
트래픽 전환과 설정 관리 주체 이전을 나누기
이번 작업에는 두 가지 목표가 섞여 있었다.
- 기존 URL의 요청을 게이트웨이로 보내 404를 반환한다.
- 기존 서비스의 배포를 정리해도 라우트가 유지되도록 설정을 게이트웨이 저장소로 옮긴다.
첫 번째 목표만 필요하다면 기존 VirtualService의 목적지를 바꾸는 것으로 끝낼 수 있다. 하지만 규칙을 계속 기존 서비스의 배포에 남겨두면, 나중에 그 배포를 제거할 때 종료된 API를 처리하는 라우트까지 사라질 수 있다. 그래서 설정을 관리하는 주체도 옮겨야 했다.
전환 과정을 상태로 나열하면 다음과 같다. 표의 목적지는 해당 단계의 설정이 ingress에 전파된 이후를 기준으로 한다.
| 상태 | 기존 서비스가 관리하는 VS | 게이트웨이가 관리하는 VS | 요청의 목적지 |
|---|---|---|---|
| 시작 | 기존 서비스로 전달 | 없음 | 기존 서비스 |
| 1단계 | 기존 서비스로 전달 | 없음 | 기존 서비스, 게이트웨이는 404 처리 준비 |
| 2단계 | 게이트웨이로 전달 | 없음 | 게이트웨이 |
| 3단계 | 게이트웨이로 전달 | 게이트웨이로 전달 | 어느 규칙을 골라도 게이트웨이 |
| 4단계 | 없음 | 게이트웨이로 전달 | 게이트웨이 |
이 순서는 서로 다른 목적지를 가진 중복 규칙을 만들지 않는다. 두 규칙이 잠시 겹치는 3단계에서는 어느 쪽이 선택돼도 같은 동작을 하도록 만든다.
1단계. 게이트웨이의 404 처리 코드만 먼저 배포한다
먼저 게이트웨이에 종료된 API의 응답을 준비한다. 아래는 공통 prefix 아래의 요청을 처리하도록 단순화한 Echo v4 예시다.
goe.Any("/v1/retired/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) })
실제 변경에서는 기존 API 경로들을 각각 등록하고, 더 이상 기존 서비스를 호출하지 않도록 프록시 의존성도 제거했다. 위 예시처럼 prefix로 묶는다면 그 아래에 종료 대상이 아닌 API가 섞여 있지 않은지 먼저 확인해야 한다.
회귀 테스트에서는 기존 경로들의 GET과 POST가 빈 본문의 404를 반환하는지 확인했다. GET 핸들러만 등록하면 다른 메서드는 프레임워크가 405로 처리할 수 있기 때문에, ingress의 메서드 매칭 조건과 애플리케이션의 등록 범위를 함께 봤다.
이 단계에서는 외부 URL의 ingress 라우팅을 바꾸지 않는다. 새 코드가 준비된 모든 게이트웨이 인스턴스에서 기존 서비스를 호출하지 않고 404를 반환하는지 확인한다. ingress에서 해당 Kubernetes Service에 접근할 수 있는지와 포트·접근 정책도 확인 대상이다.
2단계. 기존 VirtualService의 목적지만 바꾼다
게이트웨이가 준비되면 기존 서비스 저장소에서 라우트의 목적지만 수정한다. 리소스의 이름, 네임스페이스, 호스트와 경로 조건은 유지한다.
diffroute: - destination: - host: legacy-service.legacy.svc.cluster.local + host: api-gateway.edge.svc.cluster.local port: number: 8080
네임스페이스가 다른 서비스로 보낼 때는 FQDN을 사용했다. Istio는 짧은 서비스명을 규칙이 정의된 네임스페이스 기준으로 해석하기 때문이다. 게이트웨이 쪽으로 설정을 옮긴 뒤에도 같은 서비스를 가리키게 만들려면 목적지를 명확히 적는 편이 낫다. VirtualService의 destination 설명에 이 해석 방식이 나와 있다.
여기서는 새 VirtualService를 추가하지 않는다. 기존 규칙 하나가 가리키는 목적지만 교체하므로, 서로 다른 목적지를 가진 두 규칙 사이의 평가 순서를 고민할 필요가 없다.
다만 설정이 모든 ingress에 동시에 적용되는 것은 아니다. 전파 중에는 기존 응답과 404가 함께 관찰될 수 있으므로 이 단계에서도 기존 서비스를 유지한다. 새 목적지를 사용할 준비를 끝낸 뒤 라우트를 바꾸고, 전파를 확인한 다음 이전 대상을 정리하는 순서다. Istio의 라우트 재설정 가이드도 설정 변경 중 목적지의 준비 상태와 전파 순서를 주의하도록 설명한다.
3단계. 게이트웨이 저장소에도 같은 규칙을 추가한다
기존 규칙을 통해 요청이 게이트웨이로 전달되는 것을 확인한 뒤, 게이트웨이 저장소에도 라우트를 등록한다. 앞에서 본 YAML과 비교하면 리소스를 관리할 이름·네임스페이스와 목적지가 달라진다.
yamlapiVersion: networking.istio.io/v1 kind: VirtualService metadata: name: gateway-retired-api-routes namespace: edge spec: hosts: - api.example.com gateways: - istio-system/public-ingress http: - name: retired-api match: - uri: prefix: /v1/retired/ route: - destination: host: api-gateway.edge.svc.cluster.local port: number: 8080
이제 같은 호스트와 경로를 가진 두 규칙이 공존한다. 하지만 2단계에서 기존 규칙도 게이트웨이를 가리키도록 바꿨으므로 어느 규칙이 먼저 평가돼도 목적지가 같다.
이때 목적지 주소만 같으면 충분한 것은 아니다. 실제 설정에서는 Gateway 바인딩, 리소스 가시성, match 조건, rewrite, 헤더 처리, timeout, 재시도와 CORS 정책도 비교해야 한다. 요청이나 응답을 바꾸는 설정이 다르면 선택된 규칙에 따라 동작이 달라질 수 있다. 위 YAML에서는 설명을 위해 이런 정책을 생략했다.
4단계. 기존 서비스 저장소에서 라우트를 제거한다
새 규칙이 ingress에 반영된 것을 확인하면 기존 서비스 저장소의 라우트 설정을 제거한다. 최종적으로는 게이트웨이 저장소에서 배포하는 규칙만 남는다.
Helm이나 GitOps가 관리하는 환경에서는 클러스터에서 리소스만 수동 삭제해 끝내지 않는다. 배포 설정의 원본도 바꿔야 다음 배포나 동기화 과정에서 기존 규칙이 다시 생성되지 않는다.
이 단계가 끝났다고 기존 서비스의 모든 리소스를 곧바로 삭제하는 것은 아니다. 외부 HTTP 요청 이외의 내부 호출이나 배치 작업도 있는지 확인해야 한다. 여기서 분리한 작업 범위는 해당 ingress 라우트의 전환과 관리 주체 이전까지다.
404만 확인해서는 전환 성공을 알 수 없다
종료된 API를 다루다 보니 성공 여부를 판단하기가 오히려 까다로웠다. 다음 두 응답은 상태 코드만 보면 같다.
text의도한 상태 ingress → api-gateway → 핸들러의 404 라우트가 빠진 상태 ingress → 매칭되는 라우트 없음 → ingress의 404
따라서 실제 URL의 응답과 함께 어느 upstream이 요청을 처리했는지도 봐야 한다. 게이트웨이 요청 로그와 메트릭, ingress에서 선택한 라우트·upstream, 기존 서비스의 해당 경로 요청량을 함께 확인하도록 배포 조건을 적었다.
원본 YAML이 올바른지도 중요하지만 ingress가 실제로 받은 설정은 별도로 확인해야 한다. 예를 들어 다음 명령으로 프록시의 동기화 상태와 라우트 구성을 볼 수 있다.
bashistioctl proxy-status istioctl proxy-config routes INGRESS_POD -n istio-system -o json
INGRESS_POD는 실제 확인할 ingress Pod 이름으로 바꾼다. ingress 인스턴스가 여러 개라면 하나만 확인하고 전파가 끝났다고 판단하지 않는다. 동기화 상태 확인과 함께 대상 호스트·경로의 목적지가 기대한 서비스인지 확인한다. 명령 사용법은 Debugging Envoy and Istiod를 참고했다.
애플리케이션 테스트에도 범위가 있다. 라우터 단위 테스트가 통과해도 전역 미들웨어와 ingress 정책까지 검증한 것은 아니다. 예를 들어 브라우저의 CORS 사전 요청은 정책에 따라 200이나 204로 끝날 수 있다. 실제 API 요청의 404 계약과 OPTIONS 처리를 구분해 확인해야 한다.
PR도 독립적으로 배포할 수 있는 상태로 나누기
최종적으로 변경을 다음 네 PR로 분리하고, 모든 본문에 같은 배포 순서와 다음 단계로 넘어갈 조건을 적었다.
| PR | 변경 범위 | 다음 단계로 진행할 조건 |
|---|---|---|
| 1 | 게이트웨이의 404 처리 코드와 불필요한 upstream 의존성 제거 | 게이트웨이의 준비 상태와 직접 요청의 404 확인 |
| 2 | 기존 VirtualService의 목적지만 변경 | 실제 ingress의 목적지, 외부 요청의 404, 기존 API 트래픽 감소 확인 |
| 3 | 게이트웨이 저장소에 동일한 라우트 추가 | 공존하는 두 규칙의 동작과 ingress 반영 상태 확인 |
| 4 | 기존 서비스 저장소의 라우트 제거 | 새 규칙만 남은 상태와 실제 요청 처리 경로 확인 |
설정 검증에서는 각 단계의 YAML을 파싱하고 구조를 비교했다. 2단계는 목적지 이외의 설정이 유지되는지, 3단계는 두 규칙의 매칭·목적지·재시도·CORS가 같은지, 4단계는 기존 라우트만 사라지는지를 확인했다.
뒤의 두 PR은 선행 변경과의 차이만 보이도록 연결하고 Draft로 두었다. 선행 PR이 기본 브랜치에 합쳐지면 후속 PR의 base와 diff를 다시 확인해야 한다. 그리고 PR 병합 완료와 배포 완료는 별개이므로, 앞 단계의 배포·검증이 끝나기 전에 다음 단계를 한꺼번에 병합하지 않도록 본문에 명시했다.
되돌릴 때도 현재 상태를 기준으로 순서를 생각해야 한다. 3단계에서 기존 규칙의 목적지만 이전 서비스로 되돌리면, 다시 서로 다른 목적지의 중복 규칙이 생긴다. 이 상태에서는 새 규칙을 제거하고 전파를 확인한 뒤 기존 규칙의 목적지를 되돌려야 한다. 4단계 이후라면 기존 규칙을 같은 게이트웨이 목적지로 복원하는 과정부터 필요하다.
처음에는 몇 줄의 라우팅 설정과 짧은 핸들러 변경이었다. 배포 단위로 나눠보니 최종 설정뿐 아니라 그 사이에 잠시 존재할 상태들도 설명할 수 있어야 했다. 이번 작업에서 PR을 나눈 기준은 파일이나 저장소의 개수보다, 각 배포가 끝난 뒤 어떤 규칙이 남고 어디로 요청이 가는지 확인할 수 있는가였다.