🔄

Kubernetes + Jenkins + ArgoCD로 검색엔진을 포함한 블로그 CI/CD 파이프라인 구축기

최민석·2026-02-19

Kubernetes + Jenkins + ArgoCD로 검색엔진을 포함한 블로그 CI/CD 파이프라인 구축기

지금 이 글을 보고 있는 이 블로그는 온프레미스 Kubernetes 클러스터 위에서 돌아가고 있다.

사실 블로그 하나 띄우는 데 Kubernetes까지 필요하냐고 물으면, 솔직히 필요 없다. Vercel에 올리면 5분이면 끝난다. 근데 나는 이 블로그를 인프라 놀이터로 쓰고 싶었다. 코드를 push하면 이미지 빌드부터 배포, 검색 색인 갱신까지 자동으로 돌아가는 파이프라인을 직접 만들어보고 싶었고, 실제로 만들었다.

이 글에서는 이 파이프라인이 어떤 아키텍처로 구성되어 있고, 각 설계 결정에 어떤 이유가 있었는지를 실제 매니페스트와 함께 다뤄보려 한다.

전체 아키텍처

먼저 전체 그림을 보자.

flowchart LR subgraph src ["blog-new (main)"] SRC["src/\ncontent/\nJenkinsfile\nscripts/"] end subgraph manifests ["argocd-cluster (prod)"] MANIFEST["blog-new/base/\n• deployment.yaml\n• service.yaml\nroutes/blog-new/\n• httproute"] end src -->|"git push"| Jenkins["🔧 Jenkins\nPipeline"] Jenkins -->|"sed + commit & push"| manifests Jenkins -->|"PUT / DELETE"| ES["🔍 Elasticsearch"] manifests -->|"watch"| ArgoCD["🔄 ArgoCD"] ArgoCD -->|"sync"| K8s["☸ K8s Cluster\n(blog-new ns)"] K8s -->|"검색 API"| ES

핵심은 두 개의 Git 저장소가 역할을 분리한다는 것이다:

  • blog-new: 애플리케이션 소스코드. Next.js 앱, 마크다운 콘텐츠, Jenkinsfile, ES 색인 스크립트가 여기에 있다.
  • argocd-cluster: Kubernetes 매니페스트만 모아놓은 저장소. ArgoCD가 이 저장소의 prod 브랜치를 감시한다.

Jenkins가 이 둘을 이어주는 다리 역할을 하는데, 이 구조를 선택한 이유는 뒤에서 다시 설명하겠다.

클러스터 구성

비용 문제로 클라우드 대신 온프레미스 홈서버를 쓰고 있다. Proxmox 위에 Ubuntu VM 6대를 올려서 kubeadm으로 직접 구축한 클러스터다.

control-plane01~03  (10.0.0.101~103)  ── HA Control Plane
worker-node01~03    (10.0.0.111~113)  ── 워크로드 실행

네트워킹 스택은 이렇다:

  • CNI: Cilium (eBPF 기반, envoy 프록시 내장)
  • 로드밸런서: MetalLB (L2 모드, 외부 IP 10.0.0.200 할당)
  • 인그레스: Cilium Gateway API
  • TLS: cert-manager + Let's Encrypt 와일드카드 인증서

초기에는 Istio를 Gateway Controller로 사용하고 있었는데, CNI를 Cilium으로 교체하면서 Cilium이 자체적으로 Gateway API를 구현하고 있다는 것을 알게 되었다. Istio를 걷어내고 Cilium 하나로 CNI + Gateway Controller를 통합하니 관리 포인트가 확 줄었다. 개인 클러스터에서 Istio의 서비스 메시 기능이 실질적으로 필요하지 않았기 때문에 가능한 선택이었다.

Gateway API를 쓴 이유

Ingress 대신 Gateway API를 사용하고 있다. 처음에는 Ingress로 구성했지만, bare 도메인(minseoky.me)과 서브도메인(*.minseoky.me)을 각각 다른 리스너로 분리해야 하는 요구사항이 생기면서 Gateway API로 넘어갔다.

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: gw-public
  namespace: gateway
spec:
  gatewayClassName: cilium
  listeners:
    - name: https
      hostname: "*.minseoky.me"
      port: 443
      protocol: HTTPS
      tls:
        certificateRefs:
          - name: wildcard-minseoky-me-tls
        mode: Terminate
    - name: https-bare
      hostname: minseoky.me
      port: 443
      protocol: HTTPS
      tls:
        certificateRefs:
          - name: wildcard-minseoky-me-tls
        mode: Terminate
    # http 리스너도 동일한 구조로 분리...

