
최근에 인터넷 기사에 대한 크롤링 솔루션 관련하여 개발을 진행 중에 있습니다. MySQL 및 S3을 통해 모든 크롤링 데이터 및 기사 원문을 저장하고 관리하였습니다만, 크롤링 되는 기사 수가 늘어남에 따라 더 확장성있는 아키텍처를 고민했습니다.
하루에 만 건 ~ 수십 만 건의 기사 데이터가 크롤링 되고 이를 저장하고 검색함에 있어서 다양한 문제가 야기될 것이 예상되었습니다. 기존에는 각 기사에 대한 메타데이터를 MySQL에서 관리하고, 기사 원문은 MySQL의 PK를 S3키에 포함하여 S3에 저장하고 있었는데요, 각 기사 원문에 대한 접근은 초기에는 접근이 빈번하지만 시간이 지날수록 접근 빈도가 지수적으로 감소할 것이 예상되었기 때문에 S3 지능형 관리나 Glacier를 통해 비용 문제를 해소하고자 했습니다.
이 구조는 초기에는 단순하고 확장성 있으면서 비용효율적인 접근으로 보였지만, 팀 내 논의 끝에 데이터가 급격히 증가할 경우, 기사 원문 검색 소요가 발생할 경우 문제가 발생할 것이 예상되었습니다. 저희 서비스는 하루 수만 건에서 수십만 건에 달하는 인터넷 기사 데이터를 크롤링하고 있으며, 이는 비가역적으로 누적되어 폭발적인 데이터 증가를 초래할 것이 자명했습니다.
| 구분 | 문제점 | 영향 및 결과 |
|---|---|---|
| 검색 성능 | S3 기반 비효율적인 전문 검색 기사 원문 검색 시 MySQL 조회 후 S3에서 개별 원문을 가져오는 N+1 네트워크 홉 발생. | 실시간 검색 불가 및 치명적인 성능 저하 초래. |
| 운영 복잡성 | 분산 환경에서의 이중 쓰기 및 정합성 문제 | 기사 등록/수정 시 MySQL과 S3 두 시스템에 모두 쓰기 작업이 필요하여, 데이터 원자성(Atomicity) 보장 로직 및 유지보수 복잡도가 극도로 증가. |
이를 해결하기 위해 ElasticSearch 도입을 결정하게 되었는데요, 자세한 의사결정 과정은 아래에 첨부합니다.
| 시스템 | 역할 | 장점 |
|---|---|---|
| MySQL (RDBMS) | 메타데이터 관리 | 기사 제목, URL, 크롤링 시간 등 정형 데이터의 정확한 저장 및 관리(Transaction). |
| Elasticsearch | 기사 원문 저장 및 검색 엔진 | 기사 원문 전문에 대한 실시간, 고성능 검색 및 필터링 제공. S3 연동 비효율성 해소. |
Elasticsearch를 도입하면서 MySQL과 Elasticsearch 간의 쓰기 원자성(atomicity) 보장은 자연스럽게 새로운 고민거리가 되었습니다. 하나의 기사를 등록하거나 수정할 때, MySQL(정형 데이터)과 Elasticsearch(검색 인덱스)가 서로 다른 저장소에 쓰기 작업을 수행하기 때문입니다. 이 과정에서 어느 한쪽의 쓰기가 실패하면 일시적으로 검색 결과와 실제 데이터가 불일치할 수 있습니다.
이 문제를 해결하기 위한 전통적인 접근 방식은 트랜잭셔널 아웃박스(Outbox) 패턴이나 CDC(Change Data Capture) 기반의 이벤트 스트리밍 구조를 도입하는 것입니다. 이 두 방식 모두 "최종 일관성(Eventual Consistency)" 철학에 기반하며, 시스템의 복잡성을 높이는 대신 데이터 유실이나 순서 역전을 안전하게 방지합니다.
하지만 저희 시스템은 다음과 같은 이유로 Outbox 패턴을 도입하지 않기로 결정했습니다.
기사 원문은 핵심 트랜잭션 데이터가 아니다.
기사 본문은 MySQL에 직접 저장되지 않고, 외부 URL을 통해 언제든 접근할 수 있습니다.
즉, Elasticsearch 인덱스에 일시적인 누락이 발생하더라도, 실제 원문은 언제든 URL을 통해 재조회 가능합니다.
따라서 ES 인덱싱 실패가 서비스의 핵심 트랜잭션 무결성에 영향을 주지 않습니다.
메시지 유실은 드문 이벤트이며, 유실 시 영향이 제한적이다.
SQS를 통한 비동기 메시징은 이미 높은 신뢰성을 제공하며,
“메시지 유실 + 해당 기사 원문이 꼭 필요한 케이스”는 극히 드물다는 점을 팀에서 실측했습니다.
따라서 메시지 유실을 0%로 줄이기 위해 Outbox를 도입하는 것은 복잡도 대비 실익이 낮다고 판단했습니다.
복잡도와 운영비용 증가
Outbox를 도입하면 트랜잭션 관리, 이벤트 중복 방지(Idempotency), 재시도 로직, DLQ(Dead Letter Queue) 모니터링 등
추가적인 운영 비용이 발생합니다. 이는 시스템 복잡성을 높이고 장애 복구 경로를 늘리는 결과로 이어집니다.
저희의 뉴스 크롤링 시스템은 트랜잭션 중심의 금융·결제 서비스가 아니기 때문에,
이런 수준의 강한 일관성을 추구할 필요는 없다고 판단했습니다.
이에 따라 저희는 단순화된 비동기 동기화 전략을 채택했습니다.
결과적으로, 저희의 선택은 완벽한 원자성 보장보다는 단순성과 비용 효율성에 초점을 맞춘 현실적 접근이었습니다. Elasticsearch는 본질적으로 “정합성보다 가용성과 검색 성능에 초점이 맞춰진 시스템”이기 때문에, 이번 결정은 Elasticsearch의 설계 철학(High Availability & Eventual Consistency) 에도 부합하는 선택이라 할 수 있습니다.
이 포스트에서는 ECK(Elastic Cloud on Kubernetes) 를 이용해 Kubernetes 상에서 Elasticsearch + Kibana 클러스터를 안정적으로 배포하고, Gateway API + cert-manager + Let’s Encrypt를 통해 HTTPS로 외부에 노출하는 전 과정을 정리합니다.
ECK는 Elastic에서 공식 제공하는 오퍼레이터로, CRD(Custom Resource Definition)를 통해 Elasticsearch, Kibana, Beats, APM Server 등을 Kubernetes 네이티브하게 관리할 수 있도록 돕습니다.
관련 내용은 여기에서 확인하실 수 있습니다.
💡이 글은 다음 선행 지식을 요구합니다: 쿠버네티스 기초 지식, Operator Pattern, CRD, Gateway API, TLS(for HTTPS)
ECK는 Helm을 통해 간단히 배포할 수 있습니다.
ECK는 K8s의 오퍼레이터 패턴을 기반으로 동작하기 때문에, CRD와 operator를 먼저 설치해야합니다.
helm repo add elastic https://helm.elastic.co && helm repo update
# CRD 설치
helm install eck-crds elastic/eck-operator-crds -n elasticsearch --create-namespace
# Operator 설치
helm install eck-operator elastic/eck-operator -n elasticsearch
ECK Operator는 StatefulSet 형태로 배포되며, 내부적으로 elastic-operator라는 Pod가 생성됩니다. 이 Operator는 Elasticsearch, Kibana, Beat 등 Custom Resource를 감시하며, 스펙에 맞춰 StatefulSet, Secret 등을 자동 생성합니다.

