<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>230's</title>
    <link>https://2-3-0.tistory.com/</link>
    <description>안녕하세요 230입니다.</description>
    <language>ko</language>
    <pubDate>Thu, 11 Jun 2026 06:04:19 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>2-30</managingEditor>
    <image>
      <title>230's</title>
      <url>https://tistory1.daumcdn.net/tistory/8367811/attach/f9c062e02acc44e983adb18ef09d415e</url>
      <link>https://2-3-0.tistory.com</link>
    </image>
    <item>
      <title>ArgoCD Sync 해도 안 풀리는 OutOfSync 트러블슈팅</title>
      <link>https://2-3-0.tistory.com/19</link>
      <description>&lt;p&gt;ArgoCd는 GitOps 도구로 항상 Git 저장소의 상태(Desired State, 원하는 상태)와 실제 클러스터 상태(Live State, 현재상태)를 똑같이 일치시키려고 감시한다.&lt;br&gt;OutOfSync는 내가 깃헙에 올려둔 코드랑 실제 쿠버네티스 클러스터에 배포되어 있는 상태가 서로 다를 때를 말한다.&lt;br&gt;이때 Sync(동기화) 버튼을 누르거나(CLI로 Sync하거나) 자동 동기화(Auto-Sync)가 작동하면 Synced로 바뀐다.&lt;br&gt;이번 케이스는 Synced가 안되는 상황 중 하나를 트러블슈팅한 것이다.&lt;/p&gt;
&lt;h2&gt;배경 및 문제상황&lt;/h2&gt;
&lt;p&gt;발생 일자: 2026-05-14&lt;br&gt;재현 일자: 2026-05-29&lt;/p&gt;
&lt;p&gt;shoong-delivery의 GitOps 파이프라인을 처음 셋업하던 중이었다. 4개 MSA 서비스(order/kitchen/delivery/notification)와 batch CronJob을 공통 Helm 차트(&lt;code&gt;charts/shoong-app&lt;/code&gt;)로 배포하려고 dev 환경 ArgoCD Application 5개를 등록했다.&lt;/p&gt;
&lt;p&gt;ArgoCD UI에서 dev-shoong-* 앱 4개가 OutOfSync 상태로 표시됐다.&lt;br&gt;Sync 버튼을 아무리 눌러도 아주잠깐 Synced되었다가 OutOfSync로 돌아왔다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Jcaeu/dJMcahR7GjC/jT60KGfEwhysuKKTDgflS1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Jcaeu/dJMcahR7GjC/jT60KGfEwhysuKKTDgflS1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Jcaeu/dJMcahR7GjC/jT60KGfEwhysuKKTDgflS1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJcaeu%2FdJMcahR7GjC%2FjT60KGfEwhysuKKTDgflS1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDFqz7/dJMcajh7tp7/VD5ifNcUokbKFPXKlK4mpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDFqz7/dJMcajh7tp7/VD5ifNcUokbKFPXKlK4mpK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDFqz7/dJMcajh7tp7/VD5ifNcUokbKFPXKlK4mpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDFqz7%2FdJMcajh7tp7%2FVD5ifNcUokbKFPXKlK4mpK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;CLI로 OutOfSync 앱만 필터링한 화면.&lt;/p&gt;
&lt;h2&gt;원인분석&lt;/h2&gt;
&lt;p&gt;원인을 찾기 위해 각 OutOfSync 앱으로 들어가 DIFF 탭을 살폈다.&lt;br&gt;어느 라인에서 계속 다른 상태로 남아있는지 확인하기 위함이었다.&lt;/p&gt;
&lt;h3&gt;Namespace 리소스의 tracking-id 어노테이션 충돌&lt;/h3&gt;
&lt;p&gt;4개 앱(batch/delivery/kitchen/order)의 DIFF 탭을 열어보니 공통적으로 &lt;code&gt;/Namespace/shoong&lt;/code&gt; 리소스 하나에서 diff가 잡혔다. 차이 나는 필드는 &lt;code&gt;argocd.argoproj.io/tracking-id&lt;/code&gt; 어노테이션이었고, 라이브 클러스터에 박혀있는 값이 해당 앱의 이름이 아닌 다른 앱 이름이었다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;dev-shoong-batch DIFF — 라이브: &lt;code&gt;dev-shoong-order&lt;/code&gt;, 원하는 값: &lt;code&gt;dev-shoong-batch&lt;/code&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sjoGq/dJMcacJ8aPU/VGkO195CbpKHyjV4ohLN51/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sjoGq/dJMcacJ8aPU/VGkO195CbpKHyjV4ohLN51/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sjoGq/dJMcacJ8aPU/VGkO195CbpKHyjV4ohLN51/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsjoGq%2FdJMcacJ8aPU%2FVGkO195CbpKHyjV4ohLN51%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;dev-shoong-delivery DIFF — 라이브: &lt;code&gt;dev-shoong-order&lt;/code&gt;, 원하는 값: &lt;code&gt;dev-shoong-delivery&lt;/code&gt;&lt;br&gt;  &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cMl8MO/dJMcaftgqq0/vVqbXswT2yM7lvSDagk9b1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cMl8MO/dJMcaftgqq0/vVqbXswT2yM7lvSDagk9b1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cMl8MO/dJMcaftgqq0/vVqbXswT2yM7lvSDagk9b1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcMl8MO%2FdJMcaftgqq0%2FvVqbXswT2yM7lvSDagk9b1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;dev-shoong-kitchen DIFF — 라이브: &lt;code&gt;dev-shoong-order&lt;/code&gt;, 원하는 값: &lt;code&gt;dev-shoong-kitchen&lt;/code&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/88t4j/dJMb997EN8z/Ikfk7R64wbFZHLoZS8Scr1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/88t4j/dJMb997EN8z/Ikfk7R64wbFZHLoZS8Scr1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/88t4j/dJMb997EN8z/Ikfk7R64wbFZHLoZS8Scr1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F88t4j%2FdJMb997EN8z%2FIkfk7R64wbFZHLoZS8Scr1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;dev-shoong-order DIFF — 라이브: &lt;code&gt;dev-shoong-notification&lt;/code&gt;, 원하는 값: &lt;code&gt;dev-shoong-order&lt;/code&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bebDhG/dJMcaf7QbSF/sBaakymkrUC9qEN9M30gV0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bebDhG/dJMcaf7QbSF/sBaakymkrUC9qEN9M30gV0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bebDhG/dJMcaf7QbSF/sBaakymkrUC9qEN9M30gV0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbebDhG%2FdJMcaf7QbSF%2FsBaakymkrUC9qEN9M30gV0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;tracking-id&lt;/code&gt;는 ArgoCD가 sync할 때 클러스터에 직접 기록하는 어노테이션으로 사용자가 git에 직접 써넣는 값이 아니다.&lt;br&gt;ArgoCD가 클러스터에 배포된 수많은 리소스(Deployment, Service, Pod 등)들이 &amp;#39;어떤 ArgoCD 애플리케이션&amp;#39;에 의해 관리되고 있는지 추적하기 위해 사용하는 식별 태그이다.&lt;/p&gt;
&lt;p&gt;예를 들어 shoong-batch 앱의 diff에 있는 것을 봤을 때&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: v1
kind: Namespace
metadata:
  annotations:
    argocd.argoproj.io/tracking-id: dev-shoong-delivery:/Namespace:shoong/shoong # 기존 것.
    argocd.argoproj.io/tracking-id: dev-shoong-batch:/Namespace:shoong/shoong # 바꾸려고 하는 것.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;batch 앱이 말하는 것은 &amp;quot;클러스터의 Namespace/shoong에는 &lt;code&gt;dev-shoong-order&lt;/code&gt;가 소유자라고 적혀있는데 나(&lt;code&gt;dev-shoong-batch&lt;/code&gt;)는 내가 소유자여야 한다고 생각한다. 그러니 내 이름으로 바꾸겠다.&amp;quot;&lt;/p&gt;
&lt;p&gt;같은 논리로 delivery, kitchen, order도 각자 자기 이름으로 바꾸려고 시도했던거다.&lt;/p&gt;
&lt;p&gt;왜 notification은 문제 없는거지?, 다른 앱들은 자기 Namespace 리소스의 소유권을 주장하는 하는거지 궁금했다.&lt;/p&gt;
&lt;p&gt;좀 더 문제의 원인을 찾아보기 위해 앱을 하나씩 수동 sync 해봤다.&lt;/p&gt;
&lt;p&gt;shoong-batch를 sync하면 batch가 Synced 상태가 된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqt4rr/dJMcaftgqrD/gNZlXJjBaPW5cqRwqUeLYK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqt4rr/dJMcaftgqrD/gNZlXJjBaPW5cqRwqUeLYK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqt4rr/dJMcaftgqrD/gNZlXJjBaPW5cqRwqUeLYK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbqt4rr%2FdJMcaftgqrD%2FgNZlXJjBaPW5cqRwqUeLYK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;이어서 shoong-delivery를 sync하면,&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bYitzf/dJMcahknmah/s1b0lwJsmp2cekFKz1QaqK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bYitzf/dJMcahknmah/s1b0lwJsmp2cekFKz1QaqK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bYitzf/dJMcahknmah/s1b0lwJsmp2cekFKz1QaqK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbYitzf%2FdJMcahknmah%2Fs1b0lwJsmp2cekFKz1QaqK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;batch는 OutOfSync로 돌아가고 delivery가 Synced가 된다. 이 시점에서 batch의 DIFF를 보면 &lt;code&gt;dev-shoong-delivery → dev-shoong-batch&lt;/code&gt;로 바뀌어 있다.&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zjLOw/dJMcaaS25I4/sS2n9dyp9PjI9E7B0z47ek/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zjLOw/dJMcaaS25I4/sS2n9dyp9PjI9E7B0z47ek/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zjLOw/dJMcaaS25I4/sS2n9dyp9PjI9E7B0z47ek/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzjLOw%2FdJMcaaS25I4%2FsS2n9dyp9PjI9E7B0z47ek%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;한 앱을 sync할 때마다 해당 앱이 &lt;code&gt;/Namespace/shoong&lt;/code&gt;의 tracking-id를 자기 이름으로 덮어쓰고, 그 순간 다른 앱들은 라이브 값과 자신이 원하는 값이 달라져 OutOfSync로 떨어지는 구조였다. &lt;strong&gt;어떤 순서로 sync해도 한 번에 하나만 Synced가 될 수 있었고, 나머지는 항상 OutOfSync인 무한 순환&lt;/strong&gt;이었다.&lt;/p&gt;
&lt;p&gt;tracking-id를 서로 덮어쓰는 메커니즘은 파악했지만, 왜 5개 앱이 모두 같은 &lt;code&gt;Namespace/shoong&lt;/code&gt; 리소스를 관리하려 하는지 처음에 이해하지 못했다.&lt;br&gt;5개 앱의 파드와 서비스가 shoong 네임스페이스 안에서 동작하는 건 맞지만 그게 Namespace 리소스 자체를 앱 각자가 관리해야 한다는 의미는 아닌데 말이다.&lt;/p&gt;
&lt;p&gt;이 문제의 발단은 헬름 차트에 &lt;code&gt;namespace.yaml&lt;/code&gt; 파일을 추가하면서 발생했다.&lt;br&gt;istio 서비스 메시 구축 중에 shoong 네임스페이스 안의 모든 파드에 Istio 사이드카를 주입 시키려고 했었다. 파드마다 개별 설정하지 않고 Namespace에 istio-injection: enabled 라벨을 붙이면 그 안의 모든 리소스에 일괄 적용되기 때문에, 이를 자동화하려고 공통 차트에 namespace.yaml을 추가했던 것이 문제의 발단이었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# charts/shoong-app/templates/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: shoong
  labels:
    istio-injection: enabled&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;문제해결&lt;/h2&gt;
&lt;p&gt;shoong-app 차트를 기반으로 5개 앱이 각각 독립적인 Helm 릴리스로 배포된다. 차트 안에 namespace.yaml이 있으니 5개 릴리스 모두가 Namespace/shoong를 자기 소속 리소스로 포함하게 된다. ArgoCD 입장에서는 5개 앱이 동시에 같은 Namespace를 내꺼라고 선언한 상황이 된 것이었다.&lt;/p&gt;
&lt;h3&gt;namespace.yaml 삭제&lt;/h3&gt;
&lt;p&gt;먼저, 공통 차트에서 &lt;code&gt;namespace.yaml&lt;/code&gt;을 제거해 5개 앱이 Namespace 리소스를 각자 관리하려는 충돌 자체를 없앴다.&lt;/p&gt;
&lt;h3&gt;istio-injection: enabled 라벨은 어디에 붙여야하나?&lt;/h3&gt;
&lt;p&gt;그럼 결국에는 각각의 리소스에 &lt;code&gt;sidecar.istio.io/inject: &amp;quot;true&amp;quot;&lt;/code&gt; 라벨을 붙여야하나?&lt;br&gt;특정 파드만 Istio 사이드카 주입하고 싶다면 그럴 수 있겠지만 이 프로젝트에서는 shoong 네임스페이스에 속하는 모든 파드들 Istio 서비스 메쉬에 포함되어야했다.&lt;/p&gt;
&lt;p&gt;몇 가지 대안을 찾아봤다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Terraform으로 Namespace 사전 생성&lt;br&gt;ArgoCD가 배포되기 전 Terraform 단계에서 istio-injection: enabled 라벨을 포함해 Namespace를 미리 만들어두는 방법&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;init.sh 에서 Namespace 사전 생성&lt;br&gt;위 Terraform 방법과 비슷하게 init.sh 에서 네임스페이스를 미리 생성하는 것이다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;위 두 방법으로 문제를 간단히 해결할 수는 있지만,&lt;br&gt;Git을 단일 공급원으로 사용하면서 모든 상태를 선언적으로 기술되어야한다라는 GitOps 운영 철학과 맞지않다.&lt;/p&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;&lt;p&gt;Namespace 전용 ArgoCD Application 별도 생성&lt;br&gt;Namespace만 관리하는 별도 ArgoCD 앱(infra-namespace)을 만들어서 단독으로 소유하는 방법.&lt;br&gt;-&amp;gt; 앱 수가 늘어나고 네임스페이스 하나를 위해 Application을 추가하는 게 과하다고 생각했다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;managedNamespaceMetadata 추가&lt;br&gt;ArgoCD 애플리케이션 설정(Application Manifest) 자체에는 해당 앱이 배포될 네임스페이스가 없을 경우 자동으로 생성해 주는 내장 기능이 있다. &lt;code&gt;syncOptions&lt;/code&gt;에 &lt;code&gt;CreateNamespace=true&lt;/code&gt;를 추가하면 된다. 이미 이 방식으로 네임스페이스가 없을 경우 추가가 되고 있었는데, 이때 생성되는 네임스페이스에 라벨까지 함께 주입하도록 설정하는 방법이 managedNamespaceMetadata이다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;managedNamespaceMetadata&lt;/code&gt;는 ArgoCD가 Namespace를 생성할 때 라벨과 어노테이션을 직접 관리하는 옵션이다. Helm 릴리스가 Namespace 리소스를 만드는 게 아니라 ArgoCD 자체가 처리하기 때문에 tracking-id 충돌 대상이 되지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# argocd/dev/shoong-batch.yaml (나머지 4개도 동일)
syncPolicy:
  automated:
    prune: true
    selfHeal: true
  managedNamespaceMetadata:
    labels:
      istio-injection: enabled
  syncOptions:
    - CreateNamespace=true&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;-&amp;gt; GitOps의 선언적 관리 원칙을 지키면서도 추가적인 App 분리없이 깔끔하게 해결이 된다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;결과&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;namespace.yaml&lt;/code&gt; 삭제 + &lt;code&gt;managedNamespaceMetadata&lt;/code&gt; 추가 후 sync하면 5개 앱 모두 Synced 상태로 유지된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EOdFF/dJMcai4xlDJ/2h3OPtSkinM5ntzkEwlmL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EOdFF/dJMcai4xlDJ/2h3OPtSkinM5ntzkEwlmL0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EOdFF/dJMcai4xlDJ/2h3OPtSkinM5ntzkEwlmL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEOdFF%2FdJMcai4xlDJ%2F2h3OPtSkinM5ntzkEwlmL0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2&gt;재발방지대책&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;공유 Helm 차트에 클러스터 범위 리소스를 넣지 않는다&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Namespace, ClusterRole, ClusterRoleBinding처럼 클러스터 전체 범위의 리소스는 여러 ArgoCD Application이 같은 차트를 쓸 때 소유권 충돌이 생긴다. 공유 차트에는 Deployment, Service, ConfigMap처럼 해당 앱 고유의 네임스페이스 범위 리소스만 포함해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Namespace 라벨/어노테이션 관리는 managedNamespaceMetadata를 우선 사용한다&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;namespace.yaml&lt;/code&gt;을 차트에 넣는 대신 ArgoCD Application의 &lt;code&gt;managedNamespaceMetadata&lt;/code&gt;로 관리하면 tracking-id 충돌 없이 선언적으로 유지할 수 있다. 여러 앱이 같은 네임스페이스를 대상으로 해도 충돌이 없다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sync가 안 풀릴 때 DIFF 탭을 먼저 확인한다&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Sync 버튼을 반복해서 누르기 전에 DIFF 탭에서 어떤 리소스, 어떤 필드에서 차이가 발생하는지 먼저 파악한다. 어떤 값이 충돌하는지를 보면 ArgoCD 내부 동작 문제인지(tracking-id), 외부 컴포넌트가 런타임에 값을 바꾸는 문제인지(Istio failurePolicy 등) 빠르게 분류할 수 있다.&lt;/p&gt;</description>
      <category>TroubleShooting</category>
      <author>2-30</author>
      <guid isPermaLink="true">https://2-3-0.tistory.com/19</guid>
      <comments>https://2-3-0.tistory.com/19#entry19comment</comments>
      <pubDate>Mon, 1 Jun 2026 23:46:05 +0900</pubDate>
    </item>
    <item>
      <title>EBS CSI 드라이버 미설치로 인한 PVC Pending 이슈</title>
      <link>https://2-3-0.tistory.com/18</link>
      <description>&lt;h2&gt;배경 및 문제상황&lt;/h2&gt;
&lt;p&gt;발생 일자: 2026-05-19&lt;/p&gt;
&lt;h3&gt;배경&lt;/h3&gt;
&lt;p&gt;EKS 클러스터에 관측성(Observability) 스택을 얹는 작업을 하고 있었다.&lt;br&gt;로그 수집은 Loki, 트레이스 수집은 Tempo를 쓰기로 하고, 두 컴포넌트를 ArgoCD 애플리케이션으로 정의해 &lt;code&gt;monitoring&lt;/code&gt; 네임스페이스에 배포했다.&lt;/p&gt;
&lt;p&gt;솔직히 이때는 수집한 로그,트레이스가 어디에 어떻게 저장되는지까지 깊게 따져보지않았다.&lt;br&gt;이 컴포넌트들이 떠있으려면 별도의 영구 볼륨을 먼저 붙여야 한다는 것을 잊고 있었다.&lt;br&gt;변명하자면 이제 막 초기 셋팅 진행 중이라 정신 없어서 신경 쓸 겨를이 없었다.&lt;/p&gt;
&lt;h3&gt;문제상황&lt;/h3&gt;
&lt;p&gt;ArgoCD가 Sync를 끝냈는데도 Loki/Tempo Pod가 &lt;code&gt;Running&lt;/code&gt;으로 넘어가지 못하고&lt;br&gt;계속 &lt;code&gt;Pending&lt;/code&gt; 상태에 머물렀다. 시간이 지나도 &lt;code&gt;Running&lt;/code&gt;이 되지 못했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get pods -n monitoring | grep &amp;quot;loki|tempo&amp;quot;

infra-loki-0      0/2   Pending   0   5m55s
infra-tempo-0     0/1   Pending   0   5m51s&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Pod가 어떤 노드에도 스케줄되지 못한 채 대기만 하는 상황이었다.&lt;br&gt;애플리케이션 컨테이너 자체의 문제(이미지, 설정 등)라기보다는 뜨는 데 필요한 선행 조건이 충족되지 않아 스케줄링 단계에서 막혀 있다는&lt;br&gt;신호로 보였고, 여기서부터 원인을 좁혀 나갔다.&lt;/p&gt;
&lt;h2&gt;원인 분석&lt;/h2&gt;
&lt;h3&gt;PVC가 Pending인 이유&lt;/h3&gt;
&lt;p&gt;그래서 Pod의 &lt;code&gt;Pending&lt;/code&gt;을 PVC 쪽으로 따라 내려갔더니 PVC도 전부 &lt;code&gt;Pending&lt;/code&gt;이었다.&lt;/p&gt;
&lt;p&gt;Pending인 Pod를 보니 노드에 배치되지 못한 이유는 PVC였다.&lt;br&gt;Pod는 자신이 요구하는 PVC가 실제 볼륨에 바인딩되어야 노드에 스케줄된다.&lt;br&gt;요청한 PVC가 볼륨에 바인딩되지 않아 스케줄 자체가 멈춰 있었다.&lt;br&gt;PVC들도 확인해보니 전부 &lt;code&gt;Pending&lt;/code&gt;이었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kBUVg/dJMcajvFv9K/uANrnq3NpXMFVCfsAIBkiK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kBUVg/dJMcajvFv9K/uANrnq3NpXMFVCfsAIBkiK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kBUVg/dJMcajvFv9K/uANrnq3NpXMFVCfsAIBkiK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkBUVg%2FdJMcajvFv9K%2FuANrnq3NpXMFVCfsAIBkiK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMlTlU/dJMcadWvhya/6ebfZEu8jK2Iha4rJzlym0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMlTlU/dJMcadWvhya/6ebfZEu8jK2Iha4rJzlym0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMlTlU/dJMcadWvhya/6ebfZEu8jK2Iha4rJzlym0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMlTlU%2FdJMcadWvhya%2F6ebfZEu8jK2Iha4rJzlym0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;PVC의 이벤트로 이유를 확인했다.&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dlbpup/dJMcaayLdwR/xP0gqjN2u8eNS27KC72AeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dlbpup/dJMcaayLdwR/xP0gqjN2u8eNS27KC72AeK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dlbpup/dJMcaayLdwR/xP0gqjN2u8eNS27KC72AeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdlbpup%2FdJMcaayLdwR%2FxP0gqjN2u8eNS27KC72AeK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl describe pvc storage-infra-tempo-0 -n monitoring

Events:
  Normal  ExternalProvisioning  ...  persistentvolume-controller
    Waiting for a volume to be created either by the external provisioner
    &amp;#39;ebs.csi.aws.com&amp;#39; or manually by the system administrator.&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;동적 프로비저닝은 PVC가 StorageClass를 보고 누구한테 볼륨을 만들어 달라고 할지를 정한 다음 그 프로비저너(드라이버)가 실제 EBS를 만들어 PV로 붙여주는 흐름이다.&lt;br&gt;그런데 이벤트를 보면 PVC가 &lt;code&gt;ebs.csi.aws.com&lt;/code&gt; 앞으로 요청은 걸어놓고 한없이 기다리고만 있었다.&lt;br&gt;요청은 나갔는데 받아주는 쪽이 없다는 얘기다.&lt;/p&gt;
&lt;p&gt;그러면 막힐 수 있는 지점이 둘로 좁혀진다. 하나는 PVC가 애초에 엉뚱하거나 빈 StorageClass를 물어서 요청 자체가 잘못 나간 경우, 다른 하나는 요청은 제대로 나갔는데 정작 그 프로비저너가 클러스터에 없어서 아무도 받지 못하는 경우다.&lt;br&gt;일단 양쪽 다 짚어봤다.&lt;/p&gt;
&lt;h3&gt;1차 원인 — StorageClass 미지정&lt;/h3&gt;
&lt;p&gt;처음 이 문제를 봤을 때 &lt;code&gt;STORAGECLASS&lt;/code&gt; 컬럼이 &lt;code&gt;&amp;lt;unset&amp;gt;&lt;/code&gt;이였다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get pvc -n monitoring

NAME                    STATUS    STORAGECLASS   AGE
storage-infra-loki-0    Pending   &amp;lt;unset&amp;gt;        2m41s
storage-infra-tempo-0   Pending   &amp;lt;unset&amp;gt;        2m37s&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;PVC가 쓸 StorageClass를 못 정했다는 뜻이다. Loki/Tempo values에 StorageClass를 적어두지 않았고, 그렇다고 지정이 없을 때 대신 쓰일 default StorageClass가 클러스터에 있는 것도 아니었다.&lt;br&gt;둘 다 없으니 PVC 입장에선 어디에 볼륨을 달라고 해야 할지 알 길이 없었다.&lt;/p&gt;
&lt;p&gt;values에 적었는데도 안 먹힌것도 있었다.. Loki 6.x 차트는 키가&lt;br&gt;&lt;code&gt;storageClassName&lt;/code&gt;이 아니라 &lt;code&gt;storageClass&lt;/code&gt;인데, 이름을 잘못 쓰면 에러도 없이 그냥 무시됐다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# 반영 안 됨
singleBinary:
  persistence:
    storageClassName: gp2   # X

# 올바른 키
singleBinary:
  persistence:
    storageClass: gp2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그래서 default StorageClass(&lt;code&gt;gp2-csi&lt;/code&gt;)를 따로 만들어두고 values 키도 바로잡았다.&lt;br&gt;(기록을 적는 지금은 이 수정이 이미 들어가 있어서 PVC가 &lt;code&gt;&amp;lt;unset&amp;gt;&lt;/code&gt; 대신 &lt;code&gt;gp2-csi&lt;/code&gt;로 잡힌다.)&lt;/p&gt;
&lt;p&gt;그런데 SC를 제대로 물렸는데도 PVC는 여전히 &lt;code&gt;Pending&lt;/code&gt;이었다. StorageClass를 올바르게 지정하는 것과 그 SC가 가리키는 프로비저너가 실제로 볼륨을 만들어 주는 건 별개였던거다.&lt;br&gt;그래서 두 번째로 그 프로비저너가 진짜 있긴 한지 확인해보았다.&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;2차 원인 — EBS CSI 드라이버 미설치&lt;/h3&gt;
&lt;p&gt;앞 단계에서 SC는 멀쩡한데도 막혔으니, 이번엔 프로비저너 쪽을 봤다. 단서는 이벤트에 찍힌 &lt;code&gt;ebs.csi.aws.com&lt;/code&gt;라는 이름 그 자체였다. CSI 드라이버는 자신을 reverse-DNS 형식 이름으로 등록하는데(예: &lt;code&gt;efs.csi.aws.com&lt;/code&gt;, &lt;code&gt;pd.csi.storage.gke.io&lt;/code&gt;), &lt;code&gt;ebs.csi.aws.com&lt;/code&gt;은 AWS EBS CSI 드라이버가 등록하는 바로 그 이름이다.&lt;br&gt;이름을 그대로 읽으면 ebs + csi + aws.com 이고. 그러니까 프로비저너와 드라이버가 따로 있는 게 아니라 이 이름으로 들어온 볼륨 요청을 처리하는 실체가 곧 EBS CSI 드라이버다. (get-storageclass 캡처에서도 &lt;code&gt;gp2-csi&lt;/code&gt;의 provisioner가 &lt;code&gt;ebs.csi.aws.com&lt;/code&gt;으로 찍혀 있다.)&lt;/p&gt;
&lt;p&gt;이 드라이버는 &lt;code&gt;kube-system&lt;/code&gt;에 컨트롤러와 노드 파드로 떠 있어야 한다.&lt;br&gt;그래서 거기에 파드가 있는지부터 봤다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Gcoa6/dJMcadWvhzb/q12KkZMPYF4e3u0kLpSzkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Gcoa6/dJMcadWvhzb/q12KkZMPYF4e3u0kLpSzkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Gcoa6/dJMcadWvhzb/q12KkZMPYF4e3u0kLpSzkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGcoa6%2FdJMcadWvhzb%2Fq12KkZMPYF4e3u0kLpSzkk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/O54lG/dJMcac4mHnu/Kl8tRFgRfSDF2yQbOB97y1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/O54lG/dJMcac4mHnu/Kl8tRFgRfSDF2yQbOB97y1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/O54lG/dJMcac4mHnu/Kl8tRFgRfSDF2yQbOB97y1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FO54lG%2FdJMcac4mHnu%2FKl8tRFgRfSDF2yQbOB97y1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;그런데 &lt;code&gt;ebs-csi-controller&lt;/code&gt;, &lt;code&gt;ebs-csi-node&lt;/code&gt; 한 줄도 잡히지 않았다.&lt;br&gt;고장이라면 &lt;code&gt;CrashLoop&lt;/code&gt;으로라도 목록엔 떴을 텐데 아예 안 보인다는 건 워크로드가 배포된 적조차 없다는 뜻이다.&lt;br&gt;드라이버 자체가 설치되지 않은 것이었다.&lt;/p&gt;
&lt;p&gt;여기서 멈칫했다. 내가 EBS CSI 드라이버를 따로 설치하지 않았구나를 깨달았다.&lt;/p&gt;
&lt;p&gt;CSI(Container Storage Interface)는 쿠버네티스가 외부 스토리지와 통신하는 표준 규격이고, EBS CSI 드라이버는 그 AWS 구현체다. PVC가 볼륨을 요청하면 이 드라이버가 실제로 AWS EBS API를 호출해 볼륨을 만들고 노드에 붙여준다. 이게 없으면 요청을 받아 EBS를 만들어 줄 주체 자체가 없다.&lt;/p&gt;
&lt;p&gt;문제는 이게 EKS에 기본으로 깔려 오지 않는다는 점이다. 찾아보니 쿠버네티스 1.23이전에는 EBS 연동 코드가 쿠버네티스 안에 in-tree로 박혀 있어서 아무것도 안해도 되었다고 했다.(위 storageclass 목록에서 gp2가 아직 in-tree 프로비저너 &lt;code&gt;kubernetes.io/aws-ebs&lt;/code&gt;로 찍혀 있는 게 그 흔적이다.) 그런데 1.23부터 이 in-tree 코드는 deprecated되고 &lt;code&gt;ebs.csi.aws.com&lt;/code&gt; 드라이버로 위임됐다(CSI Migration).&lt;br&gt;(&lt;a href=&quot;https://aws.amazon.com/ko/blogs/containers/amazon-eks-now-supports-kubernetes-1-23/&quot;&gt;https://aws.amazon.com/ko/blogs/containers/amazon-eks-now-supports-kubernetes-1-23/&lt;/a&gt;)&lt;br&gt;이제는 EBS CSI 드라이버를 애드온으로 직접 설치해 줘야 한다.&lt;br&gt;안 하면 방금처럼 PVC가 영원히 Pending에 걸린다. 결국 이 드라이버가 빠져 있던 게 근본 원인이었다.&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;3차 원인 — EBS CSI 컨트롤러 IRSA 미설정&lt;/h3&gt;
&lt;p&gt;드라이버 애드온만 깔면 끝일 줄 알았는데 아니었다.&lt;/p&gt;
&lt;p&gt;애드온을 추가하고 &lt;code&gt;terraform apply&lt;/code&gt;를 돌렸는데 이번엔 apply가 끝나질 않았다.&lt;br&gt;&lt;code&gt;aws_eks_addon.ebs_csi_driver&lt;/code&gt;가 &lt;code&gt;Still creating...&lt;/code&gt;만 반복하며 18분을 넘겼다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cd3wLx/dJMb990VQ01/tdbzBrq6k4D0ZpIQksFSVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cd3wLx/dJMb990VQ01/tdbzBrq6k4D0ZpIQksFSVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cd3wLx/dJMb990VQ01/tdbzBrq6k4D0ZpIQksFSVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcd3wLx%2FdJMb990VQ01%2FtdbzBrq6k4D0ZpIQksFSVK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;결국 20분 타임아웃에 걸려 에러로 끝났다. 애드온이 &lt;code&gt;ACTIVE&lt;/code&gt;가 되기를 기다리는데&lt;br&gt;계속 &lt;code&gt;CREATING&lt;/code&gt;에 머물러 있다는 메시지였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/r1qAo/dJMcahYUweu/fE9OqP0Xf0UHq7Rl7OTAu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/r1qAo/dJMcahYUweu/fE9OqP0Xf0UHq7Rl7OTAu0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/r1qAo/dJMcahYUweu/fE9OqP0Xf0UHq7Rl7OTAu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fr1qAo%2FdJMcahYUweu%2FfE9OqP0Xf0UHq7Rl7OTAu0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;에러 메시지에서 &lt;code&gt;last state: &amp;#39;CREATING&amp;#39;&lt;/code&gt;. 애드온 생성 요청 자체는 받아들여졌고(생성에 실패한 게 아니다), 다만 그게 &lt;code&gt;ACTIVE&lt;/code&gt;로 넘어가질 못하고 있다는 뜻이다.&lt;br&gt;terraform은 &lt;code&gt;aws_eks_addon&lt;/code&gt;을 만들 때 EKS에 애드온 생성을 요청하고, 그 뒤로는 애드온이 &lt;code&gt;ACTIVE&lt;/code&gt;가 될 때까지 폴링하며 기다리기만 한다. 애드온이 &lt;code&gt;CREATING&lt;/code&gt;에 묶여 있는 건 파드는 떴는데 health check를 못 넘길 때 나오는 신호다. 그래서 다른 터미널에서 클러스터 안 파드를 직접 확인해봤다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cmLsVU/dJMcacpObRo/98hg6wd4o8Ht9rxV8etmfK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cmLsVU/dJMcacpObRo/98hg6wd4o8Ht9rxV8etmfK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cmLsVU/dJMcacpObRo/98hg6wd4o8Ht9rxV8etmfK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcmLsVU%2FdJMcacpObRo%2F98hg6wd4o8Ht9rxV8etmfK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;노드 파드(&lt;code&gt;ebs-csi-node&lt;/code&gt;)는 세 개 다 &lt;code&gt;3/3 Running&lt;/code&gt;으로 멀쩡한데 컨트롤러(&lt;code&gt;ebs-csi-controller&lt;/code&gt;)만 &lt;code&gt;CrashLoopBackOff&lt;/code&gt;에 빠져 수십 번씩 재시작을 반복하고있었다.&lt;br&gt;같은 드라이버를 이루는 두 워크로드인데 한쪽만 죽는 게 이상했다. 그래서 컨트롤러가 왜 죽는지부터 봐야 했다.&lt;/p&gt;
&lt;p&gt;로그를 한 줄씩 따라가 봤다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl logs -n kube-system -l app=ebs-csi-controller -c ebs-plugin

&amp;quot;GRPC error&amp;quot; err=&amp;quot;rpc error: code = FailedPrecondition desc = Failed health check
(verify network connection and IAM credentials): dry-run EC2 API call failed:
operation error EC2: DescribeAvailabilityZones, get identity: get credentials:
failed to refresh cached credentials, no EC2 IMDS role found,
operation error ec2imds: GetMetadata, canceled, context deadline exceeded&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;컨트롤러는 뜰 때 자신이 EC2 API를 쓸 수 있는지 &lt;code&gt;DescribeAvailabilityZones&lt;/code&gt;를 한 번 호출해 보는 health check를 한다. 그런데 그 호출에 쓸 자격증명을 구하지 못하고 있었다(&lt;code&gt;no EC2 IMDS role found&lt;/code&gt;).&lt;br&gt;마지막 줄에서 자격증명을 IMDS에서 가져오려다 &lt;code&gt;GetMetadata&lt;/code&gt;가 &lt;code&gt;context deadline exceeded&lt;/code&gt;로 시간 초과됐다. 컨트롤러 Pod가 IMDS에 닿지를 못한 것이다.&lt;/p&gt;
&lt;p&gt;EBS CSI 컨트롤러는 실제로 EBS 볼륨을 만들고 붙이려고 AWS API를 호출하는 쪽이라 자격증명이 꼭 필요하다. 따로 설정해 준 게 없으니 노드의 IMDS에 기대는데, 컨트롤러 Pod는 거기에 닿지를 못했다.&lt;br&gt;같은 노드 위에 떠 있는 &lt;code&gt;ebs-csi-node&lt;/code&gt;(DaemonSet)는 멀쩡한데 컨트롤러만 못 닿은 건, 둘이 IMDS까지 가는 네트워크 경로(hop)가 다르기 때문이다.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;IMDS랑 hop 자세히&lt;/summary&gt;

&lt;p&gt;&lt;strong&gt;IMDS(Instance Metadata Service)&lt;/strong&gt; 는 EC2 인스턴스마다 내부 주소(&lt;code&gt;169.254.169.254&lt;/code&gt;)에 떠 있는 서비스다. 그 인스턴스의 정보와 인스턴스에 붙은 IAM 역할의 임시 자격증명을 돌려준다.&lt;br&gt;AWS SDK는 명시적으로 받은 자격증명이 없으면 마지막에 이 IMDS에 물어보는데, EBS CSI 컨트롤러도 그 경로로 자격증명을 얻으려다 실패한 것이다.&lt;/p&gt;
&lt;p&gt;문제는 IMDS로 가는 요청이 몇 hop까지 갈 수 있는지를 제한하는 설정(&lt;code&gt;HttpPutResponseHopLimit&lt;/code&gt;)이 있고, 그 기본값이 보통 &lt;strong&gt;1&lt;/strong&gt;이라는 점이다. 같은 노드 위에 떠 있어도 두 파드가 IMDS까지 가는 경로가 다르다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ebs-csi-node&lt;/code&gt; (DaemonSet)&lt;/strong&gt; — &lt;code&gt;hostNetwork: true&lt;/code&gt;로 떠서 노드의 네트워크 네임스페이스를 그대로 공유한다. IMDS 요청이 노드가 직접 보내는 것과 같아 &lt;strong&gt;1 hop&lt;/strong&gt;, 제한(1) 안에 들어오니 자격증명을 잘 받아온다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ebs-csi-controller&lt;/code&gt; (Deployment)&lt;/strong&gt; — 자기만의 파드 네트워크 네임스페이스를 쓴다(hostNetwork 아님). IMDS 요청이 &lt;code&gt;파드 → veth → 노드&lt;/code&gt;로 네임스페이스를 한 칸 더 건너야 하고, 그 경계를 넘을 때 hop이 하나 늘어 &lt;strong&gt;2 hop&lt;/strong&gt;이 된다. 제한이 1이면 노드 경계에서패킷의 TTL이 0이 돼 버려지고, IMDS는 응답하지 않는다 -&amp;gt; &lt;code&gt;context deadline exceeded&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉 같은 드라이버인데도 노드 파드는 닿고 컨트롤러만 못 닿은 건 이 hop 차이 때문이다.&lt;br&gt;사실 고치는 길은 둘이다. (1) IMDS hop 제한을 2로 올려 컨트롤러도 닿게 하거나, (2) 아예&lt;br&gt;IMDS를 안 타도록 IRSA로 컨트롤러 전용 자격증명을 주거나. 1번은 결국 노드 IAM 역할을&lt;br&gt;그대로 쓰게 돼 권한 분리가 안 되니 최소 권한 면에서 2번이 깔끔하고 AWS도 이쪽을 권장한다.&lt;/p&gt;
&lt;/details&gt;

&lt;p&gt;처음엔 노드 IAM 역할에 &lt;code&gt;AmazonEBSCSIDriverPolicy&lt;/code&gt;만 붙이면 되지 않나 싶었다. 그런데 그것만으로는 안 됐다. 권한이 노드 역할에 있어도 컨트롤러가 IMDS를 통해 그 권한을 가져오지 못하면 아무 소용이 없기 때문이다.&lt;/p&gt;
&lt;p&gt;그럼 컨트롤러한테 IMDS 말고 다른 경로로 자격증명을 쥐여줘야 한다는 건데 EKS에서 이런 컨트롤러에 권한을 주는 표준 방법이 IRSA였다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;IRSA(IAM Roles for Service Accounts)&lt;/strong&gt; 는 IMDS를 거치지 않고, 쿠버네티스 ServiceAccount에 IAM 역할을 직접 묶어 Pod에 AWS 자격증명을 주는 방식이다. 그 ServiceAccount로 도는 Pod는 발급받은 토큰을 들고 AWS STS에 가서 해당 역할의 임시자격증명을 받아온다. 노드 네트워크(IMDS)를 타지 않으니 hop 문제도 자연히 사라진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그래서 컨트롤러의 ServiceAccount(&lt;code&gt;ebs-csi-controller-sa&lt;/code&gt;)에 IAM Role을 연결하고, 애드온이 그 Role을 쓰도록 지정해 주도록 했다.&lt;/p&gt;
&lt;h2&gt;문제해결&lt;/h2&gt;
&lt;h3&gt;Terraform에 IRSA 추가&lt;/h3&gt;
&lt;p&gt;컨트롤러 ServiceAccount(&lt;code&gt;ebs-csi-controller-sa&lt;/code&gt;)가 쓸 IAM Role을 만들고, 애드온이 그 Role을 쓰도록 &lt;code&gt;service_account_role_arn&lt;/code&gt;을 지정했다. Role의 신뢰 정책에는 그 ServiceAccount만 이 Role을 가져갈 수 있게 OIDC &lt;code&gt;sub&lt;/code&gt; 조건을 걸었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;# EBS CSI 컨트롤러용 IRSA Role
resource &amp;quot;aws_iam_role&amp;quot; &amp;quot;ebs_csi&amp;quot; {
  name = &amp;quot;${var.project}-${var.env}-ebs-csi-role&amp;quot;

  assume_role_policy = jsonencode({
    Version = &amp;quot;2012-10-17&amp;quot;
    Statement = [{
      Effect    = &amp;quot;Allow&amp;quot;
      Principal = { Federated = aws_iam_openid_connect_provider.eks.arn }
      Action    = &amp;quot;sts:AssumeRoleWithWebIdentity&amp;quot;
      Condition = {
        StringEquals = {
          # 이 SA만 이 Role을 가져갈 수 있도록 제한
          &amp;quot;${oidc}:sub&amp;quot; = &amp;quot;system:serviceaccount:kube-system:ebs-csi-controller-sa&amp;quot;
          &amp;quot;${oidc}:aud&amp;quot; = &amp;quot;sts.amazonaws.com&amp;quot;
        }
      }
    }]
  })
}

resource &amp;quot;aws_iam_role_policy_attachment&amp;quot; &amp;quot;ebs_csi&amp;quot; {
  role       = aws_iam_role.ebs_csi.name
  policy_arn = &amp;quot;arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy&amp;quot;
}

resource &amp;quot;aws_eks_addon&amp;quot; &amp;quot;ebs_csi_driver&amp;quot; {
  cluster_name             = module.eks.cluster_name
  addon_name               = &amp;quot;aws-ebs-csi-driver&amp;quot;
  service_account_role_arn = aws_iam_role.ebs_csi.arn   # 애드온이 이 Role을 컨트롤러 SA에 연결 → IMDS 안 타고 자격증명 획득
  depends_on               = [aws_iam_role_policy_attachment.ebs_csi]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(&lt;code&gt;oidc&lt;/code&gt;는 &lt;code&gt;replace(aws_iam_openid_connect_provider.eks.url, &amp;quot;https://&amp;quot;, &amp;quot;&amp;quot;)&lt;/code&gt;를 줄인 표기다.)&lt;/p&gt;
&lt;h3&gt;적용&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;terraform apply&lt;/code&gt; — 이번엔 애드온이 &lt;code&gt;CREATING&lt;/code&gt;에 묶이지 않고 곧 &lt;code&gt;ACTIVE&lt;/code&gt;로 넘어가면서 apply가 정상 종료됐다.&lt;br&gt;컨트롤러가 IRSA로 자격증명을 받자마자 health check를 통과했기 때문이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KC1hm/dJMcabLe157/cgIl6Eo2MDhTlfyPTs6370/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KC1hm/dJMcabLe157/cgIl6Eo2MDhTlfyPTs6370/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KC1hm/dJMcabLe157/cgIl6Eo2MDhTlfyPTs6370/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKC1hm%2FdJMcabLe157%2FcgIl6Eo2MDhTlfyPTs6370%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b7Sfix/dJMcagZYGBf/DgbiA75Lr5duc0UFcKsxE0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b7Sfix/dJMcagZYGBf/DgbiA75Lr5duc0UFcKsxE0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b7Sfix/dJMcagZYGBf/DgbiA75Lr5duc0UFcKsxE0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb7Sfix%2FdJMcagZYGBf%2FDgbiA75Lr5duc0UFcKsxE0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;컨트롤러부터 확인해보면 CrashLoop이 멈추고 살아 있다.&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bSxgFQ/dJMcageEvqx/76OHJez5IUobKkYWmsaoK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bSxgFQ/dJMcageEvqx/76OHJez5IUobKkYWmsaoK0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bSxgFQ/dJMcageEvqx/76OHJez5IUobKkYWmsaoK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbSxgFQ%2FdJMcageEvqx%2F76OHJez5IUobKkYWmsaoK0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;그러자 막혀 있던 게 줄줄이 풀렸다. 컨트롤러가 EBS 볼륨을 만들어 PV로 붙여주니 PVC가 &lt;code&gt;Bound&lt;/code&gt;로 바뀌고, 볼륨을 받은 Loki/Tempo Pod도 &lt;code&gt;Running&lt;/code&gt;으로 올라왔다.&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zocjh/dJMcaiKfHds/kAVAeNzHUtEWxDZ200dPK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zocjh/dJMcaiKfHds/kAVAeNzHUtEWxDZ200dPK0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zocjh/dJMcaiKfHds/kAVAeNzHUtEWxDZ200dPK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fzocjh%2FdJMcaiKfHds%2FkAVAeNzHUtEWxDZ200dPK0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;처음 Pending에서 출발해 StorageClass -&amp;gt; 드라이버 -&amp;gt; IRSA를 차례로 짚고 나서 PVC가 볼륨을 잡고 Pod가 떴다.&lt;/p&gt;
&lt;h2&gt;재발방지대책&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;새 EKS 클러스터엔 EBS CSI 드라이버가 기본으로 없다.&lt;/strong&gt; 스토리지를 쓰는 워크로드를 올리기 전에 애드온이 깔려 있는지부터 확인하는 걸 셋업 체크리스트에 넣었다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;애드온은 IRSA까지 묶여야 &lt;code&gt;ACTIVE&lt;/code&gt;가 된다.&lt;/strong&gt; &lt;code&gt;terraform apply&lt;/code&gt;가 &lt;code&gt;aws_eks_addon&lt;/code&gt;에서 안 끝나고 한참 멈춰 있으면, 컨트롤러가 자격증명을 못 받아 health check에서 떨어지는 경우일 수 있다. 그땐 apply를 노려보지 말고 다른 터미널에서 파드와 로그부터 본다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;컨트롤러는 노드 IAM 권한만으로는 부족하다.&lt;/strong&gt; IMDS 경로가 hop에 막히기 때문에 권한을 노드 역할에 아무리 잘 붙여도 소용없다. 이 컨트롤러는 IRSA를 붙여야 한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PVC가 Pending이면 &lt;code&gt;kubectl describe pvc&lt;/code&gt;의 Events부터 본다.&lt;/strong&gt; &lt;code&gt;ExternalProvisioning&lt;/code&gt; 메시지에 어떤 프로비저너를 기다리는지가 찍혀서 원인을 가장 빠르게 좁혀준다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>TroubleShooting</category>
      <author>2-30</author>
      <guid isPermaLink="true">https://2-3-0.tistory.com/18</guid>
      <comments>https://2-3-0.tistory.com/18#entry18comment</comments>
      <pubDate>Mon, 1 Jun 2026 23:30:01 +0900</pubDate>
    </item>
    <item>
      <title>[테스트] Load Test와 Spike Test 찍먹해보기</title>
      <link>https://2-3-0.tistory.com/17</link>
      <description>&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;서비스가 어느 정도 안정적으로되면서 자연스럽게 다음 스텝인 &lt;strong&gt;운영 고도화&lt;/strong&gt;에 눈이 가기 시작했다. 서비스가 단순히 뜨는 것을 넘어 얼마나 버틸 수 있을지 궁금해졌다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;트래픽이 10 RPS, 20 RPS로 올라갈 때 응답 시간(Latency)은 안정적으로 유지되는가?&lt;/li&gt;
&lt;li&gt;설정해 둔 HPA가 제때 발동하는가? 발동한다면 새로운 Pod이 뜨기까지 몇 초나 걸리는가?&lt;/li&gt;
&lt;li&gt;만약 시스템이 무너진다면 그 임계점은 어디인가? 앱 서버가 먼저 뻗을까? DB가 먼저 뻗을까?&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;용어 정리&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RPS (Requests Per Second)&lt;/strong&gt;: 초당 요청 수. &amp;quot;이 서비스가 1초에 몇 건을 처리하는가&amp;quot;를 재는 &lt;strong&gt;결과 지표&lt;/strong&gt;. 성능테스트가 보려는 값.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VU (Virtual User)&lt;/strong&gt;: 가상 사용자 한 명. 부하 도구가 띄우는 동시 사용자 단위로, 한 VU가 요청을 던지고 응답을 받으면 곧바로 다음 요청을 던지는 식으로 순환한다. &lt;strong&gt;RPS가 결과라면 VU는 입력&lt;/strong&gt;이다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VU와 RPS의 관계&lt;/strong&gt;: 두 값은 1:1이 아니다. 응답시간이 50ms면 1 VU가 초당 20요청(=20 RPS)을 만들지만, 응답이 500ms로 늘어지면 1 VU가 초당 2요청(=2 RPS)밖에 못 만든다. 즉 &lt;strong&gt;VU를 고정하면 RPS는 응답시간에 따라 출렁이고, RPS를 고정하면 도구가 필요한 만큼 VU를 자동으로 늘린다.&lt;/strong&gt; 시나리오 작성 시 측정 의도가 &amp;quot;특정 RPS에서의 응답시간&amp;quot;이라면 VU 기반(&lt;code&gt;ramping-vus&lt;/code&gt;)이 아니라 RPS 기반(&lt;code&gt;ramping-arrival-rate&lt;/code&gt;) 방식으로 부하를 거는 게 정확하다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;p95 / p99&lt;/strong&gt;: 응답시간 분포의 95 / 99 백분위수. p95가 200ms라는 건 100건 중 95건은 200ms 안에 응답했다는 뜻. 평균보다 꼬리(tail) 응답을 보기 위해 쓴다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;1. 성능테스트의 종류&lt;/h2&gt;
&lt;p&gt;성능테스트는 트래픽을 어떤 모양으로, 얼마나 오랫동안 주느냐에 따라 종류가 나뉜다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;종류&lt;/th&gt;
&lt;th&gt;목적&lt;/th&gt;
&lt;th&gt;형태&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Load Test&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;예상 피크 트래픽을 안정적으로 견디는가? p95/p99 응답 시간이 만족스러운가?&lt;/td&gt;
&lt;td&gt;일정 RPS를 유지하면서 응답시간 측정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stress Test&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;시스템이 어디서부터 깨지는가? 최대 한계점(Breakpoint)은 어디인가?&lt;/td&gt;
&lt;td&gt;RPS를 점진적으로 올리면서 에러율 관찰&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Spike Test&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;갑작스러운 트래픽 폭증에 오토스케일링이 따라잡는가?&lt;/td&gt;
&lt;td&gt;짧은 시간에 RPS를 급격히 ramp-up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Soak Test&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;장시간 부하에서 메모리/커넥션 누수가 없는가?&lt;/td&gt;
&lt;td&gt;같은 부하를 수 시간~수 일 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;br/&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;br/&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;용어 정리&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HPA (Horizontal Pod Autoscaler)&lt;/strong&gt;: 쿠버네티스의 기본 오토스케일러. CPU나 메모리 사용량을 보고 파드 개수를 자동으로 늘리거나 줄인다. &amp;quot;Horizontal&amp;quot;인 이유는 파드 하나의 스펙을 키우는(vertical) 게 아니라 개수를 늘리기(horizontal) 때문.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SLA (Service Level Agreement)&lt;/strong&gt;: 서비스 품질 기준. 보통 &amp;quot;99.9% 가용성, p95 &amp;lt; 500ms&amp;quot; 같은 식으로 정의.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;2. 성능테스트 실행 전&lt;/h2&gt;
&lt;h3&gt;2-1. 성능테스트 선택 이유&lt;/h3&gt;
&lt;p&gt;처음에는 Load / Stress / Spike / Soak 네 가지 성능테스트를 모두 진행하는 방향으로 생각했다. 하지만 AWS 비용과 테스트 및 분석 시간 등의 작업량을 함께 고려해야 했다. 그래서 네 가지를 한 번에 모두 수행하기보다는 우선순위를 정한 후 차례대로 진행하기로 했다. 그래서 &lt;strong&gt;Load Test&lt;/strong&gt;와 &lt;strong&gt;Spike Test&lt;/strong&gt;를 먼저 해보았다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Load Test&lt;/strong&gt; — 이 서비스가 어느 정도의 트래픽을 안정적으로 처리할 수 있는지에 대한 기준선이 아직 없었다. 실제로 어느 RPS 구간까지 안정적인지, p95/p99 응답 시간과 에러율은 어떤지를 확인하면 HPA 임계값, 리소스 request/limit, DB 인스턴스 크기 등을 판단할 때 기준이 된다. Load Test는 이후 성능 개선 작업의 출발점 역할을 한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spike Test&lt;/strong&gt; — DevOps 관점에서 가장 궁금했던 부분은 트래픽이 갑자기 튈 때 인프라가 어떻게 반응하는지였다. 실제 서비스 트래픽은 이벤트, 푸시 알림, 외부 유입 등으로 짧은 시간에 급증할 수 있고, 이때 HPA가 몇 초 만에 scale-out을 시작하는지, 새 Pod이 Ready 되기 전까지 latency와 error rate가 얼마나 흔들리는지 체크해봐야한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Stress Test&lt;/strong&gt;와 &lt;strong&gt;Soak Test&lt;/strong&gt;는 후속 작업으로 남겨두기로 했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Stress Test&lt;/strong&gt;는 시스템의 최대 한계와 병목 레이어를 찾는 데 의미가 있지만 현재 단계에서는 Load Test 결과만으로도 병목 후보를 어느 정도 확인할 수 있다. 또한 높은 RPS를 오래 밀어 넣으면 RDS, 노드, 애플리케이션 전체에 부담이 크기 때문에 먼저 기준 부하와 순간 부하를 확인한 뒤 진행하는 편이 낫다고 판단했다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Soak Test&lt;/strong&gt;는 장시간 부하에서 메모리 누수나 커넥션 누수, 메트릭 수집 안정성을 보는 테스트다. 장시간 안정성 검증은 기본 성능과 오토스케일링 결과를 정리한 뒤 진행할 예정이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2-2. 테스트 범위와 측정 기준&lt;/h3&gt;
&lt;p&gt;이번 테스트는 외부 인터넷 구간 전체의 end-to-end latency를 측정하는 테스트는 아니다. k6는 Kubernetes 클러스터 내부 Pod로 실행하고, 요청은 &lt;code&gt;istio-ingressgateway.istio-system.svc.cluster.local&lt;/code&gt;을 통해 서비스로 전달한다.&lt;br&gt;&lt;br/&gt;&lt;br&gt;따라서 CloudFront, WAF, ALB, 인터넷 왕복 구간의 지연 시간은 측정 대상에서 제외된다. 이번 테스트의 목적은 클러스터 내부에서 Istio Gateway를 경유한 애플리케이션 처리 성능과 HPA scale-out 반응을 확인하는 것이다.&lt;br&gt;&lt;br/&gt;&lt;br&gt;즉, 측정 기준은 다음에 가깝다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;특정 RPS에서 애플리케이션이 안정적으로 응답하는가&lt;/li&gt;
&lt;li&gt;부하 증가 시 Pod CPU 사용률과 HPA가 어떻게 반응하는가&lt;/li&gt;
&lt;li&gt;latency와 error rate가 어느 구간에서 흔들리기 시작하는가&lt;/li&gt;
&lt;li&gt;RDS, Pod, Node 중 어느 레이어가 병목 후보로 보이는가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2-3. 도구 선택&lt;/h3&gt;
&lt;p&gt;처음에는 어떤 도구를 써야 할지 감이 없어서, JMeter, k6, Locust, Gatling처럼 자주 언급되는 도구들을 먼저 검색해보고 각각 어떤 특징이 있는지 정리했다. 아래 비교는 직접 모든 도구를 실습해본 결과라기보다는 공식 문서와 사용 후기와 비교 글들을 보면서 초보자 입장에서 이해한 내용을 내 프로젝트 상황에 맞게 정리한 것이다.&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;조사한 도구 특징&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;JMeter&lt;/th&gt;
&lt;th&gt;k6&lt;/th&gt;
&lt;th&gt;Locust&lt;/th&gt;
&lt;th&gt;Gatling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;시나리오 언어&lt;/td&gt;
&lt;td&gt;GUI / JMX(XML)&lt;/td&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;Scala / Java&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;런타임&lt;/td&gt;
&lt;td&gt;JVM&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;CPython + gevent&lt;/td&gt;
&lt;td&gt;JVM (Akka)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;단일 머신 성능&lt;/td&gt;
&lt;td&gt;중간 (JVM 메모리 부담)&lt;/td&gt;
&lt;td&gt;높음 (Go goroutine)&lt;/td&gt;
&lt;td&gt;중간 (GIL 우회하지만 한계 있음)&lt;/td&gt;
&lt;td&gt;높음 (비동기 I/O)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prometheus 연동&lt;/td&gt;
&lt;td&gt;플러그인 별도 설치 필요&lt;/td&gt;
&lt;td&gt;표준 지원 (remote write)&lt;/td&gt;
&lt;td&gt;별도 exporter 필요&lt;/td&gt;
&lt;td&gt;플러그인 별도 설치 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기본 리포트&lt;/td&gt;
&lt;td&gt;HTML 리포트&lt;/td&gt;
&lt;td&gt;CLI 요약&lt;/td&gt;
&lt;td&gt;웹 UI (실시간)&lt;/td&gt;
&lt;td&gt;HTML 리포트 (상세)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;코드 버전 관리&lt;/td&gt;
&lt;td&gt;어색함 (XML)&lt;/td&gt;
&lt;td&gt;자연스러움&lt;/td&gt;
&lt;td&gt;자연스러움&lt;/td&gt;
&lt;td&gt;자연스러움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;러닝 커브&lt;/td&gt;
&lt;td&gt;낮음 (GUI 있음)&lt;/td&gt;
&lt;td&gt;낮음~중간&lt;/td&gt;
&lt;td&gt;낮음 (Python)&lt;/td&gt;
&lt;td&gt;높음 (Scala)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;선택 근거&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;도구 자체의 우열을 단정하기보다는 지금 내 프로젝트에서 중요하게 보는 조건을 먼저 정했다. Shoong은 이미 Prometheus/Grafana 기반 옵저버빌리티를 구성해둔 상태이고, 테스트 시나리오도 GitOps 레포에서 코드로 관리하고 싶었다. 또한 별도의 부하 발생기 서버를 크게 구성할 여유가 없어서, 가능한 단순한 구조로 시작할 수 있는 도구가 필요했다.&lt;br&gt;&lt;br/&gt;&lt;br&gt;첫째, &lt;strong&gt;부하 발생 구조가 단순해야 했다.&lt;/strong&gt; 별도 부하 발생기 서버를 따로 만들기보다는, Kubernetes 내부에서 Job/CronJob 형태로 실행하고 싶었다. 검색해보니 JMeter는 오래된 표준 도구에 가깝고 자료도 많지만 JVM 기반이고 GUI/XML 중심의 예제가 많아 GitOps로 시나리오를 관리하기에는 조금 무겁게 느껴졌다. Gatling은 성능이 좋다는 평가가 많았지만 Scala/Java 기반이라 처음 시작하는 입장에서는 러닝 커브가 부담스러웠다.&lt;br&gt;&lt;br/&gt;&lt;br&gt;둘째, &lt;strong&gt;Prometheus/Grafana와 연결하기 쉬워야 했다.&lt;/strong&gt; 이번 테스트는 단순히 CLI 결과만 보는 것이 아니라 앱 메트릭·인프라 메트릭·부하 메트릭을 같은 Grafana 화면에서 시간대별로 맞춰보는 것이 목적이었다. k6는 Prometheus remote write 연동과 Grafana 대시보드 예제가 많이 보여서 이미 구성해둔 모니터링 스택과 연결하기 가장 자연스러워 보였다. 다른 도구들도 연동은 가능하지만 exporter나 플러그인을 추가로 붙여야 하는 경우가 많아 현재 단계에서는 작업량이 더 커질 것 같았다.&lt;br&gt;&lt;br/&gt;&lt;br&gt;셋째, &lt;strong&gt;시나리오를 코드로 관리하기 쉬워야 했다.&lt;/strong&gt; Locust는 Python으로 작성할 수 있어서 접근성이 좋아 보였고, k6는 JavaScript로 작성할 수 있다는 점이 눈에 들어왔다. Shoong API 서비스들이 Node.js/Express 기반이기 때문에 JavaScript로 요청 시나리오를 작성하는 k6 쪽이 프로젝트 맥락과 더 잘 맞는다고 판단했다. 나중에 테스트 코드를 GitOps 레포에 넣고 변경 이력을 남기기에도 k6 스크립트가 읽기 쉬워 보였다.&lt;br&gt;&lt;br/&gt;&lt;br&gt;현재 내 상황에서 가장 적은 시행착오로 시작할 수 있고, 이미 구성한 Grafana/Prometheus와 연결하기 쉬우며, JavaScript 코드로 시나리오를 관리할 수 있기 때문에 k6를 선택했다. 이후 더 복잡한 테스트가 필요해지면 Locust나 Gatling도 다시 검토할 수 있겠지만 이번 성능테스트의 첫 도구로는 k6가 가장 합리적이라고 판단했다.&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;용어 정리&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;k6&lt;/strong&gt;: Grafana Labs에서 만든 성능테스트 도구. Go로 짜여있어서 한 머신에서 수만 VU를 띄울 수 있고, 결과를 Prometheus로 바로 보낼 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;2-4. 성능테스트 시 인프라 스펙&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구성 요소&lt;/th&gt;
&lt;th&gt;스펙&lt;/th&gt;
&lt;th&gt;비고&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;EKS 노드&lt;/td&gt;
&lt;td&gt;c7i-flex.large × 3 (2vCPU/4GB)&lt;/td&gt;
&lt;td&gt;desired 3, min 2, max 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDS&lt;/td&gt;
&lt;td&gt;db.t3.micro, single-AZ&lt;/td&gt;
&lt;td&gt;single-AZ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ALB&lt;/td&gt;
&lt;td&gt;단일 ALB&lt;/td&gt;
&lt;td&gt;변경 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Istio&lt;/td&gt;
&lt;td&gt;기본 sidecar 주입&lt;/td&gt;
&lt;td&gt;변경 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HPA&lt;/td&gt;
&lt;td&gt;min 2 / max 5 / CPU 70%&lt;/td&gt;
&lt;td&gt;dev에서만 enabled: true. min=2는 Istio sidecar 재시작 중 단일 파드 503 방지 목적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;앱 리소스&lt;/td&gt;
&lt;td&gt;cpu req 100m, limit 200m / mem req 64Mi, limit 128Mi&lt;/td&gt;
&lt;td&gt;변경 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;br/&gt;

&lt;p&gt;CPU request 100m, target 70% 설정이면 평균 CPU 사용률이 70m(= request의 70%)을 안정적으로 넘기 시작할 때 HPA가 파드를 늘리는 결정을 내린다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. Load Test&lt;/h2&gt;
&lt;h3&gt;3-1. 시나리오&lt;/h3&gt;
&lt;p&gt;대상: &lt;code&gt;POST /orders/:menuId&lt;/code&gt; (주문 생성, DB write 포함)&lt;/p&gt;
&lt;p&gt;처음에는 100 RPS와 300 RPS 구간을 기준으로 Load Test를 설계했다. 하지만 실제로 시도해보니 에러율이 너무 크게 올라가고(약 47%), 일부 요청은 15초 이상 지연되면서 Istio timeout으로 503이 발생했다.&lt;br&gt;주문 생성 API는 단순 조회가 아니라 DB write, kitchen/notification 서비스 호출, 상태 변경 흐름까지 포함하고 있고, RDS도 &lt;code&gt;db.t3.micro&lt;/code&gt;인 상태라 100 RPS부터 시작하는 것은 기준 부하 측정이라기보다 이미 한계에 가까운 부하를 주는 방식에 가까웠다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9dmmd/dJMcabEmhpE/TlZbKD0coZATQyyLbAqosk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9dmmd/dJMcabEmhpE/TlZbKD0coZATQyyLbAqosk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9dmmd/dJMcabEmhpE/TlZbKD0coZATQyyLbAqosk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9dmmd%2FdJMcabEmhpE%2FTlZbKD0coZATQyyLbAqosk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;100→300 RPS 첫 시도. HTTP failures 25,750건(전체 대비 약 47%).&lt;/em&gt;&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;그래서 현재 인프라에서 안정적으로 처리 가능한 기준선을 찾는 방향으로 조정했다. 최종 시나리오는 10 RPS, 30 RPS, 50 RPS 구간을 순차적으로 유지하면서 응답시간과 에러율, HPA 반응을 확인하는 방식으로 잡았다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;export const options = {
  scenarios: {
    load: {
      executor: &amp;quot;ramping-arrival-rate&amp;quot;, // RPS를 직접 제어 (도착률 기반)
      startRate: 0, // 시작 RPS
      timeUnit: &amp;quot;1s&amp;quot;, // rate의 단위 (초당 N건)
      preAllocatedVUs: 20, // 미리 띄워둘 VU 풀
      maxVUs: 200, // 부족할 때 늘릴 수 있는 상한
      stages: [
        { duration: &amp;quot;2m&amp;quot;, target: 10 }, // 0 → 10 RPS
        { duration: &amp;quot;5m&amp;quot;, target: 10 }, // 10 RPS 유지
        { duration: &amp;quot;2m&amp;quot;, target: 30 }, // 10 → 30 RPS
        { duration: &amp;quot;5m&amp;quot;, target: 30 }, // 30 RPS 유지
        { duration: &amp;quot;2m&amp;quot;, target: 50 }, // 30 → 50 RPS
        { duration: &amp;quot;5m&amp;quot;, target: 50 }, // 50 RPS 유지
        { duration: &amp;quot;2m&amp;quot;, target: 0 }, // ramp-down
      ],
    },
  },
  thresholds: {
    http_req_duration: [&amp;quot;p(95)&amp;lt;500&amp;quot;], // p95 500ms 미만
    http_req_failed: [&amp;quot;rate&amp;lt;0.01&amp;quot;], // 에러율 1% 미만
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;thresholds&lt;/strong&gt;: 테스트의 성능 합격/불합격(Pass/Fail) 기준. 예를 들어, &amp;quot;응답 시간 200ms 이하, 에러율 1% 미만&amp;quot;처럼 목표 수치를 미리 설정해 두고, 테스트 종료 시 이 기준을 통과했는지 도구가 자동 판정&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;시나리오 선택 메모&lt;/strong&gt;: k6의 기본 executor인 &lt;code&gt;ramping-vus&lt;/code&gt;는 VU 수를 시간에 따라 변동시키는 방식인데 이 경우 실제 RPS는 응답시간에 따라 출렁인다. 이 테스트는 &amp;quot;10 RPS / 30 RPS / 50 RPS에서의 응답시간&amp;quot;이 측정 의도라서 RPS를 고정하는 &lt;code&gt;ramping-arrival-rate&lt;/code&gt; executor를 선택했다. VU는 도구가 알아서 풀(&lt;code&gt;preAllocatedVUs&lt;/code&gt;~&lt;code&gt;maxVUs&lt;/code&gt; 범위)에서 끌어와 쓴다.&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;측정 대상:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;RPS별 p50/p95/p99 응답시간&lt;/li&gt;
&lt;li&gt;에러율&lt;/li&gt;
&lt;li&gt;DB CPU 사용률 (CloudWatch)&lt;/li&gt;
&lt;li&gt;노드/파드 CPU 사용률 (Prometheus)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;실행 방식&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;외부 ALB 방식 대신 내부 Pod로 실행을 택한 이유는 두 가지다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Prometheus를 외부에 노출하지 않고 ClusterIP 그대로 쓰기 위해 외부로 빼면 메트릭 데이터 공개 면적이 늘어난다. &amp;gt;&amp;gt; 원하지 않음&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;측정 노이즈를 줄이기 위해 부하 발생기에 Istio sidecar가 끼면 그 자체의 latency가 측정값에 섞인다. &lt;code&gt;loadtest&lt;/code&gt; namespace를 만들고 &lt;code&gt;istio-injection=disabled&lt;/code&gt; 라벨을 붙여 sidecar 미주입. &amp;gt;&amp;gt; 방해되는 수준은 미미할 수 있겠지만 최대한 줄여보고 싶었음.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;운영 패턴은 &lt;strong&gt;suspended CronJob&lt;/strong&gt;으로 잡았다. 시나리오·이미지·리소스는 GitOps(ArgoCD)로 관리하되, 실행 타이밍은 &lt;code&gt;kubectl create job --from=cronjob/...&lt;/code&gt; 으로 사람이 수동 trigger한다. ArgoCD가 sync마다 부하를 자동 실행해버리면 곤란하기 때문.&lt;/p&gt;
&lt;h3&gt;3-2. 테스트 전 상태&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;노드 3개(c7i-flex.large) 전체 자원 사용률&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMVi68/dJMcajvCKLZ/PTuxsVPEqkksoTuMXkSf7k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMVi68/dJMcajvCKLZ/PTuxsVPEqkksoTuMXkSf7k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMVi68/dJMcajvCKLZ/PTuxsVPEqkksoTuMXkSf7k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMVi68%2FdJMcajvCKLZ%2FPTuxsVPEqkksoTuMXkSf7k%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;CPU Utilisation 7.31%, Memory Utilisation 55.1%. Limits Commitment가 100%를 넘는 건&lt;br&gt;쿠버네티스 정상 동작으로, limits 합산이 노드 용량을 초과해도 실제 사용량이 낮으면&lt;br&gt;문제없다. 실제 사용량 기준으로는 부하를 받을 여유가 충분한 상태다.&lt;/em&gt;&lt;/p&gt;
&lt;br/&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;HPA 상태&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBtA94/dJMcadvkR2Q/bjGf7nAThRON6rrdxalRUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBtA94/dJMcadvkR2Q/bjGf7nAThRON6rrdxalRUK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBtA94/dJMcadvkR2Q/bjGf7nAThRON6rrdxalRUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBtA94%2FdJMcadvkR2Q%2FbjGf7nAThRON6rrdxalRUK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;CURRENT=2, CPU 사용률 한 자릿수%. HPA 트리거 조건(70%)에 한참 못 미치는 평시 상태. min=2는 Istio sidecar 재시작 중 단일 파드 503을 막기 위한 설정이라 부하 없이도 항상 2개가 유지된다.&lt;/em&gt;&lt;/p&gt;
&lt;br/&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Pod 상태&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxHdaS/dJMcahR5bJG/IwmzKFtJt6tKXbKaTvx2vK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxHdaS/dJMcahR5bJG/IwmzKFtJt6tKXbKaTvx2vK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxHdaS/dJMcahR5bJG/IwmzKFtJt6tKXbKaTvx2vK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxHdaS%2FdJMcahR5bJG%2FIwmzKFtJt6tKXbKaTvx2vK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;kubectl top 결과. 각 파드가 수십 mCore 수준으로 request(100m)의 절반 이하. 노드도 여유가 충분하다.&lt;/em&gt;&lt;/p&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;RDS CloudWatch&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CPUCreditBalance&lt;/strong&gt;: t3.micro는 burstable이라 크레딧이 얼마나 남아있냐가 테스트 시작 조건에 영향을 줌. 높을수록 좋음&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CPUUtilization&lt;/strong&gt;: DB가 부하 전에 얼마나 쉬고 있는지&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DatabaseConnections&lt;/strong&gt;: 테스트 전 커넥션 수 기준값. 나중에 테스트 중 급증하는 것과 비교할 때 기준이 됨&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FreeableMemory&lt;/strong&gt;: 메모리 여유&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blnBiI/dJMcah5GISU/baov4gU7nHYWGkgBOiovv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blnBiI/dJMcah5GISU/baov4gU7nHYWGkgBOiovv1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blnBiI/dJMcah5GISU/baov4gU7nHYWGkgBOiovv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblnBiI%2FdJMcah5GISU%2Fbaov4gU7nHYWGkgBOiovv1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;테스트 직전 RDS 4개 메트릭. 부하가 없는 안정 상태에서 시작했다.&lt;/em&gt;&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;3-3. 실행 절차&lt;/h3&gt;
&lt;p&gt;테스트는 다음 순서로 진행했다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;ArgoCD에서 &lt;code&gt;loadtest-dev&lt;/code&gt; 애플리케이션이 Synced 상태인지 확인&lt;/li&gt;
&lt;li&gt;&lt;code&gt;loadtest&lt;/code&gt; namespace에 Istio sidecar injection이 비활성화되어 있는지 확인&lt;/li&gt;
&lt;li&gt;k6 CronJob이 &lt;code&gt;suspend: true&lt;/code&gt; 상태인지 확인&lt;/li&gt;
&lt;li&gt;테스트 전 HPA, Pod, Node, RDS baseline 상태 캡처&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kubectl create job --from=cronjob/k6-load-test ...&lt;/code&gt; 명령으로 k6 Job 수동 실행&lt;/li&gt;
&lt;li&gt;실행 중 Grafana, HPA, Pod 상태를 동시에 관찰&lt;/li&gt;
&lt;li&gt;테스트 종료 후 k6 Job 로그와 Grafana/CloudWatch 지표 확인&lt;/li&gt;
&lt;li&gt;필요 시 테스트 데이터 정리&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;실제 실행 전 점검 명령은 다음과 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# ArgoCD Application 동기화 상태 확인
kubectl get application loadtest-dev -n argocd

# loadtest namespace의 Istio sidecar injection 비활성화 확인
kubectl get namespace loadtest --show-labels

# k6 CronJob이 자동 실행되지 않도록 suspend 상태인지 확인
kubectl get cronjob k6-load-test -n loadtest

# 부하 전 HPA / Pod / Node 상태 확인
kubectl get hpa -n shoong
kubectl get pods -n shoong -o wide
kubectl top pods -n shoong
kubectl top nodes&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실행 전 &lt;code&gt;loadtest-dev&lt;/code&gt; 애플리케이션은 Synced/Healthy 상태였고, k6 CronJob은 &lt;code&gt;suspend: true&lt;/code&gt;로 설정되어 있었다. 즉, 테스트 리소스는 GitOps로 관리하지만 ArgoCD sync만으로 부하 테스트가 자동 실행되지는 않는 상태다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btgcGC/dJMcageBQBw/9s5KP1KGwxeJiT5GKV7DK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btgcGC/dJMcageBQBw/9s5KP1KGwxeJiT5GKV7DK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btgcGC/dJMcageBQBw/9s5KP1KGwxeJiT5GKV7DK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbtgcGC%2FdJMcageBQBw%2F9s5KP1KGwxeJiT5GKV7DK1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;k6 Job은 CronJob 정의를 기준으로 수동 생성했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;jobName=&amp;quot;k6-load-$(date +%Y%m%d-%H%M%S)&amp;quot;
kubectl create job --from=cronjob/k6-load-test $jobName -n loadtest&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실행 중에는 HPA와 Pod 변화를 별도 터미널에서 계속 관찰했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl get hpa -n shoong -w
kubectl get pods -n shoong -w&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;테스트 종료 후에는 Job 상태와 k6 로그를 확인했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl get jobs -n loadtest
kubectl logs -n loadtest job/$jobName&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3-4. 실행 결과&lt;/h3&gt;
&lt;p&gt;테스트는 20:02쯤 시작했고, 10 RPS → 30 RPS → 50 RPS 순서로 약 23분 동안 진행했다. Grafana k6 대시보드 기준으로 전체 요청 수는 약 3.7만 건이었고, 50 RPS 구간에서 실패 요청이 본격적으로 보이기 시작했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTEI3y/dJMcacwwGSM/SZWnOcDtmdDrVY82mzhhmK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTEI3y/dJMcacwwGSM/SZWnOcDtmdDrVY82mzhhmK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTEI3y/dJMcacwwGSM/SZWnOcDtmdDrVY82mzhhmK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTEI3y%2FdJMcacwwGSM%2FSZWnOcDtmdDrVY82mzhhmK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;전체 overview에서는 10 RPS와 30 RPS 구간은 비교적 안정적으로 유지되었지만, 50 RPS 구간 진입 후 &lt;code&gt;http_req_s_errors&lt;/code&gt;가 나타났다. Grafana의 &lt;code&gt;HTTP Request Duration&lt;/code&gt; 패널은 No data로 표시되었기 때문에, 최종 latency 값은 k6 Job summary를 기준으로 확인했다.&lt;/p&gt;
&lt;h4&gt;10 RPS 구간&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKz2s9/dJMcadvkR57/c5kLV3dnznm3ZE6YreQRfK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKz2s9/dJMcadvkR57/c5kLV3dnznm3ZE6YreQRfK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKz2s9/dJMcadvkR57/c5kLV3dnznm3ZE6YreQRfK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKz2s9%2FdJMcadvkR57%2Fc5kLV3dnznm3ZE6YreQRfK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;10 RPS 유지 구간에서는 요청 수가 9~11 req/s 범위에서 안정적으로 유지되었고, HTTP request failures 패널에도 실패 데이터가 표시되지 않았다. 이 구간은 현재 인프라에서 무리 없이 처리 가능한 수준으로 볼 수 있다.&lt;/p&gt;
&lt;h4&gt;30 RPS 구간&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6Fiz0/dJMb99Ng0Xs/QPmiPJe3mPpUdsxZNPSHx0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6Fiz0/dJMb99Ng0Xs/QPmiPJe3mPpUdsxZNPSHx0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6Fiz0/dJMb99Ng0Xs/QPmiPJe3mPpUdsxZNPSHx0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6Fiz0%2FdJMb99Ng0Xs%2FQPmiPJe3mPpUdsxZNPSHx0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;30 RPS 유지 구간도 대체로 목표 RPS 근처를 유지했다. 이 구간까지는 실패 요청이 눈에 띄게 발생하지 않았고, Load Test의 기준 부하로는 안정적인 편이었다.&lt;/p&gt;
&lt;h4&gt;50 RPS 구간&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cLe4n3/dJMcac4jYZJ/SjcMT6KNm1xNoQ2lvrrsck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cLe4n3/dJMcac4jYZJ/SjcMT6KNm1xNoQ2lvrrsck/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cLe4n3/dJMcac4jYZJ/SjcMT6KNm1xNoQ2lvrrsck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcLe4n3%2FdJMcac4jYZJ%2FSjcMT6KNm1xNoQ2lvrrsck%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;50 RPS 구간에 들어가면서 요청 성공선과 실패선이 함께 나타났다. 순간적으로 전체 request rate가 100 req/s 근처까지 튀는 구간도 있었고, 실패 요청이 누적되었다. 최종 summary 기준 실패율은 1.58%로, 설정한 threshold인 1% 미만을 넘었다.&lt;/p&gt;
&lt;h4&gt;HPA 반응&lt;/h4&gt;
&lt;p&gt;부하가 올라가자 HPA는 실제로 scale-out을 수행했다. 20:19쯤에는 order 서비스가 최대 replica 5까지 증가했고, kitchen과 notification도 replica가 늘어났다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhdYAh/dJMcahYRT6Z/jqoqWmLCHdDV3QWPS2UVsK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhdYAh/dJMcahYRT6Z/jqoqWmLCHdDV3QWPS2UVsK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhdYAh/dJMcahYRT6Z/jqoqWmLCHdDV3QWPS2UVsK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbhdYAh%2FdJMcahYRT6Z%2FjqoqWmLCHdDV3QWPS2UVsK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;20:22쯤에도 order는 replica 5를 유지했고, kitchen은 4개까지 증가했다. 즉, 50 RPS 구간에서 주문 생성 경로의 주요 서비스들이 HPA target CPU 70% 근처 또는 그 이상으로 올라갔음을 확인할 수 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bV8uU7/dJMcad3cjAr/KBjvUVOdnWzTMH73zCK8dK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bV8uU7/dJMcad3cjAr/KBjvUVOdnWzTMH73zCK8dK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bV8uU7/dJMcad3cjAr/KBjvUVOdnWzTMH73zCK8dK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbV8uU7%2FdJMcad3cjAr%2FKBjvUVOdnWzTMH73zCK8dK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;delivery는 order/kitchen처럼 크게 증가하지 않았다. 처음에는 이 부분이 이상해 보였지만, 실제 호출 흐름을 다시 보면 자연스러운 결과다. k6가 직접 호출한 것은 주문 생성 API이고, 이 요청은 즉시 &lt;code&gt;order → kitchen /start&lt;/code&gt; 흐름을 만든다. 반면 delivery는 주문 생성 직후 바로 호출되는 것이 아니라, COOKING 상태 주문을 배치가 &lt;code&gt;kitchen /complete&lt;/code&gt;로 넘긴 뒤 &lt;code&gt;kitchen /complete → delivery /assign&lt;/code&gt; 흐름에서 호출된다.&lt;/p&gt;
&lt;p&gt;따라서 order와 kitchen은 k6 요청마다 직접 부하를 받았고, kitchen은 &lt;code&gt;/start&lt;/code&gt;와 &lt;code&gt;/complete&lt;/code&gt; 양쪽 흐름의 영향을 받았다. delivery도 후속 배달 배정 흐름에서 영향을 받았지만 호출 빈도와 처리 비용이 상대적으로 낮아 HPA target CPU 70%에 도달하지 않은 것으로 보인다.&lt;/p&gt;
&lt;p&gt;테스트가 끝난 뒤 20:40에는 모든 서비스가 다시 minReplicas인 2개로 돌아왔다. 이 시점에도 delivery CPU는 28% 정도로 약간의 부하 흔적이 남아 있었지만, replica를 늘릴 정도는 아니었다. 즉, scale-out뿐 아니라 부하 종료 후 scale-in까지 정상적으로 동작했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfSwLM/dJMcafNAsfg/vgfHaQRrdETdFmi1AKkDPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfSwLM/dJMcafNAsfg/vgfHaQRrdETdFmi1AKkDPk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfSwLM/dJMcafNAsfg/vgfHaQRrdETdFmi1AKkDPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfSwLM%2FdJMcafNAsfg%2FvgfHaQRrdETdFmi1AKkDPk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JNZW3/dJMcabj6L3j/5DO8BdWaYj9WNDdYDa6pBK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JNZW3/dJMcabj6L3j/5DO8BdWaYj9WNDdYDa6pBK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JNZW3/dJMcabj6L3j/5DO8BdWaYj9WNDdYDa6pBK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJNZW3%2FdJMcabj6L3j%2F5DO8BdWaYj9WNDdYDa6pBK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4&gt;Pod 상태&lt;/h4&gt;
&lt;p&gt;scale-out 중에는 새 Pod이 &lt;code&gt;Pending&lt;/code&gt;, &lt;code&gt;Running&lt;/code&gt;, &lt;code&gt;1/2&lt;/code&gt; 상태를 거쳐 올라오는 모습이 보였다. 특히 order, kitchen, notification 쪽에서 새 replica가 생성되었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JTUJd/dJMcadIRgrs/RKTLWPc5dFOcd0XAdtEPUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JTUJd/dJMcadIRgrs/RKTLWPc5dFOcd0XAdtEPUk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JTUJd/dJMcadIRgrs/RKTLWPc5dFOcd0XAdtEPUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJTUJd%2FdJMcadIRgrs%2FRKTLWPc5dFOcd0XAdtEPUk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;테스트 종료 후에는 다시 기존 replica 중심으로 안정화되었고, 주요 애플리케이션 Pod은 Running 상태를 유지했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lGmGl/dJMcabdfsl9/zjSflzKIRxUFBlk3aLqNKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lGmGl/dJMcabdfsl9/zjSflzKIRxUFBlk3aLqNKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lGmGl/dJMcabdfsl9/zjSflzKIRxUFBlk3aLqNKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlGmGl%2FdJMcabdfsl9%2FzjSflzKIRxUFBlk3aLqNKK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4&gt;애플리케이션 리소스 사용량&lt;/h4&gt;
&lt;p&gt;Grafana Kubernetes namespace 대시보드에서도 테스트 구간 동안 Pod CPU 사용량이 증가했다가 종료 후 내려가는 흐름을 확인할 수 있었다. 화면의 시간 범위는 UTC 기준 11:00 ~11:29로 표시되어 있는데 한국 시간으로는 20:00 ~ 20:29 테스트 구간에 해당한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KWcKS/dJMcah5GIVs/2JyqAVtXKT0JnME3EZ6ZU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KWcKS/dJMcah5GIVs/2JyqAVtXKT0JnME3EZ6ZU0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KWcKS/dJMcah5GIVs/2JyqAVtXKT0JnME3EZ6ZU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKWcKS%2FdJMcah5GIVs%2F2JyqAVtXKT0JnME3EZ6ZU0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4&gt;RDS 상태&lt;/h4&gt;
&lt;p&gt;RDS CloudWatch 지표를 보면 CPUUtilization은 테스트 중 약 12% 수준까지 상승했지만, CPUCreditBalance는 감소하지 않고 오히려 증가했다. 따라서 이번 테스트에서 DB CPU credit 소진이 직접적인 병목이었다고 보기는 어렵다. 다만 DatabaseConnections는 약 20개 수준에서 41개까지 증가했고, FreeableMemory도 테스트 중 일시적으로 감소했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/crU6AA/dJMb990S9lW/AupVIc7a2i2DPNBeTv5Mi1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/crU6AA/dJMb990S9lW/AupVIc7a2i2DPNBeTv5Mi1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/crU6AA/dJMb990S9lW/AupVIc7a2i2DPNBeTv5Mi1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcrU6AA%2FdJMb990S9lW%2FAupVIc7a2i2DPNBeTv5Mi1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4&gt;k6 summary와 threshold 결과&lt;/h4&gt;
&lt;p&gt;최종 k6 summary 기준으로 전체 요청은 37,645건, 실패 요청은 597건이었다. &lt;code&gt;http_req_failed&lt;/code&gt;는 1.58%로 threshold인 1% 미만을 넘었고, &lt;code&gt;http_req_duration&lt;/code&gt; p95도 2.59초로 threshold인 500ms 미만을 만족하지 못했다. 그래서 k6 Job은 정상적으로 끝까지 실행되었지만 threshold 실패로 &lt;code&gt;Failed&lt;/code&gt; 상태가 되었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/D2Y9E/dJMcai4uJQb/TC8hWaHerK3DIfCESlA0G0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/D2Y9E/dJMcai4uJQb/TC8hWaHerK3DIfCESlA0G0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/D2Y9E/dJMcai4uJQb/TC8hWaHerK3DIfCESlA0G0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FD2Y9E%2FdJMcai4uJQb%2FTC8hWaHerK3DIfCESlA0G0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vp5Wg/dJMcaiQ0bXe/Q8YCN7WVbkyRCcjM4JKt9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vp5Wg/dJMcaiQ0bXe/Q8YCN7WVbkyRCcjM4JKt9k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vp5Wg/dJMcaiQ0bXe/Q8YCN7WVbkyRCcjM4JKt9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fvp5Wg%2FdJMcaiQ0bXe%2FQ8YCN7WVbkyRCcjM4JKt9k%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;이번 Load Test의 결과를 정리하면 다음과 같다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;총 요청 수&lt;/td&gt;
&lt;td&gt;37,645건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실패 요청 수&lt;/td&gt;
&lt;td&gt;597건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실패율&lt;/td&gt;
&lt;td&gt;1.58%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 응답시간&lt;/td&gt;
&lt;td&gt;2.59초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;checks&lt;/td&gt;
&lt;td&gt;98.41%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dropped iterations&lt;/td&gt;
&lt;td&gt;154건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;threshold 결과&lt;/td&gt;
&lt;td&gt;실패 (&lt;code&gt;http_req_duration&lt;/code&gt;, &lt;code&gt;http_req_failed&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HPA 반응&lt;/td&gt;
&lt;td&gt;order 최대 5개, kitchen 최대 4개, notification 최대 3개까지 증가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;delivery 반응&lt;/td&gt;
&lt;td&gt;후속 흐름 영향으로 CPU는 증가했지만 HPA target 미달로 replica 2개 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;br/&gt;

&lt;p&gt;10 RPS와 30 RPS 구간은 비교적 안정적으로 처리되었지만, 50 RPS 구간에서는 latency와 실패율이 기준치를 넘었다. RDS CPU credit은 소진되지 않았기 때문에, 현재로서는 DB CPU보다 애플리케이션 처리 지연, 내부 서비스 호출 흐름, DB 커넥션 대기 가능성을 우선 의심해볼 수 있다. 다음 단계에서는 order 서비스의 요청 처리 흐름과 Prisma 커넥션 풀 설정을 먼저 확인해볼 필요가 있다.&lt;/p&gt;
&lt;h4&gt;다음 할 일&lt;/h4&gt;
&lt;p&gt;이번 테스트는 &amp;quot;50 RPS에서 threshold를 넘는다&amp;quot;는 사실까지 확인한 단계다. 다음에는 단순히 RPS를 더 올리기보다 실패 원인을 좁히는 작업을 먼저 해야 할 것 같다...&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;order API 처리 흐름 확인&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;주문 생성 요청 하나가 내부적으로 어떤 순서로 처리되는지 먼저 확인한다. 특히 DB write가 몇 번 발생하는지, kitchen/notification 호출을 동기적으로 기다리는지, 내부 API 호출 timeout이 있는지, 응답을 언제 반환하는지 봐야 한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prisma connection pool 확인&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;RDS CPU credit은 소진되지 않았지만 DatabaseConnections는 증가했다. 따라서 &lt;code&gt;DATABASE_URL&lt;/code&gt;에 &lt;code&gt;connection_limit&lt;/code&gt; 설정이 있는지, Prisma 기본 pool size가 컨테이너 CPU 제한에서 어떻게 잡히는지 확인해야 한다. Pod 수가 늘어날 때 서비스별 connection pool이 함께 늘어나므로, 전체 DB connection 수가 RDS 한계에 가까워지는지도 봐야 한다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;50 RPS 구간 애플리케이션 로그 확인&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;k6 summary만으로는 어느 서비스에서 지연이나 실패가 시작됐는지 알기 어렵다. 테스트 시간대인 20:17~20:23 구간의 order, kitchen, notification 로그에서 timeout, error, 긴 responseTime 로그를 확인해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl logs -n shoong deploy/dev-shoong-order --since=1h
kubectl logs -n shoong deploy/dev-shoong-kitchen --since=1h
kubectl logs -n shoong deploy/dev-shoong-notification --since=1h&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;시나리오 분리&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;이번 테스트는 주문 생성 API 중심이라 order/kitchen에 부하가 집중되었다. delivery까지 명확히 검증하려면 별도 시나리오가 필요하다. 예를 들면 주문 생성 전용, 조리 완료 전용, 배달 완료 전용, 알림 조회 전용으로 나누어 각 서비스가 어떤 조건에서 scale-out되는지 확인할 수 있다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이번 결과는 단순 실패라기보다는 현재 인프라에서 10/30 RPS는 비교적 안정적이고 50 RPS부터 병목이 드러난다는 기준선을 잡은 것으로 볼 수 있다. 또한 모든 서비스가 동시에 늘어난 것이 아니라 실제 부하가 걸린 서비스 중심으로 HPA가 반응했다는 점에서 서비스별 독립 스케일링도 확인할 수 있었다.&lt;/p&gt;
&lt;h3&gt;3-5. Load Test 후 확인&lt;/h3&gt;
&lt;p&gt;Load Test 결과에서 50 RPS 진입 직후 에러가 집중된 것을 확인한 뒤, 원인 추정을 위해 애플리케이션 로그와 Prisma 설정을 간단하게 점검했다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Loki — order 서비스 로그 볼륨&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2bwLQ/dJMcafGNoUx/YIC9P8k6LRkcfWHsSo9KUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2bwLQ/dJMcafGNoUx/YIC9P8k6LRkcfWHsSo9KUk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2bwLQ/dJMcafGNoUx/YIC9P8k6LRkcfWHsSo9KUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2bwLQ%2FdJMcafGNoUx%2FYIC9P8k6LRkcfWHsSo9KUk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;&lt;code&gt;dev-shoong-order&lt;/code&gt; 전체 로그. 테스트 구간(20:02~20:25) 동안 info 로그가 급증했고, 20:18 부근에 error 88건이 집중됐다.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Loki — order error 필터 분포&lt;/strong&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UDKmE/dJMcacwwGY8/YjAIiRTekwDUShCZ5IReTk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UDKmE/dJMcacwwGY8/YjAIiRTekwDUShCZ5IReTk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UDKmE/dJMcacwwGY8/YjAIiRTekwDUShCZ5IReTk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUDKmE%2FdJMcacwwGY8%2FYjAIiRTekwDUShCZ5IReTk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;&lt;code&gt;| json | level = &amp;quot;error&amp;quot;&lt;/code&gt; 필터를 적용한 결과. error 88건 전부가 20:18~20:20 구간에 몰려 있고, 로그 항목에는 &lt;code&gt;POST /api/orders/:menuId?userName=tester&lt;/code&gt; 요청이 담겨 있다. 에러가 분산되지 않고 특정 시점에 집중된 것은 점진적 지연이 아니라 임계점 도달 직후 일시에 터진 패턴에 가깝다.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Loki — order 500 에러 상세&lt;/strong&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgMi9i/dJMcacccw4U/HS2XClUDhLPtyFVmoYATk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgMi9i/dJMcacccw4U/HS2XClUDhLPtyFVmoYATk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgMi9i/dJMcacccw4U/HS2XClUDhLPtyFVmoYATk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgMi9i%2FdJMcacccw4U%2FHS2XClUDhLPtyFVmoYATk1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;order 서비스 error 레벨 로그 필터 결과. statusCode 500, responseTime 7089ms. 정상 요청(2ms) 대비 3500배로, 커넥션 대기 후 타임아웃으로 추정된다.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Loki — kitchen 500 에러 상세&lt;/strong&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/us96w/dJMcagMt73i/HYcxTzO1FXy9UTKKlhYfJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/us96w/dJMcagMt73i/HYcxTzO1FXy9UTKKlhYfJ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/us96w/dJMcagMt73i/HYcxTzO1FXy9UTKKlhYfJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fus96w%2FdJMcagMt73i%2FHYcxTzO1FXy9UTKKlhYfJ1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;kitchen 서비스 error 레벨 로그 필터 결과. statusCode 500, responseTime 6398ms. order와 동일한 20:18 시점에 76건 발생. 로그에서 Prisma 에러 코드(P2024)가 직접 확인되지 않아 단정할 수는 없지만, Prisma connection_limit 기본값(pod당 5) 상태에서 두 서비스가 같은 시각에 동시 실패한 점은 DB 커넥션 경합이 원인 후보임을 보여준다.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prisma connection_limit 미설정 확인&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;connection_limit&lt;/code&gt; 미설정으로 pod당 커넥션이 5개로 제한된 상태에서, 50 RPS 도달 시 응답 지연이 누적되어 500 에러로 이어진 것으로 추정된다. 정확한 원인(DB 커넥션 경합, 내부 서비스 호출 지연 등)은 Prisma 에러 코드 확인이나 distributed tracing으로 별도 분석이 필요하다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;3-6. Load Test 결과 요약&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구간&lt;/th&gt;
&lt;th&gt;결과&lt;/th&gt;
&lt;th&gt;비고&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;10 RPS&lt;/td&gt;
&lt;td&gt;안정&lt;/td&gt;
&lt;td&gt;p95 정상, 에러 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 RPS&lt;/td&gt;
&lt;td&gt;안정&lt;/td&gt;
&lt;td&gt;p95 정상, 에러 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 RPS&lt;/td&gt;
&lt;td&gt;에러 발생&lt;/td&gt;
&lt;td&gt;진입 직후(20:18) order/kitchen 동시 500, responseTime 6~7초&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;threshold&lt;/strong&gt;: &lt;code&gt;p(95)&amp;lt;500ms&lt;/code&gt;, &lt;code&gt;error rate&amp;lt;1%&lt;/code&gt; 모두 실패&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HPA&lt;/strong&gt;: order는 max(5)까지, kitchen은 4까지 scale-out 발생 (notification도 일부 증가, delivery는 부하 경로 영향이 간접적이라 2 유지)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;병목 후보&lt;/strong&gt;: Prisma connection_limit 기본값(pod당 5) 상태에서 50 RPS 부하 시 커넥션 경합 추정. 단, 로그에서 Prisma 에러 코드 미확인으로 단정하지 않음&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;다음 단계&lt;/strong&gt;: Spike Test로 갑작스러운 트래픽 폭증 시 HPA 반응 속도 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h2&gt;4. Spike Test&lt;/h2&gt;
&lt;h3&gt;4-1. 시나리오&lt;/h3&gt;
&lt;p&gt;Load Test에서 50 RPS에 도달하는 데 약 16분이 걸렸다(0 → 10 → 30 → 50 RPS 단계적 ramp-up). 이 과정에서 HPA는 단계가 높아질수록 서서히 pod를 늘려갈 수 있었다. 하지만 실제 트래픽은 예고 없이 몰린다.&lt;/p&gt;
&lt;p&gt;Spike Test는 안정 기준선(10 RPS)에서 순식간에 100 RPS로 올린 뒤 다음을 확인한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;HPA가 스파이크를 감지하고 pod를 추가하기까지 걸리는 시간&lt;/li&gt;
&lt;li&gt;scale-out 완료 전후 에러율 변화 (HPA가 따라잡기 전 에러가 발생하고, 이후 회복되는지)&lt;/li&gt;
&lt;li&gt;스파이크가 끝난 뒤 latency가 정상으로 돌아오는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;스파이크 목표를 100 RPS로 잡은 이유: Load Test에서 50 RPS가 이미 한계에 가까웠다. &amp;quot;정상 운영 중 갑자기 10배 트래픽이 몰리는 상황&amp;quot;을 시뮬레이션하려면 기준선(10 RPS) 대비 10배인 100 RPS 정도가 적절하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;import { orderRequest } from &amp;quot;./common.js&amp;quot;;

export const options = {
  scenarios: {
    spike: {
      executor: &amp;quot;ramping-arrival-rate&amp;quot;,
      startRate: 0,
      timeUnit: &amp;quot;1s&amp;quot;,
      preAllocatedVUs: 50, // 스파이크 대비해 Load Test(20)보다 크게 설정
      maxVUs: 500,
      stages: [
        { duration: &amp;quot;2m&amp;quot;, target: 10 }, // 기준선 (Load Test에서 안정 확인된 구간)
        { duration: &amp;quot;30s&amp;quot;, target: 100 }, // 스파이크: 30초 만에 10 → 100 RPS
        { duration: &amp;quot;5m&amp;quot;, target: 100 }, // 스파이크 유지 (HPA 반응 관찰)
        { duration: &amp;quot;30s&amp;quot;, target: 10 }, // 급격한 감소
        { duration: &amp;quot;2m&amp;quot;, target: 10 }, // 회복 확인
        { duration: &amp;quot;1m&amp;quot;, target: 0 }, // ramp-down
      ],
    },
  },
  thresholds: {
    http_req_duration: [&amp;quot;p(95)&amp;lt;500&amp;quot;],
    http_req_failed: [&amp;quot;rate&amp;lt;0.01&amp;quot;],
  },
};

export default function () {
  orderRequest();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Load Test와 다른 점은 &lt;code&gt;preAllocatedVUs&lt;/code&gt;를 50으로 높인 것이다. 스파이크 구간에서 VU가 갑자기 늘어나는 걸 풀에 미리 준비해 두지 않으면 k6 자체가 VU 생성에 시간을 써서 RPS 목표를 제때 달성하지 못한다.&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;측정 포인트&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Grafana k6 대시보드: 스파이크 구간 req/s, error rate&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kubectl get hpa -n shoong -w&lt;/code&gt;: HPA REPLICAS 변화 타임라인&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kubectl get pods -n shoong -w&lt;/code&gt;: pod 추가까지 걸리는 시간&lt;/li&gt;
&lt;li&gt;Loki: 스파이크 구간 에러 로그 밀도&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;준비&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;scenarios-configmap.yaml&lt;/code&gt;에 &lt;code&gt;spike.js&lt;/code&gt; 추가 후 ArgoCD sync → CronJob 수동 트리거 방식은 Load Test와 동일. &lt;code&gt;spike.js&lt;/code&gt;를 실행하는 별도 CronJob &lt;code&gt;k6-spike-test&lt;/code&gt;를 추가한다.&lt;/p&gt;
&lt;br/&gt;

&lt;h3&gt;4-2. 테스트 전 상태&lt;/h3&gt;
&lt;p&gt;Load Test 종료 후 HPA가 scale-in 완료한 것을 확인한 뒤 Spike Test를 시작했다. 시작 시점의 pod 수가 min=2임을 확인해두지 않으면, 나중에 scale-out 타임라인을 볼 때 기준점이 없어진다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HPA 상태&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/R3ySu/dJMcadozwZZ/W1seTxiK0KykbmXgMLsikk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/R3ySu/dJMcadozwZZ/W1seTxiK0KykbmXgMLsikk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/R3ySu/dJMcadozwZZ/W1seTxiK0KykbmXgMLsikk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FR3ySu%2FdJMcadozwZZ%2FW1seTxiK0KykbmXgMLsikk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;모든 서비스 REPLICAS=2, CPU 2~3%/70%. Load Test가 끝나고 HPA scale-in이 완료된 상태. Spike Test 시작 기준점으로 충분하다.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pod 상태&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UxuzT/dJMcadB5bow/xSF9mUz6WvHTT3DXsPrjMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UxuzT/dJMcadB5bow/xSF9mUz6WvHTT3DXsPrjMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UxuzT/dJMcadB5bow/xSF9mUz6WvHTT3DXsPrjMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUxuzT%2FdJMcadB5bow%2FxSF9mUz6WvHTT3DXsPrjMk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;delivery, kitchen, notification, order 모두 Running 2/2. batch Job은 Completed 상태로 서비스 파드에는 영향 없음.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;4-3. 실행 절차&lt;/h3&gt;
&lt;p&gt;Load Test와 절차는 거의 동일하다. 다른 점은 ArgoCD sync가 한 번 더 필요하다는 것(ConfigMap과 CronJob을 새로 추가했으므로)과 트리거할 CronJob 이름이 &lt;code&gt;k6-spike-test&lt;/code&gt;라는 것뿐이다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;shoong-gitops&lt;/code&gt; push 후 ArgoCD에서 &lt;code&gt;loadtest-dev&lt;/code&gt; 애플리케이션 Synced 상태 확인&lt;/li&gt;
&lt;li&gt;&lt;code&gt;k6-spike-test&lt;/code&gt; CronJob이 배포됐는지, &lt;code&gt;suspend: true&lt;/code&gt; 상태인지 확인&lt;/li&gt;
&lt;li&gt;테스트 전 HPA, Pod 기준 상태 캡처 (4-2에서 완료)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kubectl create job --from=cronjob/k6-spike-test ...&lt;/code&gt; 명령으로 k6 Job 수동 실행&lt;/li&gt;
&lt;li&gt;HPA와 Pod 변화를 별도 터미널에서 관찰 — 스파이크 진입 시점을 기록&lt;/li&gt;
&lt;li&gt;테스트 종료 후 k6 Job 로그, Grafana 대시보드 확인&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# k6-spike-test CronJob 배포 및 suspend 상태 확인
kubectl get cronjob k6-spike-test -n loadtest

# Spike Test Job 수동 트리거
jobName=&amp;quot;k6-spike-$(date +%Y%m%d-%H%M%S)&amp;quot;
kubectl create job --from=cronjob/k6-spike-test $jobName -n loadtest&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실행 중에는 HPA와 Pod 변화를 별도 터미널에서 관찰했다. Spike Test에서 이 두 명령이 핵심이다. 스파이크가 들어오는 순간부터 &lt;code&gt;REPLICAS&lt;/code&gt; 숫자가 언제 바뀌는지를 타임라인으로 기록해두어야 나중에 &amp;quot;HPA가 몇 초 만에 반응했는가&amp;quot;를 설명할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl get hpa -n shoong -w
kubectl get pods -n shoong -w&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;테스트 종료 후 k6 Job 로그를 확인했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;kubectl get jobs -n loadtest
kubectl logs -n loadtest job/$jobName&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4-4. 실행 결과&lt;/h3&gt;
&lt;p&gt;Spike Test는 23:09:38에 시작했다. 시나리오상 2분간 10 RPS 기준선을 유지한 뒤 30초 만에 100 RPS로 올라가므로 스파이크 진입 시각은 약 &lt;strong&gt;23:11:38 + 30초 = 23:12:08&lt;/strong&gt; 부근으로 예상됐다. 그런데 실제 부하 반영은 그보다 늦게 나타났고 HPA가 처음 반응한 시각은 23:18:55였다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Job 생성 직후&lt;/strong&gt; &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pEHYy/dJMcabqQUkk/lpxMntNy8J3k3urGW1h9y1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pEHYy/dJMcabqQUkk/lpxMntNy8J3k3urGW1h9y1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pEHYy/dJMcabqQUkk/lpxMntNy8J3k3urGW1h9y1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpEHYy%2FdJMcabqQUkk%2FlpxMntNy8J3k3urGW1h9y1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;&lt;code&gt;k6-spike-20260527-230938&lt;/code&gt; Job이 Running 상태. 이 시각(23:09:38)을 기준으로 stage별 진입 시각을 역산한다.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HPA 첫 반응&lt;/strong&gt; &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cQkssp/dJMcajh4CRV/J6y0nFbpK8wOVwejO4cko0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cQkssp/dJMcajh4CRV/J6y0nFbpK8wOVwejO4cko0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cQkssp/dJMcajh4CRV/J6y0nFbpK8wOVwejO4cko0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcQkssp%2FdJMcajh4CRV%2FJ6y0nFbpK8wOVwejO4cko0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;23:18:47에 order CPU가 108%, kitchen 76%, notification 62%까지 치솟았다(target 70% 초과). 8초 뒤인 23:18:55에 order REPLICAS가 2→4로 바뀌었다. HPA는 CPU 임계치 초과를 감지하면 한 번에 2단계 점프할 수도 있다.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;새 Pod Init 단계&lt;/strong&gt; &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/buveDJ/dJMcafNAsnD/e11szkBWPKT1RXGxUdzQM0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/buveDJ/dJMcafNAsnD/e11szkBWPKT1RXGxUdzQM0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/buveDJ/dJMcafNAsnD/e11szkBWPKT1RXGxUdzQM0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbuveDJ%2FdJMcafNAsnD%2Fe11szkBWPKT1RXGxUdzQM0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;HPA scale-out 결과 새 order/kitchen pod이 생성됐다. &lt;code&gt;Init:0/2&lt;/code&gt;는 Istio sidecar init container가 도는 단계로, 이 시점에는 아직 트래픽을 받지 못한다.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;scale-in 시작 시점&lt;/strong&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qayHU/dJMcahkkzdB/W4xliFbg08rZoEjGtL8XX1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qayHU/dJMcahkkzdB/W4xliFbg08rZoEjGtL8XX1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qayHU/dJMcahkkzdB/W4xliFbg08rZoEjGtL8XX1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqayHU%2FdJMcahkkzdB%2FW4xliFbg08rZoEjGtL8XX1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;부하가 끝난 뒤(23:24 ramp-down) HPA가 곧바로 scale-in 하지 않고 약 5분간 cooldown을 유지했다. 23:29:09부터 kitchen 5→4, notification 4→3 순으로 줄어들기 시작했다. HPA 기본 stabilization window(downscale 5분) 동작이다.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;완전 복귀&lt;/strong&gt;  &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eeOHVf/dJMcaiDuoe7/SvyDWUJmbXV6E844qIsFLK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eeOHVf/dJMcaiDuoe7/SvyDWUJmbXV6E844qIsFLK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eeOHVf/dJMcaiDuoe7/SvyDWUJmbXV6E844qIsFLK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeeOHVf%2FdJMcaiDuoe7%2FSvyDWUJmbXV6E844qIsFLK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;23:31:45 기준 4개 서비스 모두 REPLICAS=2, CPU 5~19%/70%. scale-in 완료.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Job 종료&lt;/strong&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/umDw2/dJMcaiKc16G/4yQ8H6WAqkGedVWSLD5aPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/umDw2/dJMcaiKc16G/4yQ8H6WAqkGedVWSLD5aPK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/umDw2/dJMcaiKc16G/4yQ8H6WAqkGedVWSLD5aPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FumDw2%2FdJMcaiKc16G%2F4yQ8H6WAqkGedVWSLD5aPK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;Job duration 16분, threshold를 두 개 모두 넘겨 Failed로 종료. &lt;code&gt;backoffLimit: 0&lt;/code&gt;이라 재시도 없이 한 번에 끝났다.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;k6 summary&lt;/strong&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lBdbj/dJMcacDjmXt/ePYS2BieYaj4awWhuMu0K0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lBdbj/dJMcacDjmXt/ePYS2BieYaj4awWhuMu0K0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lBdbj/dJMcacDjmXt/ePYS2BieYaj4awWhuMu0K0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlBdbj%2FdJMcacDjmXt%2FePYS2BieYaj4awWhuMu0K0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;전체 35158 요청 중 실패 15826건(45.01%). p(95) 6.83초, max 18.8초. threshold(&lt;code&gt;p(95)&amp;lt;500ms&lt;/code&gt;, &lt;code&gt;error rate&amp;lt;1%&lt;/code&gt;) 둘 다 큰 폭으로 미달.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Grafana 전체 흐름&lt;/strong&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bOIsR3/dJMcabRQ8oi/K3vAgwR15C5YOR5Tudhvl0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bOIsR3/dJMcabRQ8oi/K3vAgwR15C5YOR5Tudhvl0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bOIsR3/dJMcabRQ8oi/K3vAgwR15C5YOR5Tudhvl0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbOIsR3%2FdJMcabRQ8oi%2FK3vAgwR15C5YOR5Tudhvl0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;vus(노란선)는 23:18 부근에 0 → 최대 300까지 튀고, http_req_s는 약 118 req/s까지 도달했지만 동시에 http_req_s_errors(빨간선)도 100 req/s에 육박했다. peak RPS는 118 req/s. 패널의 &amp;quot;HTTP requests 19,332&amp;quot;는 checks 성공 카운트 기준이라 전체 요청 수와는 다르다 — 전체 통계(35,158 요청 / 15,826 실패 / 45.01%)는 &lt;a href=&quot;#4-5-spike-test-%EA%B2%B0%EA%B3%BC-%EC%9A%94%EC%95%BD&quot;&gt;4-5 결과 요약&lt;/a&gt;의 k6 summary 기준.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;타임라인 정리&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;시각&lt;/th&gt;
&lt;th&gt;사건&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;23:09:38&lt;/td&gt;
&lt;td&gt;k6-spike Job 시작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:11:38~&lt;/td&gt;
&lt;td&gt;기준선 10 RPS 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:18:30~&lt;/td&gt;
&lt;td&gt;100 RPS 부하 본격 진입 (Grafana 기준)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:18:47&lt;/td&gt;
&lt;td&gt;order CPU 108%, HPA 임계치 초과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:18:55&lt;/td&gt;
&lt;td&gt;HPA 첫 반응 관측 — order REPLICAS 2→4 (CLI 스냅샷 간격 5초)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:24 부근&lt;/td&gt;
&lt;td&gt;k6 ramp-down 시작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:29:09&lt;/td&gt;
&lt;td&gt;HPA scale-in 시작 (cooldown 약 5분)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23:31:45&lt;/td&gt;
&lt;td&gt;모든 서비스 REPLICAS=2 복귀&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;br/&gt;

&lt;p&gt;&lt;strong&gt;관찰 포인트&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;HPA 반응 자체는 한 polling 주기(CLI 5초) 안에 관측됐다 — 23:18:47 스냅샷에서 임계 돌파, 다음 스냅샷인 23:18:55에 REPLICAS 변경 관측. 정확한 반응 시간은 polling 한계로 초 단위 단정은 어렵지만, HPA controller sync 주기(15초) 안에 끝났다는 의미에서 충분히 빠른 편.&lt;/li&gt;
&lt;li&gt;다만 새 Pod이 Init container 단계를 거쳐 트래픽을 받기까지의 시간이 더 길어서 그 사이 누적된 요청들이 그대로 에러로 잡혔다(전체 실패율 45%).&lt;/li&gt;
&lt;li&gt;Spike Test 의도(&amp;quot;HPA가 따라잡을 수 있는가&amp;quot;) 관점에서는 HPA는 반응했지만 &lt;strong&gt;새 Pod이 Ready 되기 전 구간을 어떻게 흡수할 것인가&lt;/strong&gt;가 다음 과제로 남는다. (warm pool, PDB, 사전 ramp-up, Istio retry 등이 후보)&lt;/li&gt;
&lt;li&gt;scale-in은 cooldown 5분 후 천천히 일어났는데, 이는 의도된 동작(짧은 부하 변동에 반복 scale 하지 않도록)이라 문제는 아니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;br/&gt;

&lt;h3&gt;4-5. Spike Test 결과 요약&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Peak RPS&lt;/td&gt;
&lt;td&gt;118 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전체 요청&lt;/td&gt;
&lt;td&gt;35,158건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실패&lt;/td&gt;
&lt;td&gt;15,826건 (45.01%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 응답시간&lt;/td&gt;
&lt;td&gt;6.83s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HPA 첫 반응 시간&lt;/td&gt;
&lt;td&gt;한 polling 주기(5초) 안 (CLI 스냅샷 기준)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pod Ready까지의 흡수 갭&lt;/td&gt;
&lt;td&gt;발생 (그 사이 요청 실패)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scale-in 완료&lt;/td&gt;
&lt;td&gt;ramp-down 후 약 7분 (cooldown 5분 포함)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;threshold&lt;/strong&gt;: &lt;code&gt;p(95)&amp;lt;500ms&lt;/code&gt;, &lt;code&gt;error rate&amp;lt;1%&lt;/code&gt; 모두 큰 폭 실패&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HPA&lt;/strong&gt;: 한 polling 주기 안에 첫 scale-out 관측. order는 한 번에 2→4로 2단계 점프&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;병목&lt;/strong&gt;: HPA 반응 자체는 빠르지만 새 Pod이 Init/Ready 단계를 거치는 사이 누적 요청이 그대로 에러로 잡힘&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;다음 단계&lt;/strong&gt;: HPA 반응이 아니라 &amp;quot;새 Pod이 트래픽 받기까지의 갭&amp;quot;을 어떻게 줄일지가 개선 포인트&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;5. Spike Test (재시도, 50 RPS)&lt;/h2&gt;
&lt;p&gt;Spike Test를 정리하고 보니 100 RPS는 인프라가 정적으로도 못 견디는 부하였다. Load Test에서 30 RPS 안정 / 50 RPS 임계 / 100 RPS 한계 초과로 이미 확인된 영역이었는데 왜 100 RPS를 골랐을까.. 규모가 작은 인프라니까 좀 낮게 잡고 점점 커져야했을텐데 말이다.&lt;/p&gt;
&lt;p&gt;4장의 측정값은 &lt;strong&gt;&amp;#39;HPA가 따라잡는가?&amp;#39;&lt;/strong&gt;라는 Spike Test 본래 질문에 답하지 못했다. Spike Test가 답하고 싶은 건 단순히 &amp;#39;HPA가 발동하는가?&amp;#39;가 아니라 &amp;#39;&lt;strong&gt;HPA가 발동한 뒤 새 Pod이 합류해서 에러율이 회복되는가&lt;/strong&gt;?&amp;#39;이다. 그러려면 부하 자체가 HPA가 max replica(=5)까지 띄웠을 때 처리 가능한 영역 안에 있어야 한다. 100 RPS는 그 영역을 이미 초과한 부하라 회복 곡선 자체가 그려질 수 없었고, 45% 실패율도 HPA 반응이 느려서인지 그냥 부하가 한계 초과라서인지 분리되지 않는다.&lt;/p&gt;
&lt;p&gt;다시 시도한다. 이번에는 부하를 &lt;strong&gt;50 RPS&lt;/strong&gt;로 낮춰서. 갑작스러운 진입 시점에 실패가 누적되다가 HPA scale-out 후 회복되는지를 측정한다.&lt;/p&gt;
&lt;h3&gt;5-1. 시나리오&lt;/h3&gt;
&lt;p&gt;후보 부하 수준을 Load Test 데이터와 맞춰보면 다음과 같다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;RPS&lt;/th&gt;
&lt;th&gt;Load Test에서 본 처리 가능성&lt;/th&gt;
&lt;th&gt;Spike로 줬을 때 의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;30 RPS&lt;/td&gt;
&lt;td&gt;안정 (p95 정상, 에러 없음)&lt;/td&gt;
&lt;td&gt;HPA 트리거 임계(CPU 70%)에 못 닿을 가능성. scale-out 자체가 안 일어날 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;50 RPS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;점진 ramp-up 시 실패율 1.58% — max=5까지 띄우면 정상 상태로 처리&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;초기 burst 실패 → scale-out → 1.58% 근처로 수렴하는 회복 곡선 측정 가능&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 RPS&lt;/td&gt;
&lt;td&gt;47% 실패 — HPA max=5도 부족&lt;/td&gt;
&lt;td&gt;회복 곡선 자체가 안 그려짐. Stress Test에 가까움&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;50 RPS는 &amp;quot;정적 상태로는 빠듯하지만 max replica까지 띄우면 결국 처리되는&amp;quot; 영역이다. 이 부하를 갑자기 주면 시작 시점에는 Pod 2개로 못 받아 실패가 누적되다가, HPA scale-out 후 새 Pod이 합류하면 실패율이 1.58% 근처로 수렴해야 한다. 그 수렴 곡선이 보이면 &amp;quot;HPA가 따라잡았다&amp;quot;고 말할 수 있다.&lt;/p&gt;
&lt;p&gt;100 RPS는 이 곡선 자체가 그려질 수 없었고, 70 RPS 같은 중간 값은 Load Test에서 검증되지 않아 처리 가능 여부가 불명확하다. 결과 해석을 흐리지 않으려면 Load Test로 임계가 검증된 50 RPS에서 다시 측정하는 것이 가장 합리적이라고 봤다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;import { orderRequest } from &amp;quot;./common.js&amp;quot;;

export const options = {
  scenarios: {
    spike: {
      executor: &amp;quot;ramping-arrival-rate&amp;quot;,
      startRate: 0,
      timeUnit: &amp;quot;1s&amp;quot;,
      preAllocatedVUs: 30,
      maxVUs: 300,
      stages: [
        { duration: &amp;quot;2m&amp;quot;, target: 10 }, // 기준선
        { duration: &amp;quot;30s&amp;quot;, target: 50 }, // 스파이크: 30초 만에 10 → 50 RPS
        { duration: &amp;quot;5m&amp;quot;, target: 50 }, // 50 RPS 유지 (HPA 반응 + 회복 관찰)
        { duration: &amp;quot;30s&amp;quot;, target: 10 }, // 감소
        { duration: &amp;quot;2m&amp;quot;, target: 10 }, // 회복 확인
        { duration: &amp;quot;1m&amp;quot;, target: 0 }, // ramp-down
      ],
    },
  },
  thresholds: {
    http_req_duration: [&amp;quot;p(95)&amp;lt;500&amp;quot;],
    http_req_failed: [&amp;quot;rate&amp;lt;0.01&amp;quot;],
  },
};

export default function () {
  orderRequest();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;기존 100 RPS 시나리오와 다른 점:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;target: 100&lt;/code&gt; → &lt;code&gt;target: 50&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;preAllocatedVUs: 50&lt;/code&gt; → &lt;code&gt;30&lt;/code&gt; (부하가 줄었으므로)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;maxVUs: 500&lt;/code&gt; → &lt;code&gt;300&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;측정 포인트는 동일&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;HPA REPLICAS 첫 변경 시각 (스파이크 진입 대비 몇 초)&lt;/li&gt;
&lt;li&gt;새 Pod Running 전환 시각 (HPA 변경 대비 몇 초)&lt;/li&gt;
&lt;li&gt;50 RPS 유지 구간에서 &lt;strong&gt;에러율이 시간에 따라 감소하는가&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;회복 구간(10 RPS) 응답시간 정상화 여부&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이전 100 RPS 테스트와 비교했을 때 가장 다르게 보고 싶은 것은 마지막 항목이다. 100 RPS에서는 인프라가 어차피 처리할 수 없어서 시간이 지나도 에러율이 떨어지지 않았다. 50 RPS에서는 HPA가 scale-out을 마치고 새 Pod이 트래픽을 받기 시작하면 에러율이 감소세로 돌아서야 한다. 그 변곡점이 보이면 &amp;quot;HPA가 따라잡았다&amp;quot;고 말할 수 있다.&lt;/p&gt;
&lt;h3&gt;5-2. 준비&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;scenarios-configmap.yaml&lt;/code&gt;의 &lt;code&gt;spike.js&lt;/code&gt;를 50 RPS 시나리오로 교체한다. CronJob은 그대로 &lt;code&gt;k6-spike-test&lt;/code&gt;를 사용한다(시나리오 파일만 바뀌므로 새 CronJob은 필요 없음).&lt;/p&gt;
&lt;h3&gt;5-3. 실행 절차&lt;/h3&gt;
&lt;p&gt;절차는 &lt;a href=&quot;#4-3-%EC%8B%A4%ED%96%89-%EC%A0%88%EC%B0%A8&quot;&gt;4-3&lt;/a&gt;와 동일하다. 트리거할 CronJob 이름도 &lt;code&gt;k6-spike-test&lt;/code&gt; 그대로(시나리오 파일만 50 RPS로 교체됐다).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;jobName=&amp;quot;k6-spike-$(date +%Y%m%d-%H%M%S)&amp;quot;
kubectl create job --from=cronjob/k6-spike-test $jobName -n loadtest&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;이번 테스트에서 주목한 캡처 포인트&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;시점&lt;/th&gt;
&lt;th&gt;캡처 대상&lt;/th&gt;
&lt;th&gt;봐야 할 것&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Job 생성 직후&lt;/td&gt;
&lt;td&gt;k6 Job Running&lt;/td&gt;
&lt;td&gt;시작 시각 기준점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스파이크 진입 (~2:30 경과)&lt;/td&gt;
&lt;td&gt;터미널 A — HPA REPLICAS 변경&lt;/td&gt;
&lt;td&gt;4-4 대비 반응 양상 차이&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HPA 변경 직후&lt;/td&gt;
&lt;td&gt;터미널 B — 새 Pod Init/Running&lt;/td&gt;
&lt;td&gt;Ready까지 걸린 시간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 RPS 유지 후반&lt;/td&gt;
&lt;td&gt;터미널 A — HPA CPU 안정, 에러 감소&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;회복 곡선이 보이는가&lt;/strong&gt; (핵심)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;종료 후&lt;/td&gt;
&lt;td&gt;k6 Job 로그, Grafana overview&lt;/td&gt;
&lt;td&gt;최종 실패율, 시간대별 에러 분포&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;4-4와 가장 다르게 봐야 할 것은 50 RPS 유지 구간 중반 이후 &lt;strong&gt;에러 발생이 줄어드는 변곡점이 있는가&lt;/strong&gt;이다. 100 RPS 때는 끝까지 평탄했지만, 이번에는 HPA가 따라잡으면 분명한 변곡점이 생긴다. Grafana는 테스트 종료 후 시간 범위를 맞춰 캡처하면 되고, 진행 중에는 터미널 A/B를 우선 확보한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;테스트 전 HPA/Pod 평시 상태 확인&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qRSku/dJMcaijbrzs/YngCJTkWZjOtJNxsrXd3f1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qRSku/dJMcaijbrzs/YngCJTkWZjOtJNxsrXd3f1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qRSku/dJMcaijbrzs/YngCJTkWZjOtJNxsrXd3f1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FqRSku%2FdJMcaijbrzs%2FYngCJTkWZjOtJNxsrXd3f1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;4-4 Spike Test 종료 후 충분히 시간이 지나 모든 서비스가 REPLICAS=2로 돌아온 상태. Spike Test 2 시작 기준점.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;5-4. 실행 결과&lt;/h3&gt;
&lt;p&gt;k6 컨테이너 로그 기준 테스트 실제 시작 시각은 17:01:34. 시나리오 stage별 진입 시각을 다시 정리하면:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;누적 경과&lt;/th&gt;
&lt;th&gt;시각&lt;/th&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;0:00&lt;/td&gt;
&lt;td&gt;17:01:34&lt;/td&gt;
&lt;td&gt;10 RPS 기준선 시작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2:00&lt;/td&gt;
&lt;td&gt;17:03:34&lt;/td&gt;
&lt;td&gt;스파이크 ramp-up 시작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2:30&lt;/td&gt;
&lt;td&gt;17:04:04&lt;/td&gt;
&lt;td&gt;50 RPS 도달&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7:30&lt;/td&gt;
&lt;td&gt;17:09:04&lt;/td&gt;
&lt;td&gt;50 RPS 유지 끝, ramp-down 시작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11:00&lt;/td&gt;
&lt;td&gt;17:12:34&lt;/td&gt;
&lt;td&gt;시나리오 종료&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Job 생성 직후 — 16:54:43&lt;/strong&gt; &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/leU0x/dJMcadWsxHo/RCKOKjJX251PdfSBTtOhn1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/leU0x/dJMcadWsxHo/RCKOKjJX251PdfSBTtOhn1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/leU0x/dJMcadWsxHo/RCKOKjJX251PdfSBTtOhn1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FleU0x%2FdJMcadWsxHo%2FRCKOKjJX251PdfSBTtOhn1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;&lt;code&gt;k6-spike-20260528-165443&lt;/code&gt; Job이 Running 9s. Job 이름의 timestamp(16:54:43)는 jobname 환경변수를 내가 따로 추가해서 나온거다. 실제 Job 생성 시각이나 부하 시작 시각과는 별개고, 진짜 시작은 17:01:34.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;k6 실제 시작 직후 상태 — 17:01&lt;/strong&gt; &lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6mRug/dJMcabxzE1K/FHMM4IvN4maKpqduazEx51/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6mRug/dJMcabxzE1K/FHMM4IvN4maKpqduazEx51/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6mRug/dJMcabxzE1K/FHMM4IvN4maKpqduazEx51/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6mRug%2FdJMcabxzE1K%2FFHMM4IvN4maKpqduazEx51%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cCBAx0/dJMcafUkdds/e4Tn9nAklZD5GhAduYnRKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cCBAx0/dJMcafUkdds/e4Tn9nAklZD5GhAduYnRKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cCBAx0/dJMcafUkdds/e4Tn9nAklZD5GhAduYnRKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcCBAx0%2FdJMcafUkdds%2Fe4Tn9nAklZD5GhAduYnRKK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;17:01:45 — k6 시작 11초 후. 10 RPS 기준선 구간이라 HPA REPLICAS=2, CPU 2~3% 그대로다. 약 2분 뒤 17:03:34부터 스파이크 ramp-up이 들어온다.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HPA 첫 반응 — 17:04&lt;/strong&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MYFns/dJMcabdfsvw/r0LVkknrQUzakesMvuGISK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MYFns/dJMcabdfsvw/r0LVkknrQUzakesMvuGISK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MYFns/dJMcabdfsvw/r0LVkknrQUzakesMvuGISK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMYFns%2FdJMcabdfsvw%2Fr0LVkknrQUzakesMvuGISK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;CLI 캡처. 17:04:26 스냅샷에서 order CPU 111%, kitchen 87%로 임계 돌파가 보였고, 5초 뒤 17:04:34에 order REPLICAS 2→4, kitchen 2→3으로 첫 scale-out이 잡혔다.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JhIoq/dJMcagllVl3/ow5GfY8eNnBulVmekoDuik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JhIoq/dJMcagllVl3/ow5GfY8eNnBulVmekoDuik/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JhIoq/dJMcagllVl3/ow5GfY8eNnBulVmekoDuik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJhIoq%2FdJMcagllVl3%2Fow5GfY8eNnBulVmekoDuik%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;  &lt;em&gt;Grafana &lt;code&gt;kube_horizontalpodautoscaler_status_current_replicas{namespace=&amp;quot;shoong&amp;quot;}&lt;/code&gt; 시계열. CLI보다 정밀도는 낮지만(scrape 주기 15초) step function이라 변경 시점이 한 눈에 들어온다. 17:04:45 datapoint에서 order 2→4, kitchen 2→3, notification 2→3이 동시에 점프하고, 17:05:00에 order 4→5, 17:05:15에 kitchen 3→4가 추가로 들어갔다. delivery는 50 RPS 부하 경로에 직접 안 걸려서 끝까지 2 유지.&lt;/em&gt;&lt;/p&gt;
&lt;br/&gt;

&lt;p&gt;  두 출처 다 polling 한계가 있어서 진짜 반응 시간을 초 단위로 못 잡는다. 다만 둘 다 한 polling 주기 안에 잡혔고, HPA controller 자체 sync 주기가 15초인 걸 감안하면 이 설정에서 나올 수 있는 거의 최선으로 보인다.&lt;/p&gt;
&lt;br/&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;새 Pod Init / Ready 갭&lt;/strong&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cMLHoB/dJMcaiXJykP/6NCmukkT8kSvWLRaoBcso1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cMLHoB/dJMcaiXJykP/6NCmukkT8kSvWLRaoBcso1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cMLHoB/dJMcaiXJykP/6NCmukkT8kSvWLRaoBcso1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcMLHoB%2FdJMcaiXJykP%2F6NCmukkT8kSvWLRaoBcso1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;CLI 캡처. HPA scale-out 결과 새 order/kitchen pod이 떴다. &lt;code&gt;Init:0/2&lt;/code&gt;, &lt;code&gt;PodInitializing&lt;/code&gt; 상태라 아직 트래픽은 못 받는 시점.&lt;/em&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpZQ3d/dJMcaiXJyk3/DLSkSx66sHhq1pevMbs6ik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpZQ3d/dJMcaiXJyk3/DLSkSx66sHhq1pevMbs6ik/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpZQ3d/dJMcaiXJyk3/DLSkSx66sHhq1pevMbs6ik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpZQ3d%2FdJMcaiXJyk3%2FDLSkSx66sHhq1pevMbs6ik%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;&lt;code&gt;kube_deployment_status_replicas_ready{namespace=&amp;quot;shoong&amp;quot;}&lt;/code&gt; 시계열을 17:04:20~17:06:30로 줌인. HPA가 결정한 시각과 실제 Ready replica가 늘어난 시각을 같은 축에서 비교.&lt;/em&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;서비스&lt;/th&gt;
&lt;th&gt;HPA current_replicas&lt;/th&gt;
&lt;th&gt;Deployment replicas_ready&lt;/th&gt;
&lt;th&gt;Pod startup 갭&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;order&lt;/td&gt;
&lt;td&gt;17:04:45 → 4&lt;/td&gt;
&lt;td&gt;17:04:50 부근 → 4&lt;/td&gt;
&lt;td&gt;약 5~10초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;order&lt;/td&gt;
&lt;td&gt;17:05:00 → 5&lt;/td&gt;
&lt;td&gt;17:05:30 부근 → 5&lt;/td&gt;
&lt;td&gt;약 30초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kitchen&lt;/td&gt;
&lt;td&gt;17:04:45 → 3&lt;/td&gt;
&lt;td&gt;17:05:00 부근 → 3&lt;/td&gt;
&lt;td&gt;약 15초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kitchen&lt;/td&gt;
&lt;td&gt;17:05:15 → 4&lt;/td&gt;
&lt;td&gt;17:05:30 부근 → 4&lt;/td&gt;
&lt;td&gt;약 15초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;notification&lt;/td&gt;
&lt;td&gt;17:04:45 → 3&lt;/td&gt;
&lt;td&gt;17:05:00 부근 → 3&lt;/td&gt;
&lt;td&gt;약 15초&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;첫 scale-out에서는 새 pod이 빠르게 떴고(이미지 캐시·Init container 통과가 짧았던 듯), max까지 늘어나는 두 번째 단계에선 길어졌다. HPA가 결정한 시점부터 실제 트래픽을 받을 capacity가 늘기까지 최대 30초 정도 갭이 있다는 얘기.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wze2h/dJMcaffJ1aF/1mYDKeY1yAdHVnVCpArCkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wze2h/dJMcaffJ1aF/1mYDKeY1yAdHVnVCpArCkK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wze2h/dJMcaffJ1aF/1mYDKeY1yAdHVnVCpArCkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fwze2h%2FdJMcaffJ1aF%2F1mYDKeY1yAdHVnVCpArCkK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;같은 시간대 k6 메트릭 줌인. 17:04~17:06 2분 동안 6,602 요청 중 697건 실패(Checks Success 94%). 전체 1,630건 실패의 약 43%가 이 burst 구간에서 나왔다 — 부하 진입 직후부터 새 Pod Ready 직전까지가 실패가 몰리는 구간.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;HPA 반응 자체는 한 polling 주기 안에 끝났는데 Pod startup이 30초 정도라, 16% 실패의 큰 몫은 결국 이 startup 갭에서 나온 셈이다. 그래서 개선 포인트도 HPA 설정 쪽이 아니라 Pod warm-up 단계(이미지 pull, Istio sidecar init, readinessProbe 통과) 쪽으로 봐야 할 것 같다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;REPLICAS max 도달 + 부하 분산 — 17:05&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EloSj/dJMcadPEGuS/3vTI66pY8frJnHgFBKtLC0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EloSj/dJMcadPEGuS/3vTI66pY8frJnHgFBKtLC0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EloSj/dJMcadPEGuS/3vTI66pY8frJnHgFBKtLC0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEloSj%2FdJMcadPEGuS%2F3vTI66pY8frJnHgFBKtLC0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;CLI 캡처. 17:05:20에 order REPLICAS=5(max) 도달 후 HPA가 보는 평균 CPU가 94% → 81% → 60%로 내려간다(17:05:13~17:05:44). HPA target view 기준으로는 안정화가 시작된 모양.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EHR1a/dJMcaiDuom6/5a0E7ZkAVpnjjplgoWJZlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EHR1a/dJMcaiDuom6/5a0E7ZkAVpnjjplgoWJZlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EHR1a/dJMcaiDuom6/5a0E7ZkAVpnjjplgoWJZlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEHR1a%2FdJMcaiDuom6%2F5a0E7ZkAVpnjjplgoWJZlK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;order pod별 CPU(mCore) 시계열. 보라색 점선이 HPA target(70 mCore = CPU request 100m의 70%). 그래프 흐름을 따라가보면:&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;17:04:00&lt;del&gt;17:04:45 — 기존 pod 2개(green, yellow)가 임계 한참 위인 220&lt;/del&gt;245 mCore까지 올라간다. 단일 pod이 50 RPS를 다 떠안고 있는 모양.&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;17:05:00~17:05:30 — 새 pod 3개(blue, orange, red)가 0에서 올라온다. HPA가 띄운 pod이 실제 트래픽을 받기 시작한 시점.&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;17:05:30&lt;del&gt;17:06:30 — 기존 pod CPU가 200+에서 30&lt;/del&gt;80으로 떨어진다. 부하가 5개로 분산된 모양이 그대로 보인다.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dyuPDj/dJMcajoL12G/bAzk0ApuKvKWLK93F4IuZK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dyuPDj/dJMcajoL12G/bAzk0ApuKvKWLK93F4IuZK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dyuPDj/dJMcajoL12G/bAzk0ApuKvKWLK93F4IuZK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdyuPDj%2FdJMcajoL12G%2FbAzk0ApuKvKWLK93F4IuZK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;  &lt;em&gt;kitchen도 같은 모양이다. pod 4개(max=4까지 도달)에서 분산이 같은 방식으로 일어났으니 order에서만 우연히 그런 건 아닌 듯.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;  다만 CPU가 임계선(70 mCore) 아래로 안정화된 건 아니다. 5개 pod에서 100~250 mCore 사이로 계속 출렁였고, 정상 수준 회복이라기보단 &amp;quot;max replica 안에서 부하를 나눠가지며 임계 위에서 처리 가능한 영역으로 들어온&amp;quot; 정도로 보는 게 맞을 것 같다. CPU가 임계선 아래로 명확히 내려온 건 17:09 ramp-down 이후고.&lt;/p&gt;
&lt;p&gt;  4-4(100 RPS)와 차이는 CPU 값 자체보단 요청을 처리하느냐인 것 같다. 4-4는 같은 max=5에서도 실패율 45% / Checks 54%로 처리가 안 됐고, 5-4는 16% / 91.9%로 빠듯해도 처리는 됐다. 50 RPS는 max replica 기준 &amp;quot;빠듯하지만 들어가는&amp;quot; 부하 정도로 정리할 수 있겠다. (4-4 시점 Prometheus 메트릭이 이미 날아가서 CPU 시계열 비교 캡처는 못 했고, 처리 결과 비교는 &lt;a href=&quot;#4-4100-rps-vs-5-450-rps-%EB%B9%84%EA%B5%90&quot;&gt;4-4 vs 5-4 비교 표&lt;/a&gt;로 대신.)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;scale-in 시작 — 17:14&lt;/strong&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wUozd/dJMcabRQ8vF/YJy7NnQdiXkzWpQSdutsNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wUozd/dJMcabRQ8vF/YJy7NnQdiXkzWpQSdutsNk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wUozd/dJMcabRQ8vF/YJy7NnQdiXkzWpQSdutsNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwUozd%2FdJMcabRQ8vF%2FYJy7NnQdiXkzWpQSdutsNk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;HPA가 부하 종료 후 곧바로 줄이지 않고 ramp-down 시작 시점(17:09:04, CPU가 임계 아래로 내려가기 시작)부터 약 5분 stabilization window를 두고 17:14:52에 첫 scale-in을 했다. 4-4와 동일한 HPA 기본 downscale 동작.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;완전 복귀 — 17:15&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cOnomU/dJMcaaepBLA/9b05HYVXLKvcLKLqGmgOrk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cOnomU/dJMcaaepBLA/9b05HYVXLKvcLKLqGmgOrk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cOnomU/dJMcaaepBLA/9b05HYVXLKvcLKLqGmgOrk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcOnomU%2FdJMcaaepBLA%2F9b05HYVXLKvcLKLqGmgOrk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;17:15:48 시점에 모두 REPLICAS=2, CPU 2~3%. scale-in 완료.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Job 종료&lt;/strong&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dIoKpR/dJMcacccxft/zPKexTK9MLe4n7dXUhvcj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dIoKpR/dJMcacccxft/zPKexTK9MLe4n7dXUhvcj1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dIoKpR/dJMcacccxft/zPKexTK9MLe4n7dXUhvcj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdIoKpR%2FdJMcacccxft%2FzPKexTK9MLe4n7dXUhvcj1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;Job duration 15분. threshold(&lt;code&gt;p(95)&amp;lt;500ms&lt;/code&gt;, &lt;code&gt;error rate&amp;lt;1%&lt;/code&gt;)를 넘겨 여전히 Failed로 마감. 다만 threshold 미달이 곧 회복 실패는 아니다 — 시간대별 실패 분포는 Grafana에서 확인한다.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Grafana 전체 흐름&lt;/strong&gt;&lt;br&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5NTww/dJMcajvCK4z/416jPP7jVevtlUZHrdDkTk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5NTww/dJMcajvCK4z/416jPP7jVevtlUZHrdDkTk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5NTww/dJMcajvCK4z/416jPP7jVevtlUZHrdDkTk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5NTww%2FdJMcajvCK4z%2F416jPP7jVevtlUZHrdDkTk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;전체 10,209 요청 중 실패 1,630건(약 16%). Checks Success Rate 91.9%. 4-4 대비 큰 폭 개선이고, 그래프 모양도 17:09 이후부터 빨간 실패선이 점차 0으로 수렴하는 형태로 잡힌다.&lt;/em&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;4-4(100 RPS) vs 5-4(50 RPS) 비교&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;4-4 (100 RPS)&lt;/th&gt;
&lt;th&gt;5-4 (50 RPS)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;전체 요청&lt;/td&gt;
&lt;td&gt;35,158&lt;/td&gt;
&lt;td&gt;10,209&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실패&lt;/td&gt;
&lt;td&gt;15,826 (45.01%)&lt;/td&gt;
&lt;td&gt;1,630 (약 16%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checks Success&lt;/td&gt;
&lt;td&gt;54.98%&lt;/td&gt;
&lt;td&gt;91.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HPA 첫 반응&lt;/td&gt;
&lt;td&gt;한 polling 주기 안&lt;/td&gt;
&lt;td&gt;한 polling 주기 안&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;order REPLICAS&lt;/td&gt;
&lt;td&gt;2→4 (한 번에 2단계)&lt;/td&gt;
&lt;td&gt;2→4 (한 번에 2단계)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;처리 결과&lt;/td&gt;
&lt;td&gt;max=5에서도 처리 불가&lt;/td&gt;
&lt;td&gt;max=5에서 빠듯하지만 처리됨&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;100 RPS는 max=5까지 띄워도 부족한 부하였고, 50 RPS는 max에 도달한 뒤 부하가 5개 pod으로 분산되며 빠듯하게나마 처리되는 영역으로 들어왔다. Spike Test가 답하려던 &amp;quot;HPA가 따라잡는가&amp;quot;에 50 RPS 기준으로는 &amp;quot;그렇다&amp;quot;고 정리할 수 있을 것 같다.&lt;/p&gt;
&lt;h4&gt;관찰 포인트&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;HPA 반응 자체는 한 polling 주기 안에 끝났고 4-4와 동일하게 빠른 편. 즉 HPA 설정 자체에는 문제가 없는 것 같다.&lt;/li&gt;
&lt;li&gt;새 Pod이 트래픽 받기 시작하기까지의 갭은 여전히 있다 — 16% 실패의 대부분이 이 초기 burst 구간(17:04~17:06)에 몰렸다.&lt;/li&gt;
&lt;li&gt;부하가 max replica 처리 영역 안에 있으면 빠듯해도 처리는 된다는 게 5-4의 결론. 다만 CPU 자체가 평시 수준으로 돌아온 건 아니고, 임계 위에서 5개 pod이 분산해서 부담한 정도.&lt;/li&gt;
&lt;li&gt;16% 실패율은 SLA 목표(&amp;lt;1%)와는 거리가 멀다. 다음 개선 후보는 초기 burst 구간 흡수 — warm pool, readinessProbe 튜닝, HPA &lt;code&gt;behavior.scaleUp&lt;/code&gt; policy 조정 등.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;5-5. Spike Test 2 결과 요약&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Peak RPS&lt;/td&gt;
&lt;td&gt;187 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전체 요청&lt;/td&gt;
&lt;td&gt;10,209건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실패&lt;/td&gt;
&lt;td&gt;1,630건 (약 16%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checks Success&lt;/td&gt;
&lt;td&gt;91.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HPA 첫 반응&lt;/td&gt;
&lt;td&gt;한 polling 주기 안 (4-4와 동일)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pod startup 갭&lt;/td&gt;
&lt;td&gt;최대 약 30초 (HPA 결정 → 새 Pod Ready)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scale-in 완료&lt;/td&gt;
&lt;td&gt;ramp-down 후 약 7분 (stabilization 5분)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;threshold&lt;/strong&gt;: &lt;code&gt;p(95)&amp;lt;500ms&lt;/code&gt;, &lt;code&gt;error rate&amp;lt;1%&lt;/code&gt; 여전히 실패. 그치만 4-4 대비 실패율이 45% → 16%, Checks Success가 55% → 91.9%로 크게 개선됐다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HPA&lt;/strong&gt;: 4-4와 동일한 패턴으로 작동 — order 2→4→5, kitchen 2→3→4, notification 2→3. CPU 임계 인지 후 한 polling 주기 안에 scale-out 결정.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;병목&lt;/strong&gt;: HPA 자체가 아니라 새 Pod이 트래픽 받기까지의 startup 갭. 전체 실패의 약 43%가 burst 구간(17:04~17:06)에 몰림.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;다음 단계&lt;/strong&gt;: Pod warm-up 단계(readinessProbe 튜닝, warm pool, HPA &lt;code&gt;behavior.scaleUp&lt;/code&gt; policy) 개선 후 재테스트.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;6. 종합 결론과 다음 액션&lt;/h2&gt;
&lt;h3&gt;6-1. 세 테스트로 본 것&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Load Test (10/30/50 RPS)&lt;/strong&gt; — 30 RPS까지는 안정, 50 RPS부터 임계. order/kitchen이 동시에 500을 뱉어서 DB 커넥션 경합으로 추정했지만 로그에서 Prisma 에러 코드(P2024)는 직접 확인되지 않아 가설로만 남았다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spike Test 1 (10 → 100 RPS)&lt;/strong&gt; — 부하 자체가 Load Test 기준 한계 초과 영역이라 HPA 반응 능력을 깔끔하게 측정하지 못했다. 45% 실패라는 결과가 HPA가 따라잡지 못한 건지, 그냥 부하가 한계 초과인 건지 분리되지 않은 셈.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spike Test 2 (10 → 50 RPS)&lt;/strong&gt; — 처리 가능한 부하로 재조정해서 측정. HPA 자체 반응은 polling 주기 안에 끝났고, max replica에 도달한 뒤 부하가 분산되며 처리되는 영역으로 들어갔다. 다만 16% 실패의 큰 몫은 새 Pod이 트래픽 받기까지의 startup 갭에서 나왔다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;HPA 설정 자체에는 문제가 없어 보이고, 진짜 병목은 (a) 부하 진입 직후의 Pod warm-up 갭과 (b) DB 레이어 두 군데로 좁혀진다.&lt;/p&gt;
&lt;h3&gt;6-2. 개선해보고 싶은 것&lt;/h3&gt;
&lt;h4&gt;1순위 — Pod warm-up 갭 단축&lt;/h4&gt;
&lt;p&gt;Spike Test 2에서 16% 실패의 약 43%가 burst 구간에 몰렸고, HPA 결정부터 새 Pod이 트래픽 받기까지 최대 30초 갭이 있었다. 이 갭을 줄이는 게 가장 효과가 클 것 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;readinessProbe 튜닝&lt;/strong&gt; — initialDelaySeconds·periodSeconds·failureThreshold 값 조정 여지 검토&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HPA &lt;code&gt;behavior.scaleUp&lt;/code&gt; policy 조정&lt;/strong&gt; — 한 번에 더 많은 pod을 미리 띄우도록 (예: &lt;code&gt;policies: [{type: Pods, value: 4, periodSeconds: 15}]&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;min replica 상향&lt;/strong&gt; — min=2를 3 또는 4로. burst 흡수력은 좋아지지만 평시 비용도 같이 올라감&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Istio sidecar 시작 시간 단축&lt;/strong&gt; — &lt;code&gt;holdApplicationUntilProxyStarts&lt;/code&gt;, image pre-pull 등&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;2순위 — Prisma 커넥션 가설 검증&lt;/h4&gt;
&lt;p&gt;Load Test 50 RPS에서 추정만 했던 DB 커넥션 경합을 확인:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Prisma &lt;code&gt;connection_limit&lt;/code&gt; 명시 설정 (지금은 미설정 → pod당 5 default)&lt;/li&gt;
&lt;li&gt;재테스트로 Prisma 에러 코드(P2024) 직접 관측&lt;/li&gt;
&lt;li&gt;검증되면 &lt;code&gt;pod 수 × connection_limit ≤ RDS max_connections&lt;/code&gt; 범위 안에서 적정 값 튜닝&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;3순위 — 분산 트레이싱&lt;/h4&gt;
&lt;p&gt;추정에 의존하는 결론을 정량 데이터로 바꾸기 위해:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tempo로 확인하기&lt;/li&gt;
&lt;li&gt;order → kitchen → notification 호출 체인에서 latency가 어디서 쌓이는지&lt;/li&gt;
&lt;li&gt;DB 호출 시간과 내부 서비스 호출 시간 분리 측정&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;4순위 — 인프라 자체 검토&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;RDS db.t3.micro 인스턴스 한계 — 더 큰 인스턴스로 변경 시 임계 RPS가 어디로 옮겨가는지&lt;/li&gt;
&lt;li&gt;HPA max=5 → 8/10 상향 (노드 capacity와 같이 검토 필요)&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Project: Shoong-Delivery</category>
      <author>2-30</author>
      <guid isPermaLink="true">https://2-3-0.tistory.com/17</guid>
      <comments>https://2-3-0.tistory.com/17#entry17comment</comments>
      <pubDate>Thu, 28 May 2026 21:46:31 +0900</pubDate>
    </item>
    <item>
      <title>[보안] WAF &amp;middot; CloudFront OAC &amp;middot; IRSA &amp;middot; ESO &amp;mdash; 보안 설계와 트레이드오프</title>
      <link>https://2-3-0.tistory.com/16</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안을 어디까지 챙겨야 할까? 과하면 업무 효율을 떨어뜨리는 과잉 규제가 되고, 미흡하면 기본적인 관리 소홀로 이어지기 때문이다. 실무에서 같은 인프라를 짠다면 어디서 멈출지를 계속 떠올리면서 선을 잡아갔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 시스템에 고정된 비밀번호나 인증 키를 남기지 않는 것을 목표로 잡았다. AWS Access Key, SSH 키, kubeconfig 같은 중요한 인증 정보를 설정에 고정으로 심어두지 않는 방식이다. 실제 운영 환경에서 이런 고정 키가 유출돼서 일어나는 경우가 많을테니 유출될 만한 키 자체를 애초에 만들지 않는 게 가장 확실한 보안이라고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것을 출발점으로 잡고 &lt;b&gt;Edge &amp;rarr; Network &amp;rarr; IAM &amp;rarr; App&lt;/b&gt; 4계층으로 나눠서 보안 설계를 했다.&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Edge &amp;mdash; WAF + CloudFront OAC + ALB TLS 종단&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;WAF에 AWS Managed Rule을 깐 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WAF 룰을 직접 짤 자신은 없었다. SQL Injection 패턴이나 XSS 우회 변형을 내가 일일이 정규식으로 잡는 건 현실적으로 효율성이 떨어진다고 판단했다. 그래서 AWS가 운영하는 Managed Rule 6종을 골라서 깔았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;AWSManagedRulesCommonRuleSet&lt;/code&gt; &amp;mdash; 공통 OWASP Top 10 계열&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AWSManagedRulesKnownBadInputsRuleSet&lt;/code&gt; &amp;mdash; 알려진 악성 페이로드&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AWSManagedRulesAmazonIpReputationList&lt;/code&gt; &amp;mdash; AWS가 추적하는 평판 나쁜 IP&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AWSManagedRulesSQLiRuleSet&lt;/code&gt; &amp;mdash; SQL Injection 전용&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AWSManagedRulesLinuxRuleSet&lt;/code&gt;, &lt;code&gt;AWSManagedRulesUnixRuleSet&lt;/code&gt; &amp;mdash; OS 명령 주입&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WAF는 CloudFront 앞단에 붙였다. ALB에 직접 붙이는 선택지도 있었는데, 이미 CloudFront로 정적/동적 트래픽을 다 받고 있어서 굳이 두 군데 붙일 이유가 없었다. 다만 CloudFront용 WAF는 &lt;b&gt;반드시 &lt;code&gt;us-east-1&lt;/code&gt;에 만들어야 한다&lt;/b&gt;는 제약이 있어서, dev/prod가 다른 리전이어도 WAF만큼은 버지니아에 따로 둬야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WAF는 CloudFront 앞단에 붙였다. ALB에 붙일 수 있었지만 WAF를 CloudFront와 ALB 양쪽에 다 붙이면 비용도 이중으로 들고, 트래픽이 WAF를 한 단계 더 거치면서 네트워크 지연(Latency)이 발생할 수 있지 않을까 생각했기 때문이다. 이 부분에 대해서는 아직 검증해보지 않아서 추측이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CloudFront용 WAF는 반드시 us-east-1(버지니아 북부) 리전에 만들어야 한다는 제약이 있다. 지금은 버지니아 리전에 인프라를 설정했기 때문에 상관 없지만 dev/prod 메인 인프라가 다른 리전에 있게 된다면 WAF는 버지니아에 따로 생성해 관리해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CloudFront OAC &amp;mdash; 이 CloudFront만 S3 읽을 수 있게&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드는 React 빌드 결과물이라 그냥 S3에 올리면 끝나는데 그 S3를 어떻게 보호할지가 고민이었다. 처음엔 S3 정적 웹사이트 호스팅을 켤까 했는데 그러면 S3가 공개되어 버린다. URL만 알면 누구나 직접 접근 가능하게 되어서 그건 싫었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;b&gt;Public Access Block 4종을 다 켜고&lt;/b&gt;, CloudFront만 OAC(Origin Access Control)로 S3를 읽을 수 있게 했다. 여기서 추가로 S3 버킷 정책에 &lt;code&gt;AWS:SourceArn&lt;/code&gt; 조건을 걸어서 &lt;b&gt;이 CloudFront 배포 한 개만&lt;/b&gt; GetObject를 할 수 있게 했다.&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;# modules/cloudfront/main.tf
Condition = {
  StringEquals = {
    &quot;AWS:SourceArn&quot; = aws_cloudfront_distribution.this.arn
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 조건 한 줄이 없으면 같은 계정의 &lt;b&gt;다른 CloudFront&lt;/b&gt;도 이 S3를 읽을 수 있게 된다. 별 거 아닌 것 같지만 멀티 프로젝트 환경이라면 의미가 크다고 봤다. 운영 환경에서 한 팀이 만든 정적 자산을 다른 팀이 실수로 자기네 CDN으로 쏘는 사고는 꽤 흔할 것 같았다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TLS는 어디서 끊을까&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CloudFront &amp;harr; ALB &amp;harr; EKS 구간에서 TLS를 어디서 종단할지 정해야 했다. 효율성과 보안의 타협점을 고민한 끝에 내린 결론은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;사용자 &amp;rarr; CloudFront&lt;/b&gt;: 가장 바깥쪽 영역인 만큼 안전한 최신 암호화 규격(TLSv1.2_2021)을 적용했다. 80포트로 들어오는 평문 요청은 HTTP 301로 강제 리다이렉트시켜서 최초 진입부터 진입 자체가 불가능하도록 차단했다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CloudFront &amp;rarr; ALB&lt;/b&gt;: CloudFront에서 ALB로 갈 때 AWS 백본망을 타긴 하지만 기본적으로 퍼블릭 인터넷 영역을 거치기 때문에 스니핑(도청)이나 중간자 공격을 막기 위해 HTTPS 통신을 강제했다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ALB &amp;rarr; EKS(Istio Ingress)&lt;/b&gt;: 여기부터는 외부인이 들여다볼 수 없는 완벽한 VPC 내부망이다. 패킷을 주고받을 때마다 암&amp;middot;복호화를 반복하면 서버 CPU 부하가 커지므로 성능 최적화와 패킷 전송 속도를 위해 HTTP로 풀었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인증서 발급과 도메인 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ACM 인증서는 와일드카드(&lt;code&gt;*.shoong.cloud&lt;/code&gt;)로 받아서 DNS 자동 검증으로 발급했고, Route53 레코드까지 Terraform이 같이 만든다. Hosted Zone 자체는 콘솔에서 만든 걸 data 소스로 참조만 하게 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 Hosted Zone 자체는 콘솔에서 만든 걸 data 소스로 참조만 하게 했다. 호스팅 영역을 Terraform이 직접 관리하게 하면 destroy했을 때 가비아에 연동해 둔 네임서버 설정까지 통째로 날아가 버리기 때문이다. 네임서버 주소가 새로 생성되면 가비아에 다시 등록하러 가야 하는 번거로움이 있어 변경이 잦은 레코드만 분리하여 IaC로 관리하기로 했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Network &amp;mdash; 3-tier + SG 체이닝 + VPC Endpoint&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;서브넷을 3단으로 나눈 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VPC를 짤 때 Public / Private / DB 3-tier로 나눴다. AZ 3개를 쓰니까 서브넷이 총 9개가 됐다. 한 AZ만 쓰면 서브넷 3개로 끝나서 깔끔하지만 AZ 장애 시 통째로 죽는다. 비용이 좀 들어도 3개로 가는 게 맞다고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AZ를 2개만 써서 서브넷 6개로 타협할 수도 있었다. 하지만 특정 가용 구역에 장애가 발생했을 때 남은 인프라가 트래픽 부하를 안정적으로 나눠 가질 수 있는 완충 효과(N+1 구조)를 고려했다. 또한 EKS와 Multi-AZ 데이터베이스 환경에서 고가용성을 확보하려면 최소 3개의 AZ를 구축하는 것이 AWS의 권장 규격이라 비용이 조금 더 들더라도 추후 발생할 장애 리스크를 최소화하기 위해 AWS 가이드를 충실히 따르는 게 맞다고 판단했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Public&lt;/b&gt; &amp;mdash; ALB와 NAT Gateway(AZ별 각 1개). 외부에서 들어오는 진입점은 ALB. NAT은 Private subnet의 outbound용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Private&lt;/b&gt; &amp;mdash; EKS 워커 노드, Bastion EC2. 인터넷에서 직접 안 보임&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DB&lt;/b&gt; &amp;mdash; RDS 전용. 자체 라우팅 테이블에 인터넷 경로 자체가 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RDS는 &lt;code&gt;publicly_accessible = false&lt;/code&gt;에 더해서 아예 인터넷 경로가 없는 서브넷에 두는 이중 안전장치를 걸었다. 그리고 &lt;code&gt;storage_encrypted = true&lt;/code&gt;로 EBS 볼륨 레벨 암호화도 켰다. RDS 암호화는 인스턴스 만들 때 안 켜면 나중에 못 켠다(스냅샷 복사로 우회해야 함)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Security Group은 CIDR 대신 SG 참조로&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SecurityGroup(SG)를 짤 때 신경 쓴 부분은 CIDR을 쓰지 않고 보안 그룹 간의 참조로만 묶는 방식이었다.&lt;br /&gt;10.0.0.0/16이나 10.0.11.0/24 같은 CIDR 대역을 인바운드 허용 조건으로 넣으면 당장은 편하겠지만 그 대역이나 서브넷 안에 새로 생성되는 모든 리소스까지 데이터베이스 접근 권한을 가지게 된다. 반면에 보안 그룹 자체를 참조하도록 설정하면 딱 그 보안 그룹이 부여된 특정 리소스만(ex: EKS 워커노드) 통과시킬 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 RDS SG의 인바운드 룰은 이런 식으로 짰다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# modules/security_group/main.tf
resource &quot;aws_security_group_rule&quot; &quot;rds_from_eks_node&quot; {
  type                     = &quot;ingress&quot;
  from_port                = 5432
  to_port                  = 5432
  protocol                 = &quot;tcp&quot;
  security_group_id        = aws_security_group.rds.id
  source_security_group_id = aws_security_group.eks_node.id   # &amp;larr; CIDR 아님
  description              = &quot;PostgreSQL from EKS Node SG&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 운영 환경에서는 새로운 인프라가 추가되면서 IP 대역이 겹치거나 원치 않는 접근이 허용되는 보안 구멍이 생기기 쉬운데 이렇게 그룹 간 참조로 묶으면 최소 권한 원칙을 확실하게 지킬 수 있어서 이 방식을 최우선으로 뒀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 dev 환경에서만 Bastion을 통해 RDS 5432 포트로 직접 접근할 수 있도록 예외를 열어뒀다. allow_ssm_db_access라는 조건문 변수로 dev/prod 분기를 처리했는데, prod 환경에서는 이 변수가 false라 룰 자체가 생성이 안 된다. 프로덕션 환경에서 누군가 직접 DB에 psql로 붙는 리스크는 원천 차단해야 한다는 판단이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 RDS SG를 구축할 때는 inline ingress 룰을 쓰지 않고 전부 별도의 aws_security_group_rule 리소스로 분리해서 선언했다. 인라인 방식과 별도 리소스 방식을 섞어 쓰거나 혼선이 생기면 Terraform이 plan/apply를 할 때마다 리소스 상태의 충돌(Drift)을 감지해 인바운드 규칙이 무한으로 수정, 삭제되는 루프 이슈가 있었기 때문이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;VPC Endpoint&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Private Subnet에 있는 워커 노드라 해도 ECR에서 이미지 받거나 SSM으로 명령 받으려면 어쨌든 AWS API를 호출해야 한다. 그게 NAT Gateway를 거쳐 인터넷으로 나가면 결국 그 트래픽은 &lt;b&gt;인터넷 망&lt;/b&gt;을 한 번 타게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안성을 극대화하기 위해 AWS 공용 서비스들과 직접 사설 연결을 맺어주는 VPC Endpoint 7종을 도입했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;S3 Gateway&lt;/b&gt; (무료) &amp;mdash; ECR 이미지 layer가 실제로 저장된 S3 접근용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ECR API / ECR DKR&lt;/b&gt; (Interface) &amp;mdash; 이미지 pull&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SSM / SSMMessages / EC2Messages&lt;/b&gt; (Interface) &amp;mdash; Bastion 호스트 및 세션 매니저 접속용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;EKS&lt;/b&gt; (Interface) &amp;mdash; 워커 노드가 EKS 컨트롤 플레인 API를 호출하기 위한 용도(kubectl)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 구성하면 워커 노드와 AWS API 간의 모든 트래픽이 외부로 나가지 않고 &lt;b&gt;VPC 내부망 안에서만 완전히 격리&lt;/b&gt;되어 처리된다. 보안 효과(인터넷 노출 원천 차단)와 비용 효과(NAT 트래픽 감소)를 동시에 잡는 셈이다. VPC Endpoint SG는 VPC CIDR에서만 443을 허용해서 외부에서 접근 시도 자체를 차단한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 사용하는 모든 서비스에 엔드포인트를 붙인 건 아니다. 상대적으로 보안 리스크가 적거나 VPC Endpoint가 굳이 필요 없는 일부 서비스(CloudWatch Logs, STS 등) 호출은 여전히 NAT Gateway를 경유하도록 두어 인프라 비용의 균형을 맞췄다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. IAM &amp;mdash; 장기 자격증명 0개, OIDC 4겹&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정적 자격증명(AWS Access Key / SSH Key / kubeconfig 등)을 코드, 환경변수, 로컬 파일 어디에도 두지 않는다는 게 목표였다. 4가지 경로 모두 OIDC 기반 임시 토큰으로 대체했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) GitHub Actions &amp;rarr; AWS&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Actions가 AWS에 인증할 때 보통 &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt;와 &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt;를 secret에 넣는다. GitHub OIDC로 바꾸면 키 자체가 없다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;# modules/iam_oidc/main.tf
Condition = {
  StringLike = {
    &quot;token.actions.githubusercontent.com:sub&quot; = [
      for repo in var.github_repos : &quot;repo:${var.github_org}/${repo}:*&quot;
    ]
  }
  StringEquals = {
    &quot;token.actions.githubusercontent.com:aud&quot; = &quot;sts.amazonaws.com&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub이 발급한 OIDC 토큰의 &lt;code&gt;sub&lt;/code&gt; 클레임은 &lt;code&gt;repo:&amp;lt;org&amp;gt;/&amp;lt;repo&amp;gt;:&amp;lt;context&amp;gt;&lt;/code&gt; 형태인데, 여기에 &lt;code&gt;repo:shoong-delivery/shoong-order-api:*&lt;/code&gt;처럼 &lt;b&gt;이 org의 이 repo가 보낸 토큰&lt;/b&gt;만 AssumeRole을 허용한다. &lt;code&gt;aud&lt;/code&gt; 조건으로 &lt;code&gt;sts.amazonaws.com&lt;/code&gt;도 강제했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 없으면 OIDC 자체는 깔려있어도 &lt;b&gt;GitHub의 다른 어떤 repo든&lt;/b&gt; 이 Role을 가져갈 수 있게 된다. OIDC 도입했다고 안심하면 안 되는 부분이 여기다. 처음엔 그냥 &lt;code&gt;sts.amazonaws.com&lt;/code&gt; aud 조건만 걸고 끝낼 뻔했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ECR 권한은 &lt;code&gt;GetAuthorizationToken&lt;/code&gt;은 AWS 제약상 어쩔 수 없이 &lt;code&gt;*&lt;/code&gt;로 줬지만 push/pull은 ECR repo ARN으로 좁혔다. 프론트엔드 S3 + CloudFront invalidation 권한도 분리해서 한 정책에 다 몰아넣지 않았다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2) Pod &amp;rarr; AWS API (IRSA 3종)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;K8s Pod이 AWS API를 호출할 일이 생각보다 많다. AWS Load Balancer Controller는 ALB를 생성해야 하고, EBS CSI Driver는 EBS 볼륨을 프로비저닝해야 하며, External Secrets Operator(ESO)는 Secrets Manager나 Parameter Store를 읽어와야 한다. 만약 이 모든 권한을 워커 노드의 인스턴스 프로파일(Instance Profile)에 몰아넣으면 그 노드 위에서 도는 모든 Pod가 노드 권한을 공유하게 되기 때문에 보안상 문제가 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 찾아보니 Pod 단위로 AWS IAM 권한을 쪼개서 부여할 수 있는 IRSA(IAM Roles for Service Accounts)가 있어 추가했다.&lt;/p&gt;
&lt;pre class=&quot;perl&quot;&gt;&lt;code&gt;Condition = {
  StringEquals = {
    &quot;${local.oidc_host}:sub&quot; = &quot;system:serviceaccount:external-secrets:external-secrets&quot;
    &quot;${local.oidc_host}:aud&quot; = &quot;sts.amazonaws.com&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테라폼으로 신뢰 관계(Trust Relationship)를 정의할 때 system:serviceaccount:: 형식을 지정하여 정확히 매핑된 특정 서비스 어카운트(SA)만 해당 Role을 AssumeRole 할 수 있도록 제한했다. 이렇게 하면 ESO용 Role을 권한이 없는 다른 Load Balancer Controller Pod가 가로채서 쓰는 등의 권한 탈취 리스크를 원천 차단할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3) 사람 &amp;rarr; Bastion EC2&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Bastion은 &lt;b&gt;public IP를 안 줬고&lt;/b&gt;(&lt;code&gt;associate_public_ip_address = false&lt;/code&gt;), &lt;b&gt;22번 포트도 안 열었다&lt;/b&gt;. 대신 SSM Session Manager로만 붙도록 했다. SSM은 IAM 권한으로 접속을 인증하기 때문에 따로 SSH 키를 관리할 필요가 없다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# modules/bastion/main.tf
resource &quot;aws_instance&quot; &quot;this&quot; {
  associate_public_ip_address = false
  iam_instance_profile        = aws_iam_instance_profile.this.name
  # SSH 키 없음, 보안 그룹에 22번 인바운드 없음
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 SSH 키 분실/유출 자체가 시나리오에서 사라진다. 키가 없으니까. SSM은 권한이 끊기면 즉시 접속 차단이 되기 때문에 실무에서 담당자가 퇴사하게 된다면 IAM에서 권한만 빼면 끝이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4) 사람 &amp;rarr; EKS API&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클러스터 인증 정보가 담긴 kubeconfig 파일을 어디서 관리하느냐도 중요한 보안 이슈인 것 같다. 만약 이 파일을 로컬 PC에 평문으로 저장해 두고 다닌다면 노트북 분실이나 해킹이 곧 클러스터 통째로 노출되는 대형 사고로 이어질 수도 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 EKS Access Entry를 도입했다. 로컬 PC의 접근을 아예 차단하고 SSM으로만 접속할 수 있는 Bastion EC2의 IAM Role만 EKS Access Entry에 등록해서 클러스터 관리자 권한을 부여했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# modules/eks/main.tf
resource &quot;aws_eks_access_entry&quot; &quot;ssm_ec2&quot; {
  cluster_name  = aws_eks_cluster.this.name
  principal_arn = var.ssm_ec2_role_arn
  type          = &quot;STANDARD&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 클러스터를 조작하려면 먼저 SSM으로 Bastion에 보안 접속한 뒤, aws eks update-kubeconfig 명령어를 통해 인증 정보를 일시적으로 생성해야 한다. 로컬에 영구 보관하는 파일이 아니라 통제된 원격 서버 안에서 세션이 유지되는 동안만 사용하는 방식이다. 세션을 끊으면 사라지게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름이 처음에는 좀 낯설었지만 익숙해지고 나니 오히려 과거에 kubeconfig 마스터 키를 로컬 PC에 들고 다녔던 것이 위험한 행동이었구나를 깨닫게 되었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. App &amp;mdash; 시크릿이 Git에도 환경변수에도 안 남게&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매니페스트에 평문 시크릿이 들어가면 Git에 영원히 박힌다. 한번 push되면 force push로 지워도 fork된 곳, 로그, 캐시 어디든 남는다고 한다. 특히 GitHub에 올라오는 실시간 커밋을 그대로 긁어가는 자동 크롤러 봇들이 엄청나게 많다고 한다. 해커들이 AWS 키나 비밀번호 같은 시크릿만 전문적으로 스캔하는 봇을 24시간 돌린다고 한다...&lt;br /&gt;그래서 K8s 매니페스트와 Helm values 어디에도 실제 시크릿 값을 적히지 않도록 노력했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;값의 민감도에 따른 시크릿 관리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS에 시크릿을 저장할 때 Parameter Store와 Secrets Manager 둘 다 쓸 수 있다.&lt;br /&gt;어떤 것인가에 따라 Parameter Store 또는 Secrets Manager를 사용할지 기준을 정했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Parameter Store&lt;/b&gt; &amp;mdash; &lt;code&gt;NODE_ENV&lt;/code&gt;, &lt;code&gt;ORDER_API_URL&lt;/code&gt; 같은 &lt;b&gt;일반 환경변수 및 설정값&lt;/b&gt; (Standard tier 기준 무료, String type)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Secrets Manager&lt;/b&gt; &amp;mdash; DB 패스워드, API 마스터 키 등 자격증명 (KMS 암호화 기본 적용, 시크릿당 월 $0.40)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 서비스를 조합한 이유는 값의 민감도가 엄연히 다른데 모든 값을 굳이 유료 서비스에 밀어 넣을 이유가 없다고 판단했기 때문이다. 서비스 URL이나 환경 이름처럼 유출되어도 치명적이지 않은 설정값들은 Parameter Store로 비용 없이 관리하고, DB 자격증명 같이 개발자조차 몰라야 하는 최고 등급의 민감 정보들은 돈을 내더라도 안전한 Secrets Manager에 격리해 두었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ESO로 K8s Secret 동기화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;External Secrets Operator(ESO)가 백그라운드에서 12시간마다 자동으로 AWS에서 값을 읽어와 쿠버네티스 고유의 내장 객체인 K8s Secret으로 만들어준다. 그리고 실제 애플리케이션인 Deployment 매니페스트는 생성된 K8s Secret을 envFrom으로 마운트해 평범한 환경변수처럼 읽기만 하면 된다. 이 과정 덕분에 Git 리포지토리에 저장되는 매니페스트에는 실제 비밀번호가 아닌, AWS의 ARN이나 키 경로만 기록된다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;# shoong-gitops/eso/dev/external-secret-db.yaml
spec:
  data:
    - secretKey: password
      remoteRef:
        key: /shoong/dev/db-credentials
        property: password&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 이 YAML 파일이 퍼블릭 Git에 완전히 노출되어도 아무런 문제가 없다. 유출되면 위험한 실제 password 값은 오직 AWS Secrets Manager/Parameter Store에만 존재하기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 이 값을 긁어오는 ESO Pod 자체도 앞서 언급한 IRSA를 통해 AWS API를 호출하므로 클러스터 내부에 AWS Access Key 같은 정적 인증 키를 저장할 필요가 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(미해결)RDS 마스터 비번이 tfstate에 평문으로 남는 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 듣고 깔끔해 보이지만 사실 한 군데 구멍이 있다. &lt;b&gt;RDS 마스터 비밀번호&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 흐름은 사람이 콘솔에서 Secrets Manager에 비번을 미리 만들어두고, Terraform이 그걸 &lt;code&gt;data&lt;/code&gt; 소스로 읽어서 RDS에 평문으로 전달한다. 이 과정에서 비번이 &lt;b&gt;tfstate 파일에 평문으로 저장&lt;/b&gt;된다. tfstate는 S3 백엔드 + KMS 암호화 + 버전 관리로 보호하긴 하지만, 이상적이지는 않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개선안은 RDS의 &lt;code&gt;manage_master_user_password = true&lt;/code&gt; 옵션을 쓰는 것이다. AWS가 자동으로 강력한 랜덤 비번을 생성하고 Secrets Manager 시크릿도 자동으로 만들어준다. Terraform이 비번 자체를 모르니까 tfstate에도 안 남는다. 추가로 &lt;code&gt;aws_secretsmanager_secret_rotation&lt;/code&gt;으로 자동 회전까지 붙일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 아직 적용 안 한 상태다. 옵션 한 줄만 켜면 끝일 것 같지만 막상 흐름을 따라가 보면 여러 군데를 연쇄적으로 건드려야 해서이다..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 구조는 부트스트랩 스크립트(&lt;code&gt;init.sh&lt;/code&gt;)가 환경변수로 받은 DB 비번을 Secrets Manager에 직접 넣고, ESO가 그걸 동기화해서 K8s Secret으로 만들고, Bastion에서 psql로 DB 초기화 SQL을 실행할 때도 그 환경변수를 그대로 쓴다. &lt;b&gt;사람이 비번을 안다는 전제로 전체 흐름이 짜여 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;manage_master_user_password = true&lt;/code&gt;로 바꾸고 &lt;code&gt;init.sh&lt;/code&gt; 리팩토링과 ESO 매니페스트 수정을 같이해야해서 다음 고도화 작업에 적용할 예정이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. CI 가드레일 &amp;mdash; Trivy로 머지 차단&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지가 런타임 보안이고, 빌드 시점에서도 한 번 더 거르는 안전장치를 마련했다. CI 파이프라인에서 Trivy를 활용해 컨테이너 이미지의 CVE 취약점을 스캔하고, &lt;b&gt;CRITICAL&lt;/b&gt; 또는 &lt;b&gt;HIGH&lt;/b&gt; 등급의 취약점이 발견되면 &lt;b&gt;ECR Push를 차단&lt;/b&gt;하도록 구성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 스캔 단계를 PR(Pull Request) 단계에 넣을지, Main 브랜치 Merge 후에 실행할지를 두고 고민이 많이 됐었다. PR 단계에서 스캔하면 취약한 코드가 머지되는 것을 원천 차단할 수 있지만 매번 빌드와 스캔이 중복 발생하여 개발 피드백 루프가 느려진다는 단점이 있었다. 반면 Merge 후에 하면 PR 속도는 빠르지만 이미 취약점이 포함된 코드가 저장소에 합쳐지게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 Merge 후 Trivy 스캔을 수행하는 방식을 선택했다. 어차피 최종 ECR Push 직전에 파이프라인이 차단되므로 운영 환경에 배포되는 이미지는 무조건 취약점 검증을 통과한 안전한 상태임이 보장되기 때문이다. 향후 애플리케이션 규모가 커질수록 CI 빌드 타임이 늘어날 텐데 PR 단계에서 무거운 빌드와 스캔 과정을 두 번씩 반복하며 파이프라인 리소스를 낭비하는 것보다 Merge 후 최종 단계에서 한 번 확실하게 검증하는 것이 효율적이라고 판단했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;</description>
      <category>Project: Shoong-Delivery</category>
      <author>2-30</author>
      <guid isPermaLink="true">https://2-3-0.tistory.com/16</guid>
      <comments>https://2-3-0.tistory.com/16#entry16comment</comments>
      <pubDate>Mon, 25 May 2026 18:56:44 +0900</pubDate>
    </item>
    <item>
      <title>[Observability] EKS MSA 옵저버빌리티 통합 &amp;mdash; Prometheus + Loki + Tempo + Grafana</title>
      <link>https://2-3-0.tistory.com/15</link>
      <description>&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;EKS 위에 마이크로서비스 5개(order, kitchen, delivery, notification, batch)를 올린 후 주문이 느려진 경우가 있었는데 어느 서비스에서 지연되는지 알 수 없는 문제가 생겼다.&lt;br&gt;처음엔 &lt;code&gt;kubectl logs -f -l app=shoong-order&lt;/code&gt;로 로그를 보려고 했다. order는 replicaCount가 2였고 --prefix로 Pod 구분은 할 수 있었지만 같은 시간대에 들어온 다른 주문 로그 사이에서 그 요청 한 건에 해당하는 줄들을 골라서 보기 어려웠다. 어찌저찌 order Pod를 찾아 본다고 해도 order는 kitchen, kitchen은 delivery, delivery는 notification을 부른다. 터미널을 네 개 열어 &lt;code&gt;kubectl logs&lt;/code&gt;를 동시에 띄우고 로그에서 userName으로 grep해서 같은 요청에 해당하는 줄들을 시간순으로 직접 짜맞춰야 했다.&lt;/p&gt;
&lt;p&gt;그래도 어디가 느린지는 못 알아냈다. 로그는 요청 시작과 요청 끝만 찍을 뿐 그 사이의 쿼리나 HTTP 호출 같은 것들이 각각 얼마나 걸렸는지는 안 보였다. 결국 코드 중간 중간 콘솔을 찍고 로그를 확인했었다.&lt;/p&gt;
&lt;p&gt;한참을 이렇게 디버깅하고 나서야 문제의 윤곽이 보였다. 로그는 노드에 흩어져 있고, 메트릭은 어딘가에 따로 쌓이고 있고, 서비스 간 호출 흐름은 아예 어디에도 없었다. 각자 따로 놀고 있었다.&lt;/p&gt;
&lt;p&gt;그래서 메트릭, 로그, 트레이스를 Grafana 한 화면에 묶어보기로 했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3BEA8/dJMcacDfyPJ/EJUEvjL8E2rxgkAYqXMhf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3BEA8/dJMcacDfyPJ/EJUEvjL8E2rxgkAYqXMhf1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3BEA8/dJMcacDfyPJ/EJUEvjL8E2rxgkAYqXMhf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3BEA8%2FdJMcacDfyPJ%2FEJUEvjL8E2rxgkAYqXMhf1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;1. 풀고 싶었던 문제: MSA에서 디버깅이 안 된다&lt;/h2&gt;
&lt;p&gt;모놀리스에서 디버깅은 한 서버에 들어가 &lt;code&gt;tail -f app.log&lt;/code&gt; 하나로 끝난다. 그런데 MSA로 분리되면 디버깅이 아래와 같은 이유로 막혔다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;문제&lt;/th&gt;
&lt;th&gt;모놀리스&lt;/th&gt;
&lt;th&gt;MSA (서비스 5개)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;로그 위치&lt;/td&gt;
&lt;td&gt;단일 파일&lt;/td&gt;
&lt;td&gt;5개 Pod의 stdout. 어느 Pod부터 봐야 할지 모름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;요청 추적&lt;/td&gt;
&lt;td&gt;스택 트레이스 하나로 충분&lt;/td&gt;
&lt;td&gt;order → kitchen → delivery → notification, 어디서 끊겼는지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;성능 측정&lt;/td&gt;
&lt;td&gt;함수 단위 프로파일링&lt;/td&gt;
&lt;td&gt;한 요청의 총 시간이 서비스별로 어떻게 분해되는지 측정 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;옵저버빌리티(observability) 외부에서 보이는 신호만 가지고 내부 상태를 짐작할 수 있는 것을 말하는데 이런 부분이 필요했다.&lt;/p&gt;
&lt;p&gt;처음엔 그냥 모니터링이랑 같은 말 아닌가 싶었는데, 찾아보니 둘이 좀 다르다고 한다. 모니터링은 CPU 사용률이나 응답 시간처럼 미리 정해둔 지표를 추적하는 쪽이고, 옵저버빌리티는 사전에 예상 못 한 질문(가령 이번 주문은 왜 유독 느렸는가 같은)에도 답을 찾을 수 있게 하는 것이라고 생각하면 될거 같다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. 시그널 3종을 따로 보면 안 되는 이유&lt;/h2&gt;
&lt;p&gt;옵저버빌리티에서 흔히 3대 시그널이라고 부르는 게 있다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;시그널&lt;/th&gt;
&lt;th&gt;답하는 질문&lt;/th&gt;
&lt;th&gt;형식&lt;/th&gt;
&lt;th&gt;예시&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Metrics&lt;/td&gt;
&lt;td&gt;&amp;quot;얼마나 자주/얼마?&amp;quot;&lt;/td&gt;
&lt;td&gt;시계열 숫자&lt;/td&gt;
&lt;td&gt;초당 요청 수, P95 지연시간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logs&lt;/td&gt;
&lt;td&gt;&amp;quot;그때 무슨 일이?&amp;quot;&lt;/td&gt;
&lt;td&gt;타임스탬프 + 텍스트&lt;/td&gt;
&lt;td&gt;&amp;quot;주문 생성 실패: DB timeout&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traces&lt;/td&gt;
&lt;td&gt;&amp;quot;어디서 시간이?&amp;quot;&lt;/td&gt;
&lt;td&gt;인과관계 있는 span 트리&lt;/td&gt;
&lt;td&gt;order → kitchen → delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;P95&lt;/strong&gt;: 전체 데이터나 요청 중 95%가 해당 값 이하에 속하는 통계 지점&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;span&lt;/strong&gt;: 트레이싱을 구성하는 가장 작은 작업의 단위이자 하나의 빌딩 블록(체크포인트)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;처음엔 셋을 같이 봐야한다는 생각을 하지 못했다. Prometheus만 있어도 P95 latency 그래프는 보이니까. 그런데 그래프에서 스파이크 하나가 보였을 때 그 원인을 알려면 같은 시점의 로그가 필요했고, 로그에서 에러 메시지를 봤을 때 그게 어떤 요청 체인의 어느 서비스에서 발생한 건지 알려면 다시 트레이스가 필요했다.&lt;/p&gt;
&lt;p&gt;결국 신호 하나하나보다 &lt;strong&gt;신호 사이를 왔다 갔다 할 수 있느냐&lt;/strong&gt;가 필요하다는 걸 알게 됐다. 그래서 도구를 고를 때도 메트릭에서 같은 시점의 로그로, 거기서 다시 그 요청의 트레이스로 자연스럽게 넘어갈 수 있는지를 보게 되었다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;3. 통합 스택 vs SaaS — 왜 OSS를 골랐나&lt;/h2&gt;
&lt;p&gt;처음엔 단일 도구로 한 방에 풀 수 있는지부터 따져봤다. DataDog 같은 SaaS는 셋을 다 묶어 주고 셋업도 가장 단순해 보였으니까. 그런데 두 가지가 걸렸다.&lt;/p&gt;
&lt;p&gt;첫째, &lt;strong&gt;비용 구조&lt;/strong&gt;. DataDog은 인프라 모니터링(호스트당 정액) + APM(호스트당 별도) + 로그 인제스션(GB당)이 따로 청구되는 구조라고 한다. dev/prod 분리에 트래픽 부하 테스트까지 시작하면 비용이 많이 들 것으로 예상되었다.&lt;/p&gt;
&lt;p&gt;둘째, &lt;strong&gt;벤더 종속&lt;/strong&gt;. 코드에 DataDog Agent SDK를 박는 순간 갈아끼우기 어렵다는 얘기를 자주 봤다. 회사마다 쓰는 스택이 다르고 옵저버빌리티는 운영 조직 색깔이 가장 강하게 드러나는 영역이라 학습 자산이 한 SaaS에 묶이는 게 아깝게 느껴졌다.&lt;/p&gt;
&lt;p&gt;그래서 눈에 들어온 게 Grafana Labs의 OSS 통합 스택이었다. Prometheus(메트릭) + Loki(로그) + Tempo(트레이스) + Grafana(시각화). Grafana가 단일 진입점이 된다는 점이 마음에 들었다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;OSS&lt;/strong&gt;: Open Source Software. 소스 코드가 공개돼 있고 무료로 쓰고 수정·재배포할 수 있는 소프트웨어&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;트레이드오프는 분명하다. 자체 운영 부담은 SaaS보다 크다. 다만 이 프로젝트의 학습 목표 자체가 운영 스택을 직접 다뤄 보는 데 있었기 때문에 그 운영 부담 자체가 학습 가치라고 봤다. 비용도 좀 줄이고.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;4. 8개 컴포넌트와 시그널별 선택 근거&lt;/h2&gt;
&lt;p&gt;전체 스택은 8개 컴포넌트로 정리된다. 그중 셋(Prometheus / Grafana / AlertManager)은 kube-prometheus-stack 한 차트에 묶여 있어서 실제 Helm 릴리스는 6개다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;컴포넌트&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;th&gt;분류&lt;/th&gt;
&lt;th&gt;Helm 차트 (버전)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Prometheus&lt;/td&gt;
&lt;td&gt;메트릭 TSDB&lt;/td&gt;
&lt;td&gt;메트릭&lt;/td&gt;
&lt;td&gt;kube-prometheus-stack 84.5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Grafana&lt;/td&gt;
&lt;td&gt;통합 시각화 UI&lt;/td&gt;
&lt;td&gt;시각화&lt;/td&gt;
&lt;td&gt;(위에 포함)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;AlertManager&lt;/td&gt;
&lt;td&gt;알림 라우팅&lt;/td&gt;
&lt;td&gt;알림&lt;/td&gt;
&lt;td&gt;(위에 포함)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Loki&lt;/td&gt;
&lt;td&gt;로그 저장소&lt;/td&gt;
&lt;td&gt;로그&lt;/td&gt;
&lt;td&gt;loki 6.55.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Promtail&lt;/td&gt;
&lt;td&gt;로그 수집 DaemonSet&lt;/td&gt;
&lt;td&gt;로그&lt;/td&gt;
&lt;td&gt;promtail 6.17.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Tempo&lt;/td&gt;
&lt;td&gt;트레이스 저장소&lt;/td&gt;
&lt;td&gt;트레이스&lt;/td&gt;
&lt;td&gt;tempo 1.24.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;OTel Collector&lt;/td&gt;
&lt;td&gt;트레이스 수집/전달&lt;/td&gt;
&lt;td&gt;트레이스&lt;/td&gt;
&lt;td&gt;opentelemetry-collector 0.153.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Kiali&lt;/td&gt;
&lt;td&gt;서비스 메시 시각화&lt;/td&gt;
&lt;td&gt;서비스 메시&lt;/td&gt;
&lt;td&gt;kiali-server 2.25.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;메트릭 — Prometheus&lt;/h3&gt;
&lt;p&gt;많은 오픈소스 메트릭 도구 중에 Prometheus / Thanos / InfluxDB 셋을 비교 후보로 좁혔다. 세 도구를 고른 기준은 두 가지다. 첫째, 쿠버네티스 환경에서 메트릭 저장 옵션으로 가장 자주 거론된다. 둘째, Grafana 데이터소스로 모두 지원되어서 다른 신호(로그/트레이스)와의 통합 비용이 작다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;후보&lt;/th&gt;
&lt;th&gt;포지셔닝&lt;/th&gt;
&lt;th&gt;트레이드오프&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Prometheus&lt;/td&gt;
&lt;td&gt;Pull 기반 단일 인스턴스 TSDB, K8s ServiceDiscovery 네이티브&lt;/td&gt;
&lt;td&gt;단일 인스턴스 보존이 짧고 HA·장기 저장이 약함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thanos&lt;/td&gt;
&lt;td&gt;Prometheus 위에 얹는 장기 저장(S3) + 글로벌 쿼리 레이어&lt;/td&gt;
&lt;td&gt;단독으로 동작하지 않음. Prometheus가 전제이며 dev 단계엔 과스펙&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;InfluxDB&lt;/td&gt;
&lt;td&gt;푸시 기반 독립 TSDB, IoT·고빈도 메트릭에 강함&lt;/td&gt;
&lt;td&gt;K8s 표준 패턴(ServiceMonitor·PrometheusRule) 부재. 수집기를 별도 구성해야 함&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;먼저 &lt;strong&gt;InfluxDB는 후보에서 뺐다.&lt;/strong&gt; 직접 운영해 본 적은 없기도 하고, K8s 환경의 메트릭 수집 표준이 Prometheus Operator의 CRD(ServiceMonitor / PodMonitor / PrometheusRule) 중심으로 자리 잡아 있어 InfluxDB는 이 패턴과는 거리가 있어 보였다. 수집 대상과 알림 룰을 별도 도구로 관리해야 한다는 점이 옵저버빌리티 전체 스택을 GitOps 한 흐름에 묶으려는 이 프로젝트 방향과 맞지 않는다고 봤다.&lt;/p&gt;
&lt;p&gt;남은 둘은 알고 보니 양자택일이 아니었다. Thanos가 Prometheus를 대체하는 게 아니라 &lt;strong&gt;위에 얹는 장기 저장/글로벌 쿼리 레이어&lt;/strong&gt;라고 한다. 그래서 Prometheus와 Thanos 중 무엇을 쓰느냐가 아니라 지금 단계에서 Thanos까지 갈 필요가 있나?라는 생각이 들었다. 이 프로젝트는 dev 환경 기준 보존 기간이 며칠 내로 짧아도 상관 없을거라고 판단했고, 나중에 보존 기간을 늘리거나 멀티 클러스터 통합 쿼리가 필요해지면 그때 Prometheus 위에 Thanos를 추가하면 될꺼라고 생각했다.&lt;/p&gt;
&lt;p&gt;그리고 &lt;code&gt;ServiceMonitor&lt;/code&gt; / &lt;code&gt;PodMonitor&lt;/code&gt; / &lt;code&gt;PrometheusRule&lt;/code&gt;이 CRD로 제공돼서 메트릭 수집 대상과 알림 룰을 K8s manifest로 선언해서 앱 배포와 옵저버빌리티 설정이 같은 GitOps 흐름 위에 올라갈 수 있다. 거기에 &lt;code&gt;kube-prometheus-stack&lt;/code&gt; 차트가 Prometheus + Grafana + AlertManager + 기본 ServiceMonitor 셋을 한 번에 묶어 준다고 해서 초기 셋업 부담도 가장 가벼웠다.&lt;/p&gt;
&lt;p&gt;장기 저장이 약하다는 단점이 있지만 실제 운영환경이 아닌 사이드 플젝이라 보존 기간을 짧게 잡아도 됐다.&lt;br&gt;보존 기간을 늘리고 싶어지면 Prometheus 위에 Thanos나 Mimir를 추가하거나 EKS의 경우 AWS Managed Prometheus로 운영하는 방법이 있다고 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# infra/monitoring/values.yaml
prometheus:
  prometheusSpec:
    retention: 7d&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;로그 — Loki + Promtail&lt;/h3&gt;
&lt;p&gt;Loki는 이번 프로젝트에서 처음 도입해 본 솔루션이다. ELK(Elasticsearch, Logstash, Kibana)와 고민하다 Loki 쪽으로 기울어진 가장 큰 이유는 앞 섹션에서 정한 방향과 정합성이 높았기 때문이다. 세 신호를 같은 시간축에서 단일 UI로 보고 trace_id 한 줄로 점프가 가능해야 한다는 기준에서 Loki는 같은 Grafana쪽 도구라 datasource 설정만으로 로그 → 트레이스, 트레이스 → 로그 양방향 점프가 잡힌다. LogQL의 쿼리도 PromQL과 비슷한데 Prometheus로 정했기 때문에 따로 익혀야 할 게 많지 않다는 점도 한몫했다.&lt;/p&gt;
&lt;p&gt;기술 자료들을 조사하며 파악한 Loki의 가장 큰 매력은 로그 본문 전체를 풀텍스트 인덱싱하지 않고 라벨만 인덱싱하여 본문은 압축 저장한다는 점이었다. 모든 로그 본문을 인덱싱하는 Elasticsearch에 비해 저장 공간과 메모리를 크게 아낄 수 있다는 장점이 있었다.&lt;/p&gt;
&lt;p&gt;현재 프로젝트의 예상 로그 사용 패턴을 그려보았을 때 namespace=shoong에서 trace_id=X인 로그처럼 특정 라벨과 ID 기반으로 범위를 좁혀 가며 디버깅하는 경우가 대부분일 것이라 생각했다. 따라서 굳이 무거운 풀텍스트 인덱싱 비용을 감당하기보다는 가볍고 핵심에 집중한 Loki가 현 상황에 더 적합한 선택지라고 판단했다.(만약 본문 텍스트 검색 자체가 핵심인 서비스라면 ELK가 여전히 더 좋은 대안이 될 것 같다)&lt;/p&gt;
&lt;p&gt;운영 관리 측면에서도 차이가 있었다. ELK는 Filebeat → Logstash → Elasticsearch → Kibana로 컴포넌트가 4개 묶여 있고 Elasticsearch는 JVM 위에서 돌아 heap 튜닝과 메모리 압력이 운영 비중에서 큰 부분을 차지한다고 한다. Loki + Promtail은 컴포넌트가 2개이고, 둘 다 Go로 작성돼 메모리 풋프린트가 작다는 점이 강점으로 꼽힌다고. 거기에 이미 메트릭용으로 띄워 둔 Grafana UI를 그대로 재사용할 수 있어서 진입점도 늘지 않았다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# infra/loki/values.yaml — SingleBinary 모드 (분산 컴포넌트 비활성화)
deploymentMode: SingleBinary
loki:
  storage:
    type: filesystem
singleBinary:
  replicas: 1
  persistence:
    size: 2Gi&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;dev 환경 기준 SingleBinary 모드에 로컬 filesystem 백엔드로 시작했다. prod로 가면 S3 백엔드로 전환 + replication 도입이 다음 과제다.&lt;/p&gt;
&lt;h3&gt;트레이스 — Tempo + OTel Collector&lt;/h3&gt;
&lt;p&gt;분산 추적(Distributed Tracing) 분야는 이번 프로젝트를 진행하며 가장 생소했던 영역 중 하나였다. 트레이스 저장소 후보군으로 Jaeger, Zipkin, Tempo 등이 있었는데, 최종적으로 Tempo를 선택했다. 결정적인 이유는 앞서 구축한 메트릭(Prometheus) 및 로그(Loki)와 마찬가지로 Grafana 생태계의 도구라는 점이었다. 대시보드 한 곳에서 데이터소스(Datasource)를 묶어 로그를 보다가 관련 트레이스로 매끄럽게 이동할 수 있는 통합 환경이 매력적이었다. (사실 Jaeger의 마스코트 로고가 귀여워서 써보고 싶다는 생각이 들긴했다..ㅎ)&lt;/p&gt;
&lt;p&gt;구축 과정에서 오픈텔레메트리(OpenTelemetry, 이하 OTel)를 접하게 되었는데, 처음에는 OTel 자체와 OTel Collector의 개념이 조금 헷갈리기도 했다. 결론적으로 애플리케이션 코드가 표준 규격(OTLP)으로 데이터를 내보내면, 이를 받아 처리해 줄 중간 대리인으로 OTel Collector를 구성했다.&lt;/p&gt;
&lt;p&gt;단순히 앱이 Tempo로 직접 데이터를 쏘게 할 수도 있었지만, 찾아 보니 Collector를 아키텍처 중간에 배치했을 때 확실한 이점이 있었다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;벤더 종립성 확보&lt;/strong&gt; — 애플리케이션은 특정 솔루션이 아닌 개방형 표준(OTLP) 인터페이스로만 데이터를 던진다. 만약 추후 저장소를 Tempo에서 Jaeger나 다른 상용 APM(Datadog 등)으로 바꾸더라도, 앱 코드는 단 한 줄도 수정할 필요 없이 Collector의 설정만 변경하면 된다.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;안정적인 데이터 가공 및 인프라 보호&lt;/strong&gt; — memory_limiter나 batch 같은 프로세서(Processor)를 파이프라인 중간에 끼워 넣을 수 있다. 트래픽이 폭발할 때 애플리케이션이 직접 무거운 전송 로직을 감당하다가 뻗는 불상사를 막고, 인프라 단에서 안전하게 버퍼링하여 전송 성능을 최적화해 준다.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# infra/otel-collector/values.yaml
config:
  receivers:
    otlp:
      protocols:
        grpc: { endpoint: &amp;quot;0.0.0.0:4317&amp;quot; }
        http: { endpoint: &amp;quot;0.0.0.0:4318&amp;quot; }
  processors:
    memory_limiter:
      { check_interval: 1s, limit_percentage: 80, spike_limit_percentage: 25 }
    batch: { timeout: 1s, send_batch_size: 1024 }
  exporters:
    otlp/tempo:
      endpoint: &amp;quot;infra-tempo.monitoring.svc:4317&amp;quot;
      tls: { insecure: true }
  service:
    pipelines:
      traces:
        receivers: [otlp]
        processors: [memory_limiter, batch]
        exporters: [otlp/tempo]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;비록 AI의 가이드를 받으며 시작한 낯선 셋업이었지만, 결과적으로 대규모 MSA 환경에서 필수적인 &amp;#39;느슨한 결합(Loose Coupling)&amp;#39;과 &amp;#39;전송 안정성&amp;#39;을 모두 챙긴 만족스러운 파이프라인을 구축할 수 있었다.&lt;/p&gt;
&lt;h3&gt;시각화 / 알림 — Grafana + AlertManager&lt;/h3&gt;
&lt;p&gt;Grafana는 Prometheus/Loki/Tempo를 모두 datasource로 붙일 수 있고, 그 결과 메트릭 패널에서 로그·트레이스로 점프하는 동선이 만들어진다. 단일 UI에서 세 신호를 같은 시간축으로 본다는 가치가 가장 컸다.&lt;/p&gt;
&lt;p&gt;AlertManager는 Prometheus 진영 표준이라 별도 비교는 안 했다. PrometheusRule(K8s CRD) → AlertManager → Slack 흐름이 가장 단순하다.&lt;/p&gt;
&lt;h3&gt;서비스 메시 — Kiali&lt;/h3&gt;
&lt;p&gt;Istio가 이미 깔려 있는 환경에선 Kiali가 사실상 디폴트 선택이다. 별도 저장소를 두지 않고 &lt;strong&gt;Prometheus 메트릭을 그대로 재사용&lt;/strong&gt;해서 서비스 그래프를 그린다. 같은 데이터를 두 번 저장하지 않는다는 점이 깔끔했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# infra/kiali/values-dev.yaml
external_services:
  prometheus:
    url: &amp;quot;http://infra-monitoring-kube-prom-prometheus.monitoring.svc:9090&amp;quot;
  grafana:
    enabled: true
    in_cluster_url: &amp;quot;http://infra-monitoring-grafana.monitoring.svc&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;5. 전체 데이터 흐름&lt;/h2&gt;
&lt;p&gt;선택한 컴포넌트들이 어떻게 연결되는지 본다. 신호별로 경로가 다르다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ 소스 (앱 Pod) ]             [ 수집 / 저장 ]           [ 시각화 / 알림 ]

 shoong-* x 5
 (order/kitchen/delivery/
  notification/batch)

   ├── /metrics  ────────▶ Prometheus (TSDB) ─────────▶  Grafana
   │
   ├── stdout    ────────▶ Promtail ──▶ Loki ───────▶  Grafana
   │             (DaemonSet)
   │
   └── OTLP gRPC ────────▶ OTel Collector ──▶ Tempo ──▶  Grafana

                            Kiali  ─(Prometheus 메트릭 재사용)─▶ 자체 UI

                            PrometheusRule 발화 ─▶AlertManager─▶ Slack&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;화살표마다 프로토콜과 책임이 다르다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;화살표&lt;/th&gt;
&lt;th&gt;방식&lt;/th&gt;
&lt;th&gt;비고&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;앱 → Prometheus&lt;/td&gt;
&lt;td&gt;HTTP pull&lt;/td&gt;
&lt;td&gt;ServiceMonitor가 &lt;code&gt;/metrics&lt;/code&gt;를 30초마다 scrape&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;앱 → Promtail&lt;/td&gt;
&lt;td&gt;파일 tail&lt;/td&gt;
&lt;td&gt;Promtail이 노드 &lt;code&gt;/var/log/pods/*&lt;/code&gt; 감시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;앱 → OTel Collector&lt;/td&gt;
&lt;td&gt;gRPC push&lt;/td&gt;
&lt;td&gt;OTLP 4317 포트, 앱이 능동적으로 전송&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kiali → Prometheus&lt;/td&gt;
&lt;td&gt;HTTP pull&lt;/td&gt;
&lt;td&gt;별도 저장소 없이 메트릭 재사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AlertManager → Slack&lt;/td&gt;
&lt;td&gt;HTTPS webhook&lt;/td&gt;
&lt;td&gt;AlertmanagerConfig CRD로 receiver/route 선언&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhVkAa/dJMcab5nc6S/L9CyOKgDAX2p9gapGMU3PK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhVkAa/dJMcab5nc6S/L9CyOKgDAX2p9gapGMU3PK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhVkAa/dJMcab5nc6S/L9CyOKgDAX2p9gapGMU3PK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbhVkAa%2FdJMcab5nc6S%2FL9CyOKgDAX2p9gapGMU3PK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;6. 설치 — ArgoCD App-of-Apps로 통째로&lt;/h2&gt;
&lt;p&gt;직접 &lt;code&gt;helm install&lt;/code&gt;을 치지 않는다. 모든 컴포넌트는 ArgoCD Application으로 선언되어 있고, app-of-apps 부트스트랩 후 자동 동기화된다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;App-of-Apps 패턴&lt;/strong&gt;: 여러 개의 쿠버네티스 애플리케이션 정의 파일들을 하나의 마스터 애플리케이션(Root App)으로 묶어, Git에 올리기만 하면 하위 앱들이 줄줄이 자동 설치·관리되도록 만드는 ArgoCD의 배포 설계 기법&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# argocd/dev/infra-monitoring.yaml — kube-prometheus-stack 설치 Application
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: infra-monitoring
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: &amp;quot;1&amp;quot;
spec:
  sources:
    - repoURL: https://prometheus-community.github.io/helm-charts
      chart: kube-prometheus-stack
      targetRevision: 84.5.0
      helm:
        valueFiles:
          - $values/infra/monitoring/values.yaml
          - $values/infra/monitoring/values-dev.yaml
    - repoURL: https://github.com/shoong-delivery/shoong-gitops
      targetRevision: HEAD
      ref: values
  destination:
    namespace: monitoring
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions: [CreateNamespace=true, ServerSideApply=true]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;sync-wave로 설치 순서가 정해진다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Wave&lt;/th&gt;
&lt;th&gt;컴포넌트&lt;/th&gt;
&lt;th&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;infra-monitoring&lt;/td&gt;
&lt;td&gt;Prometheus가 먼저 떠야 다른 컴포넌트가 메트릭 송신처 확보&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;infra-loki, infra-tempo, infra-kiali&lt;/td&gt;
&lt;td&gt;저장소·뷰어 계층 (Prometheus 의존 가능)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;infra-promtail, infra-otel-collector&lt;/td&gt;
&lt;td&gt;수집기 계층 (저장소가 떠 있어야 의미 있음)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dCQ1uM/dJMcag6GagG/VP8Zw1Lmkpk4cKTI1xzdQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dCQ1uM/dJMcag6GagG/VP8Zw1Lmkpk4cKTI1xzdQ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dCQ1uM/dJMcag6GagG/VP8Zw1Lmkpk4cKTI1xzdQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdCQ1uM%2FdJMcag6GagG%2FVP8Zw1Lmkpk4cKTI1xzdQ0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;7. 앱 쪽에서 한 일 — 메트릭/로그/트레이스 송신&lt;/h2&gt;
&lt;p&gt;옵저버빌리티 도구들이 떠 있다고 데이터가 들어오는 건 아니다. 앱 코드에 세 가지를 박아야 한다.&lt;/p&gt;
&lt;h3&gt;트레이스 — &lt;code&gt;@opentelemetry/sdk-node&lt;/code&gt; auto instrumentation&lt;/h3&gt;
&lt;p&gt;각 Node.js 서비스의 진입점에서 OTel SDK를 먼저 부트스트랩한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// shoong-order-api/src/instrumentation.ts
import { NodeSDK } from &amp;quot;@opentelemetry/sdk-node&amp;quot;;
import { getNodeAutoInstrumentations } from &amp;quot;@opentelemetry/auto-instrumentations-node&amp;quot;;
import { OTLPTraceExporter } from &amp;quot;@opentelemetry/exporter-trace-otlp-grpc&amp;quot;;

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter(),
  instrumentations: [
    getNodeAutoInstrumentations({
      &amp;quot;@opentelemetry/instrumentation-fs&amp;quot;: { enabled: false },
    }),
  ],
});

sdk.start();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;auto-instrumentations-node&lt;/code&gt;가 HTTP / Express / Prisma 호출을 자동으로 span으로 만들어 준다. 송신처는 환경변수로 주입한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# envs/dev/shoong-order.yaml — Helm values
env:
  OTEL_SERVICE_NAME: shoong-order
  OTEL_EXPORTER_OTLP_ENDPOINT: http://infra-otel-collector-opentelemetry-collector.monitoring.svc:4317
  OTEL_TRACES_EXPORTER: otlp
  OTEL_METRICS_EXPORTER: none # 메트릭은 Prometheus가 /metrics 스크레이핑
  OTEL_LOGS_EXPORTER: none # 로그는 Promtail이 stdout 수집&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OTel SDK는 세 신호를 다 내보낼 수 있지만, 메트릭과 로그는 이미 Prometheus/Promtail 경로가 있어 트레이스만 활성화했다.&lt;/p&gt;
&lt;h3&gt;메트릭 — &lt;code&gt;prom-client&lt;/code&gt; 커스텀 메트릭&lt;/h3&gt;
&lt;p&gt;기본 시스템 메트릭(CPU/이벤트루프 등)은 &lt;code&gt;collectDefaultMetrics()&lt;/code&gt;로 자동 수집되고, 비즈니스 이벤트는 직접 정의했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// shoong-order-api/src/metrics.ts
export const orderCreateTotal = new Counter({
  name: &amp;quot;order_create_total&amp;quot;,
  help: &amp;quot;Total order creation attempts&amp;quot;,
  labelNames: [&amp;quot;result&amp;quot;] as const,
  registers: [registry],
});

export const orderStatusCount = new Gauge({
  name: &amp;quot;order_status_count&amp;quot;,
  help: &amp;quot;Current number of orders in each status&amp;quot;,
  labelNames: [&amp;quot;status&amp;quot;] as const,
  registers: [registry],
});

export const orderOrphanCookedCount = new Gauge({
  name: &amp;quot;order_orphan_cooked_count&amp;quot;,
  help: &amp;quot;Orders stuck in COOKED status without Delivery record&amp;quot;,
  registers: [registry],
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;/metrics&lt;/code&gt; 엔드포인트로 노출하면 ServiceMonitor가 30초마다 스크레이핑한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# charts/shoong-app/templates/servicemonitor.yaml — 자동 생성됨
spec:
  selector:
    matchLabels:
      app: { { .Release.Name } }
  endpoints:
    - port: http
      path: /metrics
      interval: 30s&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;로그 — pino + trace_id mixin&lt;/h3&gt;
&lt;p&gt;로그에 trace_id를 자동으로 박는 게 5장에서 말한 &lt;strong&gt;correlation의 출발점&lt;/strong&gt;이다. pino logger의 mixin으로 현재 active span을 읽어 trace_id를 모든 로그에 주입한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// shoong-order-api/src/logger.ts
import pino from &amp;quot;pino&amp;quot;;
import { trace } from &amp;quot;@opentelemetry/api&amp;quot;;

export const logger = pino({
  level: process.env.LOG_LEVEL ?? &amp;quot;info&amp;quot;,
  base: { service: process.env.OTEL_SERVICE_NAME ?? &amp;quot;order-api&amp;quot; },
  formatters: { level: (label) =&amp;gt; ({ level: label }) },
  timestamp: pino.stdTimeFunctions.isoTime,
  mixin() {
    const span = trace.getActiveSpan();
    if (!span) return {};
    const { traceId, spanId } = span.spanContext();
    return { trace_id: traceId, span_id: spanId };
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;결과적으로 stdout으로 나가는 모든 로그가 다음 형태를 갖는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;level&amp;quot;: &amp;quot;info&amp;quot;,
  &amp;quot;time&amp;quot;: &amp;quot;2026-05-19T...&amp;quot;,
  &amp;quot;service&amp;quot;: &amp;quot;shoong-order&amp;quot;,
  &amp;quot;trace_id&amp;quot;: &amp;quot;a1b2...&amp;quot;,
  &amp;quot;span_id&amp;quot;: &amp;quot;...&amp;quot;,
  &amp;quot;msg&amp;quot;: &amp;quot;order created&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Promtail은 라벨 카디널리티 폭발을 피하기 위해 본문 안에 trace_id를 두고, LogQL에서 &lt;code&gt;| json | trace_id=&amp;quot;...&amp;quot;&lt;/code&gt;로 추출한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# infra/promtail/values.yaml — 라벨은 최소화
scrapeConfigs: |
  - job_name: kubernetes-pods
    pipeline_stages:
      - cri: {}
    relabel_configs:
      - source_labels: [__meta_kubernetes_namespace]
        target_label: namespace
      - source_labels: [__meta_kubernetes_pod_label_app]
        target_label: app
      # ... pod, container, host 정도만 라벨로 승격&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;라벨에 trace_id를 넣지 않는 이유는 trace_id마다 새 stream이 생겨 Loki의 stream label limit(기본 15)을 즉시 넘기고 카디널리티가 폭발하기 때문이다. 검색은 본문(JSON) 파싱으로 한다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;8. Correlation — trace_id 한 줄로 세 신호 연결&lt;/h2&gt;
&lt;p&gt;Grafana datasource 설정에 양방향 점프 동선을 박아 뒀다.&lt;/p&gt;
&lt;h3&gt;Loki → Tempo (로그에서 트레이스로)&lt;/h3&gt;
&lt;p&gt;로그 패널에서 본문의 trace_id를 클릭하면 Tempo로 자동 점프한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# infra/monitoring/values.yaml — Grafana additionalDataSources
- name: Loki
  type: loki
  uid: loki
  url: http://infra-loki-gateway.monitoring.svc:80
  jsonData:
    derivedFields:
      - name: trace_id
        matcherRegex: &amp;#39;&amp;quot;trace_id&amp;quot;:&amp;quot;([a-f0-9]+)&amp;quot;&amp;#39;
        url: &amp;quot;$${__value.raw}&amp;quot;
        datasourceUid: tempo
        urlDisplayLabel: &amp;quot;Tempo에서 trace 보기&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;핵심은 &lt;code&gt;matcherRegex&lt;/code&gt;다. pino mixin이 박아 둔 &lt;code&gt;&amp;quot;trace_id&amp;quot;:&amp;quot;...&amp;quot;&lt;/code&gt; 패턴을 정규식으로 추출해서 Tempo로 보낸다.&lt;/p&gt;
&lt;h3&gt;Tempo → Loki (트레이스에서 로그로)&lt;/h3&gt;
&lt;p&gt;반대 방향도 마찬가지다. Tempo의 span 디테일 화면에서 &amp;quot;Logs for this span&amp;quot; 버튼을 누르면 같은 trace_id로 Loki를 조회한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;- name: Tempo
  type: tempo
  uid: tempo
  url: http://infra-tempo.monitoring.svc:3200
  jsonData:
    tracesToLogsV2:
      datasourceUid: loki
      spanStartTimeShift: &amp;quot;-5m&amp;quot;
      spanEndTimeShift: &amp;quot;5m&amp;quot;
      filterByTraceID: true
      customQuery: true
      query: &amp;#39;{namespace=&amp;quot;shoong&amp;quot;} | json | trace_id=&amp;quot;$${__trace.traceId}&amp;quot;&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;query&lt;/code&gt;에 들어간 LogQL이 결국 7장에서 본 JSON 로그를 trace_id로 좁혀 가져온다.&lt;/p&gt;
&lt;h3&gt;실제 동선&lt;/h3&gt;
&lt;p&gt;장애 디버깅 시나리오를 예로 들면 이렇게 흐른다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. AlertManager가 ShoongOrderCookingStuck 발화 → Slack 알림
2. Grafana 대시보드에서 같은 시각 order_status_count{status=&amp;quot;COOKING&amp;quot;} 스파이크 확인 (메트릭)
3. Tempo Explore → 해당 시간대의 느린 trace 검색
4. waterfall에서 느린 span 클릭 → &amp;quot;Logs for this span&amp;quot;
5. Loki에 같은 trace_id로 검색된 로그 → DB 응답 지연 메시지 확인
6. 근본 원인 도달&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;세 신호가 별도 도구에 흩어져 있을 때라면 1번에서 6번까지 가는 데 컨텍스트 스위칭이 다섯 번 일어났을 거다. 같은 Grafana UI 안에서 클릭 두 번이면 된다는 게 통합 스택의 진짜 가치였다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPHcBU/dJMcaiDqz3A/pbGOi1GYskjPwppJQ8KfDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPHcBU/dJMcaiDqz3A/pbGOi1GYskjPwppJQ8KfDk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPHcBU/dJMcaiDqz3A/pbGOi1GYskjPwppJQ8KfDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPHcBU%2FdJMcaiDqz3A%2FpbGOi1GYskjPwppJQ8KfDk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cz3NJo/dJMcabqNiOw/wRb2eYtNEzuMOouCQgYOwk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cz3NJo/dJMcabqNiOw/wRb2eYtNEzuMOouCQgYOwk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cz3NJo/dJMcabqNiOw/wRb2eYtNEzuMOouCQgYOwk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcz3NJo%2FdJMcabqNiOw%2FwRb2eYtNEzuMOouCQgYOwk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nsVXS/dJMcaiJ9pYQ/u5jMvxyQoZz8Q7Vfkgkk8k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nsVXS/dJMcaiJ9pYQ/u5jMvxyQoZz8Q7Vfkgkk8k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nsVXS/dJMcaiJ9pYQ/u5jMvxyQoZz8Q7Vfkgkk8k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnsVXS%2FdJMcaiJ9pYQ%2Fu5jMvxyQoZz8Q7Vfkgkk8k%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;9. 보는 법 — Grafana 단일 진입점&lt;/h2&gt;
&lt;p&gt;설치된 컴포넌트마다 자체 UI가 있지만(Prometheus, Kiali) 운영 시 진입점은 Grafana 하나로 모았다. Istio VirtualService로 외부 도메인을 연결한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# infra/istio-resources/dev/vs-grafana.yaml
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: vs-grafana
  namespace: istio-system
spec:
  hosts:
    - grafana.internal.dev.shoong.cloud
  gateways:
    - istio-system/shoong-gateway
  http:
    - route:
        - destination:
            host: infra-monitoring-grafana.monitoring.svc.cluster.local
            port: { number: 80 }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Prometheus와 Kiali도 같은 패턴으로 노출되어 있다 (&lt;code&gt;prometheus.internal.dev.shoong.cloud&lt;/code&gt;, &lt;code&gt;kiali.internal.dev.shoong.cloud&lt;/code&gt;).&lt;/p&gt;
&lt;h3&gt;Grafana datasource 4종&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Datasource&lt;/th&gt;
&lt;th&gt;UID&lt;/th&gt;
&lt;th&gt;용도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Prometheus&lt;/td&gt;
&lt;td&gt;prometheus&lt;/td&gt;
&lt;td&gt;메트릭 패널, AlertManager 룰 평가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Loki&lt;/td&gt;
&lt;td&gt;loki&lt;/td&gt;
&lt;td&gt;로그 탐색, trace_id derivedField&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tempo&lt;/td&gt;
&lt;td&gt;tempo&lt;/td&gt;
&lt;td&gt;트레이스 탐색, tracesToLogsV2 점프&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alertmanager&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;발화 중 알림 목록 (기본 포함)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TpxhX/dJMcafUgqQW/q69uXvTujvIy7hFXtXj7O0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TpxhX/dJMcafUgqQW/q69uXvTujvIy7hFXtXj7O0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TpxhX/dJMcafUgqQW/q69uXvTujvIy7hFXtXj7O0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTpxhX%2FdJMcafUgqQW%2Fq69uXvTujvIy7hFXtXj7O0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;비즈니스 대시보드&lt;/h3&gt;
&lt;p&gt;현재 dev에는 비즈니스 흐름 한 종(&lt;code&gt;shoong-business&lt;/code&gt;)이 ConfigMap으로 배포되어 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# infra/monitoring-config/dev/dashboard-shoong-business.yaml (발췌)
metadata:
  name: shoong-business-dashboard
  labels:
    grafana_dashboard: &amp;quot;1&amp;quot; # sidecar가 이 라벨 보고 자동 import
data:
  shoong-business.json: |-
    { &amp;quot;title&amp;quot;: &amp;quot;Shoong — Business&amp;quot;, ... }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;패널에 들어가는 PromQL은 7장에서 정의한 커스텀 메트릭을 그대로 쓴다. 위 캡처처럼 주문 생성률, 상태별 적체, 조리·배달 P50/P95, 알림 발송 추이까지 한 화면에서 본다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-promql&quot;&gt;# 시간당 주문 생성 (성공/실패)
sum by (result) (rate(order_create_total[5m])) * 3600

# 현재 상태별 주문 수
order_status_count

# Orphan COOKED (체인 호출 실패 신호)
order_orphan_cooked_count&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbAwCv/dJMcagMqd7D/59erN8wm1x2gFoFWOoP92K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbAwCv/dJMcagMqd7D/59erN8wm1x2gFoFWOoP92K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbAwCv/dJMcagMqd7D/59erN8wm1x2gFoFWOoP92K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbAwCv%2FdJMcagMqd7D%2F59erN8wm1x2gFoFWOoP92K%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;Kiali — 메시 시각화&lt;/h3&gt;
&lt;p&gt;서비스 메시 관점의 시각화는 Kiali에서 본다. Prometheus 메트릭을 재사용하므로 별도 데이터 입력은 없다. shoong 네임스페이스의 5개 서비스가 mTLS 자물쇠와 함께 그래프로 그려진다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cFz6iH/dJMcaaFrlPx/UiRrbvgrth6fLUXgaYOLA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cFz6iH/dJMcaaFrlPx/UiRrbvgrth6fLUXgaYOLA0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cFz6iH/dJMcaaFrlPx/UiRrbvgrth6fLUXgaYOLA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcFz6iH%2FdJMcaaFrlPx%2FUiRrbvgrth6fLUXgaYOLA0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;10. 알림 — PrometheusRule → AlertManager → Slack&lt;/h2&gt;
&lt;p&gt;옵저버빌리티의 마지막 단계는 사람에게 도달하는 알림이다. Prometheus 룰이 발화 조건을 평가하고, AlertManager가 라우팅·그룹핑을 처리하고, Slack으로 떨어진다.&lt;/p&gt;
&lt;h3&gt;룰은 비즈니스 시그널 중심으로&lt;/h3&gt;
&lt;p&gt;쿠버네티스/노드 레벨 알림은 kube-prometheus-stack 기본 룰로 충분하다. 그 위에 비즈니스 흐름에서 의미 있는 룰을 PrometheusRule CRD로 추가했다. 세 그룹으로 묶여 있다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;그룹&lt;/th&gt;
&lt;th&gt;룰&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;workflow&lt;/td&gt;
&lt;td&gt;ShoongOrderPendingStuck / CookingStuck / DeliveringStuck / OrphanCooked&lt;/td&gt;
&lt;td&gt;주문 워크플로우가 어느 상태에 정체&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;errors&lt;/td&gt;
&lt;td&gt;ShoongOrderCreateFailureSpike / NotificationFailureSpike&lt;/td&gt;
&lt;td&gt;비즈니스 이벤트 실패율 급증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;performance&lt;/td&gt;
&lt;td&gt;ShoongCookDurationHigh / DeliveryDurationHigh&lt;/td&gt;
&lt;td&gt;조리·배달 P95가 임계 초과&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;룰 하나의 형태는 이런 식이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# infra/monitoring-config/dev/prometheus-rule.yaml (발췌)
- alert: ShoongOrderCookingStuck
  # COOKING 임계값 3분 + 배치 주기 1분 + 여유 6분 = 10분
  expr: min_over_time(order_status_count{status=&amp;quot;COOKING&amp;quot;}[10m]) &amp;gt; 0
  for: 1m
  labels:
    severity: critical
    service: kitchen-api
  annotations:
    summary: &amp;quot;주문이 COOKING으로 멈춤&amp;quot;
    description: &amp;quot;10분간 COOKING 주문 수가 0이 된 적 없음 — 배치 미동작 또는 kitchen /complete 실패&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;AlertManager — severity 기반 채널 라우팅&lt;/h3&gt;
&lt;p&gt;기본 receiver는 &lt;code&gt;slack-observability&lt;/code&gt;이고, &lt;code&gt;severity=critical&lt;/code&gt; 라벨이 붙으면 별도 채널(&lt;code&gt;slack-critical&lt;/code&gt;)로 빠진다. Watchdog(liveness ping)은 null로 보내 노이즈를 차단한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# infra/monitoring-config/dev/alertmanager-config.yaml (발췌)
spec:
  route:
    receiver: slack-observability
    groupBy: [&amp;quot;alertname&amp;quot;, &amp;quot;namespace&amp;quot;]
    groupWait: 30s
    groupInterval: 5m
    repeatInterval: 4h
    routes:
      - matchers:
          - name: alertname
            value: Watchdog
        receiver: null-receiver
      - matchers:
          - name: severity
            value: critical
        receiver: slack-critical
        continue: false&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;slack webhook URL은 평문으로 두지 않고 ExternalSecret으로 AWS Parameter Store에서 가져온다 (&lt;code&gt;alertmanager-slack-webhook&lt;/code&gt; K8s Secret).&lt;/p&gt;
&lt;h3&gt;ArgoCD Notifications — 배포 이벤트는 별도 채널&lt;/h3&gt;
&lt;p&gt;PrometheusRule 발화와는 별개로, ArgoCD가 직접 발신하는 알림이 있다. Sync 성공/실패는 &lt;code&gt;#alerts-deploy&lt;/code&gt;로, Health Degraded·자동 롤백은 &lt;code&gt;#alerts-critical&lt;/code&gt;로 분리해 두었다 (&lt;code&gt;infra/argocd-notifications/configmap.yaml&lt;/code&gt;).&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;발신 주체&lt;/th&gt;
&lt;th&gt;시나리오&lt;/th&gt;
&lt;th&gt;채널&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;ArgoCD&lt;/td&gt;
&lt;td&gt;Sync 성공/실패&lt;/td&gt;
&lt;td&gt;#alerts-deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ArgoCD&lt;/td&gt;
&lt;td&gt;Health Degraded / 자동 롤백&lt;/td&gt;
&lt;td&gt;#alerts-critical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AlertManager&lt;/td&gt;
&lt;td&gt;severity=critical (워크플로우 정체)&lt;/td&gt;
&lt;td&gt;#alerts-critical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AlertManager&lt;/td&gt;
&lt;td&gt;severity=warning (기본)&lt;/td&gt;
&lt;td&gt;#alerts-observability&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b9D3JS/dJMcabYDqBq/asUXPAIZGBCcHm5zVIuAT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b9D3JS/dJMcabYDqBq/asUXPAIZGBCcHm5zVIuAT0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b9D3JS/dJMcabYDqBq/asUXPAIZGBCcHm5zVIuAT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb9D3JS%2FdJMcabYDqBq%2FasUXPAIZGBCcHm5zVIuAT0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DPvxr/dJMcafs96j5/GX2pvkek8UlQnkewkxDxKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DPvxr/dJMcafs96j5/GX2pvkek8UlQnkewkxDxKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DPvxr/dJMcafs96j5/GX2pvkek8UlQnkewkxDxKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDPvxr%2FdJMcafs96j5%2FGX2pvkek8UlQnkewkxDxKk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>Project: Shoong-Delivery</category>
      <author>2-30</author>
      <guid isPermaLink="true">https://2-3-0.tistory.com/15</guid>
      <comments>https://2-3-0.tistory.com/15#entry15comment</comments>
      <pubDate>Fri, 22 May 2026 22:44:37 +0900</pubDate>
    </item>
    <item>
      <title>[AWS] Shoong Delivery 네트워크 설계 정리</title>
      <link>https://2-3-0.tistory.com/14</link>
      <description>&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;Shoong Delivery를 EKS 위에 올리면서 제일 먼저 생각난 건 어떻게 열고 어디까지 막아야하는가 였다.&lt;/p&gt;
&lt;p&gt;처음에는 VPC 하나 만들고, EKS 만들고, ALB 붙이면 끝날 줄 알았다. 그런데 막상 Terraform으로 하나씩 만들다 보니 네트워크 설계가 거의 모든 리소스의 기준점이 됐다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ALB는 어디에 둘 것인가&lt;/li&gt;
&lt;li&gt;EKS Worker Node는 인터넷에 직접 노출되지 않아도 되는가&lt;/li&gt;
&lt;li&gt;RDS는 어떤 경로로만 접근하게 할 것인가&lt;/li&gt;
&lt;li&gt;Private Subnet의 노드가 ECR, SSM, EKS API에는 어떻게 접근할 것인가&lt;/li&gt;
&lt;li&gt;비용 때문에 dev와 prod를 어디까지 다르게 가져갈 것인가&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;이번 글은 Shoong Delivery에서 실제로 구성한 VPC, Subnet, Route Table, NAT Gateway, VPC Endpoint, ALB, Bastion, RDS 구조를 정리한 글이다. 이론적인 VPC 설명보다는, 포트폴리오 프로젝트를 만들면서 왜 이렇게 나눴고 어떤 부분을 조심했는지 위주로 적었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dcbXj1/dJMcabj09qX/ViMVMukIMeGefzXZnFc2f1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dcbXj1/dJMcabj09qX/ViMVMukIMeGefzXZnFc2f1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dcbXj1/dJMcabj09qX/ViMVMukIMeGefzXZnFc2f1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdcbXj1%2FdJMcabj09qX%2FViMVMukIMeGefzXZnFc2f1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;전체 구조&lt;/h2&gt;
&lt;p&gt;현재 네트워크 구조를 한 줄로 줄이면 이렇게 볼 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Route53 -&amp;gt; CloudFront + WAF -&amp;gt; ALB -&amp;gt; TargetGroupBinding -&amp;gt; Istio IngressGateway -&amp;gt; App Pods&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;VPC 내부는 3-tier로 나눴다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;VPC 10.0.0.0/16

Public Subnet: 10.0.1.0/24,  10.0.2.0/24,  10.0.3.0/24
Private Subnet: 10.0.11.0/24, 10.0.12.0/24, 10.0.13.0/24
DB Subnet: 10.0.21.0/24, 10.0.22.0/24, 10.0.23.0/24

AZ: us-east-1a / us-east-1b / us-east-1c&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Terraform 환경 기준으로 &lt;code&gt;terraform.tfvars&lt;/code&gt;에서 CIDR과 AZ를 잡고 &lt;code&gt;modules/vpc&lt;/code&gt;에서 실제 서브넷과 라우팅을 만든다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;vpc_cidr             = &amp;quot;10.0.0.0/16&amp;quot;
azs                  = [&amp;quot;us-east-1a&amp;quot;, &amp;quot;us-east-1b&amp;quot;, &amp;quot;us-east-1c&amp;quot;]
public_subnet_cidrs  = [&amp;quot;10.0.1.0/24&amp;quot;, &amp;quot;10.0.2.0/24&amp;quot;, &amp;quot;10.0.3.0/24&amp;quot;]
private_subnet_cidrs = [&amp;quot;10.0.11.0/24&amp;quot;, &amp;quot;10.0.12.0/24&amp;quot;, &amp;quot;10.0.13.0/24&amp;quot;]
db_subnet_cidrs      = [&amp;quot;10.0.21.0/24&amp;quot;, &amp;quot;10.0.22.0/24&amp;quot;, &amp;quot;10.0.23.0/24&amp;quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;3AZ를 선택한 핵심 이유는 두 가지다.&lt;br&gt;첫째, AZ 장애 시 잔여 용량이 2AZ는 50%, 3AZ는 67%로 차이가 난다. 평소에 떠안고 있어야 할 capacity 여유분이 다르고 장기적으로 비용 효율도 달라진다.&lt;br&gt;둘째, Kafka·etcd 같은 쿼럼 기반 시스템을 도입할 때 3AZ가 사실상 전제 조건이라 미래의 워크로드 확장 옵션을 열어두는 의미가 있다.&lt;br&gt;부가적으로, 대칭 구조라 NAT·RDS Subnet Group·ALB Subnet 배치를 설명할 때 깔끔하다는 장점도 있다.&lt;/p&gt;
&lt;p&gt;물론 모든 리소스를 3AZ에서 완벽하게 고가용으로 운영한다는 뜻은 아니다. dev 환경은 비용 때문에 일부 리소스를 줄여서 쓴다. 예를 들어 RDS는 dev에서는 Single-AZ로 두고, prod는 Multi-AZ Primary/Standby 구조로 가져가는 식이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Public, Private, DB 서브넷을 나눈 이유&lt;/h2&gt;
&lt;p&gt;서브넷을 나눈 기준은 단순하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Public  : 외부에서 들어오는 지점
Private : 애플리케이션 워크로드
DB      : 데이터 저장소&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Public Subnet에는 ALB와 NAT Gateway가 들어간다.&lt;/p&gt;
&lt;p&gt;Private Subnet에는 EKS Worker Node, Istio Ingress Gateway, App Pod, Bastion EC2, VPC Endpoint가 들어간다.&lt;/p&gt;
&lt;p&gt;DB Subnet에는 RDS를 배치한다.&lt;/p&gt;
&lt;p&gt;이렇게 나눠두니 어떤 리소스가 인터넷과 직접 닿아도 되는지를 네트워크 계층에서 한 번 걸러낼 수 있어서 마음이 편했다. 물론 보안 그룹만으로도 막을 수는 있겠지만, 직접 작업해보니 라우팅 자체가 나뉘어 있는 편이 실수할 여지가 적게 느껴졌다.&lt;/p&gt;
&lt;p&gt;Terraform에서는 Public Subnet에만 &lt;code&gt;map_public_ip_on_launch = true&lt;/code&gt;를 켜고, Private/DB Subnet에는 꺼두는 것으로 구성했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;aws_subnet&amp;quot; &amp;quot;public&amp;quot; {
  map_public_ip_on_launch = true
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;EKS와 ALB Controller가 서브넷을 인식할 수 있도록 Kubernetes 관련 태그를 서브넷별로 다르게 붙였다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;# Public Subnet
tags = {
  &amp;quot;kubernetes.io/role/elb&amp;quot;                    = &amp;quot;1&amp;quot;
  &amp;quot;kubernetes.io/cluster/${var.cluster_name}&amp;quot; = &amp;quot;shared&amp;quot;
}

# Private Subnet
tags = {
  &amp;quot;kubernetes.io/role/internal-elb&amp;quot;           = &amp;quot;1&amp;quot;
  &amp;quot;kubernetes.io/cluster/${var.cluster_name}&amp;quot; = &amp;quot;shared&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;처음에는 이 태그가 왜 필요한지 잘 와닿지 않았다. 그런데 작업하다 보니 AWS Load Balancer Controller가 ALB/NLB를 만들 때, &lt;strong&gt;외부용은 &lt;code&gt;kubernetes.io/role/elb&lt;/code&gt;가 붙은 Public Subnet에, 내부용은 &lt;code&gt;kubernetes.io/role/internal-elb&lt;/code&gt;가 붙은 Private Subnet에&lt;/strong&gt; 만든다는 걸 알게 됐다. 그래서 두 태그를 같은 서브넷에 함께 붙이는 게 아니라, 역할별로 나눠서 붙여야 했다. &lt;code&gt;kubernetes.io/cluster/&amp;lt;name&amp;gt;&lt;/code&gt;은 이 서브넷이 어느 EKS 클러스터 소유인지를 알려주는 태그라서 양쪽에 같이 붙였다.&lt;/p&gt;
&lt;p&gt;Terraform으로 서브넷만 만들면 되는 줄 알았는데 Kubernetes 컨트롤러가 읽을 수 있는 힌트까지 같이 심어줘야 한다는 걸 그때 처음 알게 됐다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xuOnu/dJMcagr2QVy/bT9HJZT4VetRWgKS00qKl1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xuOnu/dJMcagr2QVy/bT9HJZT4VetRWgKS00qKl1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xuOnu/dJMcagr2QVy/bT9HJZT4VetRWgKS00qKl1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxuOnu%2FdJMcagr2QVy%2FbT9HJZT4VetRWgKS00qKl1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Public Subnet: ALB와 NAT Gateway&lt;/h2&gt;
&lt;p&gt;Public Subnet에는 두 종류의 리소스가 있다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;외부 트래픽을 받는 ALB&lt;/li&gt;
&lt;li&gt;Private Subnet의 outbound를 담당하는 NAT Gateway&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;ALB는 80/443 리스너를 둔다. 80은 443으로 redirect하고, 실제 API 트래픽은 443에서 TLS를 종료한 뒤 Istio Ingress Gateway로 넘긴다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;aws_lb_listener&amp;quot; &amp;quot;http&amp;quot; {
  load_balancer_arn = aws_lb.this.arn
  port              = 80
  protocol          = &amp;quot;HTTP&amp;quot;

  default_action {
    type = &amp;quot;redirect&amp;quot;
    redirect {
      port        = &amp;quot;443&amp;quot;
      protocol    = &amp;quot;HTTPS&amp;quot;
      status_code = &amp;quot;HTTP_301&amp;quot;
    }
  }
}

resource &amp;quot;aws_lb_listener&amp;quot; &amp;quot;https&amp;quot; {
  load_balancer_arn = aws_lb.this.arn
  port              = 443
  protocol          = &amp;quot;HTTPS&amp;quot;
  certificate_arn   = var.certificate_arn
  ssl_policy        = &amp;quot;ELBSecurityPolicy-TLS13-1-2-2021-06&amp;quot;

  default_action {
    type             = &amp;quot;forward&amp;quot;
    target_group_arn = aws_lb_target_group.this.arn
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;NAT Gateway는 AZ별로 1개씩 만들었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;aws_nat_gateway&amp;quot; &amp;quot;this&amp;quot; {
  count         = length(var.public_subnet_cidrs)
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Private Subnet route table은 각 AZ의 NAT Gateway를 바라본다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;aws_route_table&amp;quot; &amp;quot;private&amp;quot; {
  count  = length(var.private_subnet_cidrs)
  vpc_id = aws_vpc.this.id

  route {
    cidr_block     = &amp;quot;0.0.0.0/0&amp;quot;
    nat_gateway_id = aws_nat_gateway.this[count.index].id
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Private Subnet이라고 해서 인터넷을 아예 못 나가는 건 아니다. Private Subnet의 EKS Worker Node는 NAT Gateway를 통해 outbound가 가능하다. 다만 외부에서 직접 들어오는 inbound 경로가 없고, public IP를 갖지 않는다.&lt;/p&gt;
&lt;p&gt;처음에는 이걸 인터넷 도달 경로가 없는 것으로 이해하고 있었는데 다시 따져보면 RDS나 Bastion에는 맞아도 EKS Worker에는 어긋난다. EKS Worker는 NAT outbound가 있기 때문이다. 정확히 말하면 외부 인입은 ALB만 받고 DB와 Bastion은 Public 경로 자체가 없어 격리되며, EKS Worker만 NAT를 통해 outbound가 열려 있는 구조다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4p57a/dJMcafmoSn2/yPrwoOXmUGRqq6lACV1Ss1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4p57a/dJMcafmoSn2/yPrwoOXmUGRqq6lACV1Ss1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4p57a/dJMcafmoSn2/yPrwoOXmUGRqq6lACV1Ss1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4p57a%2FdJMcafmoSn2%2FyPrwoOXmUGRqq6lACV1Ss1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;Public Route Table: 0.0.0.0/0 → IGW&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cHe55z/dJMcaa6sWhP/s4OwQyAkYwLDkAycKEmu9K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cHe55z/dJMcaa6sWhP/s4OwQyAkYwLDkAycKEmu9K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cHe55z/dJMcaa6sWhP/s4OwQyAkYwLDkAycKEmu9K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcHe55z%2FdJMcaa6sWhP%2Fs4OwQyAkYwLDkAycKEmu9K%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;Private Route Table: 0.0.0.0/0 → NAT Gateway. S3는 Gateway Endpoint(pl-...)로 직행.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vawwI/dJMcafmoSp8/KivpIHuSDp3TiiyKrKBEH0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vawwI/dJMcafmoSp8/KivpIHuSDp3TiiyKrKBEH0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vawwI/dJMcafmoSp8/KivpIHuSDp3TiiyKrKBEH0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvawwI%2FdJMcafmoSp8%2FKivpIHuSDp3TiiyKrKBEH0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;CLI로 한 번에 본 Route Table별 기본 게이트웨이 매핑.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;ALB에서 Istio Ingress Gateway까지&lt;/h2&gt;
&lt;p&gt;Shoong Delivery는 ALB가 직접 애플리케이션 Service로 라우팅하지 않는다. ALB Target Group은 &lt;code&gt;TargetGroupBinding&lt;/code&gt;으로 Istio Ingress Gateway Service에 붙는다.&lt;/p&gt;
&lt;p&gt;흐름은 이렇다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ALB 443
-&amp;gt; Target Group
-&amp;gt; TargetGroupBinding
-&amp;gt; istio-ingressgateway Service:80
-&amp;gt; Istio Gateway / VirtualService
-&amp;gt; 각 서비스 Pod&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Terraform에서는 ALB Target Group을 &lt;code&gt;target_type = &amp;quot;ip&amp;quot;&lt;/code&gt;로 만들었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;aws_lb_target_group&amp;quot; &amp;quot;this&amp;quot; {
  name        = &amp;quot;${var.project}-${var.env}-tg&amp;quot;
  port        = 80
  protocol    = &amp;quot;HTTP&amp;quot;
  vpc_id      = var.vpc_id
  target_type = &amp;quot;ip&amp;quot;

  health_check {
    path                = &amp;quot;/healthz/ready&amp;quot;
    port                = &amp;quot;15021&amp;quot;
    protocol            = &amp;quot;HTTP&amp;quot;
    healthy_threshold   = 2
    unhealthy_threshold = 3
    interval            = 30
    timeout             = 5
    matcher             = &amp;quot;200&amp;quot;
  }
  ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GitOps 쪽에서는 TargetGroupBinding이 이 Target Group과 Istio Ingress Gateway Service를 연결한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;apiVersion: elbv2.k8s.aws/v1beta1
kind: TargetGroupBinding
metadata:
  name: istio-ingressgateway-tgb
  namespace: istio-system
spec:
  serviceRef:
    name: istio-ingressgateway
    port: 80
  targetGroupARN: arn:aws:elasticloadbalancing:us-east-1:&amp;lt;ACCOUNT_ID&amp;gt;:targetgroup/shoong-dev-tg/&amp;lt;TG_ID&amp;gt;
  targetType: ip&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 구조를 선택한 이유는 외부 진입과 내부 라우팅의 역할을 나누기 위해서였다.&lt;/p&gt;
&lt;p&gt;ALB는 AWS 영역에서 잘하는 일을 맡는다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ACM 인증서&lt;/li&gt;
&lt;li&gt;HTTPS 종단&lt;/li&gt;
&lt;li&gt;WAF 연동&lt;/li&gt;
&lt;li&gt;CloudFront origin&lt;/li&gt;
&lt;li&gt;Target Group health check&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Istio는 Kubernetes 내부에서 잘하는 일을 맡는다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Gateway / VirtualService 기반 라우팅&lt;/li&gt;
&lt;li&gt;서비스 간 통신 제어&lt;/li&gt;
&lt;li&gt;DestinationRule&lt;/li&gt;
&lt;li&gt;mTLS&lt;/li&gt;
&lt;li&gt;Kiali, Prometheus와 연결되는 mesh 관측&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;처음에는 ALB Ingress만 써도 되지 않을까 생각했었다. 하지만 order, kitchen, delivery, notification처럼 서비스 간 호출 흐름이 있는 구조에서는 mTLS, 서비스 간 라우팅 제어, 트래픽 시각화까지 필요했다. 그래서 ALB는 외부 진입점으로만 두고 Kubernetes 내부 라우팅은 Istio로 넘겼다.&lt;/p&gt;
&lt;p&gt;최근에는 Istio Ingress Gateway도 replica를 2개로 올렸다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;replicaCount: 2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그리고 가능한 서로 다른 AZ와 노드에 퍼지도록 &lt;code&gt;topologySpreadConstraints&lt;/code&gt;도 추가했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: ScheduleAnyway
  - maxSkew: 1
    topologyKey: kubernetes.io/hostname
    whenUnsatisfiable: ScheduleAnyway&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;replicaCount: 2&lt;/code&gt;만 해도 Pod는 2개 뜨지만, 둘이 같은 노드나 같은 AZ에 몰릴 수 있다. 그래서 &amp;quot;가능하면 AZ/Node 기준으로 나눠서 배치해달라&amp;quot;는 힌트를 스케줄러에 주었다. &lt;code&gt;ScheduleAnyway&lt;/code&gt;로 둔 이유는 dev 환경에서 리소스가 부족할 때 Pod가 Pending으로 막히는 것까지는 피하고 싶었기 때문이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsrcH4/dJMcah5AX1S/ZP72b7SuCAxLutvcD5B8KK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsrcH4/dJMcah5AX1S/ZP72b7SuCAxLutvcD5B8KK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsrcH4/dJMcah5AX1S/ZP72b7SuCAxLutvcD5B8KK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsrcH4%2FdJMcah5AX1S%2FZP72b7SuCAxLutvcD5B8KK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;App Health &lt;code&gt;Healthy&lt;/code&gt;, Sync 상태 &lt;code&gt;Synced&lt;/code&gt;. values 변경이 클러스터까지 반영된 상태.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GW2Pc/dJMcaiDoAvU/XLkitCURCdhSjCr0kaym1K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GW2Pc/dJMcaiDoAvU/XLkitCURCdhSjCr0kaym1K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GW2Pc/dJMcaiDoAvU/XLkitCURCdhSjCr0kaym1K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGW2Pc%2FdJMcaiDoAvU%2FXLkitCURCdhSjCr0kaym1K%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;Pod 2개가 서로 다른 Node(10-0-13-145, 10-0-12-59)에 분산. IP 대역도 10.0.12.x / 10.0.13.x로 갈려 서로 다른 Private Subnet, 즉 서로 다른 AZ에 떠 있다.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cEYQvg/dJMcadPzg1w/8P6XcE6Z1hKbRksxizjQD0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cEYQvg/dJMcadPzg1w/8P6XcE6Z1hKbRksxizjQD0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cEYQvg/dJMcadPzg1w/8P6XcE6Z1hKbRksxizjQD0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcEYQvg%2FdJMcadPzg1w%2F8P6XcE6Z1hKbRksxizjQD0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;&lt;code&gt;kubectl describe pod&lt;/code&gt; 결과에서 &lt;code&gt;Topology Spread Constraints&lt;/code&gt; 부분만 grep으로 발췌. &lt;code&gt;hostname&lt;/code&gt;과 &lt;code&gt;zone&lt;/code&gt; 두 축으로 &lt;code&gt;maxSkew 1&lt;/code&gt;, &lt;code&gt;ScheduleAnyway&lt;/code&gt; 제약이 두 Pod 모두에 적용된 게 보인다.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Private Subnet: EKS Worker와 App Workloads&lt;/h2&gt;
&lt;p&gt;EKS Cluster와 Node Group은 Private Subnet에 배치했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;aws_eks_cluster&amp;quot; &amp;quot;this&amp;quot; {
  vpc_config {
    subnet_ids              = var.private_subnet_ids
    endpoint_private_access = true
    endpoint_public_access  = var.endpoint_public_access
  }
}

resource &amp;quot;aws_eks_node_group&amp;quot; &amp;quot;this&amp;quot; {
  subnet_ids = var.private_subnet_ids
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;dev 환경은 작업 편의 때문에 endpoint_public_access = true로 두었다. 다만 endpoint_private_access도 함께 켜둬서 워커 노드와 컨트롤 플레인 간 통신은 VPC 안 private endpoint로 흐른다. 내 PC(VPC 밖에서 EKS API를 호출하는 환경)에서 들어오는 kubectl 트래픽만 public endpoint를 거친다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;dev  : 작업 편의와 비용, 실습 속도를 우선
prod : public endpoint 축소, RDS Multi-AZ, 접근 경로 제한 강화&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실무에서도 dev와 prod를 완전히 똑같이 가져가는 경우는 많지 않을 것 같다. 왜 다르게 했고 prod에서는 어떤 방향으로 더 조이는지를 말로 풀 수 있으면 그걸로 충분하다고 생각한다.&lt;/p&gt;
&lt;p&gt;App Workloads는 API Pod 4종과 Batch CronJob으로 구성되어 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;order-api&lt;/li&gt;
&lt;li&gt;kitchen-api&lt;/li&gt;
&lt;li&gt;delivery-api&lt;/li&gt;
&lt;li&gt;notification-api&lt;/li&gt;
&lt;li&gt;batch CronJob&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqWsuO/dJMcaglguX8/eOen0umr1k4gY9JklM4Ykk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqWsuO/dJMcaglguX8/eOen0umr1k4gY9JklM4Ykk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqWsuO/dJMcaglguX8/eOen0umr1k4gY9JklM4Ykk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqWsuO%2FdJMcaglguX8%2FeOen0umr1k4gY9JklM4Ykk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;VPC Endpoint: NAT를 줄이고 Private 접근을 늘리기&lt;/h2&gt;
&lt;p&gt;Private Subnet에 있는 EKS Worker와 Bastion EC2는 AWS API를 자주 호출한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ECR에서 이미지 pull&lt;/li&gt;
&lt;li&gt;SSM Session Manager 접속&lt;/li&gt;
&lt;li&gt;EKS API 조회&lt;/li&gt;
&lt;li&gt;S3 접근&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;모든 요청을 NAT Gateway로 보내도 동작은 한다. 하지만 NAT Gateway를 통하면 데이터 처리 비용이 생기고, Private 리소스가 AWS API를 호출할 때도 굳이 인터넷 방향으로 나갔다가 돌아오는 구조가 된다.&lt;/p&gt;
&lt;p&gt;그래서 몇몇 AWS API는 VPC Endpoint를 만들었다.&lt;/p&gt;
&lt;p&gt;현재 구성한 Endpoint는 6개 Interface Endpoint와 1개 Gateway Endpoint다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Interface Endpoint
- ECR API
- ECR DKR
- SSM
- SSMMessages
- EC2Messages
- EKS

Gateway Endpoint
- S3&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Terraform에서는 &lt;code&gt;modules/vpc_endpoint&lt;/code&gt;에서 관리한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;aws_vpc_endpoint&amp;quot; &amp;quot;ecr_api&amp;quot; {
  vpc_endpoint_type   = &amp;quot;Interface&amp;quot;
  private_dns_enabled = true
}

resource &amp;quot;aws_vpc_endpoint&amp;quot; &amp;quot;s3&amp;quot; {
  vpc_endpoint_type = &amp;quot;Gateway&amp;quot;
  route_table_ids   = var.private_route_table_ids
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;단 모든 AWS API를 Endpoint로 처리하지는 않았다. ECR, SSM, EKS, S3처럼 호출 빈도가 높거나 트래픽이 큰 API만 Endpoint를 두었고, Secrets Manager, STS, ELB, ACM, WAF 같은 API는 NAT Gateway를 통해 인터넷으로 나간다. 자주 부르는 핵심 경로만 VPC 안에서 처리하고 나머지는 NAT에 맡긴 셈이다.&lt;/p&gt;
&lt;p&gt;SSM 관련 Endpoint도 처음에는 헷갈렸다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SSM&lt;/code&gt;, &lt;code&gt;SSMMessages&lt;/code&gt;, &lt;code&gt;EC2Messages&lt;/code&gt;가 왜 다 필요한지 바로 이해되지 않았다. 정리하면 이렇게 볼 수 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SSM&lt;/code&gt;: 인스턴스 등록, 명령 요청 등 Systems Manager 기본 API&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SSMMessages&lt;/code&gt;: Session Manager의 터미널 입출력 데이터 채널&lt;/li&gt;
&lt;li&gt;&lt;code&gt;EC2Messages&lt;/code&gt;: SSM Agent가 명령 상태와 응답을 주고받는 제어 채널&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉 Public IP와 SSH 22번 없이 Bastion EC2에 접속하려면 이 세트가 필요하다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vmtes/dJMcad26LJP/N0zjRWv4XHhGaK8nl5FOXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vmtes/dJMcad26LJP/N0zjRWv4XHhGaK8nl5FOXK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vmtes/dJMcad26LJP/N0zjRWv4XHhGaK8nl5FOXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fvmtes%2FdJMcad26LJP%2FN0zjRWv4XHhGaK8nl5FOXK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;ECR(API/DKR), SSM 3종(ssm/ssmmessages/ec2messages), EKS — 모두 Interface로 6개. S3만 Gateway로 1개. 총 7개.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/chkrh1/dJMcaja8HYg/7cSflkbLQv5iFt5Fz7grs1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/chkrh1/dJMcaja8HYg/7cSflkbLQv5iFt5Fz7grs1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/chkrh1/dJMcaja8HYg/7cSflkbLQv5iFt5Fz7grs1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fchkrh1%2FdJMcaja8HYg%2F7cSflkbLQv5iFt5Fz7grs1%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;콘솔 화면을 CLI로 재확인. Name/Service/State/Type만 추려서 한 줄씩 보이게 만든 출력.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Bastion: SSH 대신 SSM Session Manager&lt;/h2&gt;
&lt;p&gt;예전에 Bastion이라고 하면 Public Subnet에 EC2를 두고 SSH로 들어가는 그림을 봤었다. 이번 프로젝트에서는 그렇게 하지 않았다.&lt;/p&gt;
&lt;p&gt;Bastion EC2는 Private Subnet에 둔다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;aws_instance&amp;quot; &amp;quot;ssm&amp;quot; {
  subnet_id                   = module.vpc.private_subnet_ids[0]
  associate_public_ip_address = false
  vpc_security_group_ids      = [module.security_group.ssm_ec2_sg_id]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Security Group도 inbound를 열지 않는다. SSH 22번을 열지 않고 SSM Session Manager로만 접속한다.&lt;/p&gt;
&lt;p&gt;이 Bastion에는 &lt;code&gt;kubectl&lt;/code&gt;, &lt;code&gt;psql&lt;/code&gt;, &lt;code&gt;awscli&lt;/code&gt;를 설치해두었다. 그래서 내 PC에서 직접 EKS나 RDS로 접근하지 않고 Private Subnet 안의 점검 호스트를 통해 확인할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;내 PC
-&amp;gt; AWS Systems Manager Session Manager
-&amp;gt; SSM / SSMMessages / EC2Messages Endpoint
-&amp;gt; Private Subnet Bastion EC2
-&amp;gt; kubectl 또는 psql&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;dev 환경에서는 Bastion에서 RDS로 직접 붙을 수 있도록 SG를 열어두었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;aws_security_group_rule&amp;quot; &amp;quot;rds_from_ssm&amp;quot; {
  count = var.allow_ssm_db_access ? 1 : 0

  from_port                = 5432
  to_port                  = 5432
  protocol                 = &amp;quot;tcp&amp;quot;
  source_security_group_id = aws_security_group.ssm_ec2.id
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;prod에서는 이 접근을 더 좁힐 예정이다. dev에서부터 운영 점검용 경로는 두되 Public IP와 SSH 22번은 제거한다는 원칙을 유지하고 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cb7EP5/dJMb99TZh2j/ovP9A8Svjp7TZD1aIFyhPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cb7EP5/dJMb99TZh2j/ovP9A8Svjp7TZD1aIFyhPK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cb7EP5/dJMb99TZh2j/ovP9A8Svjp7TZD1aIFyhPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcb7EP5%2FdJMb99TZh2j%2FovP9A8Svjp7TZD1aIFyhPK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;DB Subnet: RDS는 Public 경로를 만들지 않기&lt;/h2&gt;
&lt;p&gt;RDS는 DB Subnet Group에 배치한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;aws_db_subnet_group&amp;quot; &amp;quot;this&amp;quot; {
  subnet_ids = aws_subnet.db[*].id
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RDS 자체도 Public access를 끈다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &amp;quot;aws_db_instance&amp;quot; &amp;quot;primary&amp;quot; {
  identifier             = &amp;quot;${var.project}-${var.env}-db&amp;quot;
  engine                 = &amp;quot;postgres&amp;quot;
  engine_version         = var.db_engine_version
  instance_class         = var.db_instance_class
  allocated_storage      = var.allocated_storage
  db_subnet_group_name   = var.db_subnet_group_name
  vpc_security_group_ids = [var.rds_sg_id]

  multi_az            = var.multi_az
  publicly_accessible = false
  storage_encrypted   = true
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;DB Subnet에서 가장 중요하게 본 것은 &lt;code&gt;0.0.0.0/0&lt;/code&gt; 라우팅을 만들지 않는 것이었다.&lt;/p&gt;
&lt;p&gt;Public Subnet route table에는 IGW가 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;0.0.0.0/0 -&amp;gt; Internet Gateway&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Private Subnet route table에는 NAT Gateway가 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;0.0.0.0/0 -&amp;gt; NAT Gateway&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 DB Subnet에는 인터넷으로 나가는 기본 경로를 만들지 않았다. Terraform에서도 DB Subnet에 대해 별도 route table association을 만들지 않는다. 즉 VPC local 통신만 가능하게 두는 구조다.&lt;/p&gt;
&lt;p&gt;RDS 접근은 Security Group으로 제한한다.&lt;/p&gt;
&lt;p&gt;현재 dev 기준으로는 EKS 쪽 SG와 SSM Bastion SG에서만 5432를 허용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;# eks_addons 모듈 안
resource &amp;quot;aws_security_group_rule&amp;quot; &amp;quot;rds_from_eks_cluster_sg&amp;quot; {
  type                     = &amp;quot;ingress&amp;quot;
  from_port                = 5432
  to_port                  = 5432
  protocol                 = &amp;quot;tcp&amp;quot;
  security_group_id        = var.rds_sg_id
  source_security_group_id = var.cluster_primary_security_group_id
  description              = &amp;quot;PostgreSQL from EKS cluster primary SG (worker nodes)&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 한 번 시행착오가 있었다. EKS Node Group에 내가 만든 &lt;code&gt;eks_node_sg&lt;/code&gt;만 붙는다고 생각했는데, 실제 Pod에서 RDS로 나가는 경로를 맞추려면 EKS가 자동 생성하는 cluster primary SG도 고려해야 했다. 그래서 &lt;code&gt;environments/dev/cluster.tf&lt;/code&gt;에서 &lt;code&gt;module.eks.cluster_primary_security_group_id&lt;/code&gt;를 &lt;code&gt;eks_addons&lt;/code&gt; 모듈에 넘기고, 그 모듈 안에서 위 rule을 RDS SG에 붙이는 형태로 정리했다.&lt;/p&gt;
&lt;p&gt;이 부분은 Terraform module을 나눌 때도 기준이 됐다. 모든 SG rule을 &lt;code&gt;security_group&lt;/code&gt; module 안에 넣고 싶었지만, EKS cluster primary SG는 EKS가 만들어야 알 수 있다. 그래서 EKS output을 받을 수 있는 위치(&lt;code&gt;eks_addons&lt;/code&gt; 모듈)에서 RDS rule을 추가하는 형태가 더 자연스러웠다.&lt;/p&gt;
&lt;p&gt;prod에서는 RDS를 Multi-AZ Primary/Standby 구조로 가져간다. dev에서는 비용 때문에 &lt;code&gt;multi_az = false&lt;/code&gt;, &lt;code&gt;replica_count = 0&lt;/code&gt;으로 둔다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;# dev
multi_az      = false
replica_count = 0&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RDS Primary/Standby 개수와 DB Subnet 개수는 별개다. DB Subnet은 3개를 만들어 DB Subnet Group으로 묶고, prod RDS Multi-AZ는 그 안에서 AWS가 서로 다른 AZ에 Primary와 Standby를 배치한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/64D1s/dJMcab5llxO/d4ToSCFmTF9OsGzmYrbME0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/64D1s/dJMcab5llxO/d4ToSCFmTF9OsGzmYrbME0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/64D1s/dJMcab5llxO/d4ToSCFmTF9OsGzmYrbME0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F64D1s%2FdJMcab5llxO%2Fd4ToSCFmTF9OsGzmYrbME0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;dev RDS의 연결/보안 탭. 퍼블릭 액세스 아니요, DB Subnet Group에 서브넷 3개 묶임, 전용 SG(&lt;code&gt;shoong-dev-rds-sg&lt;/code&gt;)로 격리.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oVBF8/dJMcagMoeK2/IultDehYAmOqG1w7mS7A5K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oVBF8/dJMcagMoeK2/IultDehYAmOqG1w7mS7A5K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oVBF8/dJMcagMoeK2/IultDehYAmOqG1w7mS7A5K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoVBF8%2FdJMcagMoeK2%2FIultDehYAmOqG1w7mS7A5K%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;br&gt;&lt;em&gt;&lt;code&gt;describe-db-instances&lt;/code&gt;로 추린 6개 키. Engine &lt;code&gt;postgres&lt;/code&gt; 18.3, &lt;code&gt;MultiAZ False&lt;/code&gt;, &lt;code&gt;PubliclyAccessible False&lt;/code&gt;, &lt;code&gt;StorageEncrypted True&lt;/code&gt;, DB Subnet Group까지 본문 주장과 일치.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Security Group은 계층별로 나누기&lt;/h2&gt;
&lt;p&gt;보안 그룹은 역할별로 나눴다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;alb-sg
eks-node-sg
ssm-ec2-sg
rds-sg
vpc-endpoint-sg&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ALB SG
- 80, 443 inbound
- outbound all

EKS Node SG
- ALB SG에서 들어오는 트래픽 허용
- 노드 간 통신 허용

SSM EC2 SG
- inbound 없음
- 443 outbound
- RDS 5432 outbound

RDS SG
- EKS 쪽 SG에서 5432 허용
- dev에서는 SSM EC2 SG에서 5432 허용

VPC Endpoint SG
- VPC CIDR에서 443 허용&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;커스텀 NACL은 따로 설계하지 않았다. 라우팅 분리와 Security Group만으로도 계층별 접근 제한이 충분히 되는 구조였고 NACL은 stateless라 SG와 중복으로 관리하는 게 오히려 부담이 컸다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;dev와 prod를 다르게 둔 부분&lt;/h2&gt;
&lt;p&gt;이 프로젝트는 AWS 비용을 계속 줄이면서 작업해야 했다. 그래서 dev와 prod를 완전히 같은 사이즈로 만들지는 않았다.&lt;/p&gt;
&lt;p&gt;대표적으로 다른 부분은 다음과 같다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;dev&lt;/th&gt;
&lt;th&gt;prod 방향&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;RDS&lt;/td&gt;
&lt;td&gt;Single-AZ&lt;/td&gt;
&lt;td&gt;Multi-AZ Primary/Standby&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EKS API endpoint&lt;/td&gt;
&lt;td&gt;public + private&lt;/td&gt;
&lt;td&gt;private 중심&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSM Bastion -&amp;gt; RDS&lt;/td&gt;
&lt;td&gt;허용&lt;/td&gt;
&lt;td&gt;더 제한적으로 운영&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;리소스 크기&lt;/td&gt;
&lt;td&gt;비용 우선&lt;/td&gt;
&lt;td&gt;가용성 우선&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;처음에는 포트폴리오면 prod처럼 다 해놔야 하는 거 아닌가 싶기도 했다. 그런데 생각보다 비용이 많이나가서 비용을 무시할 수가 없었다.&lt;/p&gt;
&lt;p&gt;그래서 dev는 실습과 검증을 빠르게 하기 위한 환경으로 두고, prod는 설계 의도를 보여주는 방향으로 나눴다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;정리하면서&lt;/h2&gt;
&lt;p&gt;이번 네트워크 설계를 하면서 느낀 건, VPC 설계는 그림만 예쁘게 그리는 일이 아니라는 점이었다.&lt;/p&gt;
&lt;p&gt;서브넷을 9개로 나눈다고 해서 자동으로 안전해지는 것은 아닌것 같다. Public Route가 어디에 있는지, NAT가 어디에 붙는지, RDS가 어떤 SG에서만 열리는지, EKS가 어떤 Endpoint로 AWS API를 호출하는지까지 맞아야 설계가 실제가 된다.&lt;/p&gt;
&lt;p&gt;Shoong Delivery의 현재 네트워크 설계는 이렇게 정리할 수 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;3AZ에 Public / Private / DB 서브넷을 분리했다.&lt;/li&gt;
&lt;li&gt;외부 진입은 CloudFront/WAF와 ALB로 모았다.&lt;/li&gt;
&lt;li&gt;ALB는 TLS를 종료하고, TargetGroupBinding으로 Istio Ingress Gateway에 연결한다.&lt;/li&gt;
&lt;li&gt;EKS Worker와 App Workloads는 Private Subnet에 둔다.&lt;/li&gt;
&lt;li&gt;NAT Gateway는 AZ별로 두어 Private Subnet outbound를 처리한다.&lt;/li&gt;
&lt;li&gt;ECR, SSM, EKS, S3 등 핵심 AWS API는 VPC Endpoint 경로를 우선 사용한다.&lt;/li&gt;
&lt;li&gt;Bastion은 Public IP와 SSH 없이 SSM Session Manager로만 접근한다.&lt;/li&gt;
&lt;li&gt;RDS는 DB Subnet에 두고 Public access를 끈다.&lt;/li&gt;
&lt;li&gt;RDS 접근은 EKS와 SSM Bastion에서만 Security Group으로 제한한다.&lt;/li&gt;
&lt;li&gt;dev는 비용 최적화, prod는 가용성과 보안 강화를 기준으로 차이를 둔다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;아직 개선할 부분도 있다. prod 기준 RDS Multi-AZ 적용, EKS API public endpoint 제거 같은 작업 등을 계속 다듬을 예정이다.&lt;/p&gt;
&lt;p&gt;그래도 이번 설계를 하면서 한 가지는 분명히 느꼈다. 네트워크 설계는 어디에 무엇을 배치했는지보다 어떤 경로를 허용했고 어떤 경로를 일부러 만들지 않았는지가 결과를 훨씬 크게 좌우한다는 점이다.&lt;/p&gt;
&lt;p&gt;그 기준으로 보면 Shoong Delivery의 네트워크는 아직 작지만 포트폴리오에서 설명할 수 있는 구조는 어느 정도 갖췄다고 생각한다.&lt;/p&gt;</description>
      <category>Project: Shoong-Delivery</category>
      <author>2-30</author>
      <guid isPermaLink="true">https://2-3-0.tistory.com/14</guid>
      <comments>https://2-3-0.tistory.com/14#entry14comment</comments>
      <pubDate>Wed, 20 May 2026 23:25:46 +0900</pubDate>
    </item>
    <item>
      <title>[자동화] terrafom apply 후 수동 작업 대신 자동화 스크립트</title>
      <link>https://2-3-0.tistory.com/13</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 배포 과정을 전부 수동으로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Terraform으로 EKS, RDS, CloudFront 같은 인프라를 만들고 나면 그걸로 끝일 줄 알았다. 그런데 실제로는 &lt;code&gt;terraform apply&lt;/code&gt;가 끝난 뒤에도 해야 할 일이 많았다. EKS에 접속해야 했고, Terraform output으로 나온 값을 GitOps 레포에 반영해야 했고, ArgoCD를 설치해야 했고, ESO를 붙여야 했고, DB 접속 확인과 초기 데이터 삽입까지 해야 했다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 이 프로젝트는 AWS 비용을 줄이려고 작업이 끝나면 매일 밤 &lt;code&gt;terraform destroy&lt;/code&gt;를 하고 다음 작업 때 다시 &lt;code&gt;terraform apply&lt;/code&gt;를 하는 방식으로 운영했다. 인프라를 매번 새로 만들다 보니 VPC ID, CloudFront Distribution id, ALB DNS 같은 값이 계속 바뀌었다. 하루에 한 번만 해도 반복 작업이 시간을 꽤 많이 잡아먹었고 중간에 하나라도 빠뜨리면 뒤 단계에서 이상한 에러로 돌아왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 처음에는 &lt;code&gt;배포_단계_체크리스트.md&lt;/code&gt;에 명령어를 정리해두고 순서대로 따라 했다. 체크리스트 자체는 도움이 됐다. 하지만 사람이 직접 복사해서 실행하는 방식은 결국 한계가 있었다. 그래서 이 수동 체크리스트를 기준으로 &lt;code&gt;init.sh&lt;/code&gt;를 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Terraform apply 이후 남은 배포 초기화 작업을 한 번에 실행하고 실패하면 어디서 실패했는지 바로 알 수 있게 만드려고 노력했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자동화 전에는 어떻게 했나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 수동 체크리스트의 흐름은 대략 이랬다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;terraform apply
  &amp;darr;
Terraform output 확인
  &amp;darr;
GitOps 레포 values 수정
  &amp;darr;
GitOps commit / push
  &amp;darr;
aws eks update-kubeconfig
  &amp;darr;
ArgoCD 설치
  &amp;darr;
GitOps repo 등록
  &amp;darr;
App-of-Apps bootstrap
  &amp;darr;
Istio / AWS Load Balancer Controller / Monitoring 배포 대기
  &amp;darr;
ESO 설치
  &amp;darr;
ExternalSecret 동기화 확인
  &amp;darr;
Secrets Manager에 DB endpoint 반영
  &amp;darr;
SSM EC2에서 RDS 접속 테스트
  &amp;darr;
DB 테이블 생성 + seed 데이터 삽입
  &amp;darr;
서비스 내부/외부 헬스체크
  &amp;darr;
CI/CD smoke test&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;글로만 봐도 길어보인다..&lt;br /&gt;실제로는 각 단계마다 확인할 값이 달라서 훨씬 오래걸렸다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Terraform output에서 &lt;code&gt;vpc_id&lt;/code&gt;, &lt;code&gt;target_group_arn&lt;/code&gt;, &lt;code&gt;alb_dns_name&lt;/code&gt;, &lt;code&gt;db_endpoint&lt;/code&gt; 확인&lt;/li&gt;
&lt;li&gt;GitOps 레포의 YAML 값 수정&lt;/li&gt;
&lt;li&gt;ArgoCD가 Application을 만들 때까지 대기&lt;/li&gt;
&lt;li&gt;AWS Load Balancer Controller webhook이 정상화될 때까지 대기&lt;/li&gt;
&lt;li&gt;ESO CRD 생성 후 ExternalSecret 동기화 확인&lt;/li&gt;
&lt;li&gt;SSM EC2가 Online 상태인지 확인&lt;/li&gt;
&lt;li&gt;RDS 접속 후 테이블과 초기 데이터 확인&lt;/li&gt;
&lt;li&gt;ClusterIP 내부 호출과 ALB/Istio 외부 호출 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 번에 성공하면 괜찮다. 문제는 실제 작업에서는 한 번에 성공하지 않는다는 점이었다.&lt;br /&gt;한 번에 성공한 적이 더 드물었다. 아마 아직 프로세스가 완벽하지 않다는 것이겠지..&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;수동 작업에서 가장 불편했던 부분&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Terraform output 값이 매번 바뀐다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ECR, S3, CloudFront 같은 일부 리소스는 그대로지만 EKS, ALB, RDS 등 대부분의 리소스는 비용 때문에 자주 삭제하고 다시 만들었다. 그러면 GitOps 레포에 들어가는 값도 바뀌어야 하는 경우가 있다.(테라폼을 apply 할때마다 값이 바뀌어서 레포에 새 커밋을 하는게 마음에 들진 않는다. 다른 방법이 있을까 고민하다가 이 부분에 대해서는 우선순위를 뒤로 두고 진행했다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면 이런 값이다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;값&lt;/th&gt;
&lt;th&gt;쓰이는 곳&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vpc_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AWS Load Balancer Controller values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;target_group_arn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Istio ingressgateway TargetGroupBinding&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 이 값을 직접 복사해서 YAML을 수정했다. 그런데 수동 수정은 실수하기 쉽다. 특히 Target Group ARN처럼 긴 문자열은 한 글자만 틀려도 바로 티가 안 난다. ArgoCD는 sync된 것처럼 보여도 실제 TargetGroupBinding이 올바른 대상에 붙지 않을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;code&gt;init.sh&lt;/code&gt;에서는 &lt;code&gt;terraform output -json&lt;/code&gt;을 한 번 읽고, &lt;code&gt;jq&lt;/code&gt;로 필요한 값을 파싱한 뒤 GitOps YAML에 자동 반영하도록 했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;OUTPUT_JSON=&quot;$(
  cd &quot;$TF_DIR&quot;
  terraform output -json
)&quot;

VPC_ID=$(echo &quot;$OUTPUT_JSON&quot; | jq -r '.vpc_id.value // &quot;N/A&quot;')
TG_ARN=$(echo &quot;$OUTPUT_JSON&quot; | jq -r '.target_group_arn.value // &quot;N/A&quot;')
DB_ENDPOINT_RAW=$(echo &quot;$OUTPUT_JSON&quot; | jq -r '.db_endpoint.value // &quot;N/A&quot;')&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 GitOps 레포의 값도 직접 수정한다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;sed -i &quot;s|^vpcId: .*|vpcId: $VPC_ID|&quot; \
  &quot;$GITOPS_DIR/infra/aws-lb-controller/values-dev.yaml&quot;

sed -i &quot;s|^  targetGroupARN: .*|  targetGroupARN: $TG_ARN|&quot; \
  &quot;$GITOPS_DIR/infra/istio-resources/dev/target-group-binding.yaml&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 변경사항이 있을 때만 commit/push한다. 변경이 없으면 push를 건너뛴다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. GitOps도 처음에는 수동 부트스트랩이 필요하다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitOps 구조라고 해서 처음부터 모든 것이 자동으로 되는 것은 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD가 GitOps 레포를 감시하려면 먼저 ArgoCD가 클러스터에 설치되어 있어야 한다. 그리고 private GitOps 레포를 읽을 수 있도록 repo credential도 등록해야 한다. 그 다음에야 App-of-Apps 구조로 shared/dev Application들을 올릴 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 스크립트에서는 다음 순서로 처리했다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;ArgoCD namespace 생성
  &amp;darr;
ArgoCD manifest server-side apply
  &amp;darr;
argocd-server Available 대기
  &amp;darr;
GitOps repo credential을 Kubernetes Secret으로 등록
  &amp;darr;
argocd/shared, argocd/dev Application bootstrap&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD repo 등록은 CLI 로그인으로 처리할 수도 있지만 스크립트에서는 Kubernetes Secret을 직접 만든다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;kubectl create secret generic shoong-gitops-repo \
  -n argocd \
  --from-literal=type=git \
  --from-literal=url=https://github.com/shoong-delivery/shoong-gitops \
  --from-literal=username=git \
  --from-literal=password=&quot;${GITHUB_PAT}&quot; \
  --dry-run=client -o yaml | kubectl apply -f -

kubectl label secret shoong-gitops-repo -n argocd \
  argocd.argoproj.io/secret-type=repository \
  --overwrite&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 ArgoCD CLI 로그인 상태에 의존하지 않아도 된다. 로컬 환경이 바뀌어도 스크립트만 실행하면 같은 방식으로 repo credential이 등록된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 대기 시간이 생각보다 중요했다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수동으로 할 때 가장 많이 만난 문제는 &lt;b&gt;명령어는 성공했는데 다음 단계가 실패하는&lt;/b&gt; 경우였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 ArgoCD Application을 만들었다고 해서 AWS Load Balancer Controller가 바로 Ready 상태가 되는 것은 아니다. Istio 리소스도 마찬가지다. ESO를 설치하려면 CRD와 webhook 상태가 준비되어 있어야 한다. 그런데 준비가 덜 된 상태에서 바로 다음 명령어를 실행하면 원인과 전혀 다른 에러가 나온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 기억에 남는 건 ESO 설치 중 만난 AWS Load Balancer Controller webhook x509 에러였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ESO Helm install 과정에서 Service 리소스가 만들어지는데 이때 AWS Load Balancer Controller의 webhook이 호출된다. 그런데 클러스터를 새로 만든 직후에는 Controller Pod가 Available로 보여도 webhook의 CA bundle이 아직 정상화되지 않은 경우가 있었다. 그 상태에서 ESO를 설치하면 TLS 검증 오류가 났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 스크립트에는 단순 설치만 넣지 않고 webhook이 실제로 동작하는지 dry-run으로 확인하는 로직을 넣었다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;if ! kubectl create service clusterip eso-webhook-check --tcp=80:80 \
     --dry-run=server -n kube-system &amp;gt;/dev/null 2&amp;gt;&amp;amp;1; then
  kubectl delete mutatingwebhookconfigurations aws-load-balancer-webhook 2&amp;gt;/dev/null || true
  kubectl delete validatingwebhookconfigurations aws-load-balancer-webhook 2&amp;gt;/dev/null || true
  kubectl rollout restart deployment -n kube-system \
    -l app.kubernetes.io/name=aws-load-balancer-controller
  kubectl rollout status deployment -n kube-system \
    -l app.kubernetes.io/name=aws-load-balancer-controller --timeout=120s
fi&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 식으로 상태를 직접 확인하는 로직을 몇 군데 넣어두긴 했지만 그래도 간헐적으로 실패하는 구간이 있었다. 주로 설치 직후 바로 다음 명령을 실행할 때였는데 이런 곳은 그냥 &lt;code&gt;sleep&lt;/code&gt;으로 약간의 여유를 두는 식으로 해결하기도 했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 실패하면 바로 멈춰야 했다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞 단계가 틀어지면 뒤 단계가 연결되는 경우가 있어서 뒷 단계 실행이 의미 없어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 kubeconfig가 잘못 잡혔는데 ArgoCD 설치를 계속하면 엉뚱한 클러스터에 리소스를 만들 수도 있다. ESO secret이 생성되지 않았는데 앱 Pod를 재시작하면 &lt;code&gt;CreateContainerConfigError&lt;/code&gt; 같은 에러가 날 수 있다. DB 접속이 안 되는데 API 헬스체크를 하면 앱 문제인지 네트워크 문제인지 구분하기 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 스크립트 맨 위에는 이 옵션을 넣었다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;set -euo pipefail&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 의미는 간단하다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;옵션&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-e&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;명령어 실패 시 즉시 중단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-u&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;정의되지 않은 변수 사용 시 중단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pipefail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;파이프라인 중 하나라도 실패하면 전체 실패 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덕분에 잘못된 상태로 다음 단계까지 밀고 가는 일이 줄었다. 실패한 지점에서 멈추고 로그를 보고 바로 고칠 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;init.sh의 전체 파이프라인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 스크립트는 총 15단계로 구성했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;[1] Terraform output 조회
[2] GitOps 값 업데이트
[3] GitOps 레포 commit + push
[4] EKS kubeconfig 설정
[5] ArgoCD 설치
[6] GitOps repo credential 등록
[7] ArgoCD Application bootstrap
[8] Istio ingressgateway 준비 대기
[9] ESO 설치
[10] ESO 리소스 ArgoCD Sync 및 Secret 확인
[11] AWS Secrets Manager DB secret 등록
[12] SSM Instance 확인
[13] SSM을 통한 RDS 접속 테스트
[14] DB 초기화 SQL 실행
[15] 최종 헬스체크&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좀 더 줄이면 이런 흐름이다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Terraform output
  &amp;rarr; GitOps 값 갱신
  &amp;rarr; ArgoCD bootstrap
  &amp;rarr; GitOps 기반 인프라/앱 배포
  &amp;rarr; ESO로 Secret 동기화
  &amp;rarr; RDS 연결 확인
  &amp;rarr; DB 초기화
  &amp;rarr; 내부/외부 API 헬스체크&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 스크립트가 CI/CD 파이프라인 자체를 대체하는 것은 아니다. 역할이 다르다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;init.sh&lt;/code&gt;: Terraform apply 이후 클러스터 초기 세팅과 배포 준비&lt;/li&gt;
&lt;li&gt;GitHub Actions: 앱 코드 변경 시 이미지 빌드, Trivy 스캔, ECR push, GitOps 이미지 태그 업데이트&lt;/li&gt;
&lt;li&gt;ArgoCD: GitOps 레포 변경 감지 후 Helm chart 기반 배포&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;code&gt;init.sh&lt;/code&gt;는 &quot;새로 만들어진 클러스터를 CI/CD가 동작 가능한 상태로 부트스트랩하는 스크립트&quot;에 가깝다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;단계 재시작을 넣은 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 스크립트를 처음부터 끝까지 한 번에 실행하는 것만 생각했다. 그런데 실제로 써보니 중간 실패 후 다시 해야하는 경우가 많았고, 처음부터 돌리는 게 비효율적이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 [9] ESO 설치에서 실패했는데 앞에 [2]GitOps 값 업데이트는 다시 할 필요는 없었다.&lt;br /&gt;[13] RDS 접속 테스트에서 실패했는데 ArgoCD를 다시 설치할 필요도 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;code&gt;STEP&lt;/code&gt;이라는 환경변수를 넣었다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;STEP=9 ./init.sh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 실행하면 9단계부터 다시 진행할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스크립트 내부에서는 이런 식으로 되어 있다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;STEP=&quot;${STEP:-1}&quot;

if [ &quot;$STEP&quot; -le 9 ]; then
  echo &quot;[9] ESO 설치&quot;
  ...
fi&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식이 혼자 포트폴리오 인프라를 반복해서 띄우는 상황에서 매우 유용했다. 실패한 구간만 고쳐서 다시 실행할 수 있으니까 작업 속도가 많이 빨라졌다. 쾌적~&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;재실행 가능한 형태로 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 원하는건 한 번만 성공하는 스크립트가 아니었다. 같은 스크립트를 여러 번 실행해도 최대한 안전해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 명령을 또 실행해도 &lt;b&gt;에러로 죽지 않는 것&lt;/b&gt;, 그리고 &lt;b&gt;이미 만들어둔 상태를 망가뜨리지 않는 것&lt;/b&gt;이다. 예를 들어 &lt;code&gt;kubectl create namespace foo&lt;/code&gt;는 두 번째 실행에서 &lt;code&gt;AlreadyExists&lt;/code&gt; 에러로 죽지만, &lt;code&gt;kubectl apply&lt;/code&gt;는 그렇지 않다. 이미 있으면 그대로 두고 변경분만 반영한다. 이런 식으로 두 번 실행해도 동일한 상태로 수렴하도록 만드려고 노력했다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;작업&lt;/th&gt;
&lt;th&gt;사용한 방식&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;namespace 생성&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl create --dry-run=client -o yaml | kubectl apply -f -&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ArgoCD manifest 적용&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl apply --server-side&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repo credential 등록&lt;/td&gt;
&lt;td&gt;Secret dry-run 후 apply&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ESO 설치&lt;/td&gt;
&lt;td&gt;&lt;code&gt;helm upgrade --install&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB 테이블 생성&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CREATE TABLE IF NOT EXISTS&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;seed 데이터 삽입&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitOps commit&lt;/td&gt;
&lt;td&gt;변경사항이 있을 때만 commit/push&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 패턴이 노리는 건 비슷하다.&lt;br /&gt;&lt;code&gt;--dry-run=client -o yaml | kubectl apply -f -&lt;/code&gt;는 리소스가 없으면 생성, 있으면 변경분만 반영한다.&lt;br /&gt;&lt;code&gt;helm upgrade --install&lt;/code&gt;은 release가 없으면 install, 있으면 upgrade로 처리한다.&lt;br /&gt;&lt;code&gt;CREATE TABLE IF NOT EXISTS&lt;/code&gt;나 &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt;은 SQL 레벨에서 같은 역할을 한다.&lt;br /&gt;전부 이미 있어도 죽지 않게' 하려는 패턴이다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이걸 &quot;완전한 멱등성(idempotency)&quot;이라고 말하긴 어려운거 같다.&lt;br /&gt;멱등하다는 건 엄밀히 따지면 N번 실행해도 결과 상태가 1번 실행한 것과 같아야 한다는 의미인데 스크립트 안에 그 조건을 어기는 명령이 일부 섞여 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;code&gt;kubectl rollout restart&lt;/code&gt;는 실행할 때마다 Pod를 새로 띄운다. 클러스터 입장에서는 매번 새로운 ReplicaSet과 Pod가 생기는 셈이라 &quot;같은 결과&quot;라고 말하기 애매하다. &lt;code&gt;aws secretsmanager put-secret-value&lt;/code&gt;도 호출할 때마다 새 버전(VersionId)을 만든다. 값이 동일해도 버전 히스토리는 계속 쌓인다. 그래서 &quot;여러 번 실행해도 안전하다&quot;는 말은 맞지만 &quot;여러 번 실행해도 완전히 동일한 상태가 된다&quot;라고는 말할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 완전한 idempotent 스크립트라기보다는 반복 실행을 고려한 배포 초기화 스크립트라고 보는 게 더 정확한 표현인 것 같다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최종 헬스체크를 길게 넣은 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &quot;Pod가 Running이면 된 거 아닌가?&quot;라고 생각했다. 그런데 실제 배포에서는 Running만으로는 부족했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pod가 Running이어도 Secret이 잘못 들어가면 앱이 요청 처리에서 실패할 수 있다. Istio VirtualService rewrite가 잘못되면 외부 경로만 실패할 수 있다. TargetGroupBinding이 잘못되면 클러스터 내부 서비스는 살아있지만 ALB를 통한 접근은 실패할 수 있다. DB 테이블이 없으면 health는 살아도 주문 생성에서 실패할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 마지막 단계는 가능한 한 정상동작을 확인할 수 있게끔 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확인하는 항목은 크게 나누면 이렇다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ArgoCD Application 상태&lt;/li&gt;
&lt;li&gt;ArgoCD / AWS Load Balancer Controller / Istio / ESO / Monitoring Pod 상태&lt;/li&gt;
&lt;li&gt;namespace label 상태&lt;/li&gt;
&lt;li&gt;&lt;code&gt;shoong-config&lt;/code&gt;, &lt;code&gt;shoong-db-secret&lt;/code&gt; 생성 여부&lt;/li&gt;
&lt;li&gt;Target Group health&lt;/li&gt;
&lt;li&gt;TargetGroupBinding 생성 여부&lt;/li&gt;
&lt;li&gt;SSM을 통한 DB 테이블, 메뉴 데이터 확인&lt;/li&gt;
&lt;li&gt;order/kitchen/delivery/notification 내부 health&lt;/li&gt;
&lt;li&gt;ALB &amp;rarr; Istio &amp;rarr; order/notification 외부 health&lt;/li&gt;
&lt;li&gt;메뉴 조회, 주문 생성, 주문 목록 조회&lt;/li&gt;
&lt;li&gt;ArgoCD가 보고 있는 이미지와 실제 Pod 이미지 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 검증을 통과해야 &quot;배포됐다&quot;고 볼 수 있다고 생각했다. 단순히 리소스가 만들어진 상태와 서비스가 실제로 동작하는 상태는 다르기 때문이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;만들면서 겪은 문제들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ArgoCD insecure 설정 타이밍&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분은 사실 수동 설치 때부터 계속 문제가 됐던 지점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD를 Istio 뒤에 붙이려다 보니 HTTP 라우팅을 위해 &lt;code&gt;server.insecure&lt;/code&gt; 설정이 필요했다. 그런데 이걸 처음부터 켜둔 상태로 설치하면 초기 설정 단계에서 쓰는 로컬 port-forward + CLI 로그인 조합에서 문제가 생겼다. 특히 Windows 환경에서 종종 &lt;code&gt;connection reset by peer&lt;/code&gt; 같은 에러가 떴는데, 프로토콜이랑 port-forward가 잘 안 맞는 거 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에는 internal 도메인(이름만 internal이긴 하다)을 붙이면서 port-forward 자체가 필요 없어졌다. 그래도 설치 초기에는 여전히 TLS 모드 쪽이 안전했기 때문에 초기 설치 시점에는 TLS 모드를 그대로 두고 GitOps의 &lt;code&gt;argocd-insecure.yaml&lt;/code&gt;을 부트스트랩 이후에 적용하는 순서로 정리했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Istio ingressgateway의 image: auto 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;istio-ingressgateway&lt;/code&gt; Deployment의 이미지가 &lt;code&gt;auto&lt;/code&gt;로 보이는 경우가 있었다. 이건 실제 이미지명이 아니라 istiod sidecar injector가 치환하는 placeholder였다. 그런데 &lt;code&gt;istio-system&lt;/code&gt; namespace에 injection label이 없으면 치환이 안 되고 &lt;code&gt;ImagePullBackOff&lt;/code&gt;가 났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 겪고 나서 &lt;code&gt;istio-system&lt;/code&gt; 준비 상태와 ingressgateway rollout을 따로 기다리는 단계를 넣었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ESO와 AWS Load Balancer Controller webhook&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에서 말한 x509 문제도 꽤 오래 잡고 있었다. Pod가 Available이라고 해서 webhook이 곧바로 정상이라는 뜻은 아니었다. 결국 dry-run으로 API server admission chain까지 실제로 통과하는지 확인하는 방식으로 바꿨다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RDS는 직접 접속할 수 없었다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RDS는 private subnet에 있고 public access를 열지 않았다. 그래서 로컬에서 바로 psql로 접속할 수 없다. 대신 private subnet의 SSM EC2를 통해 접속해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 Session Manager로 들어가서 직접 &lt;code&gt;psql&lt;/code&gt;을 설치하고 접속했다. 나중에는 이 과정도 &lt;code&gt;aws ssm send-command&lt;/code&gt;로 바꿔서 스크립트 안에서 처리했다. 덕분에 로컬에 DB 접근 경로를 열지 않고도 RDS 연결 테스트와 초기 SQL 실행을 자동화할 수 있었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;보안적으로 신경 쓴 부분&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동화 스크립트라고 해서 모든 값을 파일에 박아두지는 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스크립트 실행 전에 필요한 값을 env.sh라는 파일에 환경변수로 적어두고 먼저 실행한 후 init.sh 스크립트를 실행했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;export GITHUB_PAT=&quot;...&quot;
export DB_USERNAME=&quot;...&quot;
export DB_PASSWORD=&quot;...&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;init.sh 스크립트 안에서는 이 값들이 없으면 바로 중단한다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;required_env GITHUB_PAT
required_env DB_USERNAME
required_env DB_PASSWORD&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;init.sh는 TIL 깃허브에 올려두려고 생각했기 때문에 깃에 올라가면 안되는 민감정보들을 env.sh를 따로 빼둔 것이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 스크립트로 얻은 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 크게 바뀐 건 작업 시작 속도다. 적어도 한시간 이상 줄어든거 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전에는 Terraform apply 후 체크리스트를 보면서 하나씩 복사하고, 중간중간 ArgoCD UI와 kubectl을 왔다 갔다 했다. 지금은 필요한 환경변수만 준비해두고 스크립트를 실행하면 된다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;./scripts/init.sh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중간에 실패하면 실패한 단계부터 다시 실행한다.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;STEP=13 ./scripts/init.sh&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 스크립트를 만들면서 가장 좋았던 점은 시간절약이다. 그리고 너무너무 편했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 수동으로 하던 작업을 코드로 옮기다 보면서 배포 과정에서 내가 뭘 전제로 하고 있었는지가 드러났다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떤 값이 Terraform에서 나오는지&lt;/li&gt;
&lt;li&gt;어떤 값이 GitOps 레포에 반영되어야 하는지&lt;/li&gt;
&lt;li&gt;ArgoCD와 ESO의 설치 순서가 왜 중요한지&lt;/li&gt;
&lt;li&gt;secret이 생성된 뒤 앱 Pod를 왜 재시작해야 하는지&lt;/li&gt;
&lt;li&gt;Pod Running과 실제 서비스 동작 검증이 왜 다른지&lt;/li&gt;
&lt;li&gt;private RDS를 어떤 경로로 검증해야 하는지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수동 체크리스트는 &quot;명령어 모음&quot;에 가까웠다.&lt;br /&gt;&lt;code&gt;init.sh&lt;/code&gt;를 만들면서 그 명령어 사이의 의존관계와 실패 처리까지 코드로 남기게 됐다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 완벽하진 않다.&lt;br /&gt;사용하면서 추가적으로 계속 개선할 생각이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;init.sh&lt;/code&gt;는 멋있어 보이려고 만든 스크립트가 아니다. 매일 같은 실수를 반복하지 않으려고 만든 스크립트다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Terraform으로 인프라를 만들고, GitOps로 배포하고, ArgoCD와 ESO와 Istio를 붙이는 구조는 한 번 성공했다고 끝나는 게 아니었다. 매번 새로 띄워도 같은 순서로 복구되어야 했고, 실패했을 때 어디서 멈췄는지 알아야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 수동 체크리스트를 자동화 스크립트로 옮겼다. 그리고 그 과정에서 배포 초기화도 하나의 파이프라인이라는 걸 알게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 이 스크립트에서 보여주고 싶은 것은 반복되는 수동 작업을 줄이기 위해 실제로 겪은 실패를 기준으로 배포 초기화 과정을 코드화했다는 점이다.&lt;/p&gt;</description>
      <category>Project: Shoong-Delivery</category>
      <author>2-30</author>
      <guid isPermaLink="true">https://2-3-0.tistory.com/13</guid>
      <comments>https://2-3-0.tistory.com/13#entry13comment</comments>
      <pubDate>Wed, 20 May 2026 19:03:16 +0900</pubDate>
    </item>
    <item>
      <title>[Terraform] shoong-delivery 테라폼 구조 및 회고</title>
      <link>https://2-3-0.tistory.com/12</link>
      <description>&lt;h1&gt;테라폼으로 인프라 처음부터 짜보기 &amp;mdash; shoong-delivery&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테라폼은 인터넷 강의랑 공식 문서 보면서 공부했다. 예제 따라치는 거 말고 프로젝트 인프라를 IaC로 깐 건 이번이 처음이었다. 단순히 동작하는 코드를 만들기보다는 &lt;b&gt;실무에서 이 코드를 누가 받아서 운영한다면?&lt;/b&gt; 을 계속 떠올리면서 구조를 짰다. 이 글은 그 과정에서 내가 했던 선택과 그 이유를 정리한 글이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레포: shoong-terraform (배달 서비스 인프라 &amp;mdash; VPC, EKS, RDS, ALB, CloudFront 등 다 들어 있다)&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;디렉토리 구조부터&lt;/h2&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;shoong-terraform/
├── bootstrap/           # state 백엔드 자체를 만드는 별도 프로젝트
│   ├── main.tf          # S3 + DynamoDB
│   ├── provider.tf
│   ├── variables.tf
│   └── outputs.tf
├── environments/
│   ├── dev/
│   │   ├── Makefile         # terraform 명령 래퍼
│   │   ├── backend.tf       # state 백엔드 설정 (하드코딩)
│   │   ├── provider.tf      # aws / aws.us_east_1
│   │   ├── variables.tf     # 변수 선언
│   │   ├── terraform.tfvars # 환경별 값
│   │   ├── network.tf       # VPC, SecurityGroup, VPC Endpoint
│   │   ├── cluster.tf       # EKS, OIDC, IRSA 3종, Bastion EC2
│   │   ├── edge.tf          # Route53, ACM, WAF, ALB, S3, CloudFront
│   │   ├── database.tf      # RDS + Secrets Manager data
│   │   ├── pipeline.tf      # ECR, GitHub OIDC, SSM Parameter
│   │   └── outputs.tf
│   └── prod/                # dev와 거의 동일한 구성
├── modules/
│   ├── vpc/
│   ├── vpc_endpoint/
│   ├── security_group/
│   ├── eks/
│   ├── rds/
│   ├── alb/
│   ├── acm/
│   ├── route53/
│   ├── waf/
│   ├── cloudfront/
│   ├── s3/
│   ├── iam_oidc/     # GitHub Actions OIDC Role
│   └── ecr/
└── scripts/                # 클러스터 부트스트랩 스크립트
    ├── env.sh.example      # 환경변수 템플릿 (계정ID, 리전 등)
    └── init.sh             # apply 이후 ArgoCD&amp;middot;ESO&amp;middot;헬스체크 자동화&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;큰 줄기는 세 갈래다. &lt;b&gt;bootstrap / environments / modules&lt;/b&gt;. 각자 하는 일이 다르다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;bootstrap을 따로 뺀 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 테라폼을 작성할 때 가장 헷갈렸던 건 &quot;혼자가 아니라 팀으로 여러 명이 관리하면 어떻게 하지?&quot;였다. 지금이야 혼자 하는 프로젝트지만 실무에서는 결국 여러 사람이 같은 인프라를 같이 만지게 된다. 테라폼은 &lt;b&gt;state 파일&lt;/b&gt;(현재 인프라가 어떤 상태인지 기록해두는 파일)을 기준으로 다음에 뭘 만들고 뭘 지울지 판단하는데 이게 사람마다 다르면 협업이 깨진다. 그러면 state 파일은 어디에 둘까? 로컬에 두면 협업이 안 된다. 결국 S3 백엔드에 올려놓고 공유해야 하는데, &lt;b&gt;그 S3 버킷은 또 누가 만드나?&lt;/b&gt; 닭이 먼저냐 달걀이 먼저냐 같은 문제다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 가지 선택지가 있었다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;AWS 콘솔에서 손으로 만든다 &amp;rarr; 그러면 그 부분은 IaC가 아니게 됨&lt;/li&gt;
&lt;li&gt;CLI로 만든다 &amp;rarr; 그래도 코드로 안 남음&lt;/li&gt;
&lt;li&gt;별도 테라폼 프로젝트로 만든다 &amp;rarr; 그 자체의 state는 로컬에 두지만, &lt;b&gt;처음에 한 번만 apply하고 끝&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 3번을 택했다. bootstrap 폴더가 그래서 따로 존재한다. 여기서 만드는 건 단순하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;aws_s3_bucket&lt;/code&gt; &amp;mdash; 이름 &lt;code&gt;shoong-terraform-state&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;aws_s3_bucket_versioning&lt;/code&gt; &amp;mdash; state 덮어써도 이전 버전 살아있게&lt;/li&gt;
&lt;li&gt;&lt;code&gt;aws_s3_bucket_server_side_encryption_configuration&lt;/code&gt; &amp;mdash; SSE-S3 (KMS는 비용 때문에 패스)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;aws_s3_bucket_public_access_block&lt;/code&gt; &amp;mdash; 4가지 다 차단&lt;/li&gt;
&lt;li&gt;&lt;code&gt;aws_dynamodb_table&lt;/code&gt; &amp;mdash; state 잠금용. PAY_PER_REQUEST&lt;/li&gt;
&lt;li&gt;&lt;code&gt;aws_s3_bucket_lifecycle_configuration&lt;/code&gt; &amp;mdash; noncurrent 버전 60일 후 자동 삭제&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;bootstrap state는 로컬에만 있다. 어차피 한 번 만들고 거의 안 건드린다. 그리고 이 친구가 만든 S3가 &lt;code&gt;environments/dev&lt;/code&gt;, &lt;code&gt;environments/prod&lt;/code&gt;의 state 백엔드가 된다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;# environments/dev/backend.tf
terraform {
  backend &quot;s3&quot; {
    bucket         = &quot;shoong-terraform-state&quot;
    key            = &quot;dev/terraform.tfstate&quot;
    region         = &quot;us-east-1&quot;
    dynamodb_table = &quot;shoong-terraform-state-lock&quot;
    encrypt        = true
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 함정이 있었다. &lt;b&gt;backend.tf 안에서는 변수를 못 쓴다.&lt;/b&gt; 처음에 &lt;code&gt;var.bucket_name&lt;/code&gt;처럼 변수로 뺐다가 에러나서 헤맨 적이 있었다. 이유는 실행 순서 때문이었다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. terraform init &amp;rarr; 백엔드 초기화
2. provider 초기화
3. 변수 로드 (var.xxx 사용 가능)
4. 리소스 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1단계에서 변수가 아직 로드 안 됐기 때문에 backend 블록 안에서는 하드코딩.&lt;br /&gt;알고 나면 당연하지만 모르면 시간을 꽤 날린다. 까먹을까봐 코드에 주석으로 남겨놨다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;environments를 폴더로 나눈 이유 &amp;mdash; workspace 안 씀&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테라폼 환경 분리에는 보통 두 가지 방식으로 진행하는거 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;terraform workspace&lt;/b&gt; &amp;mdash; 같은 코드, state만 분리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;폴더 분리&lt;/b&gt; &amp;mdash; dev/, prod/ 폴더 따로&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;workspace는 처음 봤을 땐 &quot;간편한거 같은데?&quot; 싶었는데 알아보니 함정이 있었다. 같은 코드에서 dev/prod를 갈라야 하니까 결국 &lt;code&gt;if env == &quot;prod&quot;&lt;/code&gt; 같은 분기를 코드 곳곳에 넣어야 되더라. 그리고 prod와 dev의 차이가 단순히 인스턴스 사이즈만이면 모르겠는데 shoong-delivery는 차이가 있는 부분이 있어서 폴더 분리가 차라리 낫다고 생각했다. 또 앞으로도 다른 부분이 생길 수도 있겠다 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면 dev에는 있고 prod에는 없는 것들:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Bastion EC2의 SSM에서 RDS 직접 접근 허용 (&lt;code&gt;allow_ssm_db_access&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;EKS API endpoint의 public access (&lt;code&gt;endpoint_public_access&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prod는 보안 강화 때문에 막아야 하는데 같은 코드에 분기 박는 거보다 그냥 폴더를 나누고 root에서 모듈 호출 인자를 다르게 주는 게 훨씬 깔끔했다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;# dev/network.tf
module &quot;security_group&quot; {
  source = &quot;../../modules/security_group&quot;
  ...
  allow_ssm_db_access = true
}

# prod/network.tf
module &quot;security_group&quot; {
  source = &quot;../../modules/security_group&quot;
  ...
  allow_ssm_db_access = false
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 중복이 좀 생기긴 한다. 그런데 그 중복이 &lt;b&gt;&quot;환경 간에 실제로 다른 것&quot;을 명시적으로 보여주는&lt;/b&gt; 역할을 한다. workspace였으면 어디가 다른지 변수 비교해야 알 수 있는데, 폴더 비교하면 diff로 한 번에 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prod 환경 추가가 필요해지면 dev 폴더 복사 + tfvars와 일부 boolean 인자(&lt;code&gt;endpoint_public_access&lt;/code&gt;, &lt;code&gt;allow_ssm_db_access&lt;/code&gt; 등)만 조정하면 끝. 처음 잡을 때만 좀 번거롭고 그 뒤로는 편했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;모듈 13개 &amp;mdash; 어디까지 모듈로 빼고 어디는 root에 둘 건가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 가장 오래 고민한 부분이었다. 모듈을 너무 잘게 쪼개면 변수 전달이 길어지고, 너무 안 쪼개면 재사용이 안 된다. 내 기준은 이거였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;재사용 가능한 리소스는 모듈로 뺀다. 환경 간에 100% 중복되는 리소스도 OIDC URL 같은 환경 차이만 변수로 받으면 모듈로 간다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;modules/에 들어간 13개:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;vpc&lt;/code&gt;, &lt;code&gt;vpc_endpoint&lt;/code&gt;, &lt;code&gt;security_group&lt;/code&gt; (네트워크 3종)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;eks&lt;/code&gt;, &lt;code&gt;rds&lt;/code&gt; (컴퓨트/DB)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;alb&lt;/code&gt;, &lt;code&gt;acm&lt;/code&gt;, &lt;code&gt;route53&lt;/code&gt;, &lt;code&gt;waf&lt;/code&gt;, &lt;code&gt;cloudfront&lt;/code&gt;, &lt;code&gt;s3&lt;/code&gt; (엣지 6종)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;iam_oidc&lt;/code&gt; (GitHub Actions OIDC)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ecr&lt;/code&gt; (이미지 저장소)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 모듈은 변수만 받고 자기 일만 한다. 의존성은 root(environments/dev or prod)에서 output &amp;rarr; input으로 연결한다.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;# dev/network.tf
module &quot;vpc&quot; { ... }

module &quot;security_group&quot; {
  source = &quot;../../modules/security_group&quot;
  vpc_id = module.vpc.vpc_id   # root에서 의존성 연결
  ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 root에 직접 둔 것들:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ECR 리포지토리에 대한 &lt;code&gt;import&lt;/code&gt; 블록&lt;/li&gt;
&lt;li&gt;SSM Parameter Store 값들 (앱 환경변수)&lt;/li&gt;
&lt;li&gt;Secrets Manager의 &lt;code&gt;data&lt;/code&gt; 참조&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 모듈로 뺄 만한 게 아니라고 판단했다. ECR import는 한 번 마이그레이션하면 끝이고, SSM Parameter는 그 환경의 앱 환경변수라서 환경별로 내용이 완전히 다르다.&lt;br /&gt;Secrets Manager data는 root에서 RDS 모듈에 자격증명을 넘기는 역할이라 위치가 정해져 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;SecurityGroup(SG) rule이 모듈을 넘나드는 케이스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 SG와 관련 rule은 &lt;code&gt;security_group&lt;/code&gt; 모듈에 다 몰아두고 싶었다. 그런데 실제로는 SG rule이 세 군데에 흩어져 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. &lt;code&gt;security_group&lt;/code&gt; 모듈 안&lt;/b&gt; &amp;mdash; ALB / EKS Node / RDS / SSM / VPC Endpoint SG 본체와 그 사이의 내부 rule.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. &lt;code&gt;cluster.tf&lt;/code&gt; (root) 안&lt;/b&gt; &amp;mdash; RDS SG가 EKS cluster primary SG로부터 5432를 허용하는 rule.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# environments/dev/cluster.tf
resource &quot;aws_security_group_rule&quot; &quot;rds_from_eks_cluster_sg&quot; {
  type                     = &quot;ingress&quot;
  from_port                = 5432
  to_port                  = 5432
  protocol                 = &quot;tcp&quot;
  security_group_id        = var.rds_sg_id
  source_security_group_id = var.cluster_primary_security_group_id
  description              = &quot;PostgreSQL from EKS cluster primary SG (worker nodes)&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유는 이거다. EKS 워커 노드는 내가 만든 SG가 아니라 &lt;b&gt;EKS가 자동으로 만든 cluster primary SG&lt;/b&gt;를 쓴다. 그러면 RDS SG가 그 primary SG에서 오는 5432를 허용해야 한다. 두 SG ID가 다 있어야 만들 수 있는 rule이고, EKS 클러스터가 만들어진 다음에야 primary SG ID를 안다. 그런데 &lt;code&gt;security_group&lt;/code&gt; 모듈은 EKS 모듈에 SG ID를 넘겨주는 입장이라 의존성 그래프상 EKS보다 먼저 돌 수밖에 없다.(테라폼은 변수/output 연결을 따라 실행 순서를 자동으로 결정한다.) SG 모듈이 실행되는 시점에는 EKS 클러스터 자체가 아직 없고, 따라서 cluster primary SG ID도 존재하지 않는다. 그래서 EKS 모듈 호출 다음에 오는 root의 &lt;code&gt;cluster.tf&lt;/code&gt;에 이 rule을 직접 작성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. &lt;code&gt;eks&lt;/code&gt; 모듈 안&lt;/b&gt; &amp;mdash; ALB &amp;rarr; Istio Ingress Gateway 트래픽을 허용하는 rule.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# modules/eks/main.tf
resource &quot;aws_security_group_rule&quot; &quot;alb_to_istio_http&quot; {
  type                     = &quot;ingress&quot;
  ...
  security_group_id        = aws_eks_cluster.this.vpc_config[0].cluster_security_group_id
  source_security_group_id = var.alb_sg_id
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 rule이 가리키는 &lt;code&gt;cluster_security_group_id&lt;/code&gt;는 EKS 클러스터가 자체적으로 만든 SG라서 EKS 모듈 안에서만 참조 가능하다. 그래서 EKS 모듈 안에 두는 게 자연스러웠다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RDS의 Secrets Manager &amp;mdash; 모듈에서 뺐다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래 RDS 모듈 안에 &lt;code&gt;aws_secretsmanager_secret&lt;/code&gt;을 같이 만들었었다. 그런데 시크릿 값을 변수로 받게 되니까 결국 tfvars로 평문이 새는 문제가 생겼다. 그래서 &lt;b&gt;모듈에서 빼고 &amp;rarr; AWS CLI로 외부에서 만든 뒤 &amp;rarr; root에서 data로 읽기&lt;/b&gt;로 바꿨다. 시크릿은 아예 테라폼 관리 밖에 두었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RDS 가용성 &amp;mdash; 설계는 Primary-Standby, 지금은 Single&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아키텍처를 처음 그릴 때는 RDS를 Primary-Standby(Multi-AZ)로 잡았다. 한 AZ가 죽어도 standby로 failover돼서 DB가 살아있는 구성이 정석이라고 봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 테스트 해보려고 콘솔에 들어가니까 &lt;b&gt;다중 AZ 옵션이 아예 비활성화돼 있었다.&lt;/b&gt; 지금 프리티어 계정을 쓰는데, RDS 프리티어는 Single-AZ만 지원하기 때문이다. Multi-AZ는 standby 인스턴스가 한 대 더 뜨니까 프리티어 범위를 벗어나는 거였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 모듈은 &lt;code&gt;multi_az&lt;/code&gt;를 변수로 빼두고, &lt;b&gt;dev는 일단 &lt;code&gt;multi_az = false&lt;/code&gt;로 Single 구성&lt;/b&gt;해서 올렸다. 설계 의도(Primary-Standby)는 그대로 두되, 환경 변수로 켜고 끌 수 있게만 해뒀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 프리티어가 끝나면 그때 &lt;code&gt;multi_az = true&lt;/code&gt;로 재구성하면서 &lt;b&gt;failover가 실제로 어떻게 동작하는지, 전환 시간은 얼마나 걸리는지, 그 사이 커넥션은 어떻게 끊겼다 붙는지&lt;/b&gt; 같은 걸 직접 테스트해보려고 한다. 지금은 못 해본 부분이라 숙제로 남겨뒀다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;모듈/root 기준은 절대적인 게 아니라 의존성 방향이 그 자리를 결정한다고 본다.&quot;&lt;/b&gt; 어떤 리소스가 다른 모듈의 output에 의존한다면 그 두 output을 동시에 볼 수 있는 자리에 둘 수밖에 없다. 처음엔 깔끔하게 나누고 싶었지만 인프라가 서로 얽혀 있다보니 100%는 안 됐다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;data 소스로 &quot;만들지 않고 읽기만&quot; 하는 것들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테라폼은 만들어진 리소스를 가져올 때 &lt;code&gt;data&lt;/code&gt;를 쓴다. 처음엔 그냥 &quot;조회용&quot;이라고만 생각했는데 실제로 써보니까 &lt;b&gt;&quot;이건 테라폼이 관리하지 않겠다&quot;는 의사 표현&lt;/b&gt;이기도 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 data로만 다룬 것들이 두 군데 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Route53 호스팅 존&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;modules/route53/main.tf&lt;/code&gt;에는 resource가 없고 data만 있다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;data &quot;aws_route53_zone&quot; &quot;this&quot; {
  name         = var.zone_domain
  private_zone = false
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유: 도메인은 가비아에서 사고 Route53에 호스팅 존을 콘솔에서 미리 만들어 뒀다. 이걸 테라폼이 관리하면 &lt;code&gt;terraform destroy&lt;/code&gt; 한 번에 도메인 NS 설정 다 날아간다. 잘못 건드리면 복구가 어려운 자원은 테라폼 밖에 두는 게 안전하다고 판단했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. DB 자격증명 &amp;mdash; 시크릿은 껍데기도 만들지 않는다&lt;/h3&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# resource &quot;aws_secretsmanager_secret&quot; &quot;db_credentials&quot; {
#   name = &quot;${var.project}/${var.env}/db-credentials&quot;
#   ...
# }
# resource &quot;aws_secretsmanager_secret_version&quot; &quot;db_credentials&quot; {
#   secret_id     = aws_secretsmanager_secret.db_credentials.id
#   secret_string = jsonencode({
#     username = var.db_username
#     password = var.db_password
#   })
# }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 평범하게 위처럼 &lt;code&gt;aws_secretsmanager_secret_version&lt;/code&gt; 리소스로 값까지 다 적었다.&lt;br /&gt;근데 그 값이 어디로 들어가지? &lt;code&gt;var.db_password&lt;/code&gt;다. 그러면 &lt;code&gt;terraform.tfvars&lt;/code&gt;에 평문으로 적게 된다. 그 파일은 git에 올라간다. &lt;b&gt;시크릿이 git history에 박혀버린다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 대신 root에서 data로만 읽는 것으로 변경했다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;# environments/dev/database.tf
data &quot;aws_secretsmanager_secret_version&quot; &quot;db&quot; {
  secret_id = &quot;/shoong/dev/db-credentials&quot;
}

locals {
  db_credentials = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)
}

module &quot;rds&quot; {
  ...
  db_username = local.db_credentials[&quot;username&quot;]
  db_password = local.db_credentials[&quot;password&quot;]
}&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;테라폼은 &lt;b&gt;시크릿의 껍데기(Secret 이름)조차 만들지 않는다&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;AWS CLI로 미리 시크릿을 직접 생성 + 값 주입
&lt;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;aws secretsmanager create-secret \
  --name /shoong/dev/db-credentials \
  --secret-string '{&quot;username&quot;:&quot;{{admin}}&quot;,&quot;password&quot;:&quot;...&quot;}'&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;테라폼은 그 시크릿을 &lt;code&gt;data&lt;/code&gt;로 읽기만 한다&lt;/li&gt;
&lt;li&gt;읽은 값으로 RDS 만든다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러면 &lt;code&gt;terraform.tfvars&lt;/code&gt;에도, 코드에도 시크릿이 안 적힌다.(정확히는 state 파일에는 평문으로 남는다. 그래서 S3 백엔드에 SSE-S3 암호화를 걸어둔다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴의 장점은 또 있다. &lt;b&gt;운영 중 비밀번호 로테이션할 때 테라폼 안 건드려도 된다.&lt;/b&gt; CLI로 시크릿 값만 갈아끼우면 끝. 테라폼 입장에선 data가 자동으로 새 값을 읽는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;External Secrets Operator도 같은 정책으로 붙였다. EKS Pod의 환경변수도 SSM Parameter Store에 미리 만들어두고, ESO가 동기화해서 Pod에 ConfigMap/Secret으로 주입한다. 시크릿이 git에 안 닿게 하는 게 일관된 원칙이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이렇게 되면 테라폼으로 모든 인프라를 관리한다는 말은 성립되지 않는다.&lt;br /&gt;그래도 시크릿 같은 민감정보는 테라폼이 아니라 직접 관리하는게 낫지 않나 싶다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;ECR import 블록 &amp;mdash; 이미 있는 리소스 흡수하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 초기에는 ECR 리포지토리 5개를 콘솔에서 미리 만들어서 빌드/푸시 테스트를 돌리고 있었다.&lt;br /&gt;이후 테라폼으로 관리했는데 테라폼으로 한 번 지우고 다시 만들면 그동안 쌓인 이미지가 다 날아갔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결책: 테라폼 1.5에서 추가된 &lt;code&gt;import&lt;/code&gt; 블록.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;# environments/dev/pipeline.tf
module &quot;ecr&quot; {
  source = &quot;../../modules/ecr&quot;
  ...
}

import {
  to = module.ecr.aws_ecr_repository.this[&quot;shoong-batch&quot;]
  id = &quot;shoong-batch&quot;
}
import {
  to = module.ecr.aws_ecr_repository.this[&quot;shoong-delivery&quot;]
  id = &quot;shoong-delivery&quot;
}
# ... 5개 다 import&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;terraform plan&lt;/code&gt;을 돌리면 기존 ECR을 자동으로 state로 끌어들이고 코드와 동기화한다. CLI로 &lt;code&gt;terraform import&lt;/code&gt; 명령어 매번 치는 거보다 훨씬 깔끔하다. 그리고 import 블록은 코드로 남기 때문에 누가 와서 봐도 &quot;얘는 이미 있던 거 가져온 거구나&quot;라고 알 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Security Group &amp;mdash; drift 무한루프 피하기&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;무한 dirft: 테라폼 코드와 실제 인프라 상태가 계속 어긋나서 테라폼을 실행할 때마다 매번 불필요한 수정/삭제를 시도하는 무한 루프 상태&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테라폼 쓰기 시작한 초기에 헤맸던 부분이 있었다.&lt;br /&gt;&lt;code&gt;aws_security_group&lt;/code&gt;의 &lt;code&gt;ingress&lt;/code&gt; 블록을 &lt;b&gt;inline&lt;/b&gt;으로 쓰면서 동시에 &lt;code&gt;aws_security_group_rule&lt;/code&gt; 리소스를 별도로 또 쓰는 경우.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 RDS SG에 EKS Node SG에서 오는 5432를 처음에 inline ingress로 박아뒀다. 그러다 나중에 SSM EC2에서 오는 5432도 필요해서 &lt;code&gt;aws_security_group_rule&lt;/code&gt; 리소스로 따로 추가했다. 그러면 어떻게 되냐?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테라폼이 보기엔 SG에 inline ingress가 1개 있어야 하는데 실제로는 2개가 있다 &amp;rarr; &quot;어, 누가 추가했네? 지워야지&quot; &amp;rarr; 다음 plan에선 별도 rule이 사라졌다 &amp;rarr; &quot;어, 별도 rule이 있어야 하는데?&quot; &amp;rarr; 무한 drift.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 SG 모듈에서는 &lt;b&gt;inbound 규칙은 &lt;code&gt;aws_security_group_rule&lt;/code&gt;로 분리&lt;/b&gt;, &lt;b&gt;outbound는 inline&lt;/b&gt; 식으로 일관되게 만들었다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;resource &quot;aws_security_group&quot; &quot;rds&quot; {
  ...
  egress { ... }   # outbound는 inline OK
  # ingress는 inline으로 안 적음
}

resource &quot;aws_security_group_rule&quot; &quot;rds_from_eks_node&quot; {
  type                     = &quot;ingress&quot;
  ...
  security_group_id        = aws_security_group.rds.id
  source_security_group_id = aws_security_group.eks_node.id
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 건 한 번 당해봐야 습득하는거 같다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;IRSA &amp;mdash; 그 길고 끔찍한 trust policy&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EKS Pod가 AWS API를 호출하려면 IAM 권한이 필요하다. 옛날엔 Node EC2에 Role을 붙였는데 그러면 같은 노드 위의 모든 Pod가 같은 권한을 공유하는 보안 구멍이 생긴다. 그래서 찾은 게 IRSA(IAM Roles for Service Accounts) &amp;mdash; Pod의 ServiceAccount 단위로 IAM Role을 부여하는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원리:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;EKS가 OIDC Provider를 통해 SA에 JWT 발급&lt;/li&gt;
&lt;li&gt;Pod가 그 JWT로 STS에 &lt;code&gt;AssumeRoleWithWebIdentity&lt;/code&gt; 요청&lt;/li&gt;
&lt;li&gt;STS가 검증 후 임시 자격증명 반환&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;STS&lt;/b&gt;: Security Token Service. IAM 사용자나 역할(Role)에게 일정 시간 동안만 유효한 임시 보안 자격 증명(토큰)을 발급해 주는 AWS 서비스&lt;/li&gt;
&lt;li&gt;&lt;b&gt;AssumeRoleWithWebIdentity&lt;/b&gt;: OIDC(OpenID Connect) 인증 정보(IdP 토큰)를 기반으로 AWS STS에 요청하여 임시 보안 자격 증명(Access Key, Secret Key, Token)을 받아오는 API(기능)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;root &lt;code&gt;cluster.tf&lt;/code&gt;에 직접 작성했다. 코드는 이렇게 생겼다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;# environments/dev/cluster.tf
locals {
  oidc_host = replace(aws_iam_openid_connect_provider.eks.url, &quot;https://&quot;, &quot;&quot;)
}

resource &quot;aws_iam_role&quot; &quot;aws_lb_controller&quot; {
  name = &quot;${var.project}-${var.env}-aws-lb-controller-role&quot;

  assume_role_policy = jsonencode({
    Version = &quot;2012-10-17&quot;
    Statement = [{
      Effect = &quot;Allow&quot;
      Principal = {
        Federated = aws_iam_openid_connect_provider.eks.arn
      }
      Action = &quot;sts:AssumeRoleWithWebIdentity&quot;
      Condition = {
        StringEquals = {
          &quot;${local.oidc_host}:sub&quot; = &quot;system:serviceaccount:kube-system:aws-load-balancer-controller&quot;
          &quot;${local.oidc_host}:aud&quot; = &quot;sts.amazonaws.com&quot;
        }
      }
    }]
  })
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Condition&lt;/code&gt;의 &lt;code&gt;:sub&lt;/code&gt; 부분이 핵심이다. &lt;code&gt;system:serviceaccount:&amp;lt;namespace&amp;gt;:&amp;lt;sa-name&amp;gt;&lt;/code&gt; 형식으로 &lt;b&gt;정확히 이 SA만&lt;/b&gt; 이 Role을 Assume할 수 있게 잠근다. 다른 네임스페이스나 다른 SA는 토큰을 들고 와도 거절당한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모듈이 만들어 주는 IRSA 3종:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;AWS Load Balancer Controller&lt;/b&gt; &amp;mdash; Ingress/Service 감지해서 ALB 만드는 컨트롤러. 권한이 진짜 많다 (16개 statement)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;EBS CSI Driver&lt;/b&gt; &amp;mdash; PVC용 EBS 동적 프로비저닝&lt;/li&gt;
&lt;li&gt;&lt;b&gt;External Secrets Operator&lt;/b&gt; &amp;mdash; SSM Parameter Store / Secrets Manager 값을 Pod에 동기화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;dev/prod root에서는 &lt;code&gt;eks_addons&lt;/code&gt; 모듈을 호출하면서 클러스터의 OIDC issuer URL만 넘기면 끝이다. ESO Policy의 SSM Resource ARN(&lt;code&gt;/shoong/dev/*&lt;/code&gt; vs &lt;code&gt;/shoong/prod/*&lt;/code&gt;)도 모듈 안에서 &lt;code&gt;var.project&lt;/code&gt;, &lt;code&gt;var.env&lt;/code&gt;로 동적으로 만든다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;GitHub Actions OIDC &amp;mdash; Access Key 없는 CI&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub Actions에서 AWS로 이미지 푸시할 때 AWS Access Key를 GitHub Secrets에 넣는 방법이 있다. 이게 장기 자격증명이라 유출 위험이 있지 않을까 생각이 들어서 찾아봤는데&lt;br /&gt;OIDC를 쓰면 access key 자체가 없다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# modules/iam_oidc/main.tf
resource &quot;aws_iam_openid_connect_provider&quot; &quot;github&quot; {
  url            = &quot;https://token.actions.githubusercontent.com&quot;
  client_id_list = [&quot;sts.amazonaws.com&quot;]
  thumbprint_list = [&quot;6938fd4d98bab03faadb97b34396831e3780aea1&quot;]
}

resource &quot;aws_iam_role&quot; &quot;github_actions&quot; {
  assume_role_policy = jsonencode({
    Statement = [{
      Effect = &quot;Allow&quot;
      Principal = {
        Federated = aws_iam_openid_connect_provider.github.arn
      }
      Action = &quot;sts:AssumeRoleWithWebIdentity&quot;
      Condition = {
        StringLike = {
          &quot;token.actions.githubusercontent.com:sub&quot; = [
            for repo in var.github_repos : &quot;repo:${var.github_org}/${repo}:*&quot;
          ]
        }
      }
    }]
  })
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원리는 IRSA와 똑같다. 신뢰하는 IdP가 EKS 대신 GitHub일 뿐. 특정 org의 특정 repo에서만 이 Role을 Assume할 수 있다. GitHub Actions workflow에서는 그냥 &lt;code&gt;role-to-assume&lt;/code&gt;만 지정하면 끝.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Bastion EC2 &amp;mdash; Session Manager 점프 호스트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;dev 환경의 EKS는 편의를 위해 public access를 열어놨지만&lt;br /&gt;&lt;b&gt;prod는 endpoint_public_access=false&lt;/b&gt;다. 그러면 어떻게 kubectl을 친단 말인가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 VPC 안에 Bastion EC2를 하나 띄워뒀다. SSH는 안 쓰고 SSM Session Manager로 붙는다. 공인 IP도 안 주고 22번 포트도 안 열어뒀다. root &lt;code&gt;cluster.tf&lt;/code&gt;에 직접 작성했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# environments/dev/cluster.tf
resource &quot;aws_instance&quot; &quot;ssm&quot; {
  ami                         = var.ami
  instance_type               = var.instance_type
  subnet_id                   = var.subnet_id
  iam_instance_profile        = aws_iam_instance_profile.this.name
  vpc_security_group_ids      = var.security_group_ids
  associate_public_ip_address = false   # 공인 IP 없음

  user_data = &amp;lt;&amp;lt;-EOF
    #!/bin/bash
    # kubectl, psql, awscli 설치
    ...
    aws eks update-kubeconfig --region ${var.aws_region} --name ${var.cluster_name}
  EOF
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IAM Role과 EC2 인스턴스를 cluster.tf 안에 직접 선언한다. 서브넷은 VPC 모듈 output, SG는 security_group 모듈 output에서 가져온다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# environments/dev/cluster.tf
resource &quot;aws_instance&quot; &quot;ssm&quot; {
  ami                         = var.ssm_ec2_ami
  instance_type               = var.ssm_ec2_instance_type
  subnet_id                   = module.vpc.private_subnet_ids[0]
  iam_instance_profile        = aws_iam_instance_profile.ssm_ec2.name
  vpc_security_group_ids      = [module.security_group.ssm_ec2_sg_id]
  associate_public_ip_address = false
  ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;aws ssm start-session --target i-xxx&lt;/code&gt;로 접속하면 끝. SSH 키 관리 안 해도 되고 22번 포트가 인터넷에 노출되지 않는다. IAM 권한으로 접속자를 통제한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포트를 최소한으로 열어야지 항상 생각해왔는데 22번 포트도 닫을 수 있다해서 이렇게 구성했다.&lt;br /&gt;근데 실무에서는 어떻게 하는지 궁금하긴 하다. 다른 사람들과 회사들은 어떻게 접근하는지 궁금하다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CloudFront/WAF는 us-east-1만 &amp;mdash; provider alias&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 AWS의 제약인데, &lt;b&gt;CloudFront 인증서와 WAF Web ACL은 us-east-1에서만 만들 수 있다.&lt;/b&gt; 다른 리전에서 만들면 CloudFront가 못 가져다 쓴다고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 provider alias로 처리했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# environments/dev/provider.tf
provider &quot;aws&quot; {
  region  = var.aws_region
  profile = var.aws_profile
}

provider &quot;aws&quot; {
  alias   = &quot;us_east_1&quot;
  region  = &quot;us-east-1&quot;
  profile = var.aws_profile
}

# environments/dev/edge.tf
module &quot;acm&quot; {
  source = &quot;../../modules/acm&quot;
  providers = {
    aws = aws.us_east_1   # 이 모듈만 us-east-1로
  }
  ...
}

module &quot;waf&quot; {
  providers = {
    aws = aws.us_east_1
  }
  ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;provider 블록만 두 개 만들고, 특정 모듈에만 alias를 넘기면 된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하면서 느낀 것들&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;테라폼은 &quot;코드만 잘 짠다&quot;가 아니라 &quot;운영을 어떻게 할지&quot; 고민이 더 중요하다.&lt;/b&gt; 시크릿을 어디에 둘지, state를 어디에 둘지, 환경을 어떻게 나눌지 같은 것들. 코드 잘 짜는 건 두 번째인거 같다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;시크릿 관리는 진짜 신경 써야 한다.&lt;/b&gt; &quot;테라폼이 시크릿을 만들고 값을 넣는다&quot;는 함정에 빠지면 안 된다. 그리고 한번 git 같은 곳에 노출되면 골치 아파질게 선명하게 그려진다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;data 소스는 &quot;테라폼이 관리 안 함&quot; 선언이다.&lt;/b&gt; Route53 호스팅 존이나 Secrets Manager 값처럼 잘못 건드리면 큰일 나는 건 data로만 다룬다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;drift는 코드 잘 짜면 안 생긴다.&lt;/b&gt; SG inline/별도 rule 섞지 않기 같은 작은 규칙들.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테라폼은 처음 한 달은 정말 어려웠다. 강의 보면서 흉내내는 거랑 실제로 내 프로젝트 구조 잡는 건 완전히 다른 차원의 일이었다. 근데 한 번 구조를 잡아두니까, 나중에 prod 환경 추가할 때 폴더 복사 + tfvars 수정 + 시크릿 CLI로 한 번 만들면 끝났다. 또 비용때문에 매일 terraform apply/destroy를 반복했었는데 이거를 콘솔로 작업한다고 생각했을 때 &quot;아, 이래서 IaC를 하는구나&quot; 싶었던 순간이었다.&lt;/p&gt;</description>
      <category>Project: Shoong-Delivery</category>
      <author>2-30</author>
      <guid isPermaLink="true">https://2-3-0.tistory.com/12</guid>
      <comments>https://2-3-0.tistory.com/12#entry12comment</comments>
      <pubDate>Wed, 20 May 2026 18:00:35 +0900</pubDate>
    </item>
    <item>
      <title>[네트워크]EKS 트래픽 설계: ALB + Istio를 선택한 이유</title>
      <link>https://2-3-0.tistory.com/11</link>
      <description>&lt;h2&gt;들어가며&lt;/h2&gt;
&lt;p&gt;EKS 위에 마이크로서비스를 올릴 때 가장 먼저 고민된 부분이 있었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&amp;quot;외부 트래픽을 어떻게 받고, 서비스끼리는 어떻게 통신시킬 것인가?&amp;quot;&lt;br&gt;&lt;br/&gt;&lt;br&gt;단순히 &amp;quot;Ingress 하나 만들면 되는 거 아냐?&amp;quot; 라고 생각했다가, 선택지가 생각보다 많다는 걸 알게 됐다. 그리고 조합도 너무 많았다......&lt;br&gt;이 글은 각 게이트웨이를 비교하고, 최종적으로 &lt;strong&gt;ALB + Istio&lt;/strong&gt; 를 선택한 이유와, 그로 인해 만들어진 트래픽 흐름을 정리한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;1. 후보 기술 정리: 각 레이어에 뭐가 있는가&lt;/h2&gt;
&lt;p&gt;조합을 따져보기 전에 각 레이어에서 후보로 둘 만한 기술들의 특성을 먼저 정리한다. 트래픽이 인터넷에서 서비스 Pod까지 도달하려면 보통 세 개의 레이어를 거친다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;인터넷 → [① 로드밸런서] → [② 클러스터 내부 라우팅] → [③ 서비스 간 통신] → Pod&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;① 외부 진입: 로드밸런서 (ALB vs NLB)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;기술&lt;/th&gt;
&lt;th&gt;레이어&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;단점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;ALB&lt;/td&gt;
&lt;td&gt;L7&lt;/td&gt;
&lt;td&gt;WAF·ACM 네이티브 연동, 경로/호스트 기반 라우팅, HTTPS 종단 가능&lt;/td&gt;
&lt;td&gt;고정 IP 불가, 초저지연/대규모 동시 연결에 약함, TCP/UDP 처리 X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NLB&lt;/td&gt;
&lt;td&gt;L4&lt;/td&gt;
&lt;td&gt;초저지연, 고정 IP, 대규모 동시 연결, TCP/UDP 패스스루&lt;/td&gt;
&lt;td&gt;WAF 직접 연결 불가, HTTPS 종단·경로 라우팅을 LB 단에서 못 끝냄&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h3&gt;② 클러스터 내부 라우팅 (Ingress / Gateway)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;기술&lt;/th&gt;
&lt;th&gt;위치&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;단점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;Nginx Ingress&lt;/td&gt;
&lt;td&gt;K8s Ingress&lt;/td&gt;
&lt;td&gt;자료 풍부, 구성 단순, 학습 곡선 낮음&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2026-03 프로젝트 retire (EOL)&lt;/strong&gt; — 보안 패치 중단. 그 외에도 어노테이션 의존, East-West 미지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gateway API&lt;/td&gt;
&lt;td&gt;K8s 표준&lt;/td&gt;
&lt;td&gt;K8s 공식 차세대 스펙, 인프라/앱 역할 분리(Gateway/HTTPRoute)&lt;/td&gt;
&lt;td&gt;메시 기능 없음(East-West 맹점), 구현체 성숙도 편차, 리소스 종류 증가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Istio Gateway&lt;/td&gt;
&lt;td&gt;Istio 전용&lt;/td&gt;
&lt;td&gt;Istio 메시와 통합 운영, VirtualService로 정교한 L7 제어&lt;/td&gt;
&lt;td&gt;Istio 종속, 사이드카 운영 부담, 표준 스펙은 아님&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;참고 (2026-05 시점)&lt;/strong&gt;: &lt;code&gt;ingress-nginx&lt;/code&gt;는 메인테이너 부족으로 2026년 3월 retire 됐고, 후속으로 발표됐던 &lt;code&gt;InGate&lt;/code&gt;도 함께 retire 됐다. K8s 커뮤니티는 &lt;strong&gt;Gateway API&lt;/strong&gt; 로의 마이그레이션을 공식 권고한다. Ingress API 스펙 자체가 deprecated는 아니지만 feature-frozen 상태이므로, 신규 설계에서 Nginx Ingress는 사실상 후보에서 빠진다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;③ 서비스 간 통신: 서비스 메시&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;기술&lt;/th&gt;
&lt;th&gt;장점&lt;/th&gt;
&lt;th&gt;단점&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;메시 없음&lt;/td&gt;
&lt;td&gt;운영 부담 0, 리소스 비용 최소&lt;/td&gt;
&lt;td&gt;mTLS·서킷브레이커·재시도·분산 추적을 모두 앱이 직접 구현&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Istio&lt;/td&gt;
&lt;td&gt;자동 mTLS, 트래픽 정책, 관측성(Kiali·Jaeger), 풍부한 생태계&lt;/td&gt;
&lt;td&gt;사이드카 리소스·메모리 비용, 학습 곡선, 디버깅 난도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linkerd&lt;/td&gt;
&lt;td&gt;가벼운 사이드카, 단순성, 빠른 시작&lt;/td&gt;
&lt;td&gt;기능 폭이 Istio보다 좁음 (Wasm 확장·고급 라우팅 일부 부재)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;참고&lt;/strong&gt;: Gateway API / Istio Gateway는 &lt;strong&gt;외부에 노출되는 네트워크 엔드포인트가 아니다.&lt;/strong&gt; 인터넷에서 클러스터로 트래픽이 들어오려면 ①의 로드밸런서가 반드시 앞에 있어야 한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이제 이 후보들을 조합하면 의미 있는 시나리오가 어떻게 나오는지 본다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;2. 선택지 정리: 뭘 조합할 수 있는가&lt;/h2&gt;
&lt;h3&gt;조합 매트릭스&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;로드밸런서&lt;/th&gt;
&lt;th&gt;내부 라우팅&lt;/th&gt;
&lt;th&gt;서비스 메시&lt;/th&gt;
&lt;th&gt;한 줄 요약&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;ALB&lt;/td&gt;
&lt;td&gt;Nginx Ingress&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;가장 단순한 구조. 서비스 간 제어 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;ALB&lt;/td&gt;
&lt;td&gt;Gateway API&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;K8s 표준 + WAF 가능. 메시 없어서 East-West 맹점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;ALB&lt;/td&gt;
&lt;td&gt;Istio Gateway&lt;/td&gt;
&lt;td&gt;Istio&lt;/td&gt;
&lt;td&gt;L7이 ALB·Istio 양쪽에 중복. &lt;strong&gt;현실적 절충안&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;ALB&lt;/td&gt;
&lt;td&gt;Gateway API&lt;/td&gt;
&lt;td&gt;Istio&lt;/td&gt;
&lt;td&gt;Gateway API + Istio 동시 운영 → 오버엔지니어링&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;NLB&lt;/td&gt;
&lt;td&gt;Istio Gateway&lt;/td&gt;
&lt;td&gt;Istio&lt;/td&gt;
&lt;td&gt;Istio 정석 구조. WAF 직접 연결 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;NLB&lt;/td&gt;
&lt;td&gt;Gateway API&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;표준 기반, 단순 MSA. 메시 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2&gt;3. 내 서비스에 무엇이 필요한가 (요구사항 분석)&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;요구사항&lt;/th&gt;
&lt;th&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;HTTPS + 인증서 자동 갱신&lt;/td&gt;
&lt;td&gt;사용자 향 서비스 (ACM 활용)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAF 연동&lt;/td&gt;
&lt;td&gt;배달앱 = 결제 포함 → 앱 레벨 공격 방어 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;서비스 간 암호화 (mTLS)&lt;/td&gt;
&lt;td&gt;order → kitchen → delivery 내부 통신 보안&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;외부 노출 최소화&lt;/td&gt;
&lt;td&gt;kitchen / delivery는 외부에서 호출 불가해야 함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;트래픽 제어 (서킷브레이커 등)&lt;/td&gt;
&lt;td&gt;주문 폭주 시 downstream 장애 전파 차단&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;판단&lt;/strong&gt;: 외부 진입 레이어와 내부 서비스 메시 레이어 &lt;strong&gt;둘 다&lt;/strong&gt; 필요하다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;4. 1단계 의사결정: 로드밸런서는 ALB냐 NLB냐&lt;/h2&gt;
&lt;p&gt;외부에서 클러스터로 트래픽이 들어오려면 로드밸런서가 반드시 필요하다. 그럼 ALB와 NLB 중 무엇을 쓸 것인가?&lt;/p&gt;
&lt;h3&gt;NLB의 강점이 필요한 상황인가?&lt;/h3&gt;
&lt;p&gt;NLB는 L4라서 TCP/UDP 레벨 처리에 강하고, 초저지연이 필요한 대규모 연결 유지에 적합하다. 게임 서버나 실시간 통신처럼 동시 연결 수가 폭발적으로 많은 서비스라면 NLB가 더 적절하다고 생각된다.&lt;/p&gt;
&lt;p&gt;하지만 &lt;strong&gt;Shoong Delivery는 그 정도 규모가 아니다.&lt;/strong&gt; REST API 기반이고, 동시 연결 수도 NLB의 강점이 빛날 만한 수준이 아니다.&lt;/p&gt;
&lt;h3&gt;ALB가 제공하는 것 중 내가 진짜 필요한 것&lt;/h3&gt;
&lt;p&gt;배달앱은 주문, 결제, 배달 주소 같은 &lt;strong&gt;개인정보와 결제 정보&lt;/strong&gt;를 REST API로 다룬다.(아직 Shoong-Delivery에는 결제 로직이 없긴하지만 추후 개발 가능성 염두) 이런 데이터를 다루는 API는 SQL Injection, XSS, 비정상 요청으로 결제 로직 우회 같은 &lt;strong&gt;애플리케이션 레벨 공격&lt;/strong&gt;에 노출된다. 이 방어는 L4에서는 불가능하고 L7에서만 가능하다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;NLB&lt;/th&gt;
&lt;th&gt;ALB&lt;/th&gt;
&lt;th&gt;내 요구사항과 매칭&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;WAF 연동&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;결제 데이터 → 앱 레벨 공격 방어 필수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSL 종단 (ACM)&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;인증서 자동 갱신 운영 부담 ↓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;경로 기반 라우팅&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/orders&lt;/code&gt;, &lt;code&gt;/api/notification&lt;/code&gt; 분기 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;고정 IP / 초저지연&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;배달앱 규모에선 불필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;결론&lt;/strong&gt;: NLB가 필요할 만큼의 규모가 아니고, REST API 보안이 우선이므로 &lt;strong&gt;ALB 선택&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;5. 2단계 의사결정: Gateway는 뭘 쓸 것인가&lt;/h2&gt;
&lt;p&gt;ALB는 정해졌다. 그럼 클러스터 내부 라우팅은? 여러가지 기술들이 있었고, 그 중 Nginx Ingress, Gateway API, Istio Gateway 3개로 추렸다.&lt;/p&gt;
&lt;h3&gt;사고 흐름: 요구사항이 선택을 강제한다&lt;/h3&gt;
&lt;p&gt;먼저 서비스 구성을 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;서비스 4~6개 (order, kitchen, delivery, notification, batch …)
서비스 간 직접 통신 존재 (order → kitchen → delivery)
개인정보/결제 데이터 취급(예정)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;서비스 간 통신이 있다는 게 핵심이다. 이 구조에서 발생하는 문제는 외부 라우팅으로는 해결되지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;서비스 간 통신 구간에서 생기는 문제
  1. 결제 서비스 장애 → 주문 서비스로 전파(예정)
  2. 통신 구간 평문 → 암호화 없음
  3. 어느 구간에서 병목인지 추적 불가&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;Nginx Ingress 탈락&lt;/h3&gt;
&lt;p&gt;외부 라우팅은 가능하지만 거기까지다. 서비스 간 통신은 무방비로 남는다. 고급 트래픽 제어도 어노테이션에 의존해서 구현체 종속이 강하다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;덧붙여 &lt;code&gt;ingress-nginx&lt;/code&gt; 프로젝트는 2026년 3월에 retire 되어 더 이상 보안 패치가 나오지 않는다. 결과적으로 기능 부족 이전에 &lt;strong&gt;유지보수 자체가 끝난 선택지&lt;/strong&gt;가 됐다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;Gateway API 탈락&lt;/h3&gt;
&lt;p&gt;K8s 표준이라는 장점이 있고 외부 라우팅은 깔끔하게 해결한다. 하지만 &lt;strong&gt;서비스 메시 기능이 없다.&lt;/strong&gt; 서비스 간 mTLS, 서킷브레이커, 분산 추적은 범위 밖이다. Gateway API만 쓰면 East-West 구간이 그대로 맹점으로 남는다.&lt;/p&gt;
&lt;h3&gt;Istio 선택&lt;/h3&gt;
&lt;p&gt;Istio는 외부 진입 라우팅(Gateway + VirtualService)과 서비스 간 통신 제어(사이드카 자동 mTLS, 서킷브레이커, 분산 추적의 hop span·trace context 전파)를 &lt;strong&gt;동시에&lt;/strong&gt; 해결한다. 위에서 도출한 세 가지 문제를 전부 커버한다.&lt;br&gt;&lt;br/&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;분산 추적은 사이드카만으로 완결되지 않는다.&lt;/strong&gt; 앱 내부의 함수·DB·HTTP 호출 단위 span은 OTel SDK가 만들고, Istio 사이드카는 hop 단위 span과 trace context 전파를 담당하는 &lt;strong&gt;2-layer 구조&lt;/strong&gt;다. 본 프로젝트는 4개 API 서비스에 &lt;code&gt;@opentelemetry/sdk-node&lt;/code&gt;를 적용해 OTLP gRPC로 클러스터 내 OTel Collector에 송신한다. (각 서비스 &lt;code&gt;src/instrumentation.ts&lt;/code&gt; 참조(&lt;a href=&quot;https://github.com/shoong-delivery&quot;&gt;https://github.com/shoong-delivery&lt;/a&gt;))&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;hr&gt;
&lt;h2&gt;6. 3단계 의사결정: Gateway API + Istio 조합은?&lt;/h2&gt;
&lt;p&gt;여기서 한 번 더 고민이 생겼다. 요즘 권장되는 조합 중 하나가 &lt;strong&gt;Gateway API + Istio&lt;/strong&gt;라는 것을 봤다. K8s 표준 스펙으로 외부 라우팅을 표현하고, 내부는 Istio가 처리하는 방식이다. 그럼 이걸 안 쓴 이유는?&lt;/p&gt;
&lt;h3&gt;L7 중복 문제&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;ALB         → L7 (HTTP 라우팅, WAF, TLS 종단)
Gateway API → L7 (HTTP 라우팅)
Istio       → L7 (HTTP 라우팅)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;L7이 세 번 중복된다. ALB 앞단에서 이미 경로 기반 라우팅이 가능한데, 그 뒤에 Gateway API로 한 번 더 L7 라우팅을 정의하면 &lt;strong&gt;사실상 같은 일을 두 번&lt;/strong&gt; 한다.&lt;/p&gt;
&lt;h3&gt;Gateway API + Istio&lt;/h3&gt;
&lt;p&gt;Gateway API + Istio 조합은 &lt;strong&gt;역할 분리&lt;/strong&gt;다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;인프라팀 → Gateway 리소스 (진입점 관리)
앱팀     → HTTPRoute 리소스 (라우팅 규칙)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;팀이 여러 개일 때 인프라팀과 앱팀이 각자 관심사를 분리해서 관리할 수 있다. 그리고 K8s 표준 스펙이라 나중에 Istio 대신 다른 구현체로 교체해도 스펙이 그대로 유지된다.&lt;/p&gt;
&lt;h3&gt;Shoong-Delivery 환경에선 의미가 없다&lt;/h3&gt;
&lt;p&gt;혼자 개발하는 포트폴리오다보니 인프라팀/앱팀 역할 분리가 의미 없고, 오히려 &lt;strong&gt;관리할 리소스 종류만 늘어난다.&lt;/strong&gt; Gateway API + Istio는 단일 개발자 환경에서 진가를 발휘하지 못할 것 같다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;결론&lt;/strong&gt;: Istio 리소스(Gateway, VirtualService, DestinationRule)로 외부/내부 트래픽을 통일해서 관리하는 게 더 단순하다. → &lt;strong&gt;Istio 단독 선택&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;7. 그래도 서비스 메시는 과스펙 아닌가?&lt;/h2&gt;
&lt;p&gt;Shoong Delivery 규모에 너무 과스펙이 아닌가하는 의문이 들었다. 서비스 4~6개 규모, 아직 결제 로직도 없는 포트폴리오에 Istio를 얹는 게 합당한가?&lt;/p&gt;
&lt;h3&gt;반론 1: 메시 없이 같은 보장을 얻으려면 더 비싸다&lt;/h3&gt;
&lt;p&gt;규모가 작아도 결제·개인정보 데이터가 흐르면 East-West 평문 통신은 리스크다. 메시 없이 같은 보장을 얻으려면 다음을 직접 구현해야 한다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;앱마다 TLS 인증서 발급·회전·신뢰 체인 관리 코드&lt;/li&gt;
&lt;li&gt;서킷브레이커·재시도·타임아웃 로직을 SDK 단에서 구현 (서비스마다 언어/SDK 일관성 관리 필요)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;서비스 4~6개에서 위를 모두 구현하면 결국 &amp;quot;직접 만든 메시&amp;quot;가 된다. 검증된 컴포넌트(Istio)로 위임하는 편이 오히려 단순할 것이라 판단했다.&lt;/p&gt;
&lt;h3&gt;반론 2: MSA의 진짜 비용은 &amp;quot;디버깅&amp;quot;이다&lt;/h3&gt;
&lt;p&gt;분리된 서비스 중 어디서 문제가 났는지 추적하기 어려운 게 MSA의 가장 큰 운영 비용이다. Kiali·Jaeger의 관측성은 규모와 무관하게 즉시 가치를 낸다. 오히려 작은 규모일수록 한 번 세팅으로 끝나서 ROI가 좋다.&lt;/p&gt;
&lt;h3&gt;반론 3: 포트폴리오의 목적은 &amp;quot;현실 운영 스택의 시연&amp;quot;이다&lt;/h3&gt;
&lt;p&gt;이 프로젝트는 &amp;quot;최소 비용으로 동작하는 앱&amp;quot;이 목표가 아니라 &amp;quot;실제 운영 환경에서 마주칠 DevOps 스택을 다뤄봤다&amp;quot;는 걸 보여주는 게 목표다. 메시가 필요 없는 규모라는 이유로 빼면, 그 학습 경험 자체가 없는 포트폴리오가 된다.&lt;/p&gt;
&lt;h3&gt;트레이드오프는 인정한다&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;리소스 비용&lt;/strong&gt;: 사이드카가 Pod마다 수십 MB 메모리를 추가 소비한다. dev 환경에선 감내 가능. 진짜 비용 민감한 환경이라면 Istio ambient mode(사이드카리스)나 Linkerd 같은 경량 옵션이 더 나을지도 모른다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;학습 곡선&lt;/strong&gt;: 한 번만 지불하면 되는 비용이고, 본 프로젝트의 학습 목표 자체와 일치한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;디버깅 난도&lt;/strong&gt;: Envoy 레이어가 추가되니 &amp;quot;어디서 막혔는지&amp;quot; 찾는 깊이가 한 단계 늘어난다. Kiali로 어느정도 상쇄되지 않을까라고 생각했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;만약 진짜로 메시가 부담스럽다면&lt;/h3&gt;
&lt;p&gt;단순화 경로는 있다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Linkerd로 교체&lt;/strong&gt; — Istio보다 가볍고 단순. 단, 고급 트래픽 제어·생태계 폭은 좁다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;메시 없이 가는 길&lt;/strong&gt; — mTLS는 클러스터 신뢰 도메인 가정으로 포기. 앱 단 OTel SDK는 어차피 적용 중이라 유지되지만, &lt;strong&gt;사이드카 hop span과 Kiali 서비스 그래프 같은 메시 레이어 관측성은 잃는다.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Shoong-Delivery는 1,2 대신 &lt;strong&gt;Istio&lt;/strong&gt;를 택했다. 운영에서 가장 자주 보게 될 메시이기도 하고, 학습 및 시연 가치가 높다고 판단했기 때문이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;8. 그래서 탈락한 조합들 (정리)&lt;/h2&gt;
&lt;h3&gt;NLB + Istio (정석이지만 탈락)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;WAF를 직접 붙일 수 없음 → IP/포트 수준 방어만 가능 (SQL Injection, XSS 방어 안 됨)&lt;/li&gt;
&lt;li&gt;Istio Gateway에서 TLS termination + 인증서 관리까지 담당해야 함 → 운영 복잡도 증가&lt;/li&gt;
&lt;li&gt;동시 연결 수가 많지 않은 서비스 규모에서 L4 pass-through의 장점이 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;ALB + Gateway API (단순하지만 부족)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;서비스 간 mTLS, 서킷브레이커를 지원하지 않음&lt;/li&gt;
&lt;li&gt;Istio를 추가로 선택하게 되면 어차피 아래 선택지로 이동&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;ALB + Gateway API + Istio (탈락)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;L7 라우팅이 ALB · Gateway API · Istio 세 곳에서 중복&lt;/li&gt;
&lt;li&gt;역할 분리의 가치는 팀 구조가 있을 때 빛남, 단일 개발자 환경에선 관리 포인트만 늘어남&lt;/li&gt;
&lt;li&gt;오버엔지니어링&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2&gt;9. 최종 선택: ALB + Istio&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;인터넷 → CloudFront (WAF · edge ACM) → ALB (TLS 종단 · ACM) → istio-ingressgateway → Istio VirtualService → 서비스&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;ALB가 담당하는 것&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;HTTPS 종단 (ACM 인증서 자동 갱신, ELBSecurityPolicy-TLS13)&lt;/li&gt;
&lt;li&gt;TargetGroupBinding으로 Pod IP 직접 등록 → 노드 hop 1단계 제거&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;WAF는 ALB 직접 attach가 아닌 &lt;strong&gt;앞단 CloudFront에 부착&lt;/strong&gt;되어 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QUYUi/dJMcafUdx3g/HNH4Gece1UQBAQJbkxNqF0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QUYUi/dJMcafUdx3g/HNH4Gece1UQBAQJbkxNqF0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QUYUi/dJMcafUdx3g/HNH4Gece1UQBAQJbkxNqF0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQUYUi%2FdJMcafUdx3g%2FHNH4Gece1UQBAQJbkxNqF0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;앱 레벨 방어(SQLi·XSS·Bad Inputs 등)는 Edge 계층에서 1차로 차단되고, ALB는 TLS 종단과 클러스터로의 L7 라우팅에 집중한다.&lt;/p&gt;
&lt;h3&gt;Istio가 담당하는 것&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;South-North: Gateway + VirtualService로 경로 기반 라우팅&lt;/li&gt;
&lt;li&gt;East-West: 사이드카 자동 mTLS — 메시 내부 통신은 모두 암호화 (앱 코드 0줄)&lt;/li&gt;
&lt;li&gt;DestinationRule로 서킷브레이커(connectionPool + outlierDetection) 적용&lt;/li&gt;
&lt;li&gt;kitchen / delivery에 VirtualService 없음 → 외부 호출 경로 자체가 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;현재 PeerAuthentication은 mesh 기본값 &lt;strong&gt;PERMISSIVE&lt;/strong&gt; 상태로 운영 중.&lt;br&gt;메시 내부 사이드카끼리는 auto-mTLS로 암호화되지만 평문 진입은 아직 차단되지 않는다.&lt;br&gt;STRICT으로의 점진 전환은 추후에 다룰 예정이다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;10. 실제 트래픽 흐름&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;아래 흐름이 &lt;strong&gt;실제로 동작 중&lt;/strong&gt;임을 CLI와 Kiali 캡처로 함께 증명한다. 다이어그램 → 검증 캡처 순서로 본다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;South-North (외부 → 클러스터)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;사용자
  │ HTTPS
  ▼
ALB  ─── TargetGroupBinding ip (NodePort 없음, Pod IP 직접)
  │ HTTP (TLS 종단 완료)
  ▼
istio-ingressgateway  ─── envoy sidecar
  │
  ├─ VS /api/orders/*         →  order
  ├─ VS /api/notifications/*  →  notification
  └─ VS *.internal.dev        →  argocd · grafana · kiali
      (VS 없음: kitchen · delivery → 외부 호출 불가)&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;검증 ① — ALB → Pod IP 직결 (NodePort 우회)&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;TargetGroupBinding&lt;/code&gt;의 &lt;code&gt;targetType: ip&lt;/code&gt; 설정으로 ALB가 &lt;code&gt;istio-ingressgateway&lt;/code&gt; Pod에 직접 트래픽을 보낸다. 노드 hop 1단계가 제거된다. 세 가지 출력을 한 화면에 모으면 다음이 증명된다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Target Group 자체가 &lt;code&gt;ip&lt;/code&gt; 모드 (&lt;code&gt;TargetType: ip&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;ALB에 등록된 IP가 &lt;code&gt;10.0.12.30&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kubectl&lt;/code&gt;로 확인한 &lt;code&gt;istio-ingressgateway&lt;/code&gt; Pod IP도 &lt;code&gt;10.0.12.30&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;→ 같은 IP가 AWS와 K8s 양쪽에 존재 = ALB가 노드를 거치지 않고 Pod로 직결.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHVkxg/dJMcadIKOue/VIHhDDI4eFhFn2cWMO7Bk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHVkxg/dJMcadIKOue/VIHhDDI4eFhFn2cWMO7Bk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHVkxg/dJMcadIKOue/VIHhDDI4eFhFn2cWMO7Bk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHVkxg%2FdJMcadIKOue%2FVIHhDDI4eFhFn2cWMO7Bk0%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4&gt;검증 ② — VirtualService 라우팅 (kitchen/delivery 외부 노출 X)&lt;/h4&gt;
&lt;p&gt;shoong 네임스페이스의 VS 목록을 보면 &lt;code&gt;dev-shoong-order&lt;/code&gt;, &lt;code&gt;dev-shoong-notification&lt;/code&gt; 만 존재한다. kitchen·delivery에는 VS가 없으므로 게이트웨이가 라우팅할 경로 자체가 없고, 결과적으로 외부에서 호출 불가능하다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcQp31/dJMcaffDCKZ/aur2eseSKdRU1dk61UaKvk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcQp31/dJMcaffDCKZ/aur2eseSKdRU1dk61UaKvk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcQp31/dJMcaffDCKZ/aur2eseSKdRU1dk61UaKvk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcQp31%2FdJMcaffDCKZ%2Faur2eseSKdRU1dk61UaKvk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;East-West (서비스 간 자동 mTLS)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;batch (CronJob)
  │ 자동완료 (PATCH status)
  ▼
order  ──[  mTLS]──▶  kitchen  ──[  mTLS]──▶  delivery
                          │                          │
                     PATCH status               PATCH status
                          └──────[  mTLS]──▶  notification&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;포인트&lt;/strong&gt;: 앱 코드는 평문 HTTP로 호출한다. &lt;code&gt;sidecar ↔ sidecar&lt;/code&gt; 구간에서 Istio가 자동으로 mTLS를 처리하며, istiod가 인증서 발급·회전까지 담당한다.&lt;/p&gt;
&lt;h4&gt;검증 ③ — 전체 메시 트래픽 흐름 + mTLS 자물쇠&lt;/h4&gt;
&lt;p&gt;Kiali Graph(shoong 네임스페이스, Last 30m)로 실제 트래픽이 흐르는 모습. 빨간 표시 의미:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;좌측 빨간 박스&lt;/strong&gt;: 외부 진입점 &lt;code&gt;istio-ingressgateway&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;빨간 굵은 화살표&lt;/strong&gt;: &lt;code&gt;order → kitchen → delivery → notification&lt;/code&gt; 비즈니스 체인 (East-West)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;빨간 동그라미&lt;/strong&gt;: 메시 내부 통신의 mTLS   자물쇠 (다수)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;상단 빨간 박스 + &amp;quot;DB(RDS) - 메시 밖&amp;quot;&lt;/strong&gt;: PassthroughCluster — RDS로 빠지는 egress&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;→ 메시 내부 통신은 모두 mTLS로 암호화되고 있고 (PERMISSIVE auto-mTLS), DB는 메시 밖이라 PassthroughCluster로 잡힌다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfX8iQ/dJMcaglfBuK/ZXoyIPPTmaQL55kcbdmY50/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfX8iQ/dJMcaglfBuK/ZXoyIPPTmaQL55kcbdmY50/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfX8iQ/dJMcaglfBuK/ZXoyIPPTmaQL55kcbdmY50/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfX8iQ%2FdJMcaglfBuK%2FZXoyIPPTmaQL55kcbdmY50%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4&gt;검증 ④ — 워크로드 디테일 (&lt;code&gt;dev-shoong-order&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;특정 워크로드를 들여다보면 사이드카 주입과 라이브 메트릭이 한 화면에 보인다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;좌측 Pods 섹션&lt;/strong&gt;: 2개 Pod (replica=2 적용, 둘 다 healthy)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;우측 미니 그래프&lt;/strong&gt;: 라이브 트래픽 (rps + 평균 latency +  )&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;그래프의 자물쇠&lt;/strong&gt;: order ↔ batch ↔ kitchen 사이의 mTLS&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dP30Qa/dJMb990MLsc/9qPhdEKpAPx64v2NbxisaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dP30Qa/dJMb990MLsc/9qPhdEKpAPx64v2NbxisaK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dP30Qa/dJMb990MLsc/9qPhdEKpAPx64v2NbxisaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdP30Qa%2FdJMb990MLsc%2F9qPhdEKpAPx64v2NbxisaK%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Kiali의 Pod 정보 팝업에서 &lt;code&gt;Istio Container: Not found&lt;/code&gt;로 보이는 건 아직 원인을 찾지 못했다.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;트래픽 제어 — 서킷브레이커 (DestinationRule)&lt;/h3&gt;
&lt;p&gt;mTLS와 함께 East-West 구간의 또 다른 핵심 보호 장치. shoong 네임스페이스의 4개 서비스에 DestinationRule이 적용되어 있다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;connectionPool&lt;/code&gt;: 동시 연결·대기 큐 cap → 폭주 시 즉시 fail fast (장애 전파 차단)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;outlierDetection&lt;/code&gt;: 5xx 연속 5회 시 30초 격리, 최대 50% (replica=2 환경에서 한 쪽 Pod 격리 가능)&lt;/li&gt;
&lt;li&gt;앱 코드 변경 0줄 — DestinationRule manifest만으로 활성화&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EiI2t/dJMcacpEEI7/z4zCws51zaKVomNtMTAsUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EiI2t/dJMcacpEEI7/z4zCws51zaKVomNtMTAsUk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EiI2t/dJMcacpEEI7/z4zCws51zaKVomNtMTAsUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEiI2t%2FdJMcacpEEI7%2Fz4zCws51zaKVomNtMTAsUk%2Fimg.png&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;현재 운영 상태 — PERMISSIVE (점진 적용 중)&lt;/h3&gt;
&lt;p&gt;PeerAuthentication 미정의로 mesh 기본값 PERMISSIVE 운영 중. 메시 내부는 auto-mTLS로 암호화되지만 평문 진입도 허용된다. 운영 환경 STRICT 적용은 Istio 공식 마이그레이션 경로(PERMISSIVE → 호출자 식별 → namespace-level STRICT → AuthorizationPolicy)를 따라 단계적으로 전환 예정.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;ALB + Istio 조합은 &amp;quot;현실적인 타협안&amp;quot;이다.&lt;br&gt;Istio 정석대로라면 NLB + Istio가 더 깔끔하지만, Edge 계층의 WAF(CloudFront)와 ALB의 ACM TLS 종단 등 AWS 매니지드 운영 편의성을 포기할 수 없었다&lt;br&gt;L7 라우팅 중복이라는 단점이 있지만, ALB는 외부 진입 보안에만 집중하고 Istio는 내부 서비스 메시에만 집중하도록 역할을 분리하니 구조가 명확해졌다.&lt;/p&gt;
&lt;p&gt;선택 과정을 돌아보면 결국 두 가지가 핵심이었다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;로드밸런서 선택&lt;/strong&gt;은 &amp;quot;내 서비스가 NLB의 강점이 필요할 만한 규모냐&amp;quot;의 답으로 갈렸다 → 아니오 → ALB&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gateway 선택&lt;/strong&gt;은 &amp;quot;서비스 간 통신 제어가 필요한가&amp;quot;의 답으로 갈렸다 → 예 → Istio&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;요구사항이 기술 선택을 강제하도록 흐름을 따라가니, 각 선택지에 명확한 근거가 붙었다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;참고 자료&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://istio.io/latest/docs/concepts/traffic-management/&quot;&gt;Istio Traffic Management 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/guide/targetgroupbinding/targetgroupbinding/&quot;&gt;AWS Load Balancer Controller TargetGroupBinding&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://istio.io/latest/docs/reference/config/security/peer_authentication/&quot;&gt;Istio Security - PeerAuthentication&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Project: Shoong-Delivery</category>
      <author>2-30</author>
      <guid isPermaLink="true">https://2-3-0.tistory.com/11</guid>
      <comments>https://2-3-0.tistory.com/11#entry11comment</comments>
      <pubDate>Tue, 19 May 2026 20:19:54 +0900</pubDate>
    </item>
    <item>
      <title>[아키텍처] K8S 클러스터 아키텍처 구축기</title>
      <link>https://2-3-0.tistory.com/10</link>
      <description>&lt;h1&gt;Kubernetes Architecture Design Log&lt;/h1&gt;
&lt;h2&gt;v0.1 - 초기 구조&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mIlny/dJMcafNsPZ4/r6lltkjkZ5JImtokoSAlL0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mIlny/dJMcafNsPZ4/r6lltkjkZ5JImtokoSAlL0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mIlny/dJMcafNsPZ4/r6lltkjkZ5JImtokoSAlL0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmIlny%2FdJMcafNsPZ4%2Fr6lltkjkZ5JImtokoSAlL0%2Fimg.jpg&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;구성 요소&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;EKS Control Plane (ETCD, Cloud Controller Manager, Controller Manager, K8s API Server, Scheduler)&lt;/li&gt;
&lt;li&gt;워커 노드 3개 (az a, az b, az c)&lt;/li&gt;
&lt;li&gt;Ingress ALB&lt;/li&gt;
&lt;li&gt;Namespace: argocd, todo-app&lt;/li&gt;
&lt;li&gt;todo-app: frontend, auth, projects, tasks, notification&lt;/li&gt;
&lt;li&gt;Istio 사이드카 인젝션 적용 (todo-app 네임스페이스)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;문제점&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;ArgoCD가 감지할 GitOps Repository 연결 안됨&lt;/li&gt;
&lt;li&gt;ArgoCD -&amp;gt; todo-app 배포 흐름 표현 안됨&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;v0.2 - GitOps 연결&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bauBsG/dJMcafNsP0b/MLCXUAHkqD3mk3vKpH2ykk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bauBsG/dJMcafNsP0b/MLCXUAHkqD3mk3vKpH2ykk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bauBsG/dJMcafNsP0b/MLCXUAHkqD3mk3vKpH2ykk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbauBsG%2FdJMcafNsP0b%2FMLCXUAHkqD3mk3vKpH2ykk%2Fimg.jpg&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;구성 요소&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;GitOps Repository (GitHub) 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;문제점&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;백엔드 서비스(projects, tasks 등)의 Amazon RDS 연결 표현 안됨&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;v0.3 - RDS 연결 추가&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cPZL3m/dJMcahLe76D/ekIKCLKrHCYCS5RGSbKdG1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cPZL3m/dJMcahLe76D/ekIKCLKrHCYCS5RGSbKdG1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cPZL3m/dJMcahLe76D/ekIKCLKrHCYCS5RGSbKdG1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcPZL3m%2FdJMcahLe76D%2FekIKCLKrHCYCS5RGSbKdG1%2Fimg.jpg&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;구성 요소&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Amazon RDS 추가 (클러스터 외부)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;문제점&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;HPA 적용 여부가 구성도에 명시되지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;v0.4 - HPA 구성&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BAKW1/dJMcahLe76J/1RY1Zw0urOkCETIZuDgjF0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BAKW1/dJMcahLe76J/1RY1Zw0urOkCETIZuDgjF0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BAKW1/dJMcahLe76J/1RY1Zw0urOkCETIZuDgjF0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBAKW1%2FdJMcahLe76J%2F1RY1Zw0urOkCETIZuDgjF0%2Fimg.jpg&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;구성 요소&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;HPA 아이콘 추가 (오토스케일링 적용 명시)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;문제점&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;컨테이너 이미지 저장소(Amazon ECR) 및 이미지 풀 흐름이 표현되지 않음&lt;/li&gt;
&lt;li&gt;Secret/Config 관리(AWS Secrets Manager, Parameter Store, External Secrets) 구성 누락&lt;/li&gt;
&lt;li&gt;영구 볼륨(EBS) 및 EBS CSI Driver 구성 표현 안됨&lt;/li&gt;
&lt;li&gt;IRSA(IAM Roles for Service Accounts) 권한 위임 구조 미표시&lt;/li&gt;
&lt;li&gt;ArgoCD, Istio, 모니터링, kube-system 등 네임스페이스 내부 컴포넌트가 추상화되어 있음&lt;/li&gt;
&lt;li&gt;ALB ↔ Service 연결 방식(TargetGroupBinding) 명시 안됨&lt;/li&gt;
&lt;li&gt;RDS 연결 시 통신 프로토콜/포트(TLS 5432) 표현 누락&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;v0.5-1 - 클러스터 내부 컴포넌트 상세화&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; width=&quot;100%&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhtd16/dJMcahLe76V/yRyAmJ3wOKjIjpGSioTxR0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhtd16/dJMcahLe76V/yRyAmJ3wOKjIjpGSioTxR0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhtd16/dJMcahLe76V/yRyAmJ3wOKjIjpGSioTxR0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbhtd16%2FdJMcahLe76V%2FyRyAmJ3wOKjIjpGSioTxR0%2Fimg.jpg&quot; width=&quot;100%&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3&gt;구성 요소&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Amazon ECR&lt;/strong&gt;: 컨테이너 이미지 저장소, Worker Node로 이미지 풀&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon RDS&lt;/strong&gt;: TLS 5432 포트로 백엔드 서비스와 연결&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AWS Secrets Manager / Systems Manager Parameter Store&lt;/strong&gt;: 시크릿/설정 외부 저장소&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon EBS&lt;/strong&gt;: 영구 볼륨 스토리지 (EBS CSI Driver 연동)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IRSA&lt;/strong&gt;: ALB Controller, EBS CSI Controller, External Secrets 등에 IAM 권한 위임&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;shoong 네임스페이스&lt;/strong&gt;: shoong-order, shoong-kitchen, shoong-delivery, shoong-notification, shoong-batch&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;argocd 네임스페이스&lt;/strong&gt;: argocd-server, argocd-dex-server, argocd-repo-server, argocd-notifications-controller, argocd-application-controller&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;monitoring 네임스페이스&lt;/strong&gt;: prometheus, grafana, node-exporter, alertmanager, kube-state-metrics, promtail, loki, otel-collector, tempo, prometheus-operator&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;istio-system 네임스페이스&lt;/strong&gt;: istiod, istio-ingressgateway, kiali-server&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;kube-system 네임스페이스&lt;/strong&gt;: aws-load-balancer-controller, aws-node, coredns, kube-proxy, ebs-csi-node, ebs-csi-controller&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;external-secrets 네임스페이스&lt;/strong&gt;: external-secrets-controller, external-secrets-webhook, cert-controller&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TargetGroupBinding&lt;/strong&gt;: ALB ↔ Kubernetes Service 직접 바인딩&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;shoong-gitops Repository&lt;/strong&gt;: ArgoCD가 pull 방식으로 매니페스트 감지&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;문제점&lt;/h3&gt;
&lt;p&gt;(투 비 컨티뉴...ㅠ)&lt;/p&gt;</description>
      <category>Project: Shoong-Delivery</category>
      <author>2-30</author>
      <guid isPermaLink="true">https://2-3-0.tistory.com/10</guid>
      <comments>https://2-3-0.tistory.com/10#entry10comment</comments>
      <pubDate>Mon, 18 May 2026 21:52:26 +0900</pubDate>
    </item>
  </channel>
</rss>