시작하며
기존 프로젝트는 OCI 위에 k3s 클러스터를 구성해 운영하고 있습니다. 클러스터는 master 1대와 worker 2대로 구성했고 nginx ingress controller와 Spring Boot 애플리케이션은 worker 노드에서 실행하는 형태로 운영할 계획이었습니다. 어느 날 deployment.yml의 probe 설정을 수정한 뒤 애플리케이션을 다시 배포했습니다. GitHub Actions는 정상적으로 완료되었고 Pod도 Running 상태였습니다. 하지만 외부에서 로그인을 시도하면 API 요청이 504 Gateway Timeout으로 실패했습니다. 처음에는 Cloudflare나 OCI Load Balancer 문제를 의심했습니다. 하지만 요청 경로를 하나씩 확인하면서 문제는 외부 인프라가 아니라 클러스터 내부에서 nginx ingress controller가 Spring Boot Pod까지 도달하지 못하는 구간에 있다는 것을 확인했습니다. 이번 글에서는 504가 발생한 지점을 어떻게 좁혀갔는지, 그리고 Deployment 재생성이 왜 장애로 이어졌는지 정리해보겠습니다.
1. probe 설정을 수정한 뒤 시작된 문제
문제의 시작은 livenessProbe 설정이었습니다. 기존 Deployment에는 tcpSocket과 httpGet이 동시에 설정되어 있었습니다.
livenessProbe:
tcpSocket:
port: 8080
httpGet:
path: /actuator/health
port: 8080
Kubernetes probe는 httpGet, tcpSocket, exec 중 하나의 방식만 사용할 수 있습니다. 따라서 httpGet 방식만 사용하도록 수정했습니다.
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 5
readinessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 5
당시에는 기존 Deployment를 삭제한 뒤 다시 적용했습니다.
kubectl delete deployment scenepick-app
kubectl apply -f deployment.yml
수정 후 Deployment와 Pod는 정상적으로 생성되었습니다. 하지만 외부 요청은 여전히 504로 실패했고 로그인도 되지 않았습니다. 처음에는 probe 설정이 잘못 적용된 것은 아닌지 의심했습니다. 하지만 이후 확인해보니 probe 자체의 문제는 이미 해결된 상태였습니다. 실제 장애는 Deployment를 다시 생성하는 과정에서 Spring Boot Pod의 스케줄링 위치가 달라지면서 시작되었습니다.
2. 504가 발생한 위치를 좁혀보기
먼저 애플리케이션 자체가 정상적으로 동작하는지 확인했습니다.
curl -H "Host: api.scenepick.co.kr" http://IP
응답은 다음과 같이 반환되었습니다.
{
"isSuccess": false,
"code": "COMMON401",
"message": "인증이 필요합니다."
}
인증 정보 없이 접근했기 때문에 401 응답이 내려온 것이며 Spring Boot 애플리케이션 자체는 요청을 정상적으로 처리하고 있었습니다. 따라서 애플리케이션 로직이나 프로세스 문제보다는 애플리케이션까지 요청이 전달되는 과정에 문제가 있다고 판단했습니다.다음으로 nginx ingress controller 내부에서 Spring Boot Pod IP로 직접 요청을 보내봤습니다.
kubectl exec -n ingress-nginx <NGINX_CONTROLLER_POD> -- \
curl --max-time 5 http://<SPRING_BOOT_POD_IP>:8080
<SPRING_BOOT_POD_IP>는 다음 명령으로 확인한 현재 Pod의 IP입니다.
kubectl get pod -o wide
Pod IP는 재배포 시 변경될 수 있으므로 운영용 주소가 아니라, 현재 Pod까지 직접 통신이 가능한지 확인하기 위한 진단 용도로 사용했습니다. 결과는 timeout이었습니다
curl: 28 Operation timed out
외부에서는 504가 발생하고 있었고 ingress controller 내부에서도 backend Pod로 직접 접근하지 못하고 있었습니다. 이 결과를 통해 Cloudflare나 OCI Load Balancer보다 더 안쪽인 ingress controller와 backend Pod 사이의 통신 구간에 문제가 있다는 것을 확인했습니다.
3. Pod는 Running이었지만 위치가 달라져 있었습니다
다음으로 Pod 위치를 확인했습니다
kubectl get pod -o wide
처음에는 STATUS만 확인했지만 이번에는 NODE 컬럼까지 함께 확인했습니다. Spring Boot Pod가 worker 노드가 아니라 master 노드에 스케줄링되어 있었습니다. 기존에는 다음과 같은 요청 경로를 기대하고 있었습니다.
Client
↓
Cloudflare
↓
OCI Load Balancer
↓
nginx ingress controller (worker1)
↓
Spring Boot Pod (worker)
하지만 장애 당시 실제 요청 경로는 다음과 같이 바뀌어 있었습니다.
Client
↓
Cloudflare
↓
OCI Load Balancer
↓
nginx ingress controller (worker1)
↓
Spring Boot Pod (master)
Deployment에는 애플리케이션 Pod가 worker 노드에 배치되어야 한다는 조건이 명시되어 있지 않았습니다. 따라서 Deployment를 삭제하고 새로 생성했을 때 Kubernetes scheduler는 master 노드 역시 배치 가능한 대상으로 판단했고 Spring Boot Pod가 master에 스케줄링되었습니다. 다만 Pod가 master에 배치되었다는 사실만으로 장애가 발생해야 하는 것은 아닙니다. 정상적인 클러스터 네트워크라면 worker1의 ingress controller에서 master에 있는 Pod로도 통신할 수 있어야 합니다. 따라서 다음으로 worker와 master 사이의 네트워크 경로를 확인했습니다.
4. master 노드의 iptables에서 REJECT 규칙을 발견했습니다
master 노드의 INPUT chain을 확인했습니다.
sudo iptables -L INPUT --line-numbers
확인 결과 마지막에 REJECT all 규칙이 존재하고 있었습니다. 처음에는 앞쪽에 ACCEPT 규칙이 있었기 때문에 모든 트래픽이 이미 허용되는 것처럼 보였습니다. 하지만 iptables-save 결과를 확인해보니 해당 ACCEPT 규칙은 모든 트래픽을 허용하는 규칙이 아니라 loopback 인터페이스(자기 자신에 대한 통신) 에 대한 허용 규칙이었습니다. worker1에서 master로 들어오는 트래픽은 이 규칙에 매칭되지 않았고 SSH나 Kubernetes API Server 등 앞쪽에서 명시적으로 허용된 조건에도 해당하지 않았습니다. 결과적으로 해당 트래픽은 마지막 REJECT all 규칙까지 도달할 수 있었습니다.
서비스를 우선 복구하기 위해 해당 규칙을 제거했습니다.
sudo iptables -D INPUT 12
이후 nginx ingress controller에서 Spring Boot Pod로 접근할 수 있게 되었고 외부 504도 해소되었습니다.
5. kube-router가 직접 원인이었을까
iptables를 추가로 확인하는 과정에서 KUBE-ROUTER 계열 체인도 남아 있는 것을 발견했습니다.
sudo iptables -L | grep KUBE-ROUTER
하지만 현재 클러스터에는 kube-router Pod가 존재하지 않았습니다.
kubectl get pods -A | grep kube-router
따라서 kube-router 관련 규칙이 남아 있었던 것은 확인했지만 현재 실행 중인 kube-router가 직접 REJECT all 규칙을 생성했다고 단정할 수는 없었습니다. 이번 장애에서 확실하게 확인한 사실은 다음과 같습니다.
- master 노드의 INPUT chain에 REJECT all 규칙이 있었습니다.
- worker1에서 master의 Spring Boot Pod로 접근할 수 없었습니다.
- 해당 REJECT 규칙 제거 후 통신이 정상화되었습니다.
- 외부 504와 로그인 오류도 함께 해결되었습니다.
6. REJECT 규칙만 제거하면 충분할까
REJECT 규칙을 제거하면서 서비스는 정상화되었습니다. 하지만 이것만으로는 같은 장애가 다시 발생할 가능성이 있었습니다. 이번 문제를 가능하게 만든 첫 번째 조건은 Spring Boot Pod가 master 노드에 배치된 것이었습니다. 이 클러스터에서는 master를 control-plane 용도로 사용하고 애플리케이션 워크로드는 worker 노드에서 실행하는 구조로 운영하려고 했습니다. 그렇다면 이 정책을 Kubernetes에도 명시적으로 알려줄 필요가 있었습니다. 한 가지 방법은 Deployment에 nodeSelector를 지정하는 것입니다.
spec:
template:
spec:
nodeSelector:
node-role: worker
또 다른 방법은 master 노드에 taint를 설정해 일반 워크로드가 배치되지 않도록 하는 것입니다.
kubectl taint nodes master-node node-role.kubernetes.io/control-plane=:NoSchedule
현재 taint 상태는 다음 명령으로 확인할 수 있습니다.
kubectl describe node master-node
이번에는 master 노드를 control-plane 전용으로 운영하려는 목적이 있었기 때문에 master 노드에 taint를 적용했습니다. 이후 새로 생성되는 Spring Boot Pod가 worker 노드에 배치되는 것을 확인했습니다.
9. Deployment를 삭제하는 방식도 다시 보게 되었습니다
이번 장애 이후에는 설정을 변경할 때 Deployment를 바로 삭제하는 방식을 사용하지 않기로 했습니다. 일반적인 설정 변경이라면 다음과 같이 적용할 수 있습니다.
kubectl apply -f deployment.yml
Pod를 다시 시작해야 한다면 다음 방식도 사용할 수 있습니다.
kubectl rollout restart deployment/scenepick-app
kubectl delete deployment는 단순히 기존 Pod를 재시작하는 것이 아닙니다. Deployment와 ReplicaSet, Pod가 삭제되고 새로운 Pod가 다시 스케줄링됩니다. 스케줄링 조건이 없다면 기존에는 worker에 있던 Pod가 재생성 이후 master에 배치될 수도 있습니다. 따라서 배포 이후에는 Pod가 생성되었는지만 확인하지 않고 실제 배치 위치도 함께 확인하도록 했습니다.
kubectl get pod -o wide
10. 이번 장애에서 확인한 배포 점검 포인트
kubectl get pod -o wide
배포 이후에는 Pod 상태뿐만 아니라 배치 위치도 함께 확인하는 것이 필요했습니다. 특히 여러 노드로 구성된 환경에서는 STATUS와 함께 NODE 컬럼을 확인해야 했습니다. 또한 master 노드를 control-plane 용도로만 사용하려면 일반 워크로드가 master에 배치되지 않도록 스케줄링 조건을 명시해야 합니다. 현재 노드의 라벨은 다음과 같이 확인할 수 있습니다.
kubectl get node --show-labels
master 노드의 taint 상태는 다음 명령으로 확인할 수 있습니다.
kubectl describe node master-node
이번 장애에서는 외부에서 504가 발생했지만 실제 문제는 Cloudflare나 OCI Load Balancer가 아니라 ingress controller와 backend Pod 사이에 있었습니다. 따라서 504와 같은 문제가 발생했을 때는 요청 경로를 한 번에 보지 않고 구간별로 나누어 확인하는 것이 중요했습니다. 이번에는 다음 순서로 문제를 좁혔습니다.
- 애플리케이션이 직접 요청에 응답하는지 확인합니다.
- ingress controller에서 backend Pod로 접근할 수 있는지 확인합니다.
- Service와 Endpoint가 정상인지 확인합니다.
- Pod가 어느 노드에 배치되어 있는지 확인합니다.
- 노드 사이의 통신이 가능한지 확인합니다.
- iptables에 트래픽을 차단하는 규칙이 있는지 확인합니다.
Service와 Endpoint는 다음과 같이 확인할 수 있습니다.
kubectl get svc
kubectl get endpoints
kubectl exec -n ingress-nginx <NGINX_CONTROLLER_POD> -- \
curl --max-time 5 http://<SPRING_BOOT_POD_IP>:8080
iptables 규칙은 다음과 같이 확인했습니다.
sudo iptables -L INPUT --line-numbers
sudo iptables -L FORWARD --line-numbers
sudo iptables-save | grep REJECT
또 하나 확인하고 싶었던 것은 REJECT 규칙이 언제 생겼는지였습니다. 과거 master 노드에서 iptables를 초기화한 적이 있었습니다.
sudo iptables-save > iptables.rules
sudo iptables -F
sudo iptables -X
sudo iptables -Z
sudo systemctl restart k3s
iptables -F, iptables -X, iptables -Z는 기존 규칙을 삭제하거나 카운터를 초기화하는 명령이기 때문에 이 작업 자체가 REJECT all 규칙을 새로 생성한 것은 아닙니다. 또한 해당 작업 이후에는 서비스가 정상적으로 동작했습니다. 다만 이후 OCI 인스턴스가 인프라 점검 과정에서 재부팅된 적이 있었습니다. 인스턴스가 다시 시작되면 k3s와 네트워크 관련 서비스도 함께 재시작되기 때문에 이 과정에서 iptables 상태가 달라졌을 가능성까지는 확인해볼 필요가 있었습니다. 하지만 장애 당시 남아 있던 정보만으로는 OCI 인스턴스 재부팅 과정에서 REJECT 규칙이 생성되었다고 단정할 수는 없었습니다.
마치며
결국 이번 장애는 단순한 iptables 설정 오류가 아닌 Pod 재스케줄링으로 요청 경로가 달라지면서 기존에 드러나지 않았던 네트워크 문제가 노출된 사례였습니다. 이번 경험을 통해 배포 결과를 확인할 때는 Pod가 Running인지뿐만 아니라 어느 노드에 배치되었는지와 요청이 실제 backend까지 정상적으로 전달되는지까지 함께 확인해야 한다는 점을 배웠습니다.
참조
https://www.netfilter.org/documentation/HOWTO/packet-filtering-HOWTO-7.html
'Troubleshooting' 카테고리의 다른 글
| [MyBatis] N+1 Query 문제 해결: JOIN 대신 Batch Select 전략으로 리뷰 조회 성능 개선하기 (0) | 2026.03.05 |
|---|---|
| [MyBatis/Oracle] ORA-17004: 열 유형이 부적합합니다 (TypeException) 해결 방법 (0) | 2026.03.04 |
| [Elasticsearch] AWS t3.micro에서 Elasticsearch 검색 쿼리 최적화하기 (0) | 2026.01.21 |
| [React] 빌드 무한 대기에서 초 단위 배포까지 (0) | 2026.01.17 |