Gateway API에서 *.minseoky.meminseoky.me 자체를 매칭하지 않는다. 이걸 처음에 몰라서 블로그에 접속이 안 되는데 argocd는 잘 되는 상황을 한참 디버깅했다. bare 도메인용 리스너를 따로 추가하면서 해결했다.

블로그의 라우팅은 이렇게 구성했다:

# HTTPS 트래픽 → blog-new 서비스 3000번 포트
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: blog-new
  namespace: blog-new
spec:
  parentRefs:
    - name: gw-public
      namespace: gateway
      sectionName: https-bare    # bare 도메인 리스너 참조
  hostnames:
    - minseoky.me
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: blog-new
          port: 3000

HTTP → HTTPS 301 리다이렉트용 HTTPRoute도 별도로 존재한다. 이 HTTPRoute 리소스들은 argocd-cluster 저장소의 routes/blog-new/ 디렉토리에서 관리된다.

GitOps 설계: 왜 저장소를 둘로 나눴나

처음에는 하나의 저장소에서 소스코드와 매니페스트를 함께 관리하려 했다. 근데 금방 문제를 느꼈다. Jenkins가 이미지 태그를 업데이트하고 커밋하면, 그 커밋이 다시 Jenkins 빌드를 트리거한다. 무한 루프다.

이를 피하기 위한 방법이 몇 가지 있지만 (커밋 메시지로 [skip ci]를 넣는다거나), 나는 저장소 자체를 분리하는 것이 더 깔끔하다고 판단했다:

  • blog-new (main): 소스코드 변경 → Jenkins 트리거
  • argocd-cluster (prod): 매니페스트 변경 → ArgoCD 트리거

이렇게 하면 Jenkins가 argocd-cluster에 push해도 blog-new의 빌드가 다시 돌지 않는다. 관심사가 명확하게 분리되는 셈이다.

App of Apps 패턴

ArgoCD는 App of Apps 패턴으로 구성했다. 최상위 addons Application이 하위 Application 리소스들을 자동으로 관리한다.

flowchart TD addons["addons App\nargocd/addons/apps/"] addons --> blog["blog-new App\nblog-new/base/"] addons --> routes["routes App\nroutes/"] addons --> jenkins["jenkins App\njenkinsroutes/"] addons --> etc["... 기타 Apps"] blog --> blogRes["Deployment\nService\nKustomize"] routes --> routeRes["HTTPRoute\n(blog, argocd, jenkins)"]

새 워크로드를 추가할 때 addons/apps/에 Application YAML 하나만 넣으면 ArgoCD가 알아서 인식한다. 편하다.

blog-new Application 정의는 이렇게 생겼다:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: blog-new
  namespace: argocd
spec:
  source:
    path: blog-new/base
    repoURL: https://github.com/minseoky/argocd-cluster.git
    targetRevision: prod
  destination:
    namespace: blog-new
    server: https://kubernetes.default.svc
  syncPolicy:
    automated:
      prune: true       # Git에서 삭제하면 클러스터에서도 삭제
      selfHeal: true     # kubectl로 직접 수정해도 Git 상태로 원복
    syncOptions:
      - CreateNamespace=true

selfHeal: true가 처음에는 좀 무서웠는데, 실제로 써보니 누군가 실수로 kubectl edit한 것을 자동으로 원복해주는 안전장치 역할을 한다. 개인 클러스터라 "누군가"라 하면 나 자신뿐이지만...

Jenkins 파이프라인

파이프라인은 총 5개 스테이지로 구성된다.

flowchart LR A["📥 Checkout"] --> B["📋 Read Version"] B --> C["🏗️ Build & Push\n(kaniko)"] C --> D["🚀 Deploy\n(git sed + push)"] D --> E["🔍 Reindex\n(ES delta)"]

Agent Pod 설계

Jenkins는 K8s 위에서 동작하며, 파이프라인 실행 시 동적으로 Agent Pod을 생성한다. 나는 3개의 사이드카 컨테이너를 정의했다:

containers:
- name: kaniko        # Docker 이미지 빌드 (10Gi ephemeral storage)
  image: gcr.io/kaniko-project/executor:debug
