들어가기 전
이 글은 쿠버네티스 인 액션 5장을 공부하며 작성한 글입니다.
5.1 서비스 소개
파드는 다음과 같은 특징을 가진다.
- 파드는 생성되기 바로 직전에 IP가 부여된다. 스케쥴링 시에는 알 수 없다.
- 파드는 언제든지 삭제될 수 있다.
- 파드는 스케일링 단위다. 따라서 여러 파드가 같은 서비스를 제공할 수 있어야 한다.
파드의 IP는 처음부터 알 수 없고, 매번 바뀐다. 심지어 여러 파드가 같은 서비스를 제공하기도 해야한다. 이 말은 같은 일을 하는 파드를 하나로 묶는 구심점이 있고, 클라이언트는 그 구심점으로만 접근해야한다는 것이다. 이 역할을 해주는 것이 서비스다. 서비스는 다음 특징을 가진다. 서비스는 파드의 캡슐화 + 추상화 한 녀석이 될 수 있다.
- 생성되면 IP 주소 / 포트가 절대로 바뀌지 않는다.
- 서비스는 일반적으로는 각 파드로 랜덤하게 로드밸런싱 한다.
- Session Affinity를 사용하면 특정 클라이언트 IP의 요청은 항상 특정 파드로 전달된다.
5.1.1. 서비스 생성
서비스는 파드를 그룹핑 한다고 했다. 이 말은 서비스도 Label Selector를 사용하는 것을 의미한다. 따라서 다음과 같이 생성할 수 있다. 아래 파일을 보자.
- selector : 파드를 그룹핑 할 때 사용함. 정확하게는 해당 파드 그룹을 바라보는 엔드포인트를 생성하는데 사용됨.
- port : 서비스의 포트. <서비스:포트>로 들어오는 요청을 파드 그룹 내의 임의의 파드:8080으로 전달해 줌.
- CLUSTER IP : k8s 클러스 내부에서만 사용되는 IP. 외부에서는 사용할 수 없다.
# 배포 파일
apiVersion: v1
kind: Service
metadata:
name: hello-service
labels:
service: hello
spec:
selector:
app: k8s-in-action
ports:
- port: 80 // Service의 80포트로 들어오는 요청
targetPort: 8080 // 컨테이너의 8080포트로 전달.
# 배포된 후. CLUSTER IP가 생성되어 있음.
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
hello-service ClusterIP 40.0.65.51 <none> 80/TCP 44s
kubernetes ClusterIP 40.0.0.1 <none> 443/TCP 36h
만약 위의 상황에서 hello-service에게 요청을 보내는 경우 흐름은 어떻게 동작할까?
- 특정 파드 안에 접속한다. (클러스터 IP이므로 클러스터끼리만 연락할 수 있음)
- 특정 파드 안에서 curl 40.0.65.51:80을 한다.
- curl은 hello-service(40.0.65.51)의 80으로 전달된다.
- 서비스의 80 포트로 전달된 요청은 k8s-in-action 라벨을 가진 파드의 8080 포트로 전달된다.
서비스의 세션 어피니티
일반적으로 동일 클라이언트가 여러번 서비스에 요청을 하면, 요청할 때 마다 각기 다른 파드에 전달될 것이다. 이것은 서비스가 서비스 프록시를 이용해 로드 밸런싱하기 때문이다. 그렇지만 특정 클라이언트 IP를 반드시 특정 파드에 전달해야 한다면, 서비스에서 sessionAffinity를 이용할 수 있다.
- None : 임의로 로드밸런싱
- ClinetIP : 동일한 클라이언트 IP는 항상 같은 Pod로 전달됨.
apiVersion: v1
kind: Service
metadata:
name: hello-service
labels:
service: hello
spec:
sessionAffinity: ClientIP # CLIENTIP / NONE만 존재
selector:
app: k8s-in-action
ports:
- port: 80
targetPort: 8080
쿠버네티스의 서비스 프록시는 TCP / UDP 계층에서 이루어지기 때문에 클라이언트의 IP 기반으로 Sticky한 구성을 하는 것은 가능하다. 만약 쿠키 기반으로는 처리할 수 없는데, 쿠버네티스는 서비스 프록시를 처리할 때 어플리케이션 계층까지 들여다 보지 않기 때문이다.
동일한 서비스에서 여러 개의 포트 노출 (반드시 포트 이름 설정)
하나의 서비스는 하나의 파드 그룹을 선택한다. 파드 그룹의 파드는 1개 이상의 열려있는 포트를 가질 수 있다. 이 때, 하나의 서비스는 파드 그룹에 대해서 다중 포트 포워딩을 지원해준다. 동일한 서비스에서 여러 개의 포트를 노출할 수 있는데, 이 때는 반드시 포트의 이름을 설정해야 한다는 것이다.
# 하나의 서비스는 다중 포트를 지원할 수 있음.
apiVersion: v1
kind: Service
metadata:
name: hello-service
labels:
service: hello
spec:
sessionAffinity:
selector:
app: k8s-in-action
ports:
- port: 80
targetPort: 8080
name: http # 서비스 포트 이름 설정
- port: 443
targetPort: 8443
name: https # 서비스 포트 이름 설정
이름이 지정된 포트 사용
파드에서 포트를 만들 때 이름을 지정할 수 있다. 서비스에서 포트를 생성할 때, 이 이름을 가져와서 사용할 수 있다. 이렇게 하면 다음 장점이 있다.
- 서비스는 파드의 포트 이름을 바라봄. 파드의 포트는 포트 이름에 의해서 캡슐화 됨.
- 파드의 포트가 5000 → 80으로 바뀌더라도, 서비스 입장에서는 변경되는 것이 없음.
# 파드의 파드 이름을 서비스에서 사용할 수 있음.
# 파드의 포트번호는 파드의 포트 이름으로 캡슐화 됨.
apiVersion: v1
kind: Service
metadata:
name: hello-service
labels:
service: hello
spec:
sessionAffinity: ClientIP
selector:
app: k8s-in-action
ports:
- port: 80
targetPort: http-pod # 파드의 포트 이름을 사용함.
name: http
- port: 443
targetPort: http-pods # 파드의 포트 이름을 사용함.
name: https
# 파드 포트에 이름 붙여주기
apiVersion: v1
kind: Pod
metadata:
name: hello-pod
labels:
app: k8s-in-action
spec:
containers:
- name: k8s-in-action-5-1-1
image: ojt90902/ash:latest
ports:
- containerPort: 80
name: http-pod
- containerPort: 8443
name: https-pod
5.1.2 서비스 검색
클라이언트 파드는 도대체 어떻게 서비스가 생성되었을 때, 서비스의 IP와 포트를 찾을 수 있는 것일까? 그 방법은 다음 세 가지가 존재한다. 아래에서 하나씩 살펴보고자 한다.
- 환경변수를 통한 서비스 검색
- DNS를 통한 서비스 검색
- FQDN을 통한 서비스 연결
환경변수를 통한 서비스 검색
서비스가 먼저 생성된 후 파드가 생성되는 경우, 서비스의 호스트 명 + 포트 번호가 파드의 환경 변수에 저장된다. 이런 방법을 통해 그룹 파드는 자신과 관련된 서비스의 IP + 포트 명을 알 수 있게 된다.
$ kubectl exec hello-pod env
PATH=/usr/java/openjdk-17/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=hello-pod
JAVA_HOME=/usr/java/openjdk-17
LANG=C.UTF-8
JAVA_VERSION=17.0.2
KUBERNETES_PORT=tcp://40.0.0.1:443
KUBERNETES_PORT_443_TCP=tcp://40.0.0.1:443
KUBERNETES_PORT_443_TCP_PROTO=tcp
HELLO_SERVICE_PORT_80_TCP=tcp://40.0.65.51:80
HELLO_SERVICE_PORT_80_TCP_PROTO=tcp
...
DNS를 통한 서비스 검색
쿠버네티스가 설치될 때 kube-system 네임스페이스에 아래 녀석들이 설치된다.
- core-dns Deployment
- kube-dns Service
core-dns 파드는 쿠버네티스에서 DNS 서버 역할을 한다. 고가용성을 위해서 몇 대가 떠있을 수 있는데, 이것을 kube-dns라는 서비스로 묶어준다. 쿠버네티스의 모든 파드는 자동으로 이 core-dns를 사용하도록 쿠버네티스가 /etc/resolv.conf를 수정한다. 아래를 살펴보자.
cat /etc/resolv.conf
>>>
search default.svc.cluster.local svc.cluster.local cluster.local kornet
nameserver 40.0.0.10
options ndots:5
살펴보면 이 파드는 40.0.0.10 (kube-dns 서비스 ClusterIP)를 네임 서버로 사용해서 default.svc.cluster.local → svc.cluster.local → 순으로 검색하도록 /etc/resolv.conf가 설정되어있는 것을 볼 수 있다.
참고 : 파드는 내부 클러스터 DNS를 참조하지 않도록 할 수 있음. 파드의 spec에 dnsPolicy가 있는데, 이 값을 어떻게 설정하느냐에 따라서 내부 클러스터 DNS를 참조하지 않을 수도 있다.
FQDN을 통한 서비스 연결
FQDN(Fully Qualified Domain Name)은 전체 도메인 네임을 의미한다. 아래 예시로 손쉽게 이해할 수 있다.
- www : 도메인 명
- yahoo.com : 호스트 명
- www.yahoo.com : FQDN
쿠버네티스의 서비스 자원을 생성하면 FQDN을 제공해준다. FQDN은 다음과 같은 형식으로 생성된다. 실제로 이런 값을 생성하는지는 nslookup 명령어를 사용해보면 명확히 알 수 있다. 주의할 점은 만약 같은 네임 스페이스에 있는 파드의 경우 <서비스 이름>으로만 통신이 가능하다. 다음 상황으로 이해할 수 있다.
- 네임스페이스 A에 B, C 서비스 존재
- B 서비스에서 C 서비스로 요청할 때 curl C로 가능. 다른 네임 스페이스라면 curl C.<네임스페이스>.svc.cluster.local로 요청해야 함.
// FQDN
<서비스 이름>.<네임 스페이스>.svc.cluster.local
// 예시
hello-service. default .svc.cluster.local
// 찾아보기
$ nslookup hello-service
>>>>
Server: 40.0.0.10
Address: 40.0.0.10#53
Name: hello-service.default.svc.cluster.local
Address: 40.0.110.104
서비스로 요청
만약 같은 네임스페이스의 서비스에 요청한다고 가정한다면, 아래 세 가지 요청 모두 유효하다. 이유는 쿠버네티스가 파드를 생성할 때 /etc/resolv.conf를 수정해주기 때문이다.
$ curl http://kubia.default.svc.cluster.local
$ curl http://kubia.default
$ curl http://kubia
resolv.conf 파일은 DNS를 IP로 찾을 때 어떤 방식으로 찾을지를 정리해 둔 파일이다.
네임 서버 40.0.0.10 (kube-dns 서비스)에 쿼리를 보내는데, 찾는 순서는 아래와 같다. 이렇게 구성되어 있기 때문에 우리가 http://kubia로 검색 요청한 것은 자동적으로 http://kubia.default.svc.cluster.local로 로 변경되어 DNS 서버에 요청되게 된다.
- default.svc.cluster.local
- svc.cluster.local
- cluster.local
- kornet
$ cat /etc/resolv.conf
>>>
search default.svc.cluster.local svc.cluster.local cluster.local kornet
nameserver 40.0.0.10
options ndots:5
서비스 IP에 핑을 할 수 없는 이유
서비스를 이용했을 때 다음 경우는 사람을 헷갈리게 만든다
- 서비스에 curl 요청 : 성공
- 서비스에 ping 요청 : 실패
서비스에서 생성되는 CLUSTER IP는 가상 IP이기 때문에 서비스 포트와 결합된 경우에만 의미가 있게 된다. 이 부분은 11장에서 공부한다.
5.2 클러스터 외부에 있는 서비스 연결
앞서 설명한 서비스는 클러스터 IP를 만든다. 클러스터 IP는 쿠버네티스 클러스터 내에서만 사용할 수 있는 IP다. 따라서 외부 클라이언트에서 서비스로 접근할 수 있는 방법이 없다. 외부 접근을 가능하게 하려면 특별한 방법이 필요하다.
5.2.1 서비스 엔드포인트 소개
서비스는 서비스 자체만으로는 동작하지 않는다. 서비스가 동작하기 위해서는 다음과 같은 형식으로 동작한다.
- 서비스 자원을 생성.
- 서비스에 '라벨 셀렉터'가 존재하면, 라벨 셀렉터와 관련된 엔드포인트(k8s 자원) 생성
- '라벨 셀렉터'가 없으면 어떠한 엔드포인트도 만들어 지지 않음.
- 엔드 포인트 / 서비스는 '이름'으로 연결된다. 서비스는 동일한 이름을 가진 엔드포인트를 참조함.
- 서비스는 엔드포인트를 이용해서 Pod에 접근
서비스는 라벨 셀렉터를 이용해 직접 파드를 선택하는 것이 아니라, 라벨 셀렉터를 이용해 엔드 포인트를 생성하고, 엔드 포인트를 통해서 각 파드에 접근하는 것이다. 실제로 구성은 아래 그림에 가깝게 되어 있다고 볼 수 있다.
$ kubectl describe svc hello-service
Name: hello-service
Namespace: default
Labels: service=hello
Annotations: <none>
Selector: app=k8s-in-action
Type: ClusterIP
IP Family Policy: SingleStack
IP Families: IPv4
IP: 40.0.110.104
IPs: 40.0.110.104
Port: <unset> 80/TCP
TargetPort: 8080/TCP
Endpoints: 30.0.235.172:8080
Session Affinity: None
Events: <none>
$ kubectl get endpoints,svc
NAME ENDPOINTS AGE
endpoints/hello-service 30.0.235.172:8080 25m
endpoints/kubernetes 192.168.56.120:6443 46h
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/hello-service ClusterIP 40.0.110.104 <none> 80/TCP 25m
service/kubernetes ClusterIP 40.0.0.1 <none> 443/TCP 46h
위의 명령어를 이용해서 실제 서비스가 엔드포인트를 참조하고 있음을 알 수 있다. 또한 엔드포인트 / 서비스의 이름이 동일하다는 것을 알 수 있다. 서비스 - 엔드포인트의 연결고리는 '동일한 이름'을 가질 때다.
5.2.2 서비스 엔드포인트 수동 구성
서비스를 생성할 때 라벨 셀렉터를 선택하지 않으면 엔드포인트가 만들어지지 않는다. 이 때, 사용자는 필요한 엔드포인트를 직접 정의해서 만들면 된다. 이 때, 반드시 서비스와 동일한 이름의 엔트포인트를 생성해야 서비스가 참조한다. 예를 들면 아래와 같이 작성할 수 있다.
apiVersion: v1
kind: Endpoints
metadata:
name: hello-service
subsets:
- addresses:
- ip: 11.11.11.11
- ip: 22.22.22.22
ports:
- port: 80
만약 처음에 서비스를 엔드포인트 없이 구현했다가, 서비스가 자동으로 관리하는 엔드포인트를 사용하고 싶다면 서비스에 라벨 셀렉터를 달아주면 된다.
$ kubectl edit svc hello-service-no-endpoint
## 아래 부분 스펙에 추가
selector:
app: k8s-in-action
5.2.3 외부 서비스를 위한 별칭 생성
쿠버네티스 클러스터 내부에서 클러스터 외부 서비스 이름을 추상화 하고 싶은 경우가 있다. 이 때 서비스를 ExternalName으로 선언하면 손쉽게 추상화 할 수 있다.
apiVersion: v1
kind: Service
metadata:
name: hello-service-external
spec:
type: ExternalName
externalName: www.google.com
ports:
- port : 80
위와 같이 선언하면 된다. 이렇게 생성된 서비스는 다음과 같이 동작한다.
- 쿠버네티스 클러스터 내부에서 hello-service-external:80으로 요청되는 것은 www.google.com으로 로 전달된다.
google.com으로 바로 요청을 보내도 되지만, 굳이 한번 더 추상화 하는 이유는 나중에 변경하기가 좀 더 쉬울 수 있기 때문이다. 예를 들어 hello-service-external이 google.com을 사용했었는데, 나중에 naver.com으로 바꾸고 싶을 경우가 있다. 이 때는 간단하게 서비스의 externalName을 www.google.com → www.naver.com 으로 변경해주기만 하면 된다.
ExternalName 서비스는 DNS 레벨에서만 구현된다. 서비스에 관한 간단한 CNAME DNS 레코드가 생성된다. 따라서 서비스에 연결하는 클라이언트는 서비스 프록시를 완전히 무시하고 외부 서비스에 직접 연결된다. 이런 이유로 ExternalName 서비스는 CLUSTER IP를 얻지 못한다. CNAME 레코드는 IP 주소 대신 FQDN을 가리킨다.
5.3 외부 클라이언트에 서비스 노출
지금까지는 클러스터 내부에서 통신하는 방법을 살펴봤다. 아래와 같이 외부에서 클라이언트가 쿠버네티스 클러스터 내부에 접근하려면 어떻게 해야할까? 서비스를 외부 클라이언트로 노출만 해주면 된다.
서비스를 외부 클라이언트에 노출하는 방법은 다음과 같다.
- 노드 포트 (하나의 서비스만 노출 / 네트워크 4계층)
- 각 클러스터 노드는 동일한 포트를 열고, 해당 포트로 오는 요청을 서비스로 전달함.
- 노드포트 서비스는 Cluster IP / 노드를 통한 접근이 가능함.
- 로드 밸런서 (하나의 서비스만 노출 / 네트워크 4계층)
- 노드 포트 앞에 로드 밸런서를 하나 추가함.
- 클라이언트 → 로드 밸런서 → 노드 포트 → 서비스로 트래픽이 전달됨.
- 로드 밸런서 타입으로 선언했지만 로드 밸런서가 없으면, 노드 포트로 접근 가능함.
- 인그레스 (다중 서비스 노출 / 네트워크 7계층)
- 단일 주소 IP로 여러 서비스를 노출할 수 있다.
- HTTP 레벨(네트워크 7계층)에서 작동하므로, 4계층에서 작동하는 로드밸런서 + 노드포트보다 더 많은 기능을 제공할 수 있다.
5.3.1 노드포트 서비스 사용 (방화벽 설정 필요)
'노드포트' 타입의 '서비스'를 생성하면 다음과 같은 동작이 일어난다.
- NodePort 타입의 서비스가 생성됨.
- 각 노드는 동일한 포트를 열어줌. 해당 포트로 들어오는 요청은 서비스로 전달됨.
- 서비스는 엔드포인트를 통해 파드에 접근함.
노드포트는 다음과 같이 생성할 수 있다.
# 노드포트 서비스 선언
apiVersion: v1
kind: Service
metadata:
name: hello-node-port
spec:
type: NodePort # 서비스 타입 선택
ports:
- port: 80
targetPort: 8080
nodePort: 30200 # 선언하지 않으면 알아서 생성됨.
selector:
app: from-node
다음과 같이 노드포트가 생성된다.
- Cluster IP가 존재함 → 클러스터 내부에서 서비스로도 접근 가능하다.
- PORT 80:30200 → 노드에 30200으로 온 요청은 이 서비스의 80으로 전달됨.
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
hello-node-port NodePort 40.0.236.128 <none> 80:30200/TCP 2s
동작은 다음과 같이 이루어진다. 노드포트는 쉽지만 이런 단점이 존재한다.
- 특정 노드포트 IP만 아는 경우 해당 노드포트로만 트래픽이 전달됨.
- 그 노드포트가 죽는 경우, 다른 노드포트가 정상임에도 불구하고 요청을 할 수 없어 서비스 불능 상태가 될 수 있음.
5.3.2 외부 로드밸런서로 서비스 노출 (방화벽 설정 필요 X)
로드밸런서는 공개적으로 액세스 가능한 고유한 IP 주소를 가지며, 모든 연결을 서비스로 전달한다. 따라서 외부 클라이언트는 로드밸런서 IP로 접근하면, 서비스가 제공하는 기능을 사용할 수 있다. 로드밸런서 타입의 서비스는 다음과 같이 동작한다.
- 서비스를 만들 때, 노드포트를 열어준다.
- 쿠버네티스 클러스터가 로드밸런서를 프로비저닝해주면 외부 IP가 생기고, 외부 IP를 통해서 접근 가능하다.
- 로드밸런서를 프로비저닝 하지 못하면 외부 IP는 생기지 않지만, 여전히 노드 포트를 이용해 접근할 수 있다.
로드밸런서는 노드 포트의 단순한 확장이기 때문에 프로비져닝 된 로드밸런서가 실제로 없더라도, 노드포트로 접근할 수 있게 되는 것이다. 실제 동작은 다음과 같이 이루어진다.
앞서 노드포트는 해당 노드 포트의 IP만 아는 경우, 트래픽이 특정 노드로 몰리고 최악의 경우 서비스가 중단된 것처럼 느껴질 수 있다고 했다. 이 단점을 해결하기 위해 로드밸런서 타입의 서비스가 등장했다. 클라이언트는 로드 밸런서에 요청을 하기만 하면 되고, 로드 밸런서는 자동으로 노드포트에 적절히 라우팅 해준다. 그리고 이후의 라우팅은 서비스가 담당한다.
생성은 다음과 같이 하고, 정상적으로 생성되면 EXTERNAL-IP를 볼 수 있다. 지금부터 클라이언트는 EXTERNAL IP로 접근할 수 있게 된다.
# 로드밸런서 선언
apiVersion: v1
kind: Service
metadata:
name: hello-loadbalncer
spec:
type: LoadBalancer # 로드밸런서. 쿠버네티스 클러스터에서 로드밸런서 지원하면 얻을 수 있음.
ports:
- port: 80
targetPort: 8080
nodePort: 30200 # 선언하지 않으면 알아서 생성됨.
selector:
app: from-node
##
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
hello-loadbalancer LoadBalancer 40.0.236.128 130.211.53.173 80:30200/TCP 2s
5.3.3 외부 연결의 특성 이해 (노드 포트 + 로드밸런서)
외부에서 서비스로 들어오는 연결과 관련해 알아둬야 할 몇 가지가 있다.
불필요한 네트워크 홉의 이해와 예방
노드 포트로 서비스를 만들었다면 외부 클라이언트의 요청은 일단 특정 노드 포트로 들어온다. 서비스가 라우팅하는 파드가 같은 노드에 있다면 추가 네트워크 홉이 필요없지만, 다른 곳에 있다면 추가 네트워크 홉이 발생한다. 추가 네트워크 홉이 발생하지 않도록 하는 방법이 있는데, 장단점이 존재한다.
- 설정 방법
- 서비스에 다음 설정값을 추가해주면 됨.
- externalTrafficPolicy: local
- 장점
- 네트워크 홉이 최소화 됨.
- 단점
- 전달된 노드에 로컬 파드가 없으면, 연결이 중단됨. 로드 밸런서는 해당 파드가 존재하는 노드로만 요청을 전달해야함.
- 서비스에 연결된 파드는 더 이상 균등하게 부하를 받지 않음.
가장 큰 단점은 서비스에 의해 그룹핑 된 파드가 더 이상 균등하게 부하를 받지 않는다는 것이다. 로드 밸런서는 각 노드로 균등하게 트래픽을 보내준다. 이런 상태에서 eternalTrafficPolicy가 local이면, 로컬 내에 있는 파드에만 트래픽이 전달되기 때문에 아래와 같이 동작한다. 예를 들어 어떤 노드에 파드가 2개 있으면 각각 25%의 트래픽을 처리하지만, 노드에 1개 파드가 있으면 50%의 트래픽을 처리한다.
클라이언트 IP가 보존되지 않음 인식
클러스터 내의 클라이언트가 서비스로 연결하면 서비스의 파드는 클라이언트의 IP 주소를 얻을 수 있다. 그렇지만 노드포트로 연결을 수신하면 패킷에서 소스 네트워크 주소 변환(SNAT)가 수행되므로 패킷의 소스 IP가 변경된다. 따라서 파드는 실제 클라이언트의 IP를 볼 수 없다. 이것은 클라이언트의 IP를 알아야 하는 일부 어플리케이션에 문제가 될 수 있다.
로컬 외부 트래픽 정책은 노드 - 파드 사이의 네트워크 홉이 존재하지 않는다. 따라서 SNAT가 발생하지 않고, 이것 덕분에 클라이언트 IP를 보존할 수 있다.
5.4 인그레스 리소스로 서비스 외부 노출
노드포트 / 로드밸런서 서비스 타입을 이용해서 쿠버네티스 클러스터의 서비스를 외부로 노출할 수 있었다. 그렇지만 이것은 하나의 포트는 하나의 서비스만 서빙이 가능하다는 점이다. 예를 들어 수만 개의 서비스를 서빙해야 한다면, 포트가 모자라게 될 것이다. 이것을 해결하기 위해서 인그레스가 추가된다.
인그레스가 필요한 이유
인그레스는 어플리케이션 계층(HTTP)에서 동작한다. 이것 때문에 인그레스는 호스트명 + 요청 경로를 조합해서 여러 군데의 서비스를 지원해줄 수 있다. 바꿔 이야기 하면 하나의 IP만 외부에 노출해서 여러 서비스를 지원할 수 있다는 것이다. 인그레스는 아래와 같이 호스트 + 경로를 조합해서 각 서비스로 요청을 라우팅 해준다.
인그레스 컨트롤러가 필요한 경우.
앞서서는 인그레스 리소스에 대한 설명했다. 인그레스 리소스는 각 네임 스페이스에 배포되어서, 각 서비스로 라우팅 하는 룰이 설정된 녀석이다. 실제로 이 룰대로 동작하기 위해서는 인그레스 컨트롤러가 설정되어야 한다. 인그레스 컨트롤러는 다양한 회사에서 지원을 해주고 있는데, 무료로 사용할 수 있는 것은 nginx ingress다.
Ingress Controller를 설치하면 인그레스 컨트롤러 파드가 설치된다. 이 인그레스 컨트롤러 파드에 의해서 인그레스 리소스를 인식하고 각 서비스로 라우팅 처리를 해준다.
ingress-nginx ingress-nginx-controller-controller-7dfbc455b5-qps76 1/1 Running 0 22h 30.0.235.173 worker1 <none> <none>
5.4.1 인그레스 리소스 생성
인그레스 리소스는 다음과 같이 생성할 수 있다. 아래 yaml 파일을 k8s 클러스터에 배포하면 인그레스 룰이 배포되는 것이다.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kubia
spec:
rules:
- host: kubia.example.com
http:
paths:
- path: /hello
pathType: Prefix
backend:
service:
name: kubia-nodeport
port:
number: 80
5.4.2 인그레스로 서비스 액세스
배포하고 나면 얼마 지나지 않아 인그레스 컨트롤러에 의해서 외부 IP를 할당받게 된다. (외부 IP는 로드 밸런서를 통해서 받음). 따라서 이 IP는 k8s 클러스터 외부에서 사용할 수 있는 IP가 된다.
NAME CLASS HOSTS ADDRESS PORTS AGE
kubia nginx kubia.example.com 192.168.100.101 80 6m48
이 상태는 다음을 의미한다.
- kubia.example.com으로 접속하면 192.168.100.101로 접속된다.
- 이 때 ingress에 설정된 path로 접속하면, 자동적으로 해당 서비스로 라우팅 된다.
그런데 마스터 노드에서 다음 요청을 보내보면 문제가 발생한다. 이것은 마스터 노드가 kubia.example.com 도메인에 대한 IP 주소를 모르기 때문에 발생하는 문제다. DNS 서버에도 등록이 안되어 있기 때문에 마스터 노드는 kubia.example.com 쿼리에 대한 결과를 알 수 없다.
$ curl kubia.example.com/hello
>>
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>
이 부분을 해결하기 위해 다음과 같이 처리하면 된다. 아래 값을 추가해두면, 마스터 노드가 해당 값을 참조해서 요청을 보낼 수 있게 된다.
$ vim /etc/hosts
192.168.100.101 kubia.example.com
// 요청에 대한 응답.
$ curl kubia.example.com/hello
>>> You've hit hello-68595f88cb-47fmd
인그레스 동작 방식
인그레스에 요청을 보내면 다음과 같이 동작한다.
- 클라이언트가 DNS에서 kubia.example.com을 찾는다.
- 클라이언트는 찾아온 IP로 헤더에 Host: kubia.example.com과 함께 요청을 보낸다.
- 인그레스 컨트롤러는 Header의 Host와 Path를 살펴보고, 클라이언트가 액세스 하려는 서비스를 찾는다.
- 서비스의 엔드포인트가 가지고 있는 파드 IP를 확인하고, 클라이언트의 요청을 파드로 직접 전달한다.
그림으로 살펴보면 다음과 같이 동작한다.
5.4.3 하나의 인그레스로 여러 서비스 노출
앞서 이야기 한 것처럼 하나의 인그레스는 하나의 IP로 여러 서비스로 라우팅이 가능하다. HOST + PATH를 추가해서 여러 호스트 주소를 이용해 다양한 IP로 라우팅한다. 아래에서는 ingressClassName을 이용해서 ingress를 명시했다. Default IngressClass가 존재하지 않는 경우에 IP가 부여되지 않았기 때문이다.
# 인그레스 설치
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kubia-5-15
spec:
ingressClassName: nginx # default가 없는 경우, 배포가 안될 수도.
rules:
- host: foo.example.com
http:
paths:
- path: /hello-foo
pathType: Prefix
backend:
service:
name: kubia-nodeport
port:
number: 80
- host: bar.example.com
http:
paths:
- path: /hello-bar
pathType: Prefix
backend:
service:
name: kubia-nodeport
port:
number: 80
예를 들어서 아래와 같은 상황이었다.
# ingressClass 선택하기 전
NAME CLASS HOSTS ADDRESS PORTS AGE
kubia nginx kubia.example.com 192.168.100.101 80 90m
kubia-5-15 <none> foo.example.com,bar.example.com 80 53s
# ingressClass를 선택한 후.
NAME CLASS HOSTS ADDRESS PORTS AGE
kubia nginx kubia.example.com 192.168.100.101 80 91m
kubia-5-15 nginx foo.example.com,bar.example.com 192.168.100.101 80 2m6s
5.4.5 TLS 트래픽을 처리하도록 인그레스 처리
TLS를 이용하면 HTTPS 통신도 가능해진다. TLS를 이용한 인그레스 통신은 다음과 같이 처리된다.
- 클라이언트 → 인그레스 컨트롤러 (TLS로 연결됨)
- 인그레스 컨트롤러 → 파드 (일반 프로토콜)
클라이언트가 인그레스 컨트롤러로 TLS로 연결 요청을 하더라도, 연결하는 순간 TLS 연결은 종료되고 인그레스 컨트롤러 → 파드로는 일반 프로토콜로 연결된다. 왜냐하면 인그레스 뒷쪽은 k8s 클러스터 내부이기 떄문에 암호화를 할 필요가 없기 때문이다.
인그레스 컨트롤러로 TLS 처리하기
먼저 다음 명령어를 이용해서 인증서를 생성하고, secret을 만든다.
#!/bin/bash
openssl genrsa -out tls.key 2048
openssl req -new -x509 -key tls.key -out tls.cert -days 360 -subj /CN=kubia.example.com
kubectl create secret tls tls-secret --cert=tls.cert --key=tls.key
그리고 아래 인그레스 리소스를 배포한다.
# 인그레스 설치
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kubia-5-16
spec:
ingressClassName: nginx # 이게 없으면 배포가 안되나?
tls:
- hosts:
- kubia.example.com
secretName: tls-secret
rules:
- host: kubia.example.com
http:
paths:
- path: /hello-tls
pathType: Prefix
backend:
service:
name: kubia-nodeport
port:
number: 80
마지막으로 인그레스에서 받은 IP로 https 요청을 보내본다. 이 때, IP를 resolve 하지 못하는 문제가 있다면 /etc/hosts에 일시적으로 해당 값을 넣어주도록 한다. 실행하면 You'be hit kubia... 이라는 문구를 볼 수 있다.
$ curl -k -v https://kubia.example.com/hello-tls
* Trying 192.168.100.101:443...
* TCP_NODELAY set
* Connected to kubia.example.com (192.168.100.101) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
* CAfile: /etc/ssl/certs/ca-certificates.crt
CApath: /etc/ssl/certs
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN, server accepted to use h2
* Server certificate:
* subject: CN=kubia.example.com
* start date: May 24 09:11:44 2023 GMT
* expire date: May 18 09:11:44 2024 GMT
* issuer: CN=kubia.example.com
* SSL certificate verify result: self signed certificate (18), continuing anyway.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x55745ef77320)
> GET /hello-tls HTTP/2
> Host: kubia.example.com
> user-agent: curl/7.68.0
> accept: */*
>
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
* Connection state changed (MAX_CONCURRENT_STREAMS == 128)!
< HTTP/2 200
< date: Wed, 24 May 2023 09:16:26 GMT
< strict-transport-security: max-age=15724800; includeSubDomains
<
You've hit hello-68595f88cb-47fmd
* Connection #0 to host kubia.example.com left intact
5.5 파드가 연결을 수락할 준비가 됐을 때, 신호 보내기
파드가 생성되면 레이블 셀렉터에 의해서 바로 서비스의 엔드포인트에 포함되게 된다. 즉, 외부에서 서비스로 요청이 오면 해당 파드가 요청을 처리해야하는 상황이 되는 것이다. 그런데 파드가 생성된 즉시에 바로 클라이언트 요청에 응답할 수 없을 수 있다. 이런 경우를 해결하기 위해서 쿠버네티스는 Readness Probe의 요청에 응답하는 파드만 서비스의 엔드포인트로 등록한다.
5.5.1 Readness Probe 소개
Liveness Probe는 요청에 응답하지 않는 컨테이너를 이상한 상태로 보고 파드를 재시작한다고 했다. 그와 유사하게 Readness Probe는 요청에 응답하지 않는 파드를 서비스의 엔드포인트에서 제외한다. Readness Probe는 '파드가 서비스 할 준비가 되었음'을 확인하는 용도로 이해하면 된다. Readness 프로브는 다음 세 종류가 있다.
- HTTP GET 프로브 : 특정 경로로 HTTP 요청을 보냄.
- TCP 프로브 : 파드와 TCP 연결을 맺을 수 있으면 OK.
- EXCE 프로브 : 특정 명령어를 실행해서 0 응답값을 받으면 OK
Readness Probe의 동작 + 중요한 이유
Readness Probe는 점검을 했을 때, 동작하지 않는 파드들을 서비스 포인트에서 제외시켜준다. 파드를 재시작 하지는 않는다. Readness Probe와 Liveness Probe를 비교하면 다음과 같다.
점검 대상 | 조치 | 목적 | |
Readness Probe | 파드 | 해당 파드를 서비스에서 제외. | 서비스 할 수 있는 상태인지 |
Liveness Probe | 컨테이너 | 파드 재시작. | 건강한 파드인지 |
만약 프론트 엔드 파드가 5개 있는데, 하나의 파드가 정상적으로 서비스를 할 수 없는 상황이라고 가정해보자. 비정상 파드로 들어가는 트래픽은 다시 재시도 되지 않기 때문에 클라이언트는 실패를 경험할 것이다. 만약 Readness Probe가 이것을 발라낼 수 있도록 설계되어 있다면, 해당 파드는 서비스에서 제외되기 때문에 사용자가 실패를 경험하지 않아도 된다.
5.5.2 파드에 Readness Probe 추가
파드에 /var/ready 라는 파일이 있는 경우 '클라이언트에 서빙할 준비가 된 파드'라고 정의를 해보자. 이걸 이용해서 Readness Probe의 효과를 확인할 수 있다.
먼저 아래와 같이 파드를 배포해보자.
# Readness Probe 확인
apiVersion: apps/v1
kind: Deployment
metadata:
name: kubia-readness
spec:
replicas: 5
selector:
matchLabels:
app: kubia-readness
template:
metadata:
labels:
app: kubia-readness
spec:
containers:
- name: kubia-dep
image: luksa/kubia
imagePullPolicy: Always
readinessProbe:
exec:
command:
- ls
- /var/ready
파드가 정상적으로 배포되었으면 서비스도 배포해보자.
# 헤드리스 서비스 생성
apiVersion: v1
kind: Service
metadata:
name: chapter-5-17-readness
spec:
selector:
app: kubia-readness
ports:
- port: 80
targetPort: 8080
현재 상태에서 Endpoint를 확인해보자. 아래와 같이 어떠한 파드도 Endpoint에 포함되지 않은 것을 알 수 있다. 이것은 /var/ready라는 파일이 각 파드에 존재하지 않기 때문이다.
NAME ENDPOINTS AGE
chapter-5-17-readness 14s
아래와 같이 파드에 접속해서 /var/ready 파일을 하나 생성해주고, 엔드포인트에 파드가 생성되는 것을 확인할 수 있다.
$ kubectl exec -it kubia-readness-64884949c9-4svf2 -- /bin/bash
$ touch /var/ready
$ kubectl get ep
NAME ENDPOINTS AGE
chapter-5-17-readness 30.0.182.8:8080 3m17s
5.5.3 실제 환경에서 Readness Probe가 수행해야 하는 기능
Readness Probe는 어플리케이션이 클라이언트의 요청을 수행할 수 있는지를 판단하는 근거를 제공해야만 한다. 따라서 이 부분을 고려한 구현이 필수적이다. Readness Probe를 구현할 때는 다음 내용을 반드시 중요시 하고 작성해야 한다.
- Readness Probe를 항상 정의하라.
- Readness Probe가 없으면, 파드가 시작하는 즉시 서비스의 엔드포인트에 포함된다. 그런데 파드가 생성되었으나 어플리케이션이 준비가 되는데까지 시간이 걸릴 수 있다. 이런 부분을 해결하도록 Readness Probe를 넣는 것이 좋다.
- Readness Probe에는 종료 코드를 포함하지 마라.
- 쿠버네티스는 파드를 삭제하자마자 서비스에서 파드를 삭제한다. 즉, 파드가 종료할 때 클라이언트의 요청을 받지 못할 것이라 생각하고 Readness Probe를 호출하도록 고려할 필요가 없다는 것이다.
5.6 헤드리스 서비스로 개별 파드 찾기
클라이언트가 서비스로 접근한다면, 요청 한번은 단 한번의 파드로만 전달된다. 그렇지만 클라이언트의 요청이 서비스의 엔드포인트에 해당되는 모든 파드에게 전달되어야 하는 경우가 있다. 이런 경우를 위해 쿠버네티스는 헤드리스 서비스를 통해 엔드 포인트의 모든 파드 IP 주소를 반환해준다.
- 일반적인 서비스를 DNS 조회 → 서비스 클러스터 IP 반환됨.
- 헤드리스 서비스를 DNS 조회 → 엔드포인트의 모든 파드 IP 반환됨.
따라서 한번의 요청으로 모든 파드에 접근해야 할 때라면, 헤드리스 서비스를 만들어서 유용하게 사용할 수 있다. 헤드리스 서비스는 특이한 것처럼 보이지만 클라이언트 입장에서는 동일하게 동작한다. 서비스가 독특하게 동작하는 것은 클러스터 내부의 클라이언트가 사용할 때이다.
5.6.1 헤드리스 서비스 생성
헤드리스 서비스는 clusterIP를 None으로 설정하면 만들어진다. clusterIP를 헤드로 볼 수 있는데, clusterIP가 없기 때문에 헤드리스 서비스가 된다. 그리고 실제로 헤드리스 서비스는 clusterIP를 가지지 않는다. 단순히 DNS를 이용해 모든 파드 IP를 제공하기 위해 사용된다.
# 헤드리스 서비스 생성
apiVersion: v1
kind: Service
metadata:
name: 5-18-headless
spec:
clusterIP: None
selector:
app: kubia
ports:
- port: 80
targetPort: 8080
5.6.2 DNS로 파드 찾기
앞서 이야기 했던 것처럼 헤드리스 서비스 이름으로 DNS 검색을 해보면, 해당 서비스에 있는 모든 파드 IP가 반환된다.
$ nslookup chapter-5-18-headless
>>>
Address: 30.0.235.190
Name: chapter-5-18-headless.default.svc.cluster.local
Address: 30.0.235.189
Name: chapter-5-18-headless.default.svc.cluster.local
Address: 30.0.235.191
Name: chapter-5-18-headless.default.svc.cluster.local
Address: 30.0.189.68
Name: chapter-5-18-headless.default.svc.cluster.local
Address: 30.0.189.77
클라이언트 입장의 헤드리스 서비스
클라이언트 입장에서 일반 서비스 / 헤드리스 서비스의 차이점은 느낄 수 없다. 클라이언트는 '서비스'를 통해 '파드'에 접근할 수 있기 때문이다. 일반 서비스 / 헤드리스 서비스의 차이는 쿠버네티스 클러스터 내부에서만 보여지는 차이일 뿐이다. 하지만 실제 내부적으로 동작할 때는 조금의 차이가 있다.
- 일반 서비스 : 파드로 접근할 때, 서비스 프록시를 통해서 접근한다.
- 헤드리스 서비스 : 파드로 접근할 때, 파드로 직접 접근한다. (DNS 라운드로빈 방식으로 접근)
5.6.3 모든 파드 검색 - 준비되지 않은 파드도 포함됨
헤드리스 서비스는 기본적으로 라벨 셀렉터로 선택된 모든 파드들을 엔드 포인트에 포함시킨다. 그런데 아직 준비가 되지 않은 파드들도 검색을 하고 싶은 경우가 있다. 이럴 때는 아래의 어노테이션을 사용하면, 해당 서비스의 엔드포인트에 준비되지 않은 파드들도 포함되게 된다.
kind: Service
metadata:
annotations:
service.alpha.kubernetes.io/tolerate-unready-endpoints: "true"
5.7 서비스 문제 해결
서비스로 파드에 접근할 수 없을 때 고려해 볼만한 사항은 다음과 같다.
- 클러스터 내에서 서비스의 클러스터 IP에 연결되는지 확인
- 서비스 IP에 핑하지 마라. (가상 IP이기 때문에 아무런 소용이 없음.)
- 파드에 Readness 프로브를 정의했다면, 성공했는지 확인하라. 그렇지 않으면 파드는 서비스에 포함되지 않음.
- 파드가 서비스의 일부인지 확인하려면 Endpoints 자원을 확인하라
- FDQN / 그 일부를 서비스로 접근하려고 할 때 작동하지 않는 경우, 클러스터 IP를 사용해 접근할 수 있는지 확인.
- 대상 포트가 아닌 서비스로 노출된 포트에 연결하고 있는지 확인
- 파드 IP에 직접 연결해 파드가 올바른 포트에 연결되어있는지 확인
- 파드 IP로 어플리케이션에 액세스 할 수 없는 경우, 어플리케이션이 로컬호스트에만 바인딩하고 있는지 확인한다.
5.8 요약
- 서비스는 안정된 단일 IP 주소 (변하지 않음) + 포트로 특정 레이블 셀렉터와 일치하는 여러 개의 파드를 노출함.
- 기본적으로는 클러스터 내부에서만 서비스로 접근 가능. 외부에서는 노드 포트, 로드밸런서 타입의 서비스로 접근 가능.
- 파드의 환경 변수에 서비스에 대한 IP 주소 + 포트 정보가 있음.
- 서비스와 관련된 엔드포인트 리소스를 직접 만드는 대신, 셀렉터 설정 없이 서비스 리소스를 생성해 클러스터 외부에 있는 서비스를 검색하고 통신할 수 있다.
- ExternalName 서비스 유형으로 외부 서비스에 대한 DNS CNAME 별칭을 제공한다.
- 단일 인그레스로 여러 HTTP 서비스를 노출한다.
- 파드의 Readness Probe는 서비스 엔드포인트에 포함될지를 결정함.
- 헤드리스 서비스를 생성하면 DNS로 파드 IP를 검색할 수 있음.
정리
- 서비스의 레이블 셀렉터는 엔드포인트를 만들고 관리하는데 사용됨.
- 서비스 레이블 셀렉터가 없으면 엔드포인트는 만들어지지 않음.
- 나중에 서비스에 레이블 셀렉터를 추가하면 관리되는 엔드포인트가 만들어지고, 쿠버네티스가 자동으로 관리해줌.
- 서비스 - 엔드포인트는 '같은 이름'을 가질 때 묶임.
- 서비스를 외부로 노출하는 방법은 노드포트 / 로드밸런서 / 인그레스를 이용하는 것임.
- 노드포트 : 하나의 노드 IP만 아는 경우 특정 노드가 모든 트래픽을 받음. 해당 노드가 죽으면 문제가 생김. 방화벽 설정을 해줘야 함.
https://andrewpage.tistory.com/23
https://www.oss.kr/index.php/info_techtip/show/d6e9a82d-1a08-4398-a3f9-9f6ea356488c
'Dev-Ops > kubernetes' 카테고리의 다른 글
k8s 라즈베리파이 설치 (0) | 2023.05.31 |
---|---|
Kubernetes in Action : Chapter8. 어플리케이션에서 파드 메타데이터와 그 외의 리소스에 액세스하기 (0) | 2023.05.29 |
Kubernetes in Action : Chapter7. ConfigMap, Secret (0) | 2023.05.27 |
우분투 쿠버네티스 클러스터 설치하기 (0) | 2022.10.02 |
쿠버네티스 다중 클러스터 등록 / 사용 (0) | 2022.08.19 |