helm install elasticsearch elastic/eck-stack -n elasticsearch -f elastic-stack.yaml
위 명령어를 통해 eck-stack 을 helm으로 설치 및 배포합니다.
제가 사용한 elastic-stack.yaml 파일은 아래와 같습니다.
elastic-stack.yaml
eck-elasticsearch:
http:
tls:
selfSignedCertificate:
disabled: true
nodeSets:
# 1) Master 전용 노드셋 (3개)
- name: masters
count: 3
config:
node.roles: ["master"]
node.store.allow_mmap: false
podTemplate:
spec:
containers:
- name: elasticsearch
resources:
limits:
memory: 2Gi
cpu: 1
volumeClaimTemplates:
- metadata:
name: elasticsearch-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: nfs-client
resources:
requests:
storage: 1Gi
# 2) Data + Ingest 노드셋 (3개)
- name: data
count: 3
config:
node.roles: ["data", "ingest"]
node.store.allow_mmap: false
podTemplate:
spec:
containers:
- name: elasticsearch
resources:
limits:
memory: 2Gi
cpu: 1
volumeClaimTemplates:
- metadata:
name: elasticsearch-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: nfs-client
resources:
requests:
storage: 60Gi
---
eck-kibana:
enabled: true
fullnameOverride: eck-eck-kibana
elasticsearchRef:
name: elasticsearch
http:
service:
spec:
type: ClusterIP
tls:
selfSignedCertificate:
disabled: true
이 설정으로 ECK는 다음을 자동 생성합니다.
.http.tls.selfSignedCertificate.disabled) -> Gateway API 단에서 TLS Termination을 해주기 위해 사용하였습니다. 만약 제로트러스트 환경을 구축하신다면 요구사항에 맞게 커스텀해주세요.
- 저는 테스트 목적이기도 하고 자체 노드의 자원 부족으로 널널한 worker01노드에 파드들이 집중되어있는데요, 실제로 가용성을 보장하기를 원하신다면
AntiPodAffinity를 사용해서 각 ElasticSearch 파드가 다른 노드에 위치하도록 해주시는 것이 좋습니다.- 저는 볼륨으로 NAS를 nfs로 사용했습니다. 적절한 pv로 교체해주세요.
더 다양한 커스터마이징을 하고 싶으시면 아래 명령어로 values.yaml을 확인해보세요.
# 최상위 값만 보여줍니다.
helm show values elastic/eck-stack
# eck-stack에는 eck-elasticsearch 및 eck-kibana가 포함되어 있습니다.
# 자세한 값을 보려면 아래 명령어를 입력합니다.
helm show values elastic/eck-elasticsearch
helm show values elastic/eck-kibana
cert-manager Helm 설치
helm install cert-manager jetstack/cert-manager \
-n cert-manager --create-namespace \
-f cert-manager-values.yaml
위 명령어로 cert-manager를 설치해줍니다. 저는 kibana 및 Elasticsearch를 Gateway API로 내보내기 위해 --enable-gateway-api옵션을 주었습니다.
사용한 cert-manager-values.yaml파일은 다음과 같습니다.
cert-manager-values.yaml
crds:
enabled: true
extraArgs:
- --enable-gateway-api=true
ClusterIssuer 생성(cluster-issuer.yaml)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt
spec:
acme:
email: example@example.com
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-account-key
solvers:
- http01:
gatewayHTTPRoute:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: ingress-gw
namespace: infra
sectionName: kibana-http-80
selector:
dnsNames:
- kibana.example.com
- http01:
gatewayHTTPRoute:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: ingress-gw
namespace: infra
sectionName: elasticsearch-http-80
selector:
dnsNames:
- elasticsearch.example.com
kubectl apply -f cluster-issuer.yaml
본 포스팅에서는 http01을 사용했습니다. 와일드 카드 등을 사용하실 분들은 dns01을 고려해주세요.
selector.dnsNames는 kibana와 Elasticsearch를 구분하기 위해 반드시 필요합니다.
gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: ingress-gw
namespace: infra
spec:
gatewayClassName: cilium
listeners:
- name: kibana-http-80 # <-- 추후 httpRoute에서 sectionName으로 사용됨
hostname: "kibana.example.com"
protocol: HTTP
port: 80
- name: kibana-https-443
hostname: "kibana.example.com"
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- name: kibana-example-com-tls
kind: Secret
- name: elasticsearch-http-80
hostname: "elasticsearch.example.com"
protocol: HTTP
port: 80
- name: elasticsearch-https-443
hostname: "elasticsearch.example.com"
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- name: elasticsearch-example-com-tls
kind: Secret
kubectl apply -f gateway.yaml
⭐️저는 CNI로 cilium을 사용하여 gateway API도 cilium을 지정했습니다, 사용하시는 gateway class로 gatewayClassName을 교체해주세요. (nginx, istio 등)
routes-kibana.yaml
# (A) HTTPS 요청을 Kibana Service로 프록시
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: kibana-https-route
namespace: elasticsearch
spec:
parentRefs:
- name: ingress-gw
namespace: infra
sectionName: kibana-https-443
hostnames:
- "kibana.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: eck-eck-kibana-kb-http
port: 5601
---
# (B) HTTP -> HTTPS 리다이렉트 (⚠️ 인증서 Ready 후 적용)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: kibana-route
namespace: elasticsearch
spec:
parentRefs:
- name: ingress-gw
namespace: infra
sectionName: kibana-http-80
hostnames:
- "kibana.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /
filters:
- type: RequestRedirect
requestRedirect:
scheme: https
port: 443
routes-elasticsearch.yaml
# (A) HTTPS 요청을 Elasticsearch Service로 프록시
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: elasticsearch-https-route
namespace: elasticsearch
spec:
parentRefs:
- name: ingress-gw
namespace: infra
sectionName: elasticsearch-https-443
hostnames:
- "elasticsearch.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: elasticsearch-es-http
port: 9200
---
# (B) HTTP -> HTTPS 리다이렉트 (⚠️ 인증서 Ready 후 적용)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: elasticsearch-route
namespace: elasticsearch
spec:
parentRefs:
- name: ingress-gw
namespace: infra
sectionName: elasticsearch-http-80
hostnames:
- "elasticsearch.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /
filters:
- type: RequestRedirect
requestRedirect:
scheme: https
port: 443
kubectl apply -f routes-kibana.yaml
kubectl apply -f routes-elasticsearch.yaml
⛔⛔⛔⛔⛔️ 위 HTTPRoutes에는 443, 80 포트가 모두 명시되어 있는데요, 80포트는 443으로 리다이렉션 하기 위한 규칙이지만, 위 규칙이 적용될 경우 Let's Encrypt가 80포트로 접근할때 443으로 리다이렉션이 되면서 정상적인 인증을 받지 못합니다. 따라서 80포트는 잠시 비활성화해주시고, 모든 작업이 끝난 후 다시 적용하셔야합니다.
kubectl delete httproute kibana-route -n elasticsearch kubectl delete httproute elasticsearch-route -n elasticsearch
certs-infra-kibana.yaml / certs-infra-elasticsearch.yaml
# Kibana
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: kibana-example-com-cert
namespace: infra
spec:
secretName: kibana-example-com-tls
issuerRef:
name: letsencrypt
kind: ClusterIssuer
dnsNames:
- kibana.example.com
# Elasticsearch
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: elasticsearch-example-com-cert
namespace: infra
spec:
secretName: elasticsearch-example-com-tls
issuerRef:
name: letsencrypt
kind: ClusterIssuer
dnsNames:
- elasticsearch.example.com
kubectl apply -f certs-infra-kibana.yaml
kubectl apply -f certs-infra-elasticsearch.yaml
# Order/Challenge 상태 확인
kubectl get orders,challenges -A
# Certificate Ready 확인
kubectl -n infra get certificate kibana-example-com-cert -o wide
kubectl -n infra get certificate elasticsearch-example-com-cert -o wide
# Secret 생성 확인
kubectl -n infra get secret kibana-example-com-tls
kubectl -n infra get secret elasticsearch-example-com-tls
# 최종 L7 확인 (인증 전)
curl -v http://kibana.example.com/.well-known/acme-challenge/test # 200 또는 solver 경로 확인
# 인증 후 (리다이렉트 라우트 적용 전/후)
curl -I https://kibana.example.com
curl -I http://kibana.example.com # 인증 후 리다이렉트 적용 시 301/302 기대
# elastic 사용자 비밀번호 확인
kubectl get secret elasticsearch-es-elastic-user -n elasticsearch -o go-template='{{.data.elastic | base64decode}}'