- name: git           # argocd-cluster 매니페스트 업데이트
  image: alpine/git:latest
- name: es-indexer    # Elasticsearch 증분 색인
  image: alpine:latest

각 스테이지에서 필요한 도구만 가진 컨테이너를 사용하는 구조다. kaniko에 debug 이미지를 사용한 이유는 이전 포스트에서 다뤘다.

Build & Push

container('kaniko') {
    sh '''
        mkdir -p /kaniko/.docker
        echo '{"auths":{"https://index.docker.io/v1/":{...}}}' > /kaniko/.docker/config.json
        /kaniko/executor \
            --context=dir://$(pwd) \
            --destination=$DOCKER_USER/blog-new:$VERSION \
            --destination=$DOCKER_USER/blog-new:latest
    '''
}

이전 포스트에서는 K8s Secret을 마운트하는 방식으로 Docker 인증을 처리했었다. 이번에는 Jenkins Credentials에서 받은 값으로 config.json을 직접 생성하는 방식을 택했다. K8s Secret을 따로 관리하지 않아도 되니 더 간단하다.

버전은 package.jsonversion 필드에서 읽어온다.

Deploy — GitOps의 핵심

이 스테이지가 전체 파이프라인에서 가장 중요하다. kubectl apply를 직접 실행하지 않는다.

container('git') {
    sh '''
        git clone https://$GIT_USER:$GIT_PASS@github.com/minseoky/argocd-cluster.git /tmp/argocd-cluster
        cd /tmp/argocd-cluster
        git checkout prod
        sed -i 's|image: minseoky/blog-new:.*|image: minseoky/blog-new:'"$VERSION"'|' blog-new/base/deployment.yaml
        TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
        sed -i 's|deploy-timestamp:.*|deploy-timestamp: "'"$TIMESTAMP"'"|' blog-new/base/deployment.yaml
        git config user.email "jenkins@minseoky.me"
        git config user.name "Jenkins"
        git add blog-new/base/deployment.yaml
        git commit -m "deploy: blog-new $VERSION"
        git push origin prod
    '''
}

argocd-cluster 저장소의 deployment.yaml에서 이미지 태그와 deploy-timestamp annotation을 sed로 갱신하고, commit & push한다. 이후는 ArgoCD가 알아서 처리한다.

실제로 argocd-cluster의 커밋 로그를 보면 Jenkins가 자동으로 만든 커밋들로 채워져 있다:

aa9c517 deploy: blog-new 0.2.5
967f4c1 deploy: blog-new 0.2.4
ebd1597 deploy: blog-new 0.2.3

여기서 짚고 넘어가야 할 부분이 하나 있다. 바로 deploy-timestamp다.

콘텐츠만 추가했을 때의 함정

파이프라인을 처음 만들고 한동안 잘 쓰다가, 어느 날 포스트만 하나 추가했는데 블로그에 반영이 안 되는 상황을 겪었다. 원인을 추적해보니 이랬다:

flowchart TD A["포스트만 추가\n(version 미변경)"] --> B["Jenkins: 이미지 빌드\nblog-new:0.2.5 push"] B --> C["Deploy: sed 실행\nblog-new:0.2.5 → blog-new:0.2.5"] C --> D["git diff → 변경 없음!"] D --> E["push 안 함"] E --> F["ArgoCD 변경 미감지\n롤아웃 안 됨 ❌"] style C fill:#f66,stroke:#333,color:#fff style F fill:#f66,stroke:#333,color:#fff

설상가상으로, imagePullPolicy를 명시하지 않은 태그 이미지의 기본값은 IfNotPresent다. 즉 같은 태그로 새 이미지를 push해도, 노드에 캐시된 이전 이미지를 그대로 쓴다. 반면 Reindex 스테이지는 git diff 기반이라 정상 동작해서, ES에는 새 포스트가 색인되었는데 블로그에서는 보이지 않는 불일치 상태가 발생했다.

해결을 위해 두 가지를 추가했다:

deployment.yaml에 deploy-timestamp annotation + imagePullPolicy: Always

spec:
  template:
    metadata:
      labels:
        app: blog-new
      annotations:
        deploy-timestamp: "2026-02-19T03:18:34Z"   # 매 빌드마다 Jenkins가 갱신
    spec:
      containers:
        - image: minseoky/blog-new:0.2.5
          imagePullPolicy: Always                    # 같은 태그라도 항상 pull

