Back to DevOps

DevOps

K3s Traefik: NodePort와 MetalLB VIP 병행 노출

K3s Traefik에서 기존 NodePort 노출을 유지하면서 MetalLB VIP를 추가하고, tls: {} 기본 인증서 문제를 TLSStore로 정리한 운영 기록

Published
Updated
Area
Networking
Type
troubleshooting
Series
K3s Homelab
Category
DevOps
k3straefikmetallbnodeportloadbalancertlstlsstoreingressroute

상황

기존 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>.portTraefik Pod 내부에서 실제로 listen하는 entryPoint 포트
ports.<name>.exposedPortHelm chart가 만드는 Traefik Service의 spec.ports[].port에 가까운 값
ports.<name>.nodePortNodePort Service에서 Node IP에 직접 열리는 포트
entryPointsIngressRoute가 어떤 Traefik entryPoint로 들어온 요청을 받을지 지정
targetPortKubernetes 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는 어디에 두는가

기존 HelmChartConfigtransport.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로 확인했을 때 subjectissuer가 다음처럼 나왔다.

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 / issuerCN=TRAEFIK DEFAULT CERT가 아니어야 함
fingerprintNodePort 경로와 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 구조로 전환할 수 있다.