DevOps
K3s Traefik: NodePort와 MetalLB VIP 병행 노출
K3s Traefik에서 기존 NodePort 노출을 유지하면서 MetalLB VIP를 추가하고, tls: {} 기본 인증서 문제를 TLSStore로 정리한 운영 기록
- Published
- Updated
- Area
- Networking
- Type
- troubleshooting
- Series
- K3s Homelab
- Category
- DevOps
상황
기존 K3s 클러스터에서는 Traefik을 HelmChartConfig로 조정해서 사용하고 있었다. Traefik은 DaemonSet 형태로 배포되어 있었고, 서비스 노출은 NodePort 중심이었다.
운영 방식은 대략 다음과 같았다.
NodeIP:servicePort
-> Traefik NodePort Service
-> Traefik Pod의 custom entryPoint
-> IngressRoute
-> backend Service
-> application Pod
여기에 MetalLB를 추가하면서 기존 NodePort 접근은 유지하고, 동시에 MetalLB VIP로도 같은 Traefik entryPoint에 접근하고 싶었다.
목표 구조는 다음과 같았다.
기존 경로:
[REDACTED_NODE_IP]:30000
-> Traefik NodePort Service
-> Traefik Pod
-> IngressRoute
-> Rocket.Chat Service
추가 경로:
[REDACTED_VIP]:30000
-> traefik-lb LoadBalancer Service
-> Traefik Pod
-> IngressRoute
-> Rocket.Chat Service
핵심은 Traefik Pod를 하나 더 띄우는 것이 아니라, 같은 Traefik Pod를 바라보는 LoadBalancer Service를 하나 더 만드는 것이다.
기존 Traefik 설정의 의미
기존 HelmChartConfig에는 custom entryPoint가 여러 개 정의되어 있었다. 예를 들어 Rocket.Chat은 다음과 비슷한 구조였다.
spec:
valuesContent: |-
ports:
rocketchat:
port: 30000
exposedPort: 30000
expose:
default: true
nodePort: 30000
이 값들은 Kubernetes Service의 일반적인 port, targetPort, nodePort와 섞여 보이기 때문에 구분이 필요하다.
| 항목 | 의미 |
|---|---|
ports.<name>.port | Traefik Pod 내부에서 실제로 listen하는 entryPoint 포트 |
ports.<name>.exposedPort | Helm chart가 만드는 Traefik Service의 spec.ports[].port에 가까운 값 |
ports.<name>.nodePort | NodePort Service에서 Node IP에 직접 열리는 포트 |
entryPoints | IngressRoute가 어떤 Traefik entryPoint로 들어온 요청을 받을지 지정 |
targetPort | Kubernetes Service가 실제 Pod의 어느 port/name으로 보낼지 지정 |
여기서 자칫 실수하기 쉬운 부분은 targetPort다.
targetPort에는 IngressRoute 이름을 쓰는 것이 아니다. targetPort는 Traefik Pod의 container port 이름 또는 번호를 가리킨다.
예를 들어 Traefik에 rocketchat entryPoint가 있고, 해당 Pod port 이름도 rocketchat이라면 다음처럼 쓴다.
ports:
- name: rocketchat
protocol: TCP
port: 30000
targetPort: rocketchat
또는 숫자로 직접 지정할 수도 있다.
ports:
- name: rocketchat
protocol: TCP
port: 30000
targetPort: 30000
MetalLB용 Traefik Service 추가
기존 HelmChartConfig는 그대로 두고, MetalLB용 Service를 별도로 추가한다.
apiVersion: v1
kind: Service
metadata:
name: traefik-lb
namespace: kube-system
annotations:
metallb.io/address-pool: default
spec:
type: LoadBalancer
allocateLoadBalancerNodePorts: false
externalTrafficPolicy: Local
selector:
app.kubernetes.io/name: traefik
app.kubernetes.io/instance: traefik-kube-system
ports:
- name: rocketchat
protocol: TCP
port: 30000
targetPort: rocketchat
selector는 예시값이다. 실제 환경에서는 기존 Traefik Service의 selector를 확인해서 그대로 맞춰야 한다.
확인 명령은 다음과 같다.
kubectl -n kube-system get svc traefik -o yaml
kubectl -n kube-system get svc traefik -o jsonpath='{.spec.selector}'
echo
kubectl -n kube-system get pods -l app.kubernetes.io/name=traefik --show-labels
적용 후에는 다음을 확인한다.
kubectl -n kube-system get svc traefik traefik-lb -o wide
kubectl -n kube-system get endpoints traefik-lb -o wide
traefik-lb는 기존 ClusterIP Service를 경유하지 않는다
중요한 구조적 포인트가 있다.
traefik-lb가 기존 traefik Service의 ClusterIP를 다시 포트포워딩하는 것이 아니다.
실제 구조는 다음처럼 병렬이다.
┌─ traefik Service, ClusterIP
│
Traefik Pod ◀──────┤
│
└─ traefik-lb Service, LoadBalancer
즉, 기존 traefik Service와 새 traefik-lb Service가 모두 같은 Traefik Pod를 selector로 직접 바라본다.
따라서 MetalLB 경로는 다음과 같다.
Client
-> MetalLB VIP:30000
-> traefik-lb Service
-> Traefik Pod의 rocketchat entryPoint
-> IngressRoute
-> Rocket.Chat Service
기존 traefik ClusterIP Service는 이 흐름에 직접 끼지 않는다.
transport.respondingTimeouts는 어디에 두는가
기존 HelmChartConfig에 transport.respondingTimeouts 같은 설정이 들어 있었다면, 이것은 새 traefik-lb.yaml에 넣는 값이 아니다.
예시는 다음과 같다.
spec:
valuesContent: |-
ports:
rocketchat:
port: 30000
exposedPort: 30000
expose:
default: true
nodePort: 30000
transport:
respondingTimeouts:
readTimeout: 0s
writeTimeout: 0s
idleTimeout: 180s
이 설정은 Traefik entryPoint 동작에 대한 설정이다. LoadBalancer Service는 단순히 외부 VIP의 포트를 Traefik Pod의 entryPoint로 연결하는 역할만 한다.
증상: tls: {}와 기본 인증서
MetalLB 경로를 추가한 뒤 HTTPS 접속에서 다음과 같은 현상이 있었다.
NET::ERR_CERT_AUTHORITY_INVALID
처음에는 Chrome에서 인증서 예외를 승인해도 다시 같은 경고가 뜨고, 몇 번 반복한 뒤에야 접속이 되는 것처럼 보였다.
처음 의심한 후보는 다음과 같았다.
| 후보 | 판단 |
|---|---|
| Traefik이 자기 자신을 backend로 호출 | IngressRoute가 Traefik Service를 backend로 가리키지 않았으므로 가능성 낮음 |
externalTrafficPolicy: Local 때문에 재호출 | HTTP redirect를 직접 만들지는 않음 |
Rocket.Chat ROOT_URL 문제 | 가능성은 있으나 직접 LB로 HTTP 접근 시 정상 |
| Traefik Pod마다 다른 default certificate | 초기 증상과 잘 맞음 |
PathPrefix(/)만 있는 IngressRoute와 tls: {} | 최종적으로 중요한 원인 후보 |
openssl로 확인했을 때 subject와 issuer가 다음처럼 나왔다.
CN=TRAEFIK DEFAULT CERT
즉, 만든 Secret이 실제 요청 경로에서 사용되지 않고 있었다.
왜 PathPrefix(/)만으로는 인증서 선택이 애매한가
IngressRoute가 다음처럼 되어 있었다.
routes:
- match: PathPrefix(`/`)
kind: Rule
services:
- name: rocketchat
port: 3000
tls: {}
이 설정은 HTTP 라우팅 자체에는 충분할 수 있다. 하지만 TLS 인증서 선택은 HTTP path를 보기 전에 일어난다.
순서는 다음과 같다.
1. Client가 https://[REDACTED_IP]:30000 접속
2. TLS handshake 시작
3. Traefik이 SNI 또는 TLSStore 기준으로 인증서 선택
4. TLS가 성립된 뒤 HTTP request의 Host/Path 확인
5. PathPrefix(`/`) 라우팅
따라서 PathPrefix(/)는 TLS 인증서 선택 단계에 직접적인 도움을 주기 어렵다. 특히 DNS 없이 IP로 직접 접근하는 환경에서는 SNI/Host 기반 인증서 매칭이 더 애매해질 수 있다.
Host(...)를 추가해도 IP 접속에서는 여전히 default certificate가 나올 수 있었다.
routes:
- match: Host(`[REDACTED_NODE_IP]`) || Host(`[REDACTED_VIP]`) || PathPrefix(`/`)
결과적으로 이 구조에서는 서비스별 tls.secretName만으로 모든 접근 IP와 모든 custom port를 안정적으로 처리하기 어렵다고 판단했다.
해결: Traefik default TLS certificate 고정
이 환경에서는 Rocket.Chat뿐 아니라 MinIO, Plane, Outline 등 여러 서비스가 각기 다른 포트에서 tls: {}를 사용하고 있었다.
따라서 서비스별 Secret을 모두 붙이는 대신, Traefik의 default certificate 자체를 하나로 고정하는 방식을 선택했다.
1. 접근 가능한 모든 Node IP와 MetalLB VIP를 SAN에 포함
실제 IP는 공개 글에서 redacted 처리한다.
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout traefik-default.key \
-out traefik-default.crt \
-subj "/CN=k3s-traefik-local" \
-addext "subjectAltName=IP:[REDACTED_NODE_IP_1],IP:[REDACTED_NODE_IP_2],IP:[REDACTED_NODE_IP_3],IP:[REDACTED_NODE_IP_4],IP:[REDACTED_NODE_IP_5],IP:[REDACTED_NODE_IP_6],IP:[REDACTED_NODE_IP_7],IP:[REDACTED_NODE_IP_8],IP:[REDACTED_VIP]"
중요한 점은 CN이 아니라 subjectAltName이다. IP로 직접 접속할 경우 SAN에 IP:<address>가 들어가야 한다.
2. Traefik namespace에 TLS Secret 생성
K3s 기본 Traefik은 보통 kube-system namespace에 있다.
kubectl -n kube-system delete secret traefik-default-tls --ignore-not-found
kubectl -n kube-system create secret tls traefik-default-tls \
--cert=traefik-default.crt \
--key=traefik-default.key
3. TLSStore default certificate 설정
apiVersion: traefik.io/v1alpha1
kind: TLSStore
metadata:
name: default
namespace: kube-system
spec:
defaultCertificate:
secretName: traefik-default-tls
적용한다.
kubectl apply -f traefik-default-tlsstore.yaml
이후 기존 IngressRoute들은 다음처럼 tls: {}를 유지할 수 있다.
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: rocketchat
namespace: rocketchat
spec:
entryPoints:
- rocketchat
routes:
- match: PathPrefix(`/`)
kind: Rule
services:
- name: rocketchat
port: 3000
scheme: http
tls: {}
검증
인증서가 더 이상 TRAEFIK DEFAULT CERT로 나오지 않는지 확인한다.
for ip in [REDACTED_NODE_IP_1] [REDACTED_NODE_IP_2] [REDACTED_NODE_IP_3] [REDACTED_NODE_IP_4] [REDACTED_NODE_IP_5] [REDACTED_NODE_IP_6] [REDACTED_NODE_IP_7] [REDACTED_NODE_IP_8] [REDACTED_VIP]; do
echo "=== $ip ==="
echo | openssl s_client -connect ${ip}:30000 -servername ${ip} 2>/dev/null \
| openssl x509 -noout -subject -issuer -fingerprint -sha256 -ext subjectAltName
done
기대하는 결과는 다음과 같다.
subject=CN=k3s-traefik-local
issuer=CN=k3s-traefik-local
SHA256 Fingerprint=...
X509v3 Subject Alternative Name:
IP Address:[REDACTED_NODE_IP_1], IP Address:[REDACTED_NODE_IP_2], ...
검증 포인트는 두 가지다.
| 검증 항목 | 기대 결과 |
|---|---|
subject / issuer | CN=TRAEFIK DEFAULT CERT가 아니어야 함 |
| fingerprint | NodePort 경로와 MetalLB VIP 경로에서 동일해야 함 |
self-signed 인증서를 사용하면 Chrome에서 NET::ERR_CERT_AUTHORITY_INVALID는 여전히 한 번 뜰 수 있다. 하지만 Traefik Pod마다 다른 기본 인증서를 내보내는 상황이 아니므로, 같은 인증서에 대해 한 번만 예외를 승인하면 된다.
경고 자체를 없애려면 내부 Root CA를 만들고, 그 CA를 Windows 또는 브라우저 trust store에 등록한 뒤, 해당 CA로 Traefik default certificate를 발급해야 한다.
NodePort와 MetalLB를 병행하다가 MetalLB only로 전환
초기에는 다음과 같이 병행할 수 있다.
NodePort:
[REDACTED_NODE_IP]:30000 -> Traefik
MetalLB:
[REDACTED_VIP]:30000 -> Traefik
나중에 NodePort를 비활성화하고 MetalLB만 남기려면, traefik-lb.yaml은 유지하고 HelmChartConfig에서 Traefik 기본 Service를 NodePort에서 ClusterIP로 바꾼다.
예시는 다음과 같다.
spec:
valuesContent: |-
service:
type: ClusterIP
ports:
rocketchat:
port: 30000
exposedPort: 30000
expose:
default: true
# nodePort 제거
이때 ports.<name>.port는 제거하면 안 된다. 이것은 Traefik Pod가 listen하는 entryPoint 포트이기 때문이다.
반대로 nodePort는 제거한다.
# 제거 대상
nodePort: 30000
MetalLB용 Service는 계속 유지한다.
apiVersion: v1
kind: Service
metadata:
name: traefik-lb
namespace: kube-system
spec:
type: LoadBalancer
allocateLoadBalancerNodePorts: false
externalTrafficPolicy: Local
selector:
app.kubernetes.io/name: traefik
app.kubernetes.io/instance: traefik-kube-system
ports:
- name: rocketchat
protocol: TCP
port: 30000
targetPort: rocketchat
전환 후 기대 상태는 다음과 같다.
traefik ClusterIP <cluster-ip> <none>
traefik-lb LoadBalancer <cluster-ip> [REDACTED_VIP]
확인 명령은 다음과 같다.
kubectl -n kube-system get svc traefik traefik-lb -o wide
kubectl -n kube-system get svc traefik -o yaml | grep -i nodePort
kubectl -n kube-system get svc traefik-lb -o yaml | grep -i nodePort
traefik-lb에는 allocateLoadBalancerNodePorts: false를 두면 LoadBalancer Service가 별도의 NodePort를 만들지 않는다.
포트 충돌 기준
ClusterIP 전환 과정에서 port, exposedPort, nodePort의 충돌 범위를 구분해야 한다.
| 항목 | 서로 겹쳐도 되는가 | 기준 |
|---|---|---|
서로 다른 ClusterIP Service의 port | 가능 | Service마다 ClusterIP가 다름 |
같은 Service 안의 port | 불가 | 같은 Service의 port + protocol은 유일해야 함 |
nodePort | 불가 | 클러스터 전체 NodePort 범위에서 유일해야 함 |
Traefik entryPoint port | 불가 | 같은 Traefik Pod/프로세스 안에서 같은 TCP port를 두 번 listen할 수 없음 |
같은 VIP의 LoadBalancer port | 불가 | 같은 VIP에서 같은 port/protocol을 중복 노출할 수 없음 |
즉, 일반적인 ClusterIP Service끼리는 같은 port를 써도 된다.
service-a.default.svc.cluster.local:30000
service-b.default.svc.cluster.local:30000
하지만 같은 Traefik 인스턴스의 custom entryPoint는 서로 다른 포트를 써야 한다.
ports:
rocketchat:
port: 30000
exposedPort: 30000
minio:
port: 30001
exposedPort: 30001
plane:
port: 30002
exposedPort: 30002
outline:
port: 30003
exposedPort: 30003
운영 기준 정리
이번 구성에서 정리한 기준은 다음과 같다.
| 결정 | 이유 |
|---|---|
| Traefik Pod는 추가로 띄우지 않음 | 기존 DaemonSet Traefik을 그대로 재사용 |
MetalLB용 traefik-lb Service만 추가 | NodePort와 VIP 노출을 병행하기 위해 |
targetPort는 entryPoint 이름 또는 포트로 지정 | IngressRoute 이름이 아님 |
transport.respondingTimeouts는 HelmChartConfig에 유지 | Service 설정이 아니라 Traefik entryPoint 설정 |
tls: {} 다수 사용 환경에서는 TLSStore default certificate 사용 | IP 직접 접근과 PathPrefix 라우팅에서 default cert 문제를 줄이기 위해 |
| MetalLB only 전환 시 기존 Traefik Service는 ClusterIP로 변경 | NodePort 외부 노출 제거 |
ports.<name>.port는 유지 | Traefik entryPoint 자체가 필요하기 때문 |
이 구조를 사용하면 기존 NodePort 운영을 유지한 상태에서 MetalLB VIP를 단계적으로 검증할 수 있다. 이후 VIP 경로가 안정화되면 NodePort를 제거하고 MetalLB only 구조로 전환할 수 있다.