회사에 다니면서 내가 뭘 했는지 상세히 기록을 안해두면 나중에 다 휘발되는 것 같아서, 소소한 거라도 (오히려 소소한 것일수록 더더욱) 트러블 슈팅이나 새로 알게된 것들은 기록해두기로 했다. 문제를 마주했을 때 내 의식과 행동 순서대로 작성했다. 
 

배경

현재 파이썬에 쿠버네티스를 연결하여 kubernetes client API를 통해 deployment 및 service를 관리하는 서비스를 개발하고 있다. 상사가 제공해준 스켈레톤 코드를 기반으로 기능을 추가해 나가고 있는데, 사실 대부분 클로드가 짜준 코드라고 한다. 아무튼, 이번에는 client가 우리 서비스에 요청을 보내면 그 request body를 기반으로 deployment와 service를 배포하는 API를 개발하고 있었다. 그걸 위해서 요청받은 namespace 및 기타 정보들로 클러스터에 존재 여부를 확인하고, 그에 따라 새로 생성 및 업데이트하는 기능을 개발해야 했다. 새로 생성하는 데에는 아무 문제가 없었는데, 업데이트하는 과정에서 에러가 발생했다.
 

이슈 발생 상황

요청 정보를 기반으로 동일한 deployment 및 service 가 있는 경우  patch_namespaced_deployment() 와  patch_namespaced_service() 함수를 사용해 수정 사항을 반영하도록 했다. 그런데 deployment는 문제없이 잘 실행됐는데, service 부분에서 에러가 발생했다. 에러 발생 시 요청한 request body는 다음과 같다. 즉, name이 http인 port 객체에 대해서 포트 번호만 바꾸고 정상 동작을 확인하려고 했다.

// 처음 service 생성 시 요청 정보
{
    "ports": [
    	{
            "port": 8000,
            "name": "http"
        },
        {
            "port": 8001,
            "name": "https"
        }
    ]
}

// service 업데이트 시 요청 정보
{
	"ports": [
    	{
            "port": 8010,
            "name": "http"
        },
        {
            "port": 8001,
            "name": "https"
        }
    ]
}

 

에러 분석