annotation이 pod template 안에 있으므로, 값이 바뀌면 Kubernetes가 새 ReplicaSet을 생성하고 롤아웃이 발생한다. kubectl rollout restart가 내부적으로 하는 것과 동일한 원리다.

이 변경 이후로는 version을 올리지 않더라도 매 빌드마다 timestamp가 달라지기 때문에, 항상 커밋이 생기고, ArgoCD가 sync하고, 새 이미지가 pull된다.

Elasticsearch 검색 색인

이 블로그에는 Elasticsearch 8 + Nori 한국어 형태소 분석기 기반의 전문 검색이 있다. Cmd+K를 눌러보면 동작하는 그 검색이다.

인덱스 설계

{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0,
    "analysis": {
      "analyzer": {
        "nori_analyzer": {
          "type": "custom",
          "tokenizer": "nori_tokenizer",
          "filter": ["nori_readingform", "lowercase"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "slug":       { "type": "keyword" },
      "title":      { "type": "text", "analyzer": "nori_analyzer" },
      "excerpt":    { "type": "text", "analyzer": "nori_analyzer" },
      "body":       { "type": "text", "analyzer": "nori_analyzer" },
      "categories": { "type": "keyword" },
      "date":       { "type": "date" },
      "thumbnail":  { "type": "keyword", "index": false }
    }
  }
}

nori_analyzer는 한국어를 형태소 단위로 분해한다. "쿠버네티스에서" → "쿠버네티스" + "에서" 이런 식이다. nori_readingform은 한자를 한글 독음으로 변환해주고, lowercase는 영문을 소문자로 통일한다.

개인 블로그에 멀티샤딩은 의미없다. 수백 개 문서에 shard를 나누면 오히려 오버헤드만 생기기 때문에 shards: 1, replicas: 0으로 설정했다.

색인을 CI/CD에 녹여넣기

색인 갱신을 어디서 할지 고민이 좀 있었다. 처음에는 블로그 앱 자체에서 빌드 시점에 색인하는 방법을 생각했지만, 빌드 환경에서 클러스터 내부의 ES에 접근하는 것 자체가 네트워크적으로 번거롭다. 결국 Jenkins 파이프라인의 마지막 스테이지로 넣었다. Jenkins Agent Pod은 클러스터 안에서 실행되니까 ES에 내부 DNS로 바로 접근할 수 있다.

파이프라인의 Reindex 스테이지는 이렇게 동작한다:

container('es-indexer') {
    sh '''
        PREV="${GIT_PREVIOUS_SUCCESSFUL_COMMIT:-}"
        if [ -z "$PREV" ]; then
            echo "==> No previous commit found. Skipping delta reindex."
            exit 0
        fi

        CHANGES="$(git diff --name-status "$PREV" HEAD -- content/ || true)"
        if [ -z "$CHANGES" ]; then
            echo "==> No content changes detected. Skipping."
            exit 0
        fi

        echo "$CHANGES" | ./scripts/es-delta-reindex.sh
    '''
}

GIT_PREVIOUS_SUCCESSFUL_COMMIT은 Jenkins가 제공하는 환경변수로, 마지막 성공 빌드의 커밋 해시다. 이 해시와 현재 HEAD 사이의 content/ 변경분만 추출해서 es-delta-reindex.sh에 넘긴다.

delta reindex 스크립트는 git diff 출력을 한 줄씩 읽으면서 처리한다:

  • A (추가), M (수정) → frontmatter/body를 파싱해서 PUT _doc/{id}
  • D (삭제) → DELETE _doc/{id}
  • content/*/index.md 패턴만 처리, 이미지 변경 등은 skip

body 파싱 과정에서는 코드블록, 인라인 코드, HTML 태그, 이미지 마크다운을 sed/awk로 제거한다. 코드가 검색 결과를 오염시키는 것을 막기 위해서다. 쉘 스크립트로 마크다운 파서를 만든 셈인데, 완벽하지는 않지만 블로그 검색 수준에서는 충분하다.

주의할 점이 하나 있다. 최초 빌드 시에는 GIT_PREVIOUS_SUCCESSFUL_COMMIT이 없기 때문에 delta reindex가 skip된다. 인덱스를 처음 세팅할 때는 반드시 수동으로 es-setup.sh를 실행해야 한다:

ES_PASSWORD='비밀번호' ./scripts/es-setup.sh

이 스크립트는 인덱스를 통째로 삭제하고 재생성한 다음, content/ 전체를 _bulk API로 일괄 색인한다. 스키마 변경이나 데이터 정합성이 꼬였을 때도 이걸 돌리면 된다.

읽기/쓰기 인증 분리

색인(쓰기)과 검색(읽기)에서 서로 다른 인증 방식을 사용한다:

  • 색인 (Jenkins): Basic Auth (elastic:password) — Jenkins Credential에서 주입
  • 검색 (블로그 앱): API Key — K8s Secret blog-es-apikey에서 주입

블로그 앱은 읽기 전용 API Key만 갖고 있다. 인덱스 생성이나 삭제 같은 관리 작업 권한은 없다. 이 Secret은 보안상 Git에 넣지 않고 수동으로 관리한다. ArgoCD가 관리하는 리소스가 아니라는 뜻이다.

사실 이 부분이 마음에 안 드는 지점이긴 하다. Sealed Secrets이나 External Secrets Operator 같은 걸 도입하면 Secret도 GitOps로 관리할 수 있을 텐데, 블로그 하나에 쓰기에는 좀 과한 것 같아서 아직은 수동으로 두고 있다.

Deployment 매니페스트

최종적으로 ArgoCD가 관리하는 deployment.yaml은 이렇게 생겼다:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: blog-new
  name: blog-new
  namespace: blog-new
spec:
  replicas: 1
  selector:
    matchLabels:
      app: blog-new
  template:
    metadata:
      labels:
        app: blog-new
      annotations:
        deploy-timestamp: "2026-02-19T03:18:34Z"
    spec:
      containers:
        - image: minseoky/blog-new:0.2.5
          name: blog-new
          imagePullPolicy: Always
          ports:
            - containerPort: 3000
          env:
            - name: ES_URL
              value: "http://elasticsearch.elasticsearch.svc.cluster.local:9200"
            - name: ES_APIKEY
              valueFrom:
                secretKeyRef:
                  name: blog-es-apikey
                  key: ES_APIKEY

ES 연결은 환경변수로 주입한다. ES_URL은 K8s 내부 DNS를 통한 서비스 디스커버리, ES_APIKEY는 Secret에서 가져온다. Kustomize로 관리하지만 overlay 없이 base만 쓰고 있다. 환경이 하나뿐이라 overlay가 필요하지 않았다.

트래픽 경로 정리

외부에서 블로그에 접근할 때 트래픽이 거치는 경로를 정리하면 이렇다:

flowchart TD User["🌐 사용자"] --> DNS["DNS\nminseoky.me"] DNS --> Router["공유기\n포트포워딩 80, 443"] Router --> MetalLB["MetalLB\n10.0.0.200"] MetalLB --> GW["Cilium Gateway\ngw-public\n(TLS 종료)"] GW --> Route["HTTPRoute\nminseoky.me → blog-new:3000"] Route --> Svc["Service\nClusterIP"] Svc --> Pod["Pod\nminseoky/blog-new:0.2.5"]

Gateway에서 TLS가 종료되기 때문에, Pod 내부의 Next.js 앱은 plain HTTP로만 동작한다. 이전에 ArgoCD에서 겪었던 무한 리다이렉션 문제와 같은 맥락이다. TLS 종료 지점을 명확히 해두지 않으면 문제가 생긴다.

블로그를 만들면서 부딪힌 것들

파이프라인과 인프라 이야기만 하면 이 블로그가 마치 순탄하게 만들어진 것처럼 보일 수 있다. 전혀 아니다. git log를 보면 삽질의 연대기가 고스란히 남아있다. 메이저한 기능별로 어떤 철학을 담으려 했고, 어떤 벽에 부딪혔는지 정리해보려 한다.

Jenkins sed가 YAML을 파괴한 사건

지금은 잘 돌아가는 Deploy 스테이지지만, 초기에는 Jenkins의 seddeployment.yaml을 반복적으로 망가뜨렸다.

731b020 fix: restore deployment.yaml broken by Jenkins sed
81d254b fix: restore YAML indentation broken by sed backreference
b6108d3 fix: remove SOH control character from deployment.yaml

sed의 backreference(\1)가 BusyBox ash 셸에서 SOH(Start of Header) 제어문자를 삽입하는 문제가 있었다. 눈에 보이지 않는 문자가 YAML에 들어가서 ArgoCD sync는 실패하고, kubectl apply도 먹통이 되었다. 원인을 찾는 데만 한참 걸렸다. 결국 backreference를 쓰지 않는 단순한 sed 패턴으로 교체하면서 해결했다.

교훈: 컨테이너 안의 셸 환경을 절대 가정하지 말자. Alpine의 ash와 Ubuntu의 bash는 sed 동작이 미묘하게 다르다.

버전 파싱 7연패

package.json에서 버전을 읽는 것. 이게 이렇게 어려울 줄 몰랐다.

7689aba fix: use Groovy readFile+regex to parse version instead of shell
cb8a8d6 fix: simplify version parsing to avoid Groovy quoting issues
5735773 fix: use readJSON instead of Groovy regex for version parsing
0a7d004 fix: use sh returnStdout for version parsing (readJSON plugin not installed)
25996a7 fix: parse version with readFile + Groovy string ops (no regex, no shell, no plugin)
338f280 fix: use indexOf/substring for version parsing (avoid CPS loop issues)
e6c391f fix: remove environment block + use writeFile/sh for version extraction

7번의 시도 끝에 결국 안정적인 방법을 찾았다. Groovy의 regex는 Jenkins CPS(Continuation Passing Style) 환경에서 직렬화 문제를 일으키고, readJSON은 플러그인이 미설치, readFile + Groovy string ops는 CPS 루프에서 에러... 최종적으로 writeFile로 awk 스크립트를 파일에 쓴 다음 sh로 실행하는 방식으로 안착했다. 우아하지는 않지만 확실하게 동작한다.

지금도 가끔 이 코드를 보면 웃음이 나온다. package.json에서 버전 하나 읽는 데 커밋 7개라니.

캐러셀: CSS vs JavaScript의 줄다리기

홈 화면에 프로필, 기술 스택, 고정 포스트를 보여주는 캐러셀을 만들었다. 처음에는 JavaScript로 터치 스와이프를 직접 구현했다.

876fbbc feat: add touch swipe support to home carousel
b1ea560 fix: carousel swipe not working on mobile
84cd3a6 fix: carousel drag-follow swipe for mobile
644bdbe fix: rewrite carousel touch handling with all-native listeners
96b08c3 fix: replace JS touch handling with CSS scroll-snap carousel

모바일에서 동작이 안 되고, 드래그 추적이 부자연스럽고, 네이티브 리스너로 전부 다시 짜고... 결국 JS 터치 핸들링을 전부 버리고 CSS scroll-snap으로 교체했다. 브라우저가 이미 잘 만들어놓은 걸 왜 직접 구현하려 했는지 모르겠다.

근데 여기서 끝이 아니었다. 마지막 슬라이드에서 첫 번째로 자연스럽게 돌아가는 무한 캐러셀을 구현해야 했다:

f410004 feat: wrap carousel from last slide to first on swipe
f95e9b9 fix: carousel last-to-first wrap-around using scroll position check
39cb123 feat: infinite carousel with clone-slide trick

처음에는 스크롤 위치를 감지해서 점프시키려 했지만 깜빡임이 심했다. 최종적으로 첫 슬라이드와 마지막 슬라이드의 클론을 양 끝에 배치하고, 클론에 도달하면 즉시 원본 위치로 scrollTo하는 트릭을 썼다. 약간의 눈속임이지만 사용자 입장에서는 자연스러운 무한 루프처럼 보인다.

nginx를 거쳤다가 걷어낸 이유

블로그를 처음 클러스터에 배포할 때, 앞단에 nginx 리버스 프록시를 하나 두었다:

9225de2 refactor: route minseoky.me through nginx reverse proxy to blog-new
e8c5226 refactor: remove nginx, route minseoky.me directly to blog-new

Next.js 앱 앞에 nginx를 두면 캐싱이나 헤더 제어에 유리할 거라 생각했지만, 실제로는 Gateway에서 이미 TLS 종료와 라우팅을 처리하고 있어서 nginx가 하는 일이 거의 없었다. 오히려 디버깅할 때 트래픽 경로가 한 단계 더 늘어나서 불편하기만 했다. 바로 다음 커밋에서 걷어냈다.

불필요한 레이어는 빨리 제거하는 게 맞다. 나중에 필요하면 그때 다시 추가하면 된다.

URL 정규화: SEO를 위한 집착

Gatsby에서 마이그레이션하면서 기존 URL 구조를 최대한 유지하려 했는데, 대소문자 문제가 있었다. 파일시스템 경로가 URL이 되는 구조라서, content/AWS/Amazon EMR/index.md의 URL이 /AWS/Amazon EMR이 된다. 근데 검색 엔진은 /aws/amazon%20emr/AWS/Amazon%20EMR을 다른 페이지로 인식한다.

218c535 feat: normalize all URLs to lowercase for case-insensitive routing
caa3e21 fix: preserve percent-encoding in middleware lowercase redirect for SEO
9427be3 fix: combine trailing slash + lowercase into single 301 redirect

Next.js 미들웨어에서 대문자가 포함된 URL을 소문자로 301 리다이렉트하는 로직을 추가했다. 그런데 percent-encoding된 한글 경로(%EC%95%84%EB%A7%88%EC%A1%B4)의 알파벳 부분까지 소문자로 바꿔버려서 경로가 깨지는 문제가 발생했다. percent-encoding은 보존하면서 실제 경로 문자만 소문자로 변환하도록 수정했다.

거기에 trailing slash 정규화(/posts//posts)까지 합쳐야 했는데, 리다이렉트가 두 번 발생하면 SEO에 불리하다. 최종적으로 소문자 + trailing slash 제거를 하나의 301 리다이렉트로 합쳤다.

Delta Reindex 디버깅

ES 증분 색인을 처음 도입했을 때의 기록이다:

75ef5ac feat: add Elasticsearch indexing scripts and Jenkins reindex stage
76a8a7d fix: fetch git history for delta reindex in shallow clone
76b6880 fix: use full clone for delta reindex git diff
a3eb2b2 fix: add safe.directory for git in es-indexer container
3bb2614 fix: install bash in es-indexer container

Checkout 스테이지에서 shallow: true로 clone하고 있었는데, shallow clone에는 GIT_PREVIOUS_SUCCESSFUL_COMMIT에 해당하는 커밋이 아예 없어서 git diff가 실패했다. shallow: false로 바꿔서 해결. 그 다음에는 alpine 이미지에 bash가 없어서 스크립트가 안 돌았고, safe.directory 설정이 빠져서 git이 디렉토리 접근을 거부했다.

테스트 포스트를 만들고 지우고를 반복하면서 6번의 커밋 끝에 안정화됐다. 이런 건 로컬에서 테스트하기가 어렵다. Jenkins Agent Pod 안에서만 재현되는 환경 문제들이라, 실제로 파이프라인을 돌려봐야 알 수 있었다.

돌아보며

이 파이프라인을 만들면서 몇 가지 설계 원칙을 자연스럽게 따르게 됐다:

  1. 선언적 관리: 클러스터에 kubectl로 직접 뭔가를 만들지 않는다. 모든 상태는 Git에 있다. ArgoCD의 selfHeal 덕분에 실수로 뭔가를 건드려도 원복된다.
  2. 관심사 분리: 앱 소스와 인프라 매니페스트를 별도 저장소로 나눴다. CI 무한 루프 방지도 되고, 각 저장소의 역할이 명확해졌다.
  3. 증분 처리: 전체 재색인 대신 git diff 기반 delta reindex. 포스트 230개를 매번 다시 색인할 필요가 없다.
  4. 불필요한 레이어 제거: nginx 프록시를 달았다가 바로 걷어낸 것처럼, 당장 역할이 없는 컴포넌트는 빠르게 제거한다. 필요하면 나중에 다시 추가하면 된다.

사실 블로그 하나에 이 정도 인프라는 분명히 오버엔지니어링이다. 근데 이런 구조를 직접 설계하고 운영하면서 배우는 것들이 꽤 많다. BusyBox sed의 backreference 문제, Groovy CPS의 직렬화 제약, CSS scroll-snap의 한계, Gateway API의 hostname 매칭 규칙 같은 것들은 직접 부딪혀봐야 체감할 수 있는 것들이었다. 커밋 로그에 고스란히 남은 삽질들이 그 증거다.

다음에는 이 파이프라인에 Argo Rollouts을 붙여서 카나리 배포를 해볼까 생각 중이다. 블로그에 카나리 배포가 왜 필요하냐는 질문에는... 아직 대답할 수 없다.