에러 중 핵심 메세지만 뽑으면 다음과 같다. 
Reason: Unprocessable Entity
HTTP response body: {"message": "test-node" is invalid: spec.ports[1].name: Duplicate value"...}
서비스를 배포하는 YAML의 ports 항목은 리스트로 되어있는데, 그 중 1번 인덱스의 name값이 중복된다는 것이다. 쿠버네티스 공식 문서에 따르면, multi-port 서비스에서 ports의 name 속성은 ambiguous하지 않도록 모두 정의되어야 한다. 이 말은 곧 각 port의 name들은 유니크해야 한다는 뜻이기도 하다. 
나는 단순히 동일한 이름을 가진 객체의 포트 번호만 스위칭한다고 생각했는데, 내부 로직 상 충돌이 나는 것 같다. 그런데 업데이트하고자 한 포트 정보는 첫 번째 데이터인데, 왜 에러 메세지에는 1번 인덱스로 나오는 걸까? 0번 인덱스가 맞지 않을까? 이런 의문점들을 해결하기 위해 우선 해당 API 문서를 찾아보았다. 
 

API document 분석

kubernetes client github 페이지의 README를 보면 각 API에 대한 document 링크가 있다. service를 관리하는 API들을 둘러보니 create, delete, list, patch, read, replace 등이 있었다. 내가 사용했던 patch 함수와, 비슷한 기능을 하는 것으로 보이는 replace 함수를 비교해보기로 했다.

patch_namespaced_service document

replace_namespaced_service document

두 API를 비교해보니, patch는 서비스의 일부분을 업데이트하는 것이고 replace는 전체를 업데이트하는 거였다. 그리고 업데이트할 서비스 정보를 받는 body 부분에서 차이가 있었다. patch의 경우 단순 object고, replace는 V1Service 객체를 받는다. 여기서 object는 간단히 dict로 이해하면 된다. 즉, patch의 경우 변경이 필요한 필드만 포함한 객체를 파라미터로 받아 업데이트하는 것이고, replace는 서비스 객체를 통채로 받아 교체하는 것이다. 기존의 서비스 객체를 완전히 삭제하고 새로 생성하는 방식이라고 이해하면 될 것 같다. 

 

이 내용은 쿠버네티스의 patch 함수에 관한 API 문서에서도 찾아볼 수 있다. (사실 이게 먼전데 patch 기능이 있는 걸 몰랐다)


 

해결 방안

1. client에게 서비스의 정보 전체를 받고 있기 때문에 replace 함수 사용하기(해당 서비스 존재 여부 확인 로직 필요, 이미 있음)
→ 제일 단순하고 간편한 방식
2. 클러스터에 존재하는 service 객체를 클러스터에서 가져오고, 수정 사항이 있는 부분만 비교한 후 patch 함수 사용하기
→ 수정된 부분(변경, 삭제, 추가)을 모두 파악하는 로직을 구현하는 데 많이 복잡해질 것. 특정 필드 일부만 수정된다면 이 방식이 효율적
3. 해당하는 service 객체를 가져오고, 해당 객체에 변경된 값을 수정한 후 replace 함수 사용하기
→ 단순 수정 사항만 반영하려면(기존 필드 값들을 누락하지 않으려면) 이 경우가 적절하겠다. 하지만 내 경우에는 request가 온 그대로 업데이트를 해야하므로 해당하지 않음
 
위와 같은 경우들을 고려해본 결과, 1번 방식이 내 상황에 제일 적절하다고 판단했다. 기존 patch함수룰 replace 함수로 대체했고 이번에는 에러 없이 정상적으로 실행된 것을 확인했다! 

추가 의문점

1. 에러 메세지에서 왜 인덱스가 0번이 아니라 1번으로 출력되었는가?

쿠버네티스에서 patch 작업 수행 시 Strategic Merge Patch 방식으로 동작하기 떄문인 것 같다. 쿠버네티스 공식 문서에서 deployment에 대해서 patch하는 예제를 참고했다. "patch-demo-ctr"라는 container가 존재할 때, " patch-demo-ctr2" container에 대한 정보를 yaml로 작성하고 명령어를 실행한 것이다. 그 결과 기존 container list에 새로 추가된 것을 볼 수 있다. 즉, 내 경우 "http"라는 이름을 가진 새로운 port 정보를 추가하러고 했기 때문에 충돌이 난 것이다. 그러면 리스트 안에는 다음과 같이 2개의 값이 담겼을 것이다. 그렇기 때문에 두번째 값, 즉 1번 인덱스에서 에러가 발생한 것이다.

spec:
  ports:
  - name: http
    port: 8000
    protocol: TCP
    targetPort: 8000
  - name: http
    port: 8010
    protocol: TCP
    targetPort: 8000

 

2. 왜 patch deployment에서는 잘 되고 service에서만 에러가 발생했는가?

앞서, deployment는 문제없이 잘 실행되었다고 했다. 그렇다면 동일한 patch 방식인데 왜 service에서만 에러가 났을까? gpt에게 한번 물어봤다. (정확하지 않은 내용일 수 있으니 참고만 바람) deployment와 service가 내부적으로 다르게 동작하기 때문이라고 한다. 예를 들어 deployment YAML의 containers 속성의 경우, ports와 같은 리스트 형식이지만 name이 같으면 해당 컨테이너 정보가 업데이트 되고 새로운 컨테이너가 추가되지 않는다. 즉, 이름을 기준으로 변경 사항을 확인하여 개별적으로 병합하는 것이다. 반면 service 는 병합되지 못하고 기존 리스트에 추가(append)된다.

 

동일한 patch 기능이고, 동일하게 strategic merge patch 방식을 사용할텐데 왜 내부 로직을 다르게 만든걸까? 이는 쿠버네티스의 리소스 설계 철학과 관련이 있다고 한다. deployment의 경우 하나의 deployment에서 여러 컨테이너가 실행될 수 있으며, 그 중 일부 컨테이너만 업데이트하는 경우가 많다. 변경 사항이 없는 기존 컨테이너를 유지하는 것이 불필요한 삭제 및 재배포가 발생하지 않는다. 반면 service의 ports는 개별적인 업데이트보다 전체적인 일관성이 더 중요하며, 특정 포트가 변경될 때는 보통 기존 포트를 제거하고 새 포트를 설정하는 것이 일반적이다.(병합하여 수정하는 것이 아니라) 만약 ports 리스트가 containers처럼 개별 병합된다면, 잘못된 상태(중복된 포트 설정)으로 남아있을 위험이 있다. 

즉, 각 필드별로 사용 목적에 맞게 가장 적절한 방식이 적용되도록 설계 것이다. 필드 별로 부분 업데이트/전체 변경 중 어떤 것이 더 일반적인지를 고려하고, 그에 맞게 병합 방식을 다르게 적용한 것이라고 한다. 


3. deployment를 업데이트함에 있어 patch와 replace의 정확한 차이는?

patch는 수정 사항이 있는 필드에 대해서만 업데이트하고, replace는 그와 상관없이 전부 업데이트한다. 각 함수별로 케이스를 나눠보면 다음과 같다. 

  • patch
    • container 필드의 name과 기존 컨테이너 이릉이 전부 다르다면, 새로 생성되어 배포됨
    • 이름이 중복되고, 수정된 사항(ex: 이미지)이 있다면, 재배포됨
    • 이름이 중복되었는데 수정된 사항이 없다면, 해당 컨테이너는 그대로 유지됨 (이 케이스는 gpt에게 물어봄)
  • replace
    • 이름의 중복 여부나 수정 사항을 체크하지 않고, 전부 재배포함

 

근본적 원인 및 회고

1. 단순히 주어진 스켈레톤 코드만 활용했기 때문에, 어떤 API들이 제공되는지 모르는 상태로 개발을 진행했다. 
2. 쿠버네티스에 대한 이해도가 부족했다. kubectl을 사용할 때는 apply 명령어로 생성 및 업데이트를 모두 처리했기 때문이다. 그래서 patch/replace 기능이 있는지도 몰랐고, merge patch 방식이란 것도 처음 알게 되었다. 
 
⇒ 앞으로도 새로운 기술을 공부할 때 공식 문서를 적극적으로 참고하고, 최소한 내가 쓰는 함수가 어떤 기능을 하는지는 정확히 알고 사용해야겠다. 문서에서 다양한 API를 비교해보니 내 상황을 정확히 분석해보고, 그에 따른 장단점을 파악하며 적절한 방안을 고민해볼 수 있었다. 

+ Recent posts