<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>_gyullbb.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Sat, 29 Aug 2026 17:19:10 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>_gyullbb.log</title>
            <url>https://velog.velcdn.com/images/_gyullbb/profile/90fded8b-7b64-4538-961c-628db8a7caaa/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. _gyullbb.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/_gyullbb" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[6-1편. Speculative Decoding]]></title>
            <link>https://velog.io/@_gyullbb/Speculative-Decoding</link>
            <guid>https://velog.io/@_gyullbb/Speculative-Decoding</guid>
            <pubDate>Sat, 29 Aug 2026 17:19:10 GMT</pubDate>
            <description><![CDATA[<p>이전 편에서 확인한 기법들은 한 번의 forward pass를 얼마나 효율적으로 돌릴 것인가에 집중된 기법이었다.
그런데 LLM 생성은 근본적으로 <strong>토큰을 한 개씩 순차적으로 만들어야 하는(autoregressive) 구조</strong>라서, 한 번의 pass를 아무리 최적화해도 &quot;다음 토큰을 만들려면 방금 만든 토큰을 알아야 한다&quot;는 순서 제약 자체는 사라지지 않는다.
게다가 대규모 모델의 추론은 산술 연산량보다 <strong>GPU 메모리 대역폭</strong>에 더 크게 좌우된다. 토큰 하나를 만들 때마다 거대한 가중치 전체를 메모리에서 새로 읽어와야 하는데, 이 시간이 병목이 되어 정작 GPU의 연산 능력은 상당 부분 놀고 있는 상태가 된다.
즉 지연시간을 한 단계 더 줄이려면 &quot;한 토큰씩 순차적으로 만드는 구조 자체&quot;를 깨야 하는데, 이때 필요한 것이 <strong>추측 디코딩(Speculative Decoding)</strong>이다.</p>
<h1 id="1-speculative-decoding">1) Speculative Decoding</h1>
<ul>
<li>작고 빠른 <strong>드래프트 모델</strong>이 먼저 여러 개의 다음 토큰 후보를 순차적으로 미리 만들어 둔다.</li>
<li>크고 정확한 <strong>타깃 모델</strong>이 이 후보들을 한 번의 forward pass로 병렬 검증한다.</li>
<li>드래프트가 맞으면 그대로 채택하고, 틀리면 확률적으로 걸러낸 뒤 보정된 분포에서 다시 뽑는다. 이 보정 덕분에 최종 출력 분포는 타깃 모델 혼자 생성했을 때와 <strong>수학적으로 완전히 동일</strong>하게 보장된다. 즉, 속도를 얻기 위해 품질을 희생하지 않는다.</li>
</ul>
<p>이렇게만 보면 대략적으로 어떤 기법인지에 대해 파악은 되나, 정확한 원리를 파악하기엔 부족하다. &quot;작은 모델이 미리 만들고 큰 모델이 검증한다&quot;는 구조가 왜 하필 필요한지, 그리고 왜 이게 실제로 빨라지는지를 논문을 통해 구체적으로 확인해보자.</p>
<h2 id="1-1-논문-표기-1-mp와-mq">1-1) 논문 표기 (1): Mp와 Mq</h2>
<p>논문에서 쓰는 표기를 미리 익혀두면 이후 내용을 따라가기 편하다.</p>
<ul>
<li>크고 느린 <strong>타겟 모델 Mp</strong>: 최종적으로 원하는 출력 분포 p(x)를 보장하는 모델</li>
<li>작고 빠른 <strong>근사(드래프트) 모델 Mq</strong>: Mp를 흉내 내며 분포 q(x)를 만드는 모델</li>
</ul>
<p>Mq가 먼저 γ개의 토큰을 순차적으로 &quot;추측&quot;해서 만들어 놓으면, 이 추측된 시퀀스는 이제 Mp 입장에서 &quot;이미 정해진 시퀀스&quot;가 된다. 
그러면 학습 때처럼 이 시퀀스 전체를 Mp에 한 번에 넣어서, 단 한 번의 forward pass로 γ+1개 자리의 확률분포를 동시에 뽑아낼 수 있다. 
Mp의 무거운 가중치를 딱 한 번만 읽으면서 여러 토큰 분량의 검증을 끝내는 것이다.</p>
<p>여기서 &quot;Mq를 γ번, Mp를 1번 돌리니 결국 호출 횟수는 비슷한 거 아닌가&quot;라는 의문이 들 수 있는데, 핵심은 <strong>읽어오는 양(모델 크기)이 다르다</strong>는 데 있다. </p>
<ul>
<li>Mq는 Mp보다 훨씬 작은 모델. T_q ≪ T_p</li>
<li>Mp만으로 추론을 한다면: (γ+1)×T_p</li>
<li>추론적 디코딩을 한다면: γ×T_q + T_p</li>
</ul>
<p>원래 <strong>Mp를 γ번</strong> 더 읽어야 했던 비용을 <strong>Mq를 γ번 읽는 훨씬 싼 비용</strong>으로 바꿔치기하게 된다.</p>
<p>Mq의 추측이 항상 맞을 리는 없다. 그래서 Mp가 검증하면서 확률적으로 수락·거부하고, 거부된 자리부터는 보정된 분포에서 다시 샘플링한다. 이 보정 과정 덕분에 최종 출력의 분포가 Mp 혼자 돌렸을 때와 수학적으로 완전히 동일하다는 게 이 방법의 핵심이다. — 이 증명은 논문을 따라가며 다음 절에서 자세히 확인한다.</p>
<h2 id="1-2-논문-표기-2-확률-분포">1-2) 논문 표기 (2): 확률 분포</h2>
<p>논문에서 계속 등장하는 p(x), q(x)는 둘 다 <strong>&quot;지금까지 나온 문맥이 주어졌을 때, 다음 토큰이 무엇일지&quot;에 대한 확률분포</strong>다. 
모델은 어휘 사전에 있는 모든 후보 토큰에 대해 확률을 매기는데, 이걸 시각화하면 x축에 어휘 사전의 토큰들을 쭉 나열하고 y축에 각 토큰이 다음 단어로 뽑힐 확률을 그린 막대그래프가 된다. 
타겟 모델 Mp가 그린 막대그래프와 근사 모델 Mq가 그린 막대그래프는 같은 문맥을 보고 그렸지만 모델 성능 차이 때문에 막대 높이가 서로 다르다.</p>
<p>이 논문에서 &quot;q(x)가 p(x)보다 크다/작다&quot;라고 할 때는 이 그래프 전체를 비교하는 게 아니라, <strong>특정 토큰 하나</strong>에 대해 두 모델이 매긴 확률값을 비교하는 것이다. 
즉 Mq가 방금 draft한 토큰 x 하나에 대해, 근사 모델은 q(x)만큼, 타겟 모델은 같은 자리에서 p(x)만큼의 확률을 줬다는 걸 견주는 것이다. </p>
<ul>
<li>q(x) &gt; p(x)라는 건 &quot;근사 모델이 이 토큰을 타겟 모델보다 더 자신 있게(과대평가해서) 뽑았다&quot;는 뜻이고</li>
<li>q(x) ≤ p(x)는 &quot;근사 모델의 자신감이 타겟 모델을 넘어서지 않았다&quot;는 뜻이다.</li>
</ul>
<hr>
<h1 id="2-논문-따라가기">2) 논문 따라가기</h1>
<h2 id="알고리즘-4단계">알고리즘 4단계</h2>
<ol>
<li>근사 모델 Mq로 γ개의 토큰을 순차적으로(autoregressive) 생성한다.</li>
<li>타겟 모델 Mp로 이 γ개 추측과 각각의 확률 p(x)를 병렬로(한 번의 forward pass로) 평가한다.</li>
<li>q(x) ≤ p(x)면 그 추측을 그대로 수락한다. q(x) &gt; p(x)면 1 - p(x)/q(x)의 확률로 거부한다.</li>
<li>거부된 첫 토큰 자리에서는 보정된 분포 p&#39;(x) = norm(max(0, p(x) - q(x)))에서 새 토큰을 뽑는다. 만약 γ개 전부 수락됐다면, Mp가 이미 계산해 둔 그다음 자리의 분포에서 보너스 토큰을 하나 더 뽑는다.</li>
</ol>
<h2 id="2-1-추측-수락거부-규칙">2-1) 추측 수락/거부 규칙</h2>
<p>추측 수락/거부 규칙을 하나의 수식으로 표현하면 아래와 같다.</p>
<blockquote>
<p>$\min\left(1, \dfrac{p(x)}{q(x)}\right)$</p>
</blockquote>
<p>p(x)는 큰 모델이 이 토큰에 준 확률이고, q(x)는 작은 모델이 이 토큰에 준 확률이다. </p>
<h3 id="1-qx-≤-px">1) q(x) ≤ p(x)</h3>
<p>작은 모델이 추론한 확률보다 큰 모델이 추론한 확률이 더 크다면, 작은 모델은 적절하게 추론한 셈이다. 그렇기 때문에 이 경우에는 무조건 수락 즉, 확률이 1이 된다.</p>
<h3 id="2-qx--px">2) q(x) &gt; p(x)</h3>
<p>반대로 작은 모델이 추론한 확률이 큰 모델이 추론한 확률보다 더 크다면, 작은 모델은 과도하게 추론을 해버린 것이다. 
이 토큰을 그대로 받아버린다면 실제 보다 이 토큰이 더 자주 나오는 현상이 발생한다.
적절하게 이 토큰을 받아 들이기 위해서 정한 것이 바로 p(x)/q(x) 비율이다.</p>
<p>이 두 가지 경우를 하나의 식으로 압축한 게 <strong>$\min\left(1, \dfrac{p(x)}{q(x)}\right)$이다.</strong> </p>
<ul>
<li>q(x) ≤ p(x)의 경우: $\dfrac{p(x)}{q(x)} \ge 1$이므로 $\min\left(1, \dfrac{p(x)}{q(x)}\right) = 1$</li>
<li>q(x) &gt; p(x)의 경우: $\dfrac{p(x)}{q(x)} &lt; 1$이므로 $\min\left(1, \dfrac{p(x)}{q(x)}\right) = \dfrac{p(x)}{q(x)}$</li>
</ul>
<h3 id="왜-하필-pxqx인가-rejection-sampling">왜 하필 p(x)/q(x)인가: Rejection Sampling</h3>
<p>거부 확률이 1 - p(x)/q(x)가 되는 건 통계학의 rejection sampling 기법을 가져온 것이다.
우리가 원하는 진짜 분포는 p인데 실제 샘플은 q에서 뽑는다면, p(x)/q(x) 비율만큼만 그 샘플을 인정해준다. q가 어떤 토큰을 과대평가한 만큼, 그 초과분에 비례해서 걸러내는 것이다. 
인정된 샘플들의 최종 분포를 정확히 p로 맞출 수 있다는 게 수학적으로 증명되어 있다. </p>
<h2 id="2-2-보정-분포-px">2-2) 보정 분포 p&#39;(x)</h2>
<p><strong>p&#39;(x) = norm(max(0, p(x) - q(x)))</strong>에서 프라임(&#39;)은 미분(도함수)을 뜻하는 게 아니라, 그냥 &quot;보정된 버전의 p&quot;라는 표기이다. 
실제 연산은 뺄셈(p(x)-q(x))과 음수 제거(max(0, ·)), 정규화(norm)다.</p>
<p>거부가 일어났다는 건 이번 자리에서 q가 어떤 토큰들엔 확률을 과하게 줬다는 뜻이다. 
확률의 총합은 1이므로, q가 특정 토큰에 확률을 과하게 몰아줬다면 다른 토큰들에는 p 기준으로 부족하게 줬을 것이다. 
p(x)-q(x)가 양수인 토큰들이 바로 이렇게 &quot;덜 챙겨받은&quot; 토큰들이고, 이 부족분만 모아서 정규화한 게 p&#39;이다. 
q(x)-p(x)가 아니라 p(x)-q(x)를 쓰는 이유는, 우리가 최종적으로 맞추고 싶은 목표가 p이기 때문이다.</p>
<p>norm(정규화)은 &quot;각 항목을 전체 합으로 나눠서 총합이 1이 되게 만드는 것&quot;이다. </p>
<p>이후 이 보정 분포 p&#39;(x)에서 샘플링을 하여 토큰을 하나 뽑게된다.</p>
<h3 id="샘플링이란">샘플링이란</h3>
<p>샘플링은 확률에 &quot;비례해서&quot; 무작위로 뽑는 것이다 — 확률 0.6, 0.3, 0.1을 가진 세 토큰이 있다면 각각 60%, 30%, 10%의 기회로 뽑히게 된다.
앞서 계산한 p(x), q(x)를 활용한 보정 분포를 통해 적절한 확률로 난수를 뽑는 것이다.</p>
<h2 id="2-3-추론적-디코딩으로-뽑은-토큰-분포--mp를-통한-분포">2-3) 추론적 디코딩으로 뽑은 토큰 분포 = Mp를 통한 분포</h2>
<p>이 논문이 증명하려는 핵심 주장은 추론적 디코딩으로 뽑은 토큰의 분포가 Mp 혼자 정직하게 샘플링했을 때의 분포 p(x)와 정확히 같다는 것이다.</p>
<p>이를 수학적 공식으로 따라 가면 아래와 같다.</p>
<h3 id="1-mq로-뽑은-샘플을-유지할-확률-β">1) Mq로 뽑은 샘플을 유지할 확률 β</h3>
<p>$$
\beta = \mathbb{E}_{x \sim q(x)}\left[\min\left(1, \frac{p(x)}{q(x)}\right)\right] = \sum_x \min(p(x), q(x))
$$</p>
<p>앞서 추측 수락/거부 규칙을 하나의 수식으로 표현하면 $\min\left(1, \dfrac{p(x)}{q(x)}\right)$ 라는 것을 알아보았다.</p>
<p>여기서 기댓값이라는 정의가 나온다.</p>
<blockquote>
<h4 id="기댓값expectation이란">기댓값(Expectation)이란</h4>
<p>기댓값 E[f(x)] =x를 어떤 확률분포에서 뽑았을 때 f(x)의 평균
$$
E[f(x)] = \sum_x P(x) \cdot f(x)
$$
x가 나올 수 있는 모든 경우를 하나씩 살펴보면서, 그 경우가 일어날 확률 P(x)만큼 가중치를 줘서 f(x) 값을 더한다.</p>
</blockquote>
<p>이 정의를 위 함수에 그대로 적용하면, β는 <strong>x를 q(x)라는 분포에서 뽑았을 때 min(1, p(x)/q(x))의 평균</strong>이므로 아래와 같이 표현된다.</p>
<p>$$
\beta = \mathbb{E}_{x \sim q(x)}\left[\min\left(1, \frac{p(x)}{q(x)}\right)\right] = \sum_x q(x)\cdot \min\left(1,\frac{p(x)}{q(x)}\right)
$$</p>
<p>이 곱셈을 풀어보자.</p>
<ul>
<li>q(x) &gt; p(x)인 경우 min(1, p(x)/q(x)) = p(x)/q(x)이므로,</li>
</ul>
<p>$$
q(x) \times \frac{p(x)}{q(x)} = p(x)
$$</p>
<ul>
<li>q(x) ≤ p(x)인 경우 min(1, p(x)/q(x)) = 1이므로,</li>
</ul>
<p>$$
q(x) \times 1 = q(x)
$$</p>
<p>어느 경우든 결과는 p(x)와 q(x) 중 더 작은 값이다. 따라서 위에서 봤던 식이 성립하게 된다.</p>
<p>$$
\beta = \sum_x q(x)\cdot \min\left(1,\frac{p(x)}{q(x)}\right) = \sum_x \min(p(x), q(x))
$$</p>
<h3 id="2-보정된-분포-px">2) 보정된 분포 p&#39;(x)</h3>
<p>$$
\begin{aligned}
p&#39;(x) &amp;= \text{norm}\big(\max(0, p(x)-q(x))\big) \
&amp;= \text{norm}\big(p(x)-\min(q(x),p(x))\big) \
&amp;= \frac{p(x)-\min(q(x),p(x))}{\sum_{x&#39;} \big(p(x&#39;)-\min(q(x&#39;),p(x&#39;))\big)} \
&amp;= \frac{p(x)-\min(q(x),p(x))}{1-\beta}
\end{aligned}
$$</p>
<p>위에서 보정 분포 p&#39;(x)의 개념에 대해서 알아보았다. 이를 수식으로 표현하면 $\text{norm}\big(max(0, p(x)-q(x))\big)$이다.</p>
<p>$\max(0, p(x)-q(x))$는 사실 $p(x)-\min(q(x),p(x))$와 같은 값이다. </p>
<ul>
<li><p>q(x) &lt;= p(x)인 경우
$$\max(0, p(x)-q(x)) = p(x)-q(x) = p(x)-\min(q(x),p(x))$$</p>
</li>
<li><p>q(x) &gt; p(x) 인 경우
$$\max(0, p(x)-q(x)) = 0 = p(x)-p(x) = p(x)-\min(q(x),p(x))$$</p>
</li>
</ul>
<p>정규화 분포의 분모를 살펴보면 앞서 정의한 $\beta$ 로 인해</p>
<p>$$
\sum_{x&#39;} p(x&#39;) - \sum_{x&#39;} \min(q(x&#39;),p(x&#39;)) = 1-\beta
$$</p>
<p>가 된다. ($\sum_{x&#39;} p(x&#39;)=1$이고 $\sum_{x&#39;}\min(q(x&#39;),p(x&#39;))=\beta$이므로)</p>
<h3 id="3-pxx--px">3) $P(x=x&#39;) = p(x&#39;)$</h3>
<p>이제 마지막으로 추론적 디코딩으로 뽑은 토큰의 분포가 Mp 혼자 정직하게 샘플링했을 때의 분포 p(x)와 정확히 같은지 확인해보자.</p>
<p>추론적 디코딩으로 토큰이 뽑히는 것 즉, $P(x=x&#39;)$는 두 경로의 합이다.</p>
<p>$$
P(x=x&#39;) = P(\text{수락되고 } x=x&#39;) + P(\text{거부되고 } x=x&#39;)
$$</p>
<p><strong>첫째 항</strong>: &quot;$x&#39;$가 draft되고(확률 $q(x&#39;)$) 그게 수락되는(확률 $\min(1,p(x&#39;)/q(x&#39;))$) 확률&quot;</p>
<p>$$
q(x&#39;)\cdot \min\left(1,\frac{p(x&#39;)}{q(x&#39;)}\right) = \min(q(x&#39;),p(x&#39;))
$$</p>
<p><strong>둘째 항</strong>: &quot;거부가 일어나고(확률 $1-\beta$) 재샘플링 결과가 $x&#39;$인(확률 $p&#39;(x&#39;)$) 확률&quot;</p>
<p>$$
(1-\beta)\cdot p&#39;(x&#39;)
$$</p>
<p>여기서 위에서 구한 $p&#39;(x)$ 정의를 그대로 대입하면 아래와 같다.</p>
<p>$$
(1-\beta)\cdot p&#39;(x&#39;) = (1-\beta)\times \frac{p(x&#39;)-\min(q(x&#39;),p(x&#39;))}{1-\beta} = p(x&#39;)-\min(q(x&#39;),p(x&#39;))
$$</p>
<p>결국 첫째 항의 결과 $\min(q(x&#39;),p(x&#39;))$와 둘째 항의 결과 $p(x&#39;)-\min(q(x&#39;),p(x&#39;))$를 더하면 $p(x&#39;)$만 남게 된다.</p>
<p>$$
\begin{aligned}
P(x=x&#39;) &amp;= P(\text{guess accepted}, x=x&#39;) + P(\text{guess rejected}, x=x&#39;) \
&amp;= q(x&#39;)\min!\left(1, \frac{p(x&#39;)}{q(x&#39;)}\right) + (1-\beta),p&#39;(x&#39;) \
&amp;= \min(q(x&#39;),p(x&#39;)) + p(x&#39;) - \min(q(x&#39;),p(x&#39;)) \
&amp;= p(x&#39;)
\end{aligned}
$$</p>
<p>추론적 디코딩으로 뽑은 토큰의 분포가 Mp 혼자 정직하게 샘플링했을 때의 분포 p(x)와 정확히 같은 것이 증명된 것이다. </p>
<p><strong>이를 통해 추론적 디코딩이 출력 품질을 조금도 희생하지 않으면서 순수하게 속도만 끌어올리는 무손실 효율화 기법임을 알 수 있다.</strong></p>
<h2 id="3-성능-분석-얼마나-빨라지고-얼마나-더-계산하는가">3. 성능 분석: 얼마나 빨라지고, 얼마나 더 계산하는가</h2>
<h3 id="3-1-생성-토큰-개수-기댓값">3-1. 생성 토큰 개수 기댓값</h3>
<p>각 자리의 수락 확률(β)이 서로 독립이고 동일한 분포를 따른다고 가정하고, 그 평균을 α라 하자.</p>
<p>$$
\alpha = E(\beta)
$$</p>
<p>한 라운드에서 몇 번째 토큰까지 살아남는지를 생각해보면, 첫 번째 토큰은 항상 만들어지므로 그 확률은 1이다. 두 번째 토큰까지 살아남으려면 첫 번째가 수락돼야 하므로 확률은 α, 세 번째까지 살아남으려면 앞의 두 개가 모두 수락돼야 하므로 α² — 이런 식으로 &quot;k번째 토큰까지 생성될 확률&quot;은 α^(k-1)이 된다.</p>
<p>$$
P(N \ge k) = \alpha^{k-1} \qquad (k = 1, 2, \dots, \gamma+1)
$$</p>
<p>기댓값은 &quot;적어도 k개 나올 확률&quot;을 전부 더해서 구할 수 있으므로,</p>
<p>$$
E[N] = \sum_{k=1}^{\gamma+1} P(N\ge k) = 1+\alpha+\alpha^2+\cdots+\alpha^{\gamma} = \frac{1-\alpha^{\gamma+1}}{1-\alpha}
$$</p>
<p>α가 1에 가까울수록(Mq가 Mp를 잘 근사할수록) 이 값은 커진다. 반대로 α가 낮으면 뒤쪽 항들(α², α³, ...)이 금방 0에 가까워지므로, γ를 아무리 늘려도 얻는 게 별로 없다.</p>
<h3 id="3-2-walltime-개선">3-2. Walltime 개선</h3>
<p>c를 &quot;Mq 1회 실행 시간 / Mp 1회 실행 시간&quot;의 비율이라 하자. 한 라운드는 Mp 1회 시간을 기준으로</p>
<p>$$
\gamma c + 1
$$</p>
<p>배가 걸리고, 그 라운드에서 평균 $E[N] = \dfrac{1-\alpha^{\gamma+1}}{1-\alpha}$개의 토큰을 얻는다. &quot;토큰 1개당 걸리는 시간&quot;을 순수 Mp 방식과 비교하면 속도 개선 배수는 다음과 같다.</p>
<p>$$
\text{속도 개선} = \frac{1-\alpha^{\gamma+1}}{(1-\alpha)(\gamma c+1)}
$$</p>
<p>γ=1을 대입하면 어떻게 단순화되는지 직접 풀어보자.</p>
<p>$$
\frac{1-\alpha^{2}}{(1-\alpha)(c+1)} = \frac{(1-\alpha)(1+\alpha)}{(1-\alpha)(c+1)} = \frac{1+\alpha}{1+c}
$$</p>
<p>$1-\alpha^2$을 $(1-\alpha)(1+\alpha)$로 인수분해하면 분모의 $(1-\alpha)$와 정확히 약분된다. 
여기서 <strong>α &gt; c이기만 하면 항상 속도가 개선되는 γ가 존재한다</strong>는 기준이 나온다. 
드래프트 모델을 고를 때는 &quot;얼마나 빠른가(c)&quot;만 볼 게 아니라, &quot;속도 대비 정확도(α)가 그 속도 이득을 넘어서는가&quot;를 봐야 한다는 뜻이다.</p>
<h3 id="3-3-산술-연산-횟수는-오히려-증가한다">3-3. 산술 연산 횟수는 오히려 증가한다</h3>
<p>$\hat c$를 &quot;Mq 토큰당 연산량 / Mp 토큰당 연산량&quot;의 비율이라 하자. </p>
<p>c와 다른 점은, Mp의 지연시간은 메모리 대역폭에 좌우되지만 순수 연산량 자체는 처리하는 자리 수에 정직하게 비례한다는 것이다. 그래서 이 둘을 구분해야 한다.</p>
<p>한 라운드의 총 연산량은 (Mp 토큰당 연산량 기준으로) 다음과 같다.</p>
<p>$$
\gamma\hat c + \gamma + 1
$$</p>
<p>이를 순수 Mp 방식과 비교한 연산량 배수는</p>
<p>$$
\text{연산량 배수} = \frac{(1-\alpha)(\gamma\hat c+\gamma+1)}{1-\alpha^{\gamma+1}}
$$</p>
<p>이 값은 <strong>항상 1보다 크다.</strong> 드래프트가 거부되면 그 계산은 그냥 버려지기 때문이다. α가 낮을수록(Mq 성능이 나쁠수록) 이 증가율은 더 심해진다.</p>
<h3 id="예시로-한눈에-보기">예시로 한눈에 보기</h3>
<p>지금까지 나온 세 지표를 α=0.8, c=$\hat c$=0.1, γ=4로 직접 계산해보면 다음과 같다.</p>
<p>$$
E[N] = \frac{1-0.8^{5}}{1-0.8} = \frac{0.6723}{0.2} \approx 3.36
$$</p>
<p>$$
\text{속도 개선} = \frac{3.36}{4\times0.1+1} = \frac{3.36}{1.4} \approx 2.40\text{배}
$$</p>
<p>$$
\text{연산량 배수} = \frac{0.2\times(4\times0.1+4+1)}{0.6723} = \frac{0.2\times5.4}{0.6723} \approx 1.61\text{배}
$$</p>
<table>
<thead>
<tr>
<th>지표</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>평균 생성 토큰 수 $E[N]$</td>
<td>약 3.36개 (최대 5개 중)</td>
</tr>
<tr>
<td>Walltime 개선 배수</td>
<td>약 2.4배</td>
</tr>
<tr>
<td>총 연산량 배수</td>
<td>약 1.61배</td>
</tr>
</tbody></table>
<p>시간은 2.4배 빨라졌지만, 연산은 61% 더 쓴 셈이다. <strong>시간과 연산량이 반대로 움직인다</strong>는 게 이 표의 핵심이다.</p>
<h3 id="3-4-핵심-메모리-접근은-줄어든다">3-4. 핵심: 메모리 접근은 줄어든다</h3>
<p>연산량은 늘어도, 타겟 모델의 가중치와 KV cache를 읽는 메모리 접근 횟수는 한 라운드에 딱 한 번뿐이다. 그래서 메모리 접근 횟수는 정확히</p>
<p>$$
\frac{1-\alpha^{\gamma+1}}{1-\alpha}
$$</p>
<p>배만큼 줄어든다. 원래 $(\gamma+1)$번 읽어야 했던 Mp의 무거운 가중치를 1번만 읽고, 나머지는 훨씬 가벼운 Mq의 가중치 읽기로 대체한 것이다. 
남는 연산력을 써서 부족한 메모리 대역폭을 아끼는 자원 교환이 추론적 디코딩의 핵심이다.</p>
<h3 id="3-5-최적의-γ-고르기">3-5. 최적의 γ 고르기</h3>
<p>c와 α가 정해지고 컴퓨팅 자원이 충분하다면, 최적의 γ는 3-2의 속도 개선 식</p>
<p>$$
\frac{1-\alpha^{\gamma+1}}{(1-\alpha)(\gamma c+1)}
$$</p>
<p>을 최대화하는 정수를 찾으면 된다. γ가 정수이므로 미분 없이 γ=1,2,3,...을 하나씩 대입해보는 것으로 충분하다. Mq가 정확하고(α↑) 저렴할수록(c↓) γ를 크게, Mq가 부정확하거나 상대적으로 비쌀수록 γ를 작게 잡는 게 유리하다.</p>
<h3 id="3-6-추론적-디코딩의-한계">3-6. 추론적 디코딩의 한계</h3>
<ul>
<li><strong>디코드 단계에만 도움이 된다.</strong> 추론적 디코딩은 &quot;메모리 대역폭이 병목인 상황&quot;에서는 효율적이다.
prefill처럼 입력 컨텍스트가 길어서 이미 연산(FLOPS)에 병목이 걸린 compute-bound 상황이라면, 애초에 남는 연산력이 없다. 남는 연산력이 없다는 건 γ개 자리를 동시에 계산해도 시간이 거의 그대로라는 추론적 디코딩의 이점이 사라지기 때문에, 속도 이득이 사라진다.</li>
<li><strong>수락률이 낮으면 오히려 손해다.</strong> 위에서 보았듯, 총 연산량은 항상 증가하고, 수락률(α)가 낮을수록 그 증가율이 더 커진다. compute-bound 상황과 낮은 수락률이 겹치면, 늘어난 연산량이 그대로 지연시간 증가로 이어질 수 있다.</li>
<li><strong>배치가 커지면 병목 자체가 바뀐다.</strong> 큰 배치를 처리하면 GPU 활용률이 올라가면서 memory-bound였던 병목이 compute-bound로 옮겨간다. 이 경우 지연시간에는 여전히 도움이 되지만, 늘어난 연산량이 처리량의 병목이 된다.</li>
</ul>
<h2 id="4-그-이후-등장한-아이디어들-mq-모델">4. 그 이후 등장한 아이디어들: Mq 모델</h2>
<p>Mq를 준비하는 방법은 아래 스펙트럼처럼 발전해 왔다. 별도 모델에 대한 의존도가 점점 줄어드는 방향이다.</p>
<p><strong>양자화(Quantization)</strong> — 원래 모델의 가중치는 FP32나 FP16 같은 고정밀도 숫자로 저장되는데, 이를 INT8, INT4처럼 더 적은 비트로 표현하는 기법이다. 
정밀도를 조금 희생하는 대신 모델 크기가 작아지고 메모리에서 읽어와야 할 데이터양이 줄어드니, 메모리 대역폭 병목을 직접 완화한다. Mq의 정확도가 좀 떨어져도 acceptance rate(β)만 낮아질 뿐, 최종 출력 품질은 앞서 검증한 수식으로 인해 나빠지지 않는다.</p>
<p><strong>증류(Distillation)</strong> — 원본 데이터로 작은 모델을 처음부터 학습시키는 대신, 크고 성능 좋은 Mp의 출력 확률분포 자체를 정답처럼 삼아 Mq가 그 분포를 모방하도록 학습시키는 기법이다. <strong>q(x)가 p(x)를 최대한 잘 근사하는 것</strong>을 목표로, Mp로부터 증류한 Mq는 처음부터 Mp를 흉내내도록 학습된 셈이라 acceptance rate α가 크게 올라가고, 성능 지표(속도 개선, 연산량 배수)가 함께 개선된다.</p>
<p><strong>Self-drafting</strong> — 아예 별도 모델 없이 타겟 모델 자기 자신이 드래프트를 만드는 방식이다. 타겟 모델의 중간 레이어까지만 실행해서 빠르게 대략적인 예측을 내놓는 early exit이나, 원래 모델에 여러 미래 토큰을 동시에 예측하는 추가 헤드를 붙이는 Medusa 같은 방법이 여기 속한다. 
별도 모델을 학습·저장·서빙할 필요가 없다는 게 큰 장점이다.</p>
<p><strong>N-gram</strong> — 신경망 모델을 아예 쓰지 않는다. 최근에 나온 n개의 연속 토큰이 이전 문맥이나 외부 텍스트에 등장한 적이 있는지 찾아보고, 그 뒤에 이어졌던 토큰을 그대로 추측으로 재활용한다. 코드 생성(변수명·구조 반복)이나 문서 요약·인용(입력 문구를 그대로 베끼는 경우)처럼 반복이 잦은 텍스트에서는, 모델이 예전에 나온 문구를 그대로 반복할 확률이 높아서 n-gram 매칭만으로도 상당히 정확한 추측을 연산 비용 거의 없이 만들어낼 수 있다.</p>
<h2 id="5-정리하며">5. 정리하며</h2>
<p>지금까지 추론적 디코딩(Speculative Decoding)에 대해 알아봤다. 작은 드래프트 모델(Mq)로 후보 토큰을 미리 만들고, 큰 타겟 모델(Mp)이 이를 병렬로 검증함으로써 출력 품질은 그대로 유지하면서 디코딩 속도를 끌어올리는 기법이다. 그리고 이 Mq를 어떻게 준비할 것인지에 대한 다양한 방법들(양자화, 증류, self-drafting, n-gram)도 간략하게 살펴보았다.</p>
<p>정리하면, 가장 큰 장점은 출력 분포를 전혀 희생하지 않으면서 속도만 얻을 수 있고 재학습 없이 기존 시스템에 얹을 수 있다는 점이다. 다만 이 이득은 메모리 대역폭이 병목인 상황에서만 통하며, 연산량(FLOPs)은 항상 늘어나고 acceptance rate에 따라 실제 효과가 크게 갈린다는 한계도 함께 가진다.</p>
<p>그런데 추측 디코딩은 어디까지나 <strong>모델이 이미 GPU에 올라가 있다는 것을 전제로, 디코딩 단계의 속도만 높이는 기법</strong>이다. 모델 자체가 하나의 GPU 메모리에 다 올라가지 않을 만큼 커진다면, 속도를 최적화하기 이전에 애초에 여러 GPU와 노드에 모델을 나눠 서빙하는 방법부터 필요해진다. 다음은 Multi-GPU and Multi-Node Inferencing에 대해 알아본다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[5편-2. (TBD)]]></title>
            <link>https://velog.io/@_gyullbb/5%ED%8E%B8-2.-TBD</link>
            <guid>https://velog.io/@_gyullbb/5%ED%8E%B8-2.-TBD</guid>
            <pubDate>Sat, 29 Aug 2026 11:24:03 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[5편-1. GPU 연결 구조]]></title>
            <link>https://velog.io/@_gyullbb/5%ED%8E%B8-1.-GPU-%EC%97%B0%EA%B2%B0-%EA%B5%AC%EC%A1%B0</link>
            <guid>https://velog.io/@_gyullbb/5%ED%8E%B8-1.-GPU-%EC%97%B0%EA%B2%B0-%EA%B5%AC%EC%A1%B0</guid>
            <pubDate>Sat, 22 Aug 2026 08:09:51 GMT</pubDate>
            <description><![CDATA[<h2 id="llm-서빙-최적화">LLM 서빙 최적화</h2>
<p>LLM 서비스를 사용할 때 만족해야하는 조건들이 있다.</p>
<p>1)<strong>사용자 경험</strong>. 
실제 LLM을 사용해보면 응답 속도에 따라 만족도가 달라진다. 특히 첫 토큰이 나오기까지 걸리는 시간이 길면 답답함을 느끼는 경우가 많다. 즉, 사용자 경험에는 지연시간이 중요하다.
이미 충분히 지연 시간이 축소되었다면 응답의 질이 얼마나 좋은지 즉, 같은 하드웨어·같은 지연 예산 안에서 얼마나 더 좋은 모델을 올릴 수 있느냐도 최적화의 요건이 된다.</p>
<p>2)<strong>비용</strong>. 모델을 학습시키는 비용은 선행 투자에 가깝지만, 추론은 트래픽이 늘어나는 만큼 끝없이 누적되는 비용이다. 에이전트형 워크플로우처럼 요청 하나에 LLM 호출이 여러 번 걸리는 구조가 늘면서 추론 비중은 계속 커지게 된다. 비용을 최소화 하는 것도 최적화의 요건이다.</p>
<p>3) <strong>확장성과 배포 유연성</strong>. 트래픽은 평소엔 잔잔하다가 특정 시점에 몇 배씩 튈 수 있다. 최적화가 덜 된 시스템은 스파이크에서 지연 폭증이나 요청 실패로 다운 될 수 있다. </p>
<p>사용자 경험, 비용, 확장성을 만족하기 위해서는 LLM 서빙 최적화가 필수적이다.</p>
<p>LLM 서빙 최적화를 이야기 하기 위해서는 GPU로 얼마나 빠르게, 얼마나 많이 처리할 수 있는가라는 하드웨어에 대한 지식이 필수적이다. </p>
<p>최적화 기법(배치화, 양자화, KV 캐시 압축 같은 것들)을 알아보기 전에 <strong>GPU 스펙을 어떻게 읽어야 하는지</strong>, 그리고 GPU 여러 장을 묶어 쓸 때 <strong>그 GPU들이 서로 어떻게 연결되는지</strong>에 대해서 확인해본다.</p>
<h2 id="gpu-스펙을-읽는-4가지-축">GPU 스펙을 읽는 4가지 축</h2>
<p>GPU를 서빙 관점에서 볼 때 알아야 하는 4가지 축이 있다.</p>
<ol>
<li><strong>연산 성능(FLOPS)</strong> — 초당 부동소수점 연산 횟수이다. 정밀도가 낮아질수록(FP32 → FP16 → FP8) 같은 칩에서 처리량이 대략 두 배씩 뛴다.</li>
<li><strong>메모리 용량(VRAM)</strong> — 모델 가중치를 얹을 수 있는 공간으로 메모리 용량이 부족하면 애초에 모델을 로드할 수 없다.</li>
<li><strong>메모리 대역폭</strong> — GPU 메모리와 연산 유닛 사이 데이터 이동 속도로 연산 유닛이 아무리 빨라도 데이터를 제때 공급받지 못하면 놀게 된다.</li>
<li><strong>인터커넥트</strong> — GPU 간, 노드 간 데이터 전송 속도로 모델이 GPU 한 장에 다 안 들어가거나 여러 GPU가 협업해야 할 때 결정적이다.</li>
</ol>
<p>넷 중 하나라도 부족하면 나머지가 아무리 좋아도 전체 처리량은 그 병목에 갇힌다. 앞의 셋은 스펙 시트에서 바로 확인되는 정보지만, 인터커넥트는 &quot;PCIe인지 NVLink인지, 스위치가 있는지 없는지&quot;에 따라 같은 GPU를 쓰고도 GPU 간 대역폭이 크게 벌어진다. 
GPU가 어떻게 연결되는지 알아본다.</p>
<h2 id="gpu-간-연결">GPU 간 연결</h2>
<p>GPU를 여러 장 묶을 때 실제로 쓰이는 연결 방식은 크게 두 계열로 나뉜다
범용 장치들을 연결하기 위해 만들어진 <strong>PCIe</strong>와, GPU 간 통신만을 위해 설계된 <strong>NVLink</strong>다. </p>
<h2 id="pcie">PCIe</h2>
<p><strong>PCIe(PCI Express)</strong>는 CPU와 GPU·스토리지·NIC 같은 장치들을 연결하는 표준 범용 버스다. 
물리적으로는 CPU의 루트 컴플렉스를 꼭짓점으로 하는 <strong>트리 구조</strong>를 이룬다.
스위치 하나에 GPU 여러 대가 매달리는데, 이 GPU들이 위(CPU)로 올라가는 통로는 하나뿐이고 그걸 나눠 쓴다.
<img src="https://velog.velcdn.com/images/_gyullbb/post/259ab31e-7444-4b8c-b697-87b67a1319cf/image.png" alt=""></p>
<p>이렇게 GPU 여러 장이 스위치 밑에 나란히 매달려 있고, 그 위로는 좁은 통로 하나만 있다.
스펙 시트에 적힌 &quot;GPU당 128GB/s(Gen5 x16)&quot;는 그 GPU가 스위치까지 뻗은 다운스트림 포트 하나의 최대치일 뿐이다. 여러 GPU가 동시에 통신하면 결국 <strong>스위치 위쪽의 업링크 용량을 나눠 가진다</strong>. 
이렇게 되면 부족한 업링크 용량으로 인해 대역폭 문제가 생길 수 있다. 예시로 포트 수 × 포트당 대역폭을 계산해보자.</p>
<pre><code>스위치 다운스트림 포트: 4개 × x16 슬롯

GPU 1장이 &quot;낼 수 있는&quot; 최대치: 128GB/s (Gen5 x16)

스위치 업스트림(스위치 → CPU) 링크: 128GB/s 1개
------------------------------------------------------

4개 GPU가 동시에 요구하는 대역폭 합 = 128GB/s × 4 = 512GB/s

실제 업링크 용량                    = 128GB/s

결론: 512GB/s &gt; 128GB/s → 업링크가 감당 못 함 (오버섭스크립션, 블로킹 가능)</code></pre><p>위 계산을 보면 실제 다운스트림 포트 대역폭의 합이 업스트림 링크 용량보다 훨씬 크게 설계되어 있어 업스트림 링크가 대역폭을 감당하지 못한다. — 이런 구조를 <strong>oversubscription</strong>이라 한다.</p>
<p>이는 PCIe는 애초에 GPU 전용 버스가 아니라 스토리지 컨트롤러, NIC, 각종 확장 카드까지 연결하는 <strong>범용 버스</strong>이기 때문에 나올 수 있는 설계이다. </p>
<h3 id="pcie-tlp">PCIe TLP</h3>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/5e013972-c8ad-4924-b67f-d9d7ae47d9cb/image.png" alt=""></p>
<p>이 물리 구조 위에서 실제로 오가는 패킷도 같은 논리를 따른다. 
<strong>PCIe TLP(Transaction Layer Packet)</strong>는 계층이 나뉘어 있다. </p>
<p>Transaction Layer가 TLP 헤더+데이터를 만들면, Data Link Layer가 그 앞뒤로 Sequence Number(12bit)와 LCRC(4바이트, 오류 검출)를 덧붙이고, Physical Layer가 프레이밍 심볼을 씌워 내보낸다. 
<code>Start → Seq# → TLP Header → Data → (ECRC) → LCRC → End</code>라는 꽤 긴 구조이다.</p>
<p><strong>TLP 헤더 자체</strong>도 3DW(12바이트, 32비트 주소) 또는 4DW(16바이트, 64비트 주소)로, 그 안에 채워지는 필드가 많다.
<img src="https://velog.velcdn.com/images/_gyullbb/post/006deb17-4650-4207-b2e6-5cd2ebb31417/image.png" alt=""></p>
<ul>
<li>Byte 0: Fmt(패킷 포맷: 헤더 길이·데이터 유무) + Type(Read/Write/Completion 등 요청 종류)</li>
<li>Byte 1: TC(Traffic Class, 8단계 QoS 우선순위)</li>
<li>Byte 2: TD(ECRC 존재 여부) + EP(Poisoned 데이터 표시) + Attr(순서 제어 속성) + AT(주소 타입) + Length 최상위 비트</li>
<li>Byte 3: Length 나머지 비트 (총 10비트, 최대 1024DW = 4KB까지 표현)</li>
<li>Byte 4-5: Requester ID (요청을 보낸 장치의 Bus:Device:Function 번호)</li>
<li>Byte 6: Tag (요청-응답을 매칭하기 위한 추적 ID)</li>
<li>Byte 7: Byte Enable (마지막·첫 DW에서 유효한 바이트만 표시하는 마스크)</li>
<li>Byte 8-11(혹은 8-15): Address</li>
</ul>
<p>이 구조가 실제로 의미하는 건, PCIe TLP가 페이로드 크기와 무관하게 헤더(12~16B) + Seq#(12bit) + LCRC(4B)를 거의 고정으로 가져간다는 것이다. 4KB짜리 큰 데이터를 옮길 때는 이 오버헤드 비중이 미미하지만, 몇 바이트짜리 작은 요청 하나를 보낼 때도 이 헤더를 통째로 다 채워야 하니 오버헤드 비율이 커진다.</p>
<p>GPU 워크로드에서는 이것이 이론적인 비효율로 끝나지 않는다. 
텐서 병렬화는 레이어 하나를 지날 때마다 GPU끼리 중간 결과(활성화 값, 어텐션 부분합)를 주고받아야 한다.
이런 메시지들은 대체로 몇 KB수준으로 작지만, 이런 작은 메시지가 레이어 수만큼 서빙 중이라면 토큰 하나가 나올 때마다 매번 PCIe는 고정 오버헤드를 다시 지불한다. 
그 결과 스펙 시트에 적힌 대역폭까지 실제로 거의 도달하지 못하게 된다. 
또한 헤더 파싱·라우팅 처리 시간이 메시지 수만큼 그대로 누적되면서, 레이어를 통과할 때마다 혹은 토큰을 하나씩 생성할 때마다 지연이 조금씩 쌓인다. 
GPU 여러 장을 묶어 쓰는 순간, PCIe의 고정 오버헤드가 무시할 수 없는 비용이 된다.</p>
<h3 id="pcie-전체-배선">PCIe 전체 배선</h3>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/f7b234a4-6967-4cc5-bf63-7cb8871d1068/image.png" alt=""></p>
<p>일반 PCIe 서버는 보통 CPU 소켓 2개짜리 듀얼소켓 서버이다. 
GPU 절반은 CPU 0에, 나머지 절반은 CPU 1에 붙는다.
문제는 GPU끼리 통신할 전용 회선이 없기 때문에 GPU가 다른 GPU와 통신할 때는 &quot;PCIe를 거쳐 CPU 쪽으로&quot; 나가야 한다.
CPU 간 링크는 GPU 인터커넥트보다 대역폭이 한참 낮기 때문에, 병목이 될 수 있다.</p>
<h2 id="nvlink">NVLink</h2>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/7f681758-11a7-4031-8971-7e09e1245994/image.png" alt=""></p>
<p><strong>NVLink</strong>는 PCIe 같은 범용 버스가 아니라, NVIDIA가 GPU와 GPU를 직접 잇기 위해 처음부터 새로 만든 전용 인터커넥트다. 
각 GPU가 스위치(NVSwitch)까지 <strong>자기 전용 회선</strong>으로 직행하고, 스위치 내부는 모든 포트를 서로 잇는 방사형 구조이다.
PCIe와는 다르게 &quot;업링크를 나눠 쓴다&quot;는 개념 자체가 없다.</p>
<p>PCIe와 마찬가지로 포트 수 × 포트당 대역폭으로 계산해보자.
왜 이게 <strong>논블로킹(non-blocking)</strong>인지 바로 드러난다(H100 세대 NVSwitch 기준).</p>
<pre><code>NVSwitch 포트 수: 18개 (18×18 완전연결 크로스바)

포트당 대역폭:    양방향 50GB/s (25GB/s × 2방향)

GPU 1장이 요구하는 NVLink 총 대역폭 = 900GB/s (18개 링크를 GPU 하나가 묶어서 사용)
---------------------------------------------------------

스위치 내부 총 스위칭 용량 = 18개 × 50GB/s = 900GB/s

900GB/s = 900GB/s → 포트 합계 용량이 GPU 요구 대역폭과 정확히 일치 (논블로킹 성립)</code></pre><p>PCIe와 비교하였을 때 가장 큰 차이는 <strong>&quot;스위치가 동시에 처리할 수 있는 총 용량&quot;이 &quot;포트에 매달린 장치들이 동시에 요구할 수 있는 대역폭 합&quot;을 만족한다</strong> 는 것이다. </p>
<p>이렇게 설계된 이유는 GPU 워크로드의 특성때문이다. 텐서 병렬화나 all-reduce 같은 협업 연산은 여러 GPU가 <strong>거의 같은 순간에</strong> 서로 데이터를 주고받게 된다. NVSwitch는 처음부터 동시 요구를 기준으로 용량을 맞춰 설계되었다.</p>
<h3 id="nvlink-flit">NVLink Flit</h3>
<p>물리 구조만 다른 게 아니라, 그 위를 오가는 패킷 설계도 PCIe의 TLP와는 다르다. 
NVLink는 TLP 같은 가변 길이 패킷이 아니라, 128비트(16바이트) <strong>고정 크기의 Flit</strong>을 기본 전송 단위로 쓴다.
하나의 논리적 트랜잭션(요청/응답)은 1~18개의 Flit이 이어붙어 구성된다.
계층은 PCIe와 개념적으로 비슷하게 Transaction Layer(TL) → Data Link Layer(DL) → Physical Layer로 나뉘지만, 프레이밍·필드 폭이 훨씬 좁고 압축적이다.
<img src="https://velog.velcdn.com/images/_gyullbb/post/3ed74442-c1a6-4243-bd37-623de9a5f10a/image.png" alt=""></p>
<ul>
<li>CRC 25비트 — Flit 단위 오류 검출(PCIe의 LCRC와 같은 역할을 훨씬 잘게 쪼개서 수행)</li>
<li>Transaction Field 83비트 — 명령 종류, 대상 주소, 트랜잭션 ID 등(PCIe TLP의 Type+Address+Tag를 압축한 자리)</li>
<li>Data Link Field 20비트 — 흐름 제어(credit), 시퀀싱 정보(PCIe의 Sequence Number 역할)</li>
</ul>
<p>여기서 진짜 차이는 <strong>자주 안 바뀌는 정보를 아예 기본 Flit 밖으로 뺐다</strong>는 점이다. 
주소 범위가 넓어질 때만 붙는 AE(Address Extension) Flit, 바이트 단위 마스크가 필요할 때만 붙는 BE(Byte Enable) Flit처럼, PCIe라면 매 헤더마다 고정으로 소모했을 필드를 옵션으로 돌려서 평소엔 아예 보내지 않는다.</p>
<p>이 설계 차이가 왜 GPU 워크로드에 유리한지는 트랜잭션 크기를 생각해보면 알 수 있다. PCIe는 헤더(12~16B) + Seq#(12bit) + LCRC(4B)라는 프레이밍 오버헤드를 매 패킷마다 거의 고정으로 가져가는데, 이건 데이터가 4KB짜리 큰 전송이든 몇 바이트짜리 작은 동기화 신호든 똑같이 붙는다. 큰 전송에서는 이 오버헤드 비중이 작아지지만, <strong>작고 잦은 메시지</strong>에서는 오버헤드 비중이 커진다. </p>
<p>NVLink는 아예 최소 단위 자체를 16바이트로 잡고 필요한 필드만 골라 붙이니, 이런 소규모·고빈도 트랜잭션에서 상대적으로 유리하다. 
GPU들이 어텐션 연산 중 부분합을 주고받거나 파라미터 병렬화 과정에서 자잘한 동기화 신호를 쉴 새 없이 주고받는 상황에서는 이 구조가 유리하다.</p>
<p><em>NVLink는 NVIDIA 독점 기술이라 최신 세대(NVLink 4/5)의 세부 스펙은 대부분 비공개이고, 여기서 다룬 Flit 구조는 초기 세대(NVLink 1.0)에 공개된 자료를 바탕으로 한다</em></p>
<h3 id="form-factor-sxm">Form Factor: SXM</h3>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/44d856fb-097d-4b5e-9865-856882e1100f/image.png" alt=""></p>
<p>NVLink를 실제로 구현하려면 물리적 커넥터 자체 즉, <strong>폼팩터(form factor)</strong> 가 필요하다.</p>
<p>PCIe 슬롯형 GPU는 일반 서버의 PCIe 슬롯에 그대로 꽂는 방식이다. 호환성이 좋고 저렴하지만, 슬롯 하나가 감당할 수 있는 핀 수에는 한계가 있다.</p>
<p><strong>SXM</strong>은 이 제약을 없애기 위한 별도의 폼팩터다. 카드를 슬롯에 꽂는 게 아니라, GPU 모듈을 HGX 같은 전용 베이스보드에 소켓 형태로 직결한다. 
이렇게 하면 GPU 한 장에서 18개 NVLink 링크를 전부 뽑아 NVSwitch로 보낼 수 있다.
SXM으로 인해 NVSwitch가 요구하는 핀 수와 전력을 물리적으로 감당할 수 있다.</p>
<h3 id="nvlink-전체-배선">NVLink 전체 배선</h3>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/84395619-3626-4dad-a160-6990d0c02ea2/image.png" alt=""></p>
<p>SXM은 <strong>CPU-GPU 경로(PCIe)와 GPU-GPU 경로(NVLink)를 물리적으로 완전히 분리</strong>한다. 
CPU는 가중치를 올리고 내리는 호스트 I/O만 담당하고, GPU끼리 주고받는 트래픽은 NVSwitch 패브릭을 따로 통째로 쓴다. 그 결과 GPU가 어느 소켓 밑에 있든 GPU-GPU 통신은 항상 같은 속도를 낸다.</p>
<p>모델 가중치를 로딩하는 트래픽(PCIe 경로)과 여러 GPU가 서로 협업하는 트래픽(NVLink 경로)이 <strong>서로 경합하지 않기</strong> 때문에, 로딩 중이라고 GPU 간 통신이 느려지거나, GPU끼리 바쁘게 통신 중이라고 호스트에서 데이터를 밀어 넣는 게 막히지 않는다.</p>
<h3 id="노드-간-인터커넥트">노드 간 인터커넥트</h3>
<p>한 노드에는 전력·공간 제약으로 GPU가 보통 8개까지만 들어간다. 그 이상을 묶으려면 노드를 넘어 InfiniBand(또는 RoCEv2) + GPUDirect RDMA를 써야 한다. GPUDirect RDMA는 노드 간 GPU끼리 CPU를 거치지 않고 서로의 메모리에 직접 접근하는 기술인데, 그래도 물리 네트워크 자체의 한계는 어쩔 수 없다</p>
<p>지금까지 다룬 인터커넥트 등급을 전부 한 표에 모아보면 격차가 정확히 얼마나 벌어지는지 드러난다(H100 기준).</p>
<pre><code>NVSwitch (노드 내부)             : 900 GB/s
NVLink Bridge (노드 내부)        : 600 GB/s
PCIe (노드 내부)                 : 128 GB/s
InfiniBand (노드 간, NDR 400G)   :  50 GB/s</code></pre><p>NVSwitch(900GB/s)와 InfiniBand(50GB/s)를 비교하면 900 ÷ 50 = 18배 차이가 난다. 노드 경계를 넘으면 대역폭이 이 정도로 줄어들기 때문에, 노드를 넘어서 GPU를 병렬화하려면 통신량 대비 연산량이 충분히 커야 손해를 보지 않는다. 대략 통신량 대비 18배 이상의 연산이 있어야 노드 내부와 비슷한 효율이 나온다는 계산이다. 
이런 이유로 실무에서는 GPU 8장을 한 노드 안에 두고 최대한 해결하려는 경우가 많다. 노드 경계를 넘는 순간 GPU 간 통신 속도가 크게 떨어지기 때문에, 가능하면 노드 안에서 모델 인스턴스를 구성하고 트래픽이 늘면 그 단위를 그대로 복제해서 대응하는 방식을 쓴다.</p>
<p>여기까지는 인터커넥트 위주로 봤지만, GPU를 실제로 고를 때는 두 가지를 더 같이 봐야 한다. 
하나는 연산 성능(FLOPS) — GPU가 초당 처리할 수 있는 연산 횟수로, 높을수록 같은 시간에 더 많은 행렬 연산을 처리할 수 있다. 
다른 하나는 정밀도(precision) — 가중치와 연산에 쓰는 숫자 표현 방식이다. FP32/FP16/FP8처럼 표현에 쓰는 비트 수가 줄어들수록 같은 모델도 메모리를 덜 쓰고, 지원하는 GPU에서는 처리량도 늘어난다(FP8은 숫자 하나를 8비트로 표현하는 방식으로, FP16의 절반 크기다).</p>
<p>이 두 축까지 포함해서 실제 GPU 라인업에 대입해보면 스펙 표를 좀 더 쉽게 읽을 수 있다. 추론에 흔히 쓰이는 GPU들을 나란히 놓으면 대략 이런 표가 된다.</p>
<table>
<thead>
<tr>
<th>GPU</th>
<th>메모리</th>
<th>FP16/BF16 TFLOPS</th>
<th>메모리 대역폭</th>
<th>FP8 지원</th>
<th>NVLink/NVSwitch</th>
</tr>
</thead>
<tbody><tr>
<td>H200 SXM</td>
<td>141GB</td>
<td>~1979</td>
<td>4.8TB/s</td>
<td>O</td>
<td>O</td>
</tr>
<tr>
<td>H100 SXM</td>
<td>80GB</td>
<td>~1979</td>
<td>3.35TB/s</td>
<td>O</td>
<td>O</td>
</tr>
<tr>
<td>A100 SXM</td>
<td>80GB</td>
<td>~312</td>
<td>1.935TB/s</td>
<td>X</td>
<td>O</td>
</tr>
<tr>
<td>L40S</td>
<td>48GB</td>
<td>~362</td>
<td>0.864TB/s</td>
<td>O</td>
<td>X</td>
</tr>
<tr>
<td>A10</td>
<td>24GB</td>
<td>~125</td>
<td>0.6TB/s</td>
<td>X</td>
<td>X</td>
</tr>
</tbody></table>
<p>표만 보면 H200이 모든 축에서 좋아 보이지만 가격도 가장 높다. 결국 판단은 &quot;이 모델이 이 GPU의 더 나은 스펙에서 실제로 이득을 보는가&quot;를 중심으로 보면 된다.</p>
<ul>
<li><p>메모리: 가중치 + 예상 최대 KV 캐시가 그 GPU의 VRAM 안에 들어가는지 본다. 들어가지 않는다면 NVLink/NVSwitch 지원 여부가 필요해진다(여러 GPU에 분산 필요)</p>
</li>
<li><p>정밀도: FP8을 지원하는 GPU라면 같은 모델을 절반 크기로 올릴 수 있어 메모리와 처리량 모두 여유가 생긴다. A100처럼 FP8을 지원하지 않으면 이 방법을 쓸 수 없다.</p>
</li>
<li><p>연산·대역폭: decode 위주 워크로드는 메모리 대역폭 병목에 걸리는 경우가 많다. 그렇다면 FLOPS가 높은 GPU를 써도 그만큼 활용하지 못할 수 있어서, 대역폭과 메모리가 적당하면서 저렴한 GPU가 처리량 대비 비용 면에서 나을 수도 있다.</p>
</li>
</ul>
<p>GPU 스펙을 확인할 때에는 &quot;메모리가 들어가는가 → 정밀도로 더 줄일 수 있는가 → 병목이 연산인가 대역폭인가 → 여러 장이 필요한가&quot; 순서로 확인해보는 게 GPU를 고르는 한 방법이 될 수 있다.</p>
<h2 id="정리">정리</h2>
<p>여기까지 GPU 한 장의 스펙을 읽는 법(연산력·메모리·대역폭·인터커넥트·전력)과, 여러 GPU를 묶을 때 실제로 무슨 일이 일어나는지(PCIe의 오버섭스크립션, NVLink의 논블로킹 구조, 그 위를 오가는 패킷 설계, SXM 폼팩터, 노드 간 인터커넥트의 격차)를 봤다. 
이 틀이 있으면 스펙 표에 적힌 숫자 하나하나가 왜 그 값인지 설명이 되고, 실제 GPU 라인업을 볼 때도 어떤 축을 먼저 확인해야 하는지 순서가 생긴다.</p>
<p>다음 글에서는 이 위에 LLM을 실제로 올려본다. 모델 로딩 단계에서 GPU 메모리가 무엇으로 채워지는지(가중치·KV 캐시·활성화), 그리고 모델 실행 단계에서 prefill과 decode가 왜 서로 다른 병목(연산 병목 vs 메모리 대역폭 병목)에 걸리는지를 루프라인 모델과 연산 강도로 확인해본다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[4-2편. LLM Serving Service(Multi Model Serving)]]></title>
            <link>https://velog.io/@_gyullbb/4-2%ED%8E%B8.-LLM-Serving-ServiceMulti-Model-Serving</link>
            <guid>https://velog.io/@_gyullbb/4-2%ED%8E%B8.-LLM-Serving-ServiceMulti-Model-Serving</guid>
            <pubDate>Sat, 15 Aug 2026 15:18:27 GMT</pubDate>
            <description><![CDATA[<p>4-1편에서는 <strong>하나의 GPU에서 하나의 모델</strong>을 서비스하는 문제를 다뤘다. </p>
<p>그런데 실제 서비스는 모델 하나만 필요로 하지 않는 경우가 많다.</p>
<p>서로 다른 모델에 대해서 각각 GPU 한 장씩 붙여 서비스하는 것은 비용 면에서 낭비다. 그렇다고 모든 모델을 항상 GPU 메모리에 올려두는 것도 메모리가 감당하지 못한다.</p>
<blockquote>
<p><strong>&quot;제한된 GPU 메모리 위에서, 여러 개의 서로 다른 모델을 어떻게 같이 서비스할 것인가?&quot;</strong> </p>
</blockquote>
<p>이번 편에서는 Multi-Model Serving 서비스를 구현하며 방법을 확인해본다.</p>
<hr>
<h2 id="1-multi-model-serving이-왜-어려운가">1. Multi-Model Serving이 왜 어려운가</h2>
<p>4-1편의 핵심은 &quot;요청을 어떻게 더 빨리, 더 많이 처리할 것인가&quot;였다. 모델은 항상 하나였고, 그 모델은 항상 GPU에 올라가 있었다.</p>
<p>Multi-Model Serving은 여기에 축이 하나 더 늘어난다. <strong>어떤 모델이 지금 GPU(또는 메모리)에 올라가 있는가</strong>다.</p>
<pre><code class="language-text">모델 A (500MB) ┐
모델 B (1.2GB) ├─ 전부 합치면 GPU Memory보다 크다
모델 C (800MB) ┘</code></pre>
<p>선택지는 두 가지뿐이다.</p>
<pre><code class="language-text">모든 모델을 항상 올려둔다
  → 메모리 부족

요청이 올 때마다 그때그때 로드한다
  → 매 요청마다 로딩 시간(Cold Start) 발생</code></pre>
<p>즉 앞에서 확인했던 &quot;계산을 줄이는 대신 메모리를 쓴다&quot;는 KV Cache의 Trade-off와 똑같은 구조가 여기서도 반복된다.</p>
<blockquote>
<p><strong>메모리를 아끼면 로딩 비용(Latency)이 늘고, 로딩 비용을 줄이면 메모리가 늘어난다.</strong></p>
</blockquote>
<p>이 균형을 맞추는 정책이 바로 <strong>캐시(Cache)</strong> 다. 캐시 구현을 확인해보자.</p>
<hr>
<h2 id="2-전체-구조--4개의-계층">2. 전체 구조 — 4개의 계층</h2>
<pre><code class="language-text">Client
  ↓
Server (FastAPI)
  ↓
ModelStore    ← &quot;어떤 모델이 존재하는가&quot; (메타데이터)
  ↓
ModelManager  ← &quot;지금 무엇을 GPU에 올려둘 것인가&quot; (LRU 캐시)
  ↓
ModelEngine   ← &quot;Worker를 어떻게 만들 것인가&quot; (Factory)
  ↓
ModelWorker   ← &quot;실제로 어떻게 추론할 것인가&quot; (프레임워크별 구현)</code></pre>
<p>역할을 한 줄씩 정리하면 다음과 같다.</p>
<ul>
<li><strong>ModelStore</strong> : 서비스가 다룰 수 있는 모델 목록과 메타데이터를 안다. 모델을 직접 들고 있지는 않는다.</li>
<li><strong>ModelManager</strong> : 지금 메모리에 어떤 모델이 올라가 있는지 캐시로 관리하고, 필요하면 오래된 모델을 내리고 새 모델을 올린다.</li>
<li><strong>ModelEngine</strong> : 실제로 모델을 메모리에 올리는(Worker를 생성하는) 공장 역할을 한다. 프레임워크에 따라 어떤 Worker를 만들지 결정한다.</li>
<li><strong>ModelWorker</strong> : 진짜 추론이 일어나는 곳이다. Transformer 모델, TorchVision 모델, Triton에 위임하는 모델이 각자 다른 방식으로 구현되어 있다.</li>
</ul>
<p>핵심은 &quot;<strong>어떤 모델이 존재하는가</strong>&quot;(Store)와 &quot;<strong>지금 무엇이 로드되어 있는가</strong>&quot;(Manager)를 분리했다는 점이다. 
이 분리 덕분에 캐시 정책(Manager)을 모델 목록(Store)이나 실제 추론 코드(Worker)를 건드리지 않고 바꿀 수 있다.</p>
<hr>
<h2 id="3-modelstore--존재하는-모델을-안다">3. ModelStore — 존재하는 모델을 안다</h2>
<p><code>config/models.json</code>에는 서비스가 다룰 수 있는 모델 네 개가 등록되어 있다.</p>
<pre><code class="language-json">{
    &quot;models&quot;: [
        { &quot;id&quot;: &quot;550e8400-...&quot;, &quot;name&quot;: &quot;distilbert-base-uncased-finetuned-sst-2-english&quot;,
          &quot;type&quot;: &quot;text&quot;,  &quot;framework&quot;: &quot;transformers&quot;, &quot;description&quot;: &quot;Sentiment analysis model&quot; },
        { &quot;id&quot;: &quot;6ba7b810-...&quot;, &quot;name&quot;: &quot;mrm8488/bert-tiny-finetuned-sms-spam-detection&quot;,
          &quot;type&quot;: &quot;text&quot;,  &quot;framework&quot;: &quot;transformers&quot;, &quot;description&quot;: &quot;Spam detection model&quot; },
        { &quot;id&quot;: &quot;7c9e6679-...&quot;, &quot;name&quot;: &quot;pytorch/vision:mobilenet_v2&quot;,
          &quot;type&quot;: &quot;image&quot;, &quot;framework&quot;: &quot;torchvision&quot;, &quot;description&quot;: &quot;Image classification model&quot; },
        { &quot;id&quot;: &quot;8ba7b810-...&quot;, &quot;name&quot;: &quot;densenet_onnx&quot;,
          &quot;type&quot;: &quot;image&quot;, &quot;framework&quot;: &quot;triton&quot;, &quot;description&quot;: &quot;DenseNet ... served via Triton&quot; }
    ]
}</code></pre>
<p>같은 <code>text</code> 타입이라도 감성 분석과 스팸 탐지는 서로 다른 모델이고, 같은 <code>image</code> 타입이라도 하나는 TorchVision으로 직접 실행하고 다른 하나는 Triton 서버에 위임한다. <code>ModelStore</code>는 이 목록을 읽어 메타데이터만 들고 있는다.</p>
<pre><code class="language-python">class ModelMetadata(BaseModel):
    id: str
    name: str
    type: str
    framework: str
    version: str
    description: str

class ModelStore:
    def __init__(self, config_path: str):
        self.models: Dict[str, ModelMetadata] = {}
        self._load_config(config_path)

    def get_model(self, model_id: str) -&gt; Optional[ModelMetadata]:
        return self.models.get(model_id)</code></pre>
<p>주목할 점은 <code>ModelStore</code>가 <strong>모델 가중치를 로드하지 않는다</strong>는 것이다. <code>id</code>로 조회하면 &quot;이 모델의 이름과 프레임워크가 무엇인지&quot;만 알려줄 뿐, 실제로 GPU 메모리에 올리는 일은 다른 계층의 몫이다. &quot;모델이 존재한다&quot;와 &quot;모델이 로드되어 있다&quot;를 처음부터 구분해 둔 것이다.</p>
<hr>
<h2 id="4-modelworker--프레임워크마다-추론-방식이-다르다">4. ModelWorker — 프레임워크마다 추론 방식이 다르다</h2>
<p><code>ModelWorker</code>는 추상 클래스로, 모든 Worker가 지켜야 할 두 가지 규칙만 정의한다.</p>
<pre><code class="language-python">class ModelWorker(ABC):
    def __init__(self, model_metadata):
        self.model_metadata = model_metadata
        self.model: Optional[torch.nn.Module] = None
        self._load_model()

    @abstractmethod
    def _load_model(self):
        pass

    @abstractmethod
    def predict(self, input_data: Any) -&gt; Dict[str, Any]:
        pass</code></pre>
<p>Worker가 <code>__init__</code> 되는 순간 <code>_load_model()</code>이 즉시 실행된다. 즉 <strong>Worker 객체를 만드는 것 자체가 모델을 메모리에 올리는 행위</strong>다. 
이 지점이 뒤에서 볼 캐시 로직과 맞물린다.</p>
<h3 id="transformerworker--감성-분석--스팸-탐지">TransformerWorker — 감성 분석 / 스팸 탐지</h3>
<pre><code class="language-python">class TransformerWorker(ModelWorker):
    def _load_model(self):
        self.model = AutoModelForSequenceClassification.from_pretrained(self.model_metadata.name)
        self.tokenizer = AutoTokenizer.from_pretrained(self.model_metadata.name)

    def predict(self, input_data: Any) -&gt; Dict[str, Any]:
        inputs = self.tokenizer(input_data, return_tensors=&quot;pt&quot;, padding=True, truncation=True)
        with torch.no_grad():
            outputs = self.model(**inputs)
        predictions = torch.softmax(outputs.logits, dim=-1)
        return {&quot;predictions&quot;: predictions.tolist()}</code></pre>
<p>감성 분석 모델과 스팸 탐지 모델은 서로 다른 모델이지만 <strong>같은 <code>TransformerWorker</code> 클래스</strong>를 쓴다. <code>models.json</code>의 <code>name</code>만 다를 뿐 둘 다 <code>AutoModelForSequenceClassification</code> 구조이기 때문이다. 프레임워크가 같으면 Worker 코드도 재사용된다.</p>
<h3 id="torchvisionworker--이미지-분류-모델">TorchVisionWorker — 이미지 분류 모델</h3>
<pre><code class="language-python">class TorchVisionWorker(ModelWorker):
    def _load_model(self):
        self.model = mobilenet_v2(weights=MobileNet_V2_Weights.DEFAULT)
        self.model.eval()
        self.transform = transforms.Compose([...])

    def predict(self, input_data: Any) -&gt; Dict[str, Any]:
        image = Image.open(input_data).convert(&#39;RGB&#39;) if isinstance(input_data, str) else input_data
        image_tensor = self.transform(image).unsqueeze(0)
        with torch.no_grad():
            outputs = self.model(image_tensor)
        return {&quot;predictions&quot;: torch.softmax(outputs, dim=1).tolist()}</code></pre>
<p>Text 입력을 Tokenizer로 바꾸던 TransformerWorker와 달리, 여기서는 이미지를 Resize·Crop·Normalize하는 전처리(<code>transform</code>)가 필요하다. 입력 형태가 다르면 전처리 로직도 통째로 달라진다는 것을 보여준다.</p>
<h3 id="tritonworker--모델을-아예-우리-프로세스-밖에-둔다">TritonWorker — 모델을 아예 우리 프로세스 밖에 둔다</h3>
<pre><code class="language-python">class TritonWorker(ModelWorker):
    def __init__(self, model_metadata):
        self.triton_url = &quot;0.0.0.0:8009&quot;
        self.client = httpclient.InferenceServerClient(url=self.triton_url)
        super().__init__(model_metadata)

    def _load_model(self):
        load_url = f&quot;http://{self.triton_url}/v2/repository/models/{self.model_metadata.name}/load&quot;
        requests.post(load_url)
        if not self.client.is_model_ready(self.model_metadata.name):
            raise RuntimeError(&quot;Model is not ready after loading&quot;)

    def predict(self, input_data: Dict[str, Any]) -&gt; Dict[str, Any]:
        ...
        response = self.client.infer(model_name=self.model_metadata.name, inputs=inputs, outputs=[...])
        return {output_name: response.as_numpy(output_name).tolist()}</code></pre>
<p>앞의 두 Worker는 <code>self.model</code>에 실제 PyTorch 모델 객체를 들고 있었다. <code>TritonWorker</code>는 다르다. <code>self.model</code>은 끝까지 <code>None</code>이고, <code>_load_model()</code>은 모델을 우리 프로세스의 메모리에 올리는 게 아니라 <strong>별도로 떠 있는 Triton Inference Server에 &quot;이 모델을 로드해달라&quot;고 HTTP로 요청</strong>할 뿐이다. <code>predict()</code>도 실제 연산 대신 Triton에 추론을 요청하고 결과만 받아온다.</p>
<pre><code class="language-text">TransformerWorker / TorchVisionWorker
  → 모델이 이 프로세스의 GPU 메모리 위에 있다

TritonWorker
  → 모델은 Triton 서버(다른 프로세스, 다른 GPU일 수도 있음)에 있다
  → 이 Worker는 그 앞의 HTTP Client일 뿐이다</code></pre>
<p>즉 Multi-Model Serving의 해법이 항상 &quot;내 프로세스 안에서 여러 모델을 돌려막기&quot;인 것은 아니다. <strong>모델 관리 자체를 전담 서빙 엔진에 위임</strong>하는 것도 하나의 답이다. </p>
<hr>
<h2 id="5-modelengine--worker를-만드는-공장">5. ModelEngine — Worker를 만드는 공장</h2>
<pre><code class="language-python">class ModelEngine:
    def __init__(self):
        self.workers = {}

    def create_worker(self, model_metadata: ModelMetadata) -&gt; ModelWorker:
        if model_metadata.id not in self.workers:
            if model_metadata.framework == &quot;transformers&quot;:
                self.workers[model_metadata.id] = TransformerWorker(model_metadata)
            elif model_metadata.framework == &quot;torchvision&quot;:
                self.workers[model_metadata.id] = TorchVisionWorker(model_metadata)
            elif model_metadata.framework == &quot;triton&quot;:
                self.workers[model_metadata.id] = TritonWorker(model_metadata)
            else:
                raise ValueError(f&quot;Unsupported framework: {model_metadata.framework}&quot;)
        return self.workers[model_metadata.id]

    def delete_worker(self, model_id: str):
        if model_id in self.workers:
            del self.workers[model_id]</code></pre>
<p><code>ModelEngine</code>이 하는 일은 단순하다. <code>framework</code> 값을 보고 어떤 Worker 클래스를 만들지 결정하는 것뿐이다(Factory 패턴). 
<code>create_worker</code>가 호출되는 순간 Worker의 <code>__init__</code> → <code>_load_model()</code>이 실행되므로, <strong>이 함수 호출 자체가 &quot;모델을 메모리에 올린다&quot;는 행위와 동일하다.</strong> 
반대로 <code>delete_worker</code>는 참조를 지워 모델을 메모리에서 내린다.</p>
<p><code>ModelEngine</code>은 &quot;언제&quot; 만들고 지울지는 전혀 모른다. 그 결정은 다음 계층인 <code>ModelManager</code>가 한다.</p>
<hr>
<h2 id="6-modelmanager--lru로-지금-누구를-태울지-결정한다">6. ModelManager — LRU로 &quot;지금 누구를 태울지&quot; 결정한다</h2>
<p>이 서비스의 핵심이다.</p>
<pre><code class="language-python">class ModelManager:
    def __init__(self, model_store: ModelStore, max_models: int = 2):
        self.model_store = model_store
        self.max_models = max_models
        self.model_cache = OrderedDict()  # id -&gt; worker, LRU 순서 유지
        self.model_engine = ModelEngine()

    def get_model_worker(self, model_id: str) -&gt; Optional[ModelWorker]:
        # 1. 캐시에 있으면 그대로 반환 (Cache Hit)
        if model_id in self.model_cache:
            self.model_cache.move_to_end(model_id)  # 최근 사용으로 갱신
            return self.model_engine.get_worker(model_id)

        # 2. 메타데이터 확인
        model_metadata = self.model_store.get_model(model_id)
        if not model_metadata:
            return None

        # 3. 캐시가 가득 찼으면 가장 오래 안 쓴 모델을 내린다
        if len(self.model_cache) &gt;= self.max_models:
            id, _ = self.model_cache.popitem(last=False)
            self.model_engine.delete_worker(id)

        # 4. 새 모델을 로드하고 캐시에 넣는다 (Cache Miss)
        self.model_cache[model_id] = self.model_engine.create_worker(model_metadata)
        return self.model_cache[model_id]</code></pre>
<p><code>OrderedDict</code>를 LRU 큐로 쓰는 방식은 다음과 같이 동작한다.</p>
<pre><code class="language-text">Cache Hit  → move_to_end()로 &quot;방금 쓴 모델&quot;로 갱신, 로딩 없이 즉시 반환
Cache Miss → 캐시가 가득 찼다면 popitem(last=False)로 &quot;가장 오래 안 쓴 모델&quot;을 내림
           → create_worker()로 새 모델을 로드</code></pre>
<p><code>max_models=2</code>로 설정된 상태에서 요청이 다음 순서로 들어온다고 해보자.</p>
<pre><code class="language-text">① 감성 분석 요청  → Cache Miss → 로드 → [감성분석]
② 스팸 탐지 요청  → Cache Miss → 로드 → [감성분석, 스팸탐지]
③ 이미지 분류 요청 → Cache Miss, 캐시 가득 참
                  → 가장 오래된 &quot;감성분석&quot;을 내림
                  → 이미지 분류 로드     → [스팸탐지, 이미지분류]
④ 감성 분석 요청 다시 → 이미 캐시에서 빠졌으므로 다시 Cache Miss
                     → 이번엔 &quot;스팸탐지&quot;를 내리고 재로드 → [이미지분류, 감성분석]</code></pre>
<p>세 가지 모델을 번갈아 요청하면 매번 캐시가 밀려나는 것을 볼 수 있다. 이것이 바로 <strong>Cache Thrashing</strong>이다 — <code>max_models</code>보다 자주 바뀌는 모델 종류를 요청하면, 캐시가 있어도 매번 다시 로드하게 되어 캐시의 이점이 사라진다.</p>
<p>이 구조는 <strong>PagedAttention</strong>과 같은 발상이다. 
PagedAttention이 &quot;어떤 KV Cache Block을 GPU Memory에 남겨둘 것인가&quot;를 관리했다면, <code>ModelManager</code>는 &quot;어떤 모델 전체를 GPU Memory(혹은 프로세스 메모리)에 남겨둘 것인가&quot;를 관리한다. 
관리하는 단위가 KV Cache Block이냐 모델 전체냐만 다를 뿐, &quot;한정된 메모리 위에서 무엇을 남기고 무엇을 내릴지 결정하는 캐시 정책&quot;이라는 본질은 같다.</p>
<hr>
<h2 id="7-server--manager에게-결정을-위임한다">7. Server — Manager에게 결정을 위임한다</h2>
<pre><code class="language-python">@app.post(&quot;/predict&quot;)
async def predict(request: PredictionRequest):
    worker = model_manager.get_model_worker(request.model_id)
    if not worker:
        raise HTTPException(status_code=404, detail=f&quot;Model {request.model_id} not found&quot;)
    result = worker.predict(request.input_data)
    return result

@app.get(&quot;/models&quot;)
async def list_models():
    return {
        &quot;available_models&quot;: model_store.list_models(),      # Store: 존재하는 모델
        &quot;loaded_models&quot;: model_manager.list_loaded_models()  # Manager: 지금 로드된 모델
    }</code></pre>
<p><code>/predict</code> 엔드포인트는 요청받은 <code>model_id</code>가 지금 캐시에 있는지, 새로 로드해야 하는지 전혀 알지 못한다. <code>get_model_worker()</code> 한 줄 뒤에 그 판단이 모두 숨어 있다. <code>/models</code> 엔드포인트는 &quot;존재하는 모델 목록&quot;(Store)과 &quot;지금 로드된 모델 목록&quot;(Manager)을 나란히 보여주는데, 이 둘이 다를 수 있다는 사실 자체가 Multi-Model Serving의 핵심을 보여준다.</p>
<hr>
<h2 id="8-cold-start--세-번째-종류의-병목">8. Cold Start — 세 번째 종류의 병목</h2>
<p>3-2편에서는 Prefill을 Compute-bound로, Decode를 Memory-bound로 구분했다. Multi-Model Serving에는 여기에 없던 세 번째 병목이 등장한다. 바로 <strong>Model Loading Latency(Cold Start)</strong>다.</p>
<pre><code class="language-text">Cache Hit  → predict()만 실행 → 빠름
Cache Miss → _load_model() (가중치 로딩) + predict() → 느림</code></pre>
<p>캐시에 없는 모델을 처음 요청하면, 실제 추론(<code>predict</code>)보다 모델 가중치를 불러오는 과정(<code>_load_model</code>)이 훨씬 오래 걸리는 경우가 많다. 사용자 입장에서는 같은 모델에 같은 요청을 보냈는데도 어떤 요청은 빠르고 어떤 요청은 느린 것처럼 느껴진다.</p>
<p><code>max_models</code>를 키우면 Cold Start는 줄어들지만 메모리 사용량이 늘고, <code>max_models</code>를 줄이면 메모리는 아끼지만 트래픽 패턴에 따라 Cache Thrashing이 심해진다. 정답은 없고, <strong>실제 트래픽에서 모델별 요청 비율과 GPU 메모리 여유를 보고 정해야 하는 값</strong>이다.</p>
<hr>
<h2 id="9-routing--어떤-요청을-어떤-모델로-보낼-것인가">9. Routing — 어떤 요청을 어떤 모델로 보낼 것인가</h2>
<p>지금 이 서비스에서 라우팅은 단순하다. 클라이언트가 <code>model_id</code>를 직접 지정하면 그 모델로 간다.</p>
<pre><code class="language-json">{&quot;model_id&quot;: &quot;550e8400-...&quot;, &quot;input_data&quot;: &quot;This movie was great!&quot;}</code></pre>
<p>실제 서비스에서는 이보다 복잡한 라우팅이 필요할 수 있다.</p>
<ul>
<li><strong>내용 기반 라우팅</strong> : 입력이 텍스트인지 이미지인지 보고 자동으로 모델을 고른다.</li>
<li><strong>부하 기반 라우팅</strong> : 같은 모델의 복제본(Replica)이 여러 개 떠 있다면, 덜 바쁜 쪽으로 보낸다.</li>
<li><strong>버전 기반 라우팅</strong> : 같은 모델의 새 버전과 기존 버전에 트래픽을 나눠 A/B 테스트한다.</li>
</ul>
<p>이 데모는 라우팅 자체보다 &quot;선택된 모델을 어떻게 메모리에 올리고 내릴 것인가&quot;에 집중한 구조라고 이해하면 된다.</p>
<hr>
<h2 id="10-왜-triton-같은-전용-서버가-필요한가">10. 왜 Triton 같은 전용 서버가 필요한가</h2>
<p><code>TritonWorker</code>를 다시 보자. 이 Worker는 모델을 직접 들고 있지 않고, Triton Inference Server에 로드·추론을 위임했다.</p>
<pre><code class="language-text">우리가 만든 ModelManager
  → LRU 캐시로 &quot;최대 2개 모델&quot;이라는 단순한 정책만 구현

Triton Inference Server
  → 모델별 GPU 배치, Dynamic Batching, 모델 버전 관리,
    여러 GPU에 걸친 동시 실행까지 지원하는 전용 엔진</code></pre>
<p>앞선 글에서 &quot;Single Request → Batch → Streaming Batch&quot;를 직접 구현해보고 나서야 vLLM이 왜 필요한지 체감할 수 있었던 것처럼, 여기서도 마찬가지다. <code>ModelManager</code>의 LRU 캐시는 &quot;모델이 몇 개 안 되고, 정책도 단순할 때&quot;는 충분하다. 하지만 모델 수가 늘고, GPU가 여러 장이 되고, 모델마다 트래픽 패턴이 다양해지면 이런 로직을 직접 정교하게 관리하는 것보다 Triton 같은 <strong>Multi-Model Serving 전용 플랫폼에 위임</strong>하는 편이 낫다.</p>
<blockquote>
<p><strong>Multi-Model Serving도 결국 &quot;내가 관리할 것인가, 전용 엔진에 맡길 것인가&quot;의 문제로 수렴한다.</strong></p>
</blockquote>
<hr>
<h2 id="마무리">마무리</h2>
<ul>
<li><strong>문제의 본질</strong> : 한정된 GPU 메모리보다 서비스해야 할 모델의 총합이 클 때, 무엇을 올리고 내릴지 결정하는 캐시 정책이 필요하다.</li>
<li><strong>계층 분리</strong> : &quot;존재하는 모델&quot;(Store)과 &quot;지금 로드된 모델&quot;(Manager)을 분리하면 캐시 정책을 독립적으로 바꿀 수 있다.</li>
<li><strong>LRU 캐시</strong> : 가장 오래 쓰지 않은 모델을 내리고 새 모델을 올린다. KV Cache를 Block 단위로 관리하던 PagedAttention과 같은 발상을, 모델 전체 단위로 적용한 것이다.</li>
<li><strong>Cold Start</strong> : 캐시 미스는 모델 로딩 시간을 그대로 응답 지연에 더한다. <code>max_models</code>는 메모리와 Cold Start 사이의 Trade-off를 결정하는 값이다.</li>
<li><strong>위임이라는 선택지</strong> : 모든 모델을 직접 관리할 필요는 없다. TritonWorker처럼 전용 서빙 엔진에 모델 관리 자체를 맡길 수도 있다.</li>
</ul>
<p>1편에서는 GPU가 왜 딥러닝에 적합한 구조인지, 2편에서는 그 위에서 Transformer가 어떻게 계산되는지 살펴봤다. 3-1편과 3-2편에서는 이 모델을 서비스로 만들 때 어디가 병목이 되는지 — Prefill의 Compute-bound, Decode의 Memory-bound, 그리고 KV Cache의 Trade-off를 짚었다. 4편-1과 4편-2에서는 이 이론을 직접 코드로 옮겨, 하나의 모델을 효율적으로 서비스하는 문제(Batching, Streaming, vLLM)와 여러 모델을 제한된 자원 위에서 같이 서비스하는 문제(캐시, Cold Start, Triton)를 모두 다뤘다.</p>
<p>결국 핵심은 다음이다. </p>
<blockquote>
<p><strong>한정된 Compute와 Memory 위에서, 무엇을 언제 실행하고 무엇을 언제 내릴 것인가.</strong></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[4-1편. LLM Serving Service(Single Model Serving)]]></title>
            <link>https://velog.io/@_gyullbb/4-1%ED%8E%B8.-LLM-Serving-ServiceSingleModelServing</link>
            <guid>https://velog.io/@_gyullbb/4-1%ED%8E%B8.-LLM-Serving-ServiceSingleModelServing</guid>
            <pubDate>Sat, 15 Aug 2026 08:58:38 GMT</pubDate>
            <description><![CDATA[<p>3-1편에서는 LLM Serving의 전체 구조를 살펴봤고, 3-2편에서는 Prefill, Decode, KV Cache를 통해 <strong>LLM이 실제로 어디에서 병목이 발생하는지</strong> 살펴봤다.</p>
<p>이번 편에서는 이론에서 벗어나 직접 작은 LLM Serving Service를 만들어본다.</p>
<p>가장 단순한 구조에서 시작해서 기능을 하나씩 추가해보며 확인해보자.</p>
<p>코드는 <a href="https://github.com/orca3/llm-model-inference%EC%9D%98">https://github.com/orca3/llm-model-inference의</a> 코드를 기반으로 확인한다.</p>
<pre><code class="language-text">Single Request
      ↓
Batch Request
      ↓
Streaming Batch
      ↓
vLLM</code></pre>
<hr>
<h2 id="1-single-request">1. Single Request</h2>
<p>가장 단순한 Serving System 구조이다.</p>
<pre><code class="language-text">┌──────────┐
│  Client  │
└────┬─────┘
     │ HTTP Request
     ↓
┌──────────────┐
│ Serving API  │
└──────┬───────┘
       ↓
   Tokenizer
       ↓
     Model
       ↓
     GPU
       ↓
  Detokenizer
       ↓
   Response</code></pre>
<p>사용자가 Prompt를 보내면 서버가 모델을 실행하고 생성된 결과를 반환한다.</p>
<p>요청 하나가 들어와서 응답이 나가기까지, 코드는 정확히 이 순서로 실행된다.</p>
<h3 id="1-api가-prompt를-받는다">(1) API가 prompt를 받는다</h3>
<pre><code>async def basic_generate(request, llm = Depends(get_llm)):
    generated_text = llm.basic_generate(request.prompt)</code></pre><p>prompt를 그대로 <code>llm.basic_generate</code>에 넘긴다.</p>
<h3 id="2-prompt를-sequence로-감싸-model_executor로-보낸다">(2) prompt를 Sequence로 감싸 model_executor로 보낸다</h3>
<pre><code>sequence = Sequence(str(uuid.uuid4()), prompt, None, None)
results = self.model_executor.execute_batch([sequence])</code></pre><p>prompt에 고유 id를 붙여 <code>Sequence</code>로 감싼 다음, 리스트에 담아 <code>execute_batch</code>에 넘긴다. 지금은 요청이 하나이기 때문에 길이 1짜리 배치를 보내게 된다.</p>
<h3 id="3-model_executor는-큐에-작업을-던지고-결과를-기다린다-api--gpu-연산-분리">(3) model_executor는 큐에 작업을 던지고 결과를 기다린다 (API / GPU 연산 분리)</h3>
<pre><code>self.task_queue.put((prompts, False))
results = self.result_queue.get()   # 결과가 올 때까지 블로킹</code></pre><p>여기서부터 API 프로세스와 실제 GPU 연산을 하는 프로세스가 분리된다. <code>execute_batch</code>는 작업을 <code>task_queue</code>에 던져놓고, <code>result_queue.get()</code>에서 결과가 돌아올 때까지 기다린다. 
모델 실행을 별도 프로세스로 떼어낸 이유는, 무거운 GPU 연산이 API 서버의 이벤트 루프를 막지 않게 하기 위해서다.</p>
<h3 id="4-worker는-큐에서-한번에-하나씩-작업을-꺼내-처리한다">(4) worker는 큐에서 한번에 하나씩 작업을 꺼내 처리한다</h3>
<pre><code>while True:
    batch_data = task_queue.get()
    ...
    result_queue.put((&#39;complete&#39;, worker.generate(batch)))</code></pre><p>전체 구조에서 실제로 모델을 돌리는 곳은 이 <code>while</code> 루프뿐이다. 
worker 프로세스도 하나, 루프도 하나이기 때문에 <code>task_queue.get()</code>으로 작업을 하나 꺼내면, <code>worker.generate(batch)</code>가 끝나기 전까지는 절대 다음 작업을 꺼내지 않는다.</p>
<p>즉 사용자 A, B, C가 동시에 요청을 보내도 세 요청은 모두 이 하나의 <code>while</code> 루프 앞에 줄을 서게 된다. A의 생성이 끝나야 B가 큐에서 꺼내지고, B가 끝나야 C 차례가 온다 
GPU 안에는 병렬로 계산할 여유가 있어도, 루프가 한 번에 batch 하나만 꺼내기 때문에 대기를 하게된다.</p>
<h3 id="5-worker-내부에서-추론-작업을-진행한다">(5) worker 내부에서 추론 작업을 진행한다</h3>
<pre><code>inputs = self.tokenizer(prompt_texts, padding=True, truncation=True, max_length=512).to(self.device)
outputs = self.model.generate(inputs.input_ids, attention_mask=inputs.attention_mask, max_new_tokens=50)
generated_texts = self.tokenizer.batch_decode(outputs, skip_special_tokens=True)</code></pre><p>prompt를 토크나이징하고, <code>model.generate</code>로 토큰을 생성하고, 다시 텍스트로 디코딩하는 세 단계다. </p>
<p>결과</p>
<pre><code>curl -s -X POST http://localhost:8000/basic_generate \
  -H &quot;Content-Type: application/json&quot; \
  -d &#39;{&quot;prompt&quot;: &quot;The weather is&quot;}&#39; | jq
{
  &quot;generated_text&quot;: &quot;The weather is going to be a bit of a problem for the next few days.\nThe weather is going to be a bit of a problem for the next few days.\nThe weather is going to be a bit of a problem for the next few days.&quot;
}</code></pre><p>이 서버에 사용자가 한 명만 접속한다면 문제가 없지만, 동시에 요청이 들어오면 어떻게 될까?</p>
<pre><code class="language-text">User A → Request
User B → Request
User C → Request</code></pre>
<p>방금 살펴본 구조라면 아래처럼 하나의 요청이 끝나야지만 다음 요청 처리가 가능하다.</p>
<pre><code class="language-text">A 처리
 ↓
B 처리
 ↓
C 처리</code></pre>
<p>앞서 1편에서 살펴봤듯 GPU는 <strong>많은 연산을 병렬적으로 처리할 때 강하기.</strong> 때문에 이렇게 되면 GPU를 제대로 활용하지 못하게 된다.</p>
<p>그렇기 때문에 여러 요청을 동시에 GPU에 넣을 수 있도록 구조 변경이 필요하다.</p>
<p>여기서 <strong>Batching</strong>이 등장한다.</p>
<hr>
<h2 id="2-batch-request">2. Batch Request</h2>
<p>1번의 문제는 결국 하나였다. worker가 큐에서 작업을 하나씩만 꺼내 가기 때문에, 여러 요청이 GPU 앞에 줄을 서야 했다. 그렇다면 큐에서 하나가 아니라 <strong>여러 개를 한꺼번에 묶어서</strong> 꺼내면 어떨까?</p>
<pre><code class="language-text">┌──────────┐
│  Client  │  prompts: [p1, p2, p3, p4, p5 ...]
└────┬─────┘
     │ HTTP Request
     ↓
┌──────────────┐
│ Serving API  │
└──────┬───────┘
       ↓
 WorkloadManager   ← 대기 중인 요청을 batch_size(4)개까지 모은다
       ↓
 model.generate(batch)  ← 한 번의 GPU 호출로 여러 prompt를 동시에 처리
       ↓
    Response (여러 개)</code></pre>
<h3 id="1-api는-prompt-하나가-아니라-리스트를-받는다">(1) API는 prompt 하나가 아니라 리스트를 받는다</h3>
<pre><code>@app.post(&quot;/generate&quot;, response_model=BatchGenerateResponse)
async def generate(request: BatchGenerateRequest, llm = Depends(get_llm)):
    generated_texts = llm.generate(request.prompts)</code></pre><p><code>GenerateRequest.prompt: str</code> 대신 <code>BatchGenerateRequest.prompts: List[str]</code>를 받는다.</p>
<h3 id="2-모든-prompt를-큐에-등록하고-다-끝날-때까지-배치를-반복-실행한다">(2) 모든 prompt를 큐에 등록하고, 다 끝날 때까지 배치를 반복 실행한다</h3>
<pre><code>request_ids = [self.workload_manager.add_request(p) for p in prompts]

while not self._is_batch_finished(request_ids):
    sequences = self.workload_manager.get_next_batch()
    results = self.model_executor.execute_batch(sequences)
    for result in results[1]:
        self.workload_manager.update_sequence_output(
            result[&#39;request_id&#39;], result[&#39;generated_text&#39;], is_finished=True
        )</code></pre><p>호출하는 <code>execute_batch</code>는 1번과 똑같은 함수다. 달라지는 건 넘기는 <code>sequences</code>가 하나가 아니라 여러 개라는 점이다.</p>
<h3 id="3-workloadmanager가-실제로-요청을-묶는-지점--1번의-한계가-풀리는-곳">(3) WorkloadManager가 실제로 요청을 &quot;묶는&quot; 지점 — 1번의 한계가 풀리는 곳</h3>
<pre><code>def get_next_batch(self, is_streaming=False):
    while len(self.active_sequences) &lt; self.batch_size and not self.incoming_queue.empty():
        self.active_sequences.append(self.incoming_queue.get())
    return self.active_sequences</code></pre><p><code>batch_size</code>는 4로 고정돼 있다. 대기 큐에 쌓인 요청 중 최대 4개를 한 번에 꺼내 하나의 배치로 묶는다. 
이 큐는 한 사용자의 prompt들만 모으는 게 아니라 <code>/generate</code>를 호출한 <strong>서로 다른 사용자의 요청도 순서대로 같이 쌓인다</strong>.
즉 A와 B가 동시에 요청을 보내면, 둘이 같은 배치에 섞여 같은 GPU 호출 한 번으로 함께 처리될 수 있다. </p>
<p>worker 쪽 <code>generate()</code> 코드 자체는 1번과 완전히 동일하다. 다만 이제 <code>prompts</code> 인자에 여러 개의 Sequence가 들어있을 뿐이다. <code>padding=True</code>로 길이가 다른 prompt들을 맞춰주던 것도, 사실 배치 크기가 1보다 클 때를 위한 장치였다는 게 여기서 드러난다.</p>
<p>결과 (prompt 5개를 보내면 <code>batch_size=4</code>라 4개, 1개 총 두 번의 배치로 나뉘어 처리된다)</p>
<pre><code>curl -X POST http://localhost:8000/generate \
  -H &quot;Content-Type: application/json&quot; \
  -d &#39;{&quot;prompts&quot;: [&quot;Hello, I am&quot;, &quot;The weather is&quot;, &quot;I want to&quot;, &quot;The best way to&quot;, &quot;The most efficient way to&quot;]}&#39; | jq
{
  &quot;generated_texts&quot;: [
    &quot;Hello, I am a student at the University of California, Berkeley. I am a graduate student in the Department of Psychology. I am a graduate student in the Department of Psychology. I am a graduate student in the Department of Psychology. I am a graduate student in the&quot;,
    &quot;The weather is, of course, a factor in the weather.\n\nThe weather is a factor in the weather.\n\nThe weather is a factor in the weather.\n\nThe weather is a factor in the weather.\n\nThe weather is a factor&quot;,
    &quot;I want toand I want to be a part of this.\nI want to be a part of this.\nI want to be a part of this.\nI want to be a part of this.\nI want to be a part of this.&quot;,
    &quot;The best way to get a job is to get a job.                                         &quot;,
    &quot;The most efficient way to get a job is to get a job.                                         &quot;
  ]
}</code></pre><p><strong>서버 로그 참조</strong></p>
<pre><code>4개의 prompt 먼저 처리
2026-08-15 22:51:04,344 - llm.model_executor - DEBUG - Sending batch to worker: [&lt;llm.workload_manager.Sequence object at 0x30b028b50&gt;, &lt;llm.workload_manager.Sequence object at 0x41b7af550&gt;, &lt;llm.workload_manager.Sequence object at 0x41b7aed10&gt;, &lt;llm.workload_manager.Sequence object at 0x41b7af3d0&gt;]
DEBUG:llm.model_executor:Sending batch to worker: [&lt;llm.workload_manager.Sequence object at 0x30b028b50&gt;, &lt;llm.workload_manager.Sequence object at 0x41b7af550&gt;, &lt;llm.workload_manager.Sequence object at 0x41b7aed10&gt;, &lt;llm.workload_manager.Sequence object at 0x41b7af3d0&gt;]
2026-08-15 22:51:04,344 - llm.model_executor - DEBUG - Waiting for results from worker
...
2026-08-15 22:51:04,345 - llm.model_worker - DEBUG - Batch input shape: torch.Size([4, 5])


나머지 1개의 프롬프트 처리
...
2026-08-15 22:51:06,981 - llm.model_worker - DEBUG - Received prompts: [&lt;llm.workload_manager.Sequence object at 0x16f844f50&gt;]
2026-08-15 22:51:06,982 - llm.model_worker - DEBUG - Batch input shape: torch.Size([1, 6])
...
INFO:     127.0.0.1:60584 - &quot;POST /generate HTTP/1.1&quot; 200 OK


</code></pre><p><code>/generate</code>는 배치 안 prompt들이 <strong>지정한 개수의 토큰이 다 생성될 때까지(코드 예제에서는 최대 50개의 토큰)</strong> 기다렸다가 결과를 한꺼번에 돌려준다. 
배치에 짧게 끝나는 prompt와 길게 끝나는 prompt가 섞이면, 짧은 쪽은 이미 다 끝났는데도 긴 쪽이 끝날 때까지 배치 안의 자리를 계속 붙잡고 있어야 한다. 
이 문제를 풀기 위해 등장하는 게 <strong>Streaming Batch</strong>다.</p>
<hr>
<h2 id="3-streaming-batch">3. Streaming Batch</h2>
<p>Streaming Batch의 핵심 아이디어는 한 번에 &quot;prompt 전체를 끝까지&quot; 생성하지 말고, <strong>토큰을 한 개씩만 생성</strong>하고, 
끝난 요청은 즉시 배치에서 빼고, 그 빈자리에 대기 중이던 다음 요청을 채워 넣자는 것이다.</p>
<pre><code class="language-text">┌──────────┐
│  Client  │
└────┬─────┘
     │ HTTP Request (SSE)
     ↓
┌──────────────┐
│ Serving API  │──yield──▶ token, token, token, ... (완료될 때까지)
└──────┬───────┘
       ↑ asyncio.Queue (요청별 1개)
       │
 requests_processing_loop (백그라운드 스레드)
       │  매 반복마다 활성 시퀀스들에게 &quot;딱 한 토큰씩&quot;만 생성
       ↓
   model(...)  ← model.generate()가 아니라 forward 1스텝</code></pre>
<h3 id="1-api는-결과-전체가-아니라-토큰-스트림을-server-sent-events로-돌려준다">(1) API는 결과 전체가 아니라 토큰 스트림을 Server-Sent Events로 돌려준다</h3>
<pre><code>@app.post(&quot;/generate_stream&quot;)
async def generate_stream(request: GenerateRequest, llm = Depends(get_llm)):
    async def event_generator():
        async for token in llm.event_generator(loop, request.prompt):
            yield token
    return StreamingResponse(event_generator(), media_type=&quot;text/event-stream&quot;)</code></pre><p><code>StreamingResponse</code>는 결과가 다 모일 때까지 기다리지 않고, <code>yield</code>되는 즉시 클라이언트로 흘려보낸다.</p>
<h3 id="2-요청마다-자기-전용-큐를-만들고-거기서-토큰이-나올-때까지-기다린다">(2) 요청마다 자기 전용 큐를 만들고, 거기서 토큰이 나올 때까지 기다린다</h3>
<pre><code>queue = asyncio.Queue()
seq_id = self.workload_manager.add_streaming_request(prompt, queue, loop)

while True:
    data = await queue.get()
    if data is None:      # 종료 신호
        break
    yield f&quot;data: {data}\n\n&quot;</code></pre><p>이 큐를 채우는 주체는 이 API 핸들러 자신이 아니다. <code>LLMEngine</code>이 서버가 뜰 때부터 별도 스레드에서 계속 돌리고 있는 처리 루프가 채운다.</p>
<h3 id="3-백그라운드-스레드가-활성-시퀀스들을-모아-한-스텝씩만-돌린다">(3) 백그라운드 스레드가 활성 시퀀스들을 모아 &quot;한 스텝씩&quot;만 돌린다</h3>
<pre><code>while True:
    active_sequences = self.workload_manager.get_next_batch(is_streaming=True)
    prompts = [{&#39;prompt&#39;: seq.prompt, &#39;request_id&#39;: seq.id} for seq in active_sequences]
    results = self.model_executor.execute_forward_batch(prompts)

    for result in results:
        seq = self.workload_manager.get_sequence(result[&#39;request_id&#39;])
        if result[&#39;is_finished&#39;] or seq.token_count &gt; self.max_tokens:
            asyncio.run_coroutine_threadsafe(seq.client_stream.put(None), seq.loop)
            self.workload_manager.remove_finished_sequence(result[&#39;request_id&#39;])
        else:
            asyncio.run_coroutine_threadsafe(
                seq.client_stream.put(json.dumps({&quot;token&quot;: result[&#39;token&#39;], &quot;sequence_id&quot;: result[&#39;request_id&#39;]})),
                seq.loop
            )</code></pre><p>2번과 똑같은 <code>get_next_batch</code>를 쓰지만 <code>is_streaming=True</code>를 넘겨서, 스트리밍 전용 큐를 대상으로 최대 4개까지 모은다. 
다른 점은 이 4개를 완료될 때까지 붙잡지 않는다는 것이다. 
매 반복은 &quot;딱 한 토큰&quot;만 생성하고 곧바로 다음 반복으로 넘어가기 때문에, 어떤 시퀀스가 먼저 끝나면(<code>is_finished</code>) 그 즉시 <code>remove_finished_sequence</code>로 자리를 비우고, 다음 반복에서 대기 중이던 새 요청이 그 자리를 채운다. 
짧은 요청이 긴 요청 때문에 자리를 계속 붙잡고 있어야 했던 한계가 여기서 해소된다.</p>
<h3 id="4-worker는-modelgenerate-대신-딱-한-스텝의-forward-pass만-실행한다">(4) worker는 model.generate() 대신 딱 한 스텝의 forward pass만 실행한다</h3>
<pre><code>outputs = self.model(input_ids=..., attention_mask=..., use_cache=False)
next_token_logits = outputs.logits[:, -1, :]
next_token = torch.multinomial(torch.softmax(next_token_logits / 0.7, dim=-1), num_samples=1)</code></pre><p>1·2번의 <code>worker.generate()</code>는 내부에서 최대 50번 토큰을 반복 생성하는 <code>model.generate()</code>를 한 번에 통째로 호출했다. 
여기서는 <code>self.model(...)</code>을 딱 한 번만 호출해서 다음 토큰의 logits만 얻고 샘플링까지만 한다. &quot;한 스텝 = 한 토큰&quot;이라는 단위가 이 함수 안에서 결정된다.</p>
<p><strong>결과 (토큰이 하나씩 도착한다)</strong></p>
<pre><code>curl -N -H &quot;Accept: text/event-stream&quot; \
     -H &quot;Content-Type: application/json&quot; \
     -d &#39;{&quot;prompt&quot;: &quot;The weather is&quot;}&#39; \
     http://localhost:8000/generate_stream

data: {&quot;token&quot;: &quot;.&quot;, &quot;sequence_id&quot;: &quot;614f4775-c6b0-4472-9279-8e65eee349de&quot;}

data: {&quot;token&quot;: &quot; &quot;, &quot;sequence_id&quot;: &quot;614f4775-c6b0-4472-9279-8e65eee349de&quot;}

data: {&quot;token&quot;: &quot; &quot;, &quot;sequence_id&quot;: &quot;614f4775-c6b0-4472-9279-8e65eee349de&quot;}

data: {&quot;token&quot;: &quot; Also&quot;, &quot;sequence_id&quot;: &quot;614f4775-c6b0-4472-9279-8e65eee349de&quot;}

data: {&quot;token&quot;: &quot;,&quot;, &quot;sequence_id&quot;: &quot;614f4775-c6b0-4472-9279-8e65eee349de&quot;}

data: {&quot;token&quot;: &quot; I&quot;, &quot;sequence_id&quot;: &quot;614f4775-c6b0-4472-9279-8e65eee349de&quot;}

data: {&quot;token&quot;: &quot;&#39;m&quot;, &quot;sequence_id&quot;: &quot;614f4775-c6b0-4472-9279-8e65eee349de&quot;}
...</code></pre><p>이제 여러 사용자가 동시에 <code>/generate_stream</code>을 호출해도, 같은 처리 루프 안에서 토큰 단위로 번갈아 생성되기 때문에 한 사용자의 생성이 끝날 때까지 다른 사용자가 통째로 기다릴 필요가 없다. 
다만 이 구조는 우리가 큐, 스레드, <code>while True</code> 루프를 직접 짜서 만든 것이다. 
실제 서비스에서는 이런 배칭과 스케줄링을 훨씬 정교하게 대신해주는 라이브러리가 있다. 바로 vLLM이다.</p>
<hr>
<h2 id="4-vllm">4. vLLM</h2>
<h3 id="1-서버가-뜰-때-vllm-모델도-함께-로드한다">(1) 서버가 뜰 때 vLLM 모델도 함께 로드한다</h3>
<pre><code>from vllm import LLM as VLLM, SamplingParams

self.vllm_model = VLLM(model=&quot;facebook/opt-125m&quot;)</code></pre><p>지금까지 직접 만든 <code>WorkloadManager</code> + <code>ModelExecutor</code> + <code>ModelWorker</code> 조합을, vLLM 하나가 통째로 대신한다.</p>
<h3 id="2-prompt-리스트를-그대로-넘기기만-하면-된다">(2) prompt 리스트를 그대로 넘기기만 하면 된다</h3>
<pre><code>sampling_params = SamplingParams(temperature=0.7, top_p=0.95, max_tokens=self.max_tokens)
outputs = self.vllm_model.generate(prompts, sampling_params)
generated_texts = [output.outputs[0].text for output in outputs]</code></pre><p>큐도, worker 프로세스도, 고정된 <code>batch_size</code>도, &quot;한 토큰씩 생성해서 끝난 자리를 비워주는&quot; 로직도 코드에 보이지 않는다. <code>vllm_model.generate(prompts, ...)</code> 한 줄이 내부적으로 다 처리한다.</p>
<pre><code>@app.post(&quot;/generate_vllm&quot;, response_model=BatchGenerateResponse)
async def generate_vllm(request: BatchGenerateRequest, llm = Depends(get_llm)):
    generated_texts = llm.generate_vllm(request.prompts)
    return BatchGenerateResponse(generated_texts=generated_texts)</code></pre><p><strong>결과</strong></p>
<pre><code>curl -X POST http://localhost:8000/generate_vllm \
  -H &quot;Content-Type: application/json&quot; \
  -d &#39;{&quot;prompts&quot;: [&quot;Hello, I am&quot;, &quot;The weather is&quot;, &quot;Once upon a time&quot;]}&#39; | jq
{
  &quot;generated_texts&quot;: [
    &quot; interested in a female HA Klefki if you are interested in a HA Raichu if you&quot;,
    &quot; definitely a factor, too. You can&#39;t go out and run around in the rain because it&#39;s&quot;,
    &quot;, I had a very good relationship with a woman. She was into music, and she was into&quot;
  ]
}</code></pre><p>앞서 큐로 요청을 모으고, 고정된 <code>batch_size</code>만큼 배치를 구성하고, 한 토큰씩 생성해서 끝난 시퀀스의 자리를 비워주도록 짠 코드를 vLLM은 <strong>Continuous Batching</strong>으로 내부에서 훨씬 정교하게 자동 처리한다. 
매 스텝마다 배치를 다시 구성하되 요청을 더 세밀하게 추가·제거하고, KV Cache도 <strong>PagedAttention</strong>으로 메모리 낭비 없이 관리한다.</p>
<h3 id="pagedattention을-동작-로그로-확인하기">PagedAttention을 동작 로그로 확인하기</h3>
<p>vLLM은 초기화 시점과 요청 처리 중에 KV Cache Block 상태를 로그로 그대로 찍어준다. <code>requirements.txt</code>에 고정된 <code>vllm==0.9.0.1</code> 기준으로, <code>vllm/v1/core/kv_cache_utils.py</code>와 <code>vllm/v1/metrics/loggers.py</code>에 이 로그를 찍는 코드가 그대로 들어 있다.</p>
<pre><code class="language-python">
import os
import sys
import nest_asyncio

# Colab 환경에서 vLLM V1 엔진의 fileno() 오류 회피
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
nest_asyncio.apply()

# VLLM_USE_V1 환경 변수는 최신 vLLM에서 무시될 수 있지만, 호환성을 위해 유지
os.environ[&quot;VLLM_USE_V1&quot;] = &quot;0&quot; 

from vllm import LLM, SamplingParams

llm = LLM(model=&quot;facebook/opt-125m&quot;)

# vllm==0.9.0.1 에서는 llm.llm_engine.cache_config였지만,
# 이후 버전에서는 llm.llm_engine.vllm_config.cache_config로 위치가 바뀌었다.
try:
    cache_config = llm.llm_engine.cache_config
except AttributeError:
    cache_config = llm.llm_engine.vllm_config.cache_config

print(cache_config)
print(cache_config.block_size)   
print(cache_config.num_gpu_blocks)
</code></pre>
<p>위 코드를 실행하면 초기화 로그에 다음 세 줄이 찍힌다.</p>
<pre><code class="language-text">INFO 08-15 15:35:45 [kv_cache_utils.py:2235] GPU KV cache size: 376,752 tokens
INFO 08-15 15:35:45 [kv_cache_utils.py:2236] Maximum concurrency for 2,048 tokens per request: 183.96x
</code></pre>
<p><code>num_gpu_blocks</code>가 바로  <strong>Block Pool의 실제 크기</strong>다. 
<code>GPU KV cache size</code>는 <code>num_gpu_blocks × block_size</code>로 계산된 전체 토큰 수이고, <code>Maximum concurrency</code>는 &quot;이 Block Pool로 <code>max_model_len</code>짜리 요청을 최대 몇 개 동시에 감당할 수 있는가&quot;다. Python에서 직접 값을 꺼내볼 수도 있다.</p>
<pre><code class="language-python">print(llm.llm_engine.cache_config.block_size)       # 예: 16 (Block 하나가 담는 Token 수)
print(llm.llm_engine.cache_config.num_gpu_blocks)    # 예: 8192 (물리 Block 총 개수)</code></pre>
<p>이 값이 <strong>모델이나 Prompt 길이가 아니라 GPU 여유 메모리(<code>gpu_memory_utilization</code>)로 정해진다</strong>는 사실 자체가, 요청마다 크기가 다른 KV Cache를 매번 새로 통짜로 할당하는 게 아니라 미리 잘라둔 고정 크기 Block Pool에서 빌려 쓴다는 증거다.</p>
<p>더 확실하게 보려면, 짧은 요청과 긴 요청을 섞어 동시에 여러 개 보내고 실행 중 로그를 지켜보면 된다.</p>
<pre><code class="language-python">prompts = [&quot;Hi&quot;] * 20 + [&quot;Explain how transformers work in detail. &quot; * 20] * 5
outputs = llm.generate(prompts, SamplingParams(max_tokens=100))</code></pre>
<p>요청이 몰리는 동안 다음과 같은 로그가 주기적으로 찍힌다.</p>
<pre><code class="language-text">(TBD)</code></pre>
<p><code>GPU KV cache usage</code>가 요청이 늘어날수록 올라가고, 요청이 끝나 Block이 반납되면 다시 내려가는 걸 볼 수 있다. 
<code>ModelManager</code>의 LRU 캐시가 &quot;모델 전체&quot; 단위로 하던 일을, vLLM은 &quot;KV Cache Block&quot; 단위로 훨씬 잘게 쪼개서 하고 있다는 뜻이다. 
직접 Block Pool을 구현하지 않아도, <strong>로그에 찍히는 <code>num_gpu_blocks</code>와 <code>GPU KV cache usage</code> 두 값만으로 PagedAttention이 실제로 동작 중이라는 것을 충분히 확인할 수 있다.</strong> (코드 오류로 인해 추후 수정예정)</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>지금까지 만든 네 가지 버전을 한 줄씩 정리하면 다음과 같다.</p>
<ul>
<li><strong>Single Request</strong> : 요청 하나를 큐에 넣고 worker가 하나씩 처리한다. 동시 요청이 오면 GPU가 비어 있어도 순서대로 줄을 서야 했다.</li>
<li><strong>Batch Request</strong> : 여러 요청을 <code>batch_size</code>만큼 묶어 한 번의 GPU 호출로 처리한다. 다만 배치 안에서 가장 긴 요청이 끝날 때까지 짧은 요청도 자리를 붙잡고 기다려야 했다.</li>
<li><strong>Streaming Batch</strong> : 한 번에 한 토큰씩만 생성하고, 끝난 요청은 즉시 배치에서 빼고 새 요청으로 채운다. 이것이 3-2편에서 살펴본 <strong>Continuous Batching</strong>을 손으로 구현한 버전이다.</li>
<li><strong>vLLM</strong> : 지금까지 직접 짠 큐·스레드·배치 관리 로직을 Continuous Batching과 PagedAttention으로 대체한다.</li>
</ul>
<p>단계별로 버전을 따라가면서 왜 vLLM 같은 프레임워크가 필요한지를 알 수 있다.
Single Request의 한계는 Batching이, Batching의 한계는 Streaming Batch가, 그리고 Streaming Batch를 직접 운영하는 부담은 vLLM이 풀어준다.</p>
<p>지금까지는 <strong>하나의 GPU에서 하나의 모델</strong>을 서비스하는 문제를 다뤘다.</p>
<blockquote>
<p><strong>&quot;만약 하나의 GPU에서 여러 개의 LLM을 서비스해야 한다면?&quot;</strong></p>
</blockquote>
<p>여기서부터는 <strong>Multi-Model Serving</strong>이 새로운 문제로 등장한다.</p>
<ul>
<li>여러 모델을 GPU Memory에 어떻게 함께 올릴 것인가</li>
<li>요청이 들어올 때마다 모델을 새로 로드해야 한다면 그 비용은 어떻게 감당할 것인가</li>
<li>어떤 요청을 어떤 모델로 보낼 것인가 (Routing)</li>
<li>모델마다 다른 Latency와 Cost를 어떻게 균형 있게 관리할 것인가</li>
</ul>
<p>다음 편에서는 Mult-Model Serving에 대해 알아본다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[3-2편. Prefill, Decode, KV Cache]]></title>
            <link>https://velog.io/@_gyullbb/3-2%ED%8E%B8.-Prefill-Decode-KV-Cache-6fehzwqc</link>
            <guid>https://velog.io/@_gyullbb/3-2%ED%8E%B8.-Prefill-Decode-KV-Cache-6fehzwqc</guid>
            <pubDate>Sat, 15 Aug 2026 08:52:46 GMT</pubDate>
            <description><![CDATA[<p>3-1편에서는 LLM을 실제 서비스로 제공하기 위해 어떤 문제를 해결해야 하는지 전체 지도를 그렸다. Request를 받고, Tokenization을 거치고, 여러 요청을 관리하고, GPU에서 모델을 실행한 뒤 Streaming으로 결과를 전달한다. 그리고 Quantization, Batching, Speculative Decoding, KV Cache 등 다양한 최적화 방법도 살펴봤다.</p>
<p>그중에서도 계속 등장했던 개념이 <strong>KV Cache</strong>다.</p>
<p>왜 KV Cache를 별도로 깊게 다뤄야 할까? LLM Serving의 성능을 이해하려면 결국 다음 질문에 답할 수 있어야 하기 때문이다.</p>
<blockquote>
<p><strong>GPU에서 LLM은 실제로 무엇을 계산하고, 무엇을 메모리에서 읽고 있는가?</strong></p>
</blockquote>
<p>이 질문에 답하기 위해 LLM의 생성 과정을 <strong>Prefill과 Decode</strong>로 나누어 본다. 그리고 이 둘의 차이를 이해하면 <strong>왜 Prefill은 Compute-bound이고 Decode는 Memory-bound인가?</strong>라는 질문도 자연스럽게 풀린다.</p>
<hr>
<h2 id="1-prefill과-decode">1. Prefill과 Decode</h2>
<h3 id="1-1-autoregressive-generation">1-1. Autoregressive Generation</h3>
<p>LLM은 답변 전체를 한 번에 생성하지 않는다. 2편에서 Decoder-only Transformer를 설명하며 살펴봤듯, 다음 Token을 하나 생성하고 그 결과를 다시 입력으로 사용해 다음 Token을 생성하는 과정을 반복한다. 이를 <strong>Autoregressive Generation</strong>이라고 한다.</p>
<p>Serving 관점에서 보면 이 생성 과정은 크게 두 단계로 나뉜다.</p>
<pre><code class="language-text">Prompt
  ↓
┌──────────┐
│ Prefill  │
└──────────┘
  ↓
First Token
  ↓
┌──────────┐
│  Decode  │
└──────────┘
  ↓
Next Token → Next Token → ...</code></pre>
<p>바로 <strong>Prefill</strong>과 <strong>Decode</strong>다.</p>
<hr>
<h3 id="1-2-prefill--입력-전체를-처리하는-단계">1-2. Prefill — 입력 전체를 처리하는 단계</h3>
<p>사용자가 긴 Prompt를 보냈다고 생각해보자.</p>
<pre><code class="language-text">Token 1
Token 2
...
Token 1000</code></pre>
<p>Prefill 단계에서는 이 입력 전체를 모델에 한 번에 넣는다. 많은 Token을 GPU에서 병렬로 처리할 수 있다는 뜻이다.</p>
<pre><code class="language-text">T1 ─┐
T2 ─┤
... ├──→ Transformer → GPU
T1000┘</code></pre>
<p>Attention, Linear Layer, FFN의 Matrix 연산이 대규모로 발생하기 때문에 GPU의 Compute 자원을 적극적으로 활용할 수 있다. 그래서 Prefill은 일반적으로 <strong>Compute-bound</strong> 특성을 가진다.</p>
<pre><code class="language-text">많은 Token → 대규모 Matrix 연산 → GPU Compute 활용 → Compute-bound</code></pre>
<p>즉 Prefill에서는 GPU의 <strong>연산 능력</strong>이 중요한 요소가 된다.</p>
<hr>
<h3 id="1-3-decode--한-token씩-생성하는-단계">1-3. Decode — 한 Token씩 생성하는 단계</h3>
<p>Prefill이 끝나면 모델은 첫 번째 출력 Token을 생성한다. 그 이후부터는 Decode 단계다.</p>
<p>Decode에서는 한 번에 새로운 Token 하나만 생성한다. 따라서 Prefill과 달리 GPU에 들어가는 새로운 Token 수가 매우 적다.</p>
<p>그런데 새로운 Token을 생성하려면 이전 Token들의 Attention 정보를 참고해야 한다. 여기서 <strong>KV Cache</strong>가 등장한다.</p>
<hr>
<h2 id="2-kv-cache">2. KV Cache</h2>
<h3 id="2-1-kv-cache가-없다면">2-1. KV Cache가 없다면?</h3>
<p>Token 4를 생성하려면 이전 Token 1<del>3에 대한 Key와 Value를 계산해야 한다. Token 5를 생성할 때는 Token 1</del>4에 대한 Key와 Value를 다시 계산한다.</p>
<pre><code class="language-text">Step 1  T1
Step 2  T1 T2
Step 3  T1 T2 T3
Step 4  T1 T2 T3 T4
Step 5  T1 T2 T3 T4 T5</code></pre>
<p>이미 계산한 K와 V를 매번 다시 계산하는 셈이다. 생성하는 Token이 길어질수록 이 반복은 커진다.</p>
<hr>
<h3 id="2-2-kv-cache가-있다면">2-2. KV Cache가 있다면?</h3>
<p>KV Cache는 이전 Token의 Key와 Value를 저장해둔다.</p>
<pre><code class="language-text">T1 → K1 V1
T2 → K2 V2
T3 → K3 V3
        ↓
     KV Cache</code></pre>
<p>새로운 Token 4가 들어오면 그 Token에 대한 Q, K, V만 새로 계산한다.</p>
<pre><code class="language-text">T4 → Q4 K4 V4</code></pre>
<p>그리고 이전 K/V는 Cache에서 그대로 가져와 사용한다.</p>
<pre><code class="language-text">             K1 V1
             K2 V2
Q4 ───────── K3 V3
             K4 V4</code></pre>
<p>이전 Token의 K/V를 다시 계산할 필요가 없다는 것이 핵심이다.</p>
<blockquote>
<p><strong>이미 계산한 K와 V를 저장해두고, 다음 Decode Step에서 재사용한다.</strong></p>
</blockquote>
<hr>
<h3 id="2-3-self-attention은-서빙-관점에서-왜-비싼가">2-3. Self-Attention은 서빙 관점에서 왜 비싼가</h3>
<p>2편에서 살펴본 Self-Attention 수식을 다시 떠올려보자.</p>
<p>$$
Attention(Q,K,V)
=
Softmax
\left(
\frac{QK^T}{\sqrt{d_k}}
\right)
V
$$</p>
<p>이번에는 Q, K, V가 무엇인지가 아니라, <strong>이 계산에 비용이 얼마나 드는지</strong>를 살펴본다.</p>
<p>시퀀스 길이를 $L$, Head Dimension을 $D$라고 하면, $QK^T$ 단계에서 모든 Query가 모든 Key와 내적을 계산한다.</p>
<pre><code class="language-text">        K1 K2 K3 ... KL
Q1      ●  ●  ●  ... ●
Q2      ●  ●  ●  ... ●
Q3      ●  ●  ●  ... ●
...
QL      ●  ●  ●  ... ●</code></pre>
<p>즉 $L \times L$개의 Token 쌍을 계산하고, 각 내적은 $D$차원 연산이므로 Attention의 핵심 계산량은 대략</p>
<p>$$
O(L^2D)
$$</p>
<p>로 증가한다. 여기서 중요한 것은 $L^2$다.</p>
<pre><code class="language-text">Sequence Length  2L
Attention 관계    4배</code></pre>
<p>시퀀스 길이가 2배가 되면 Token 간 관계의 수는 대략 4배가 된다. 따라서 Context가 길어질수록 Attention 계산 비용은 빠르게 늘어난다.</p>
<p><strong>Autoregressive Decode에서는 문제가 더 커진다.</strong></p>
<p>LLM은 Token을 하나씩 생성하기 때문에, 새로운 Token을 만들 때마다 이전 Token들과의 Attention을 계산해야 한다. KV Cache가 없다면 이전 Token들의 K와 V까지 매번 다시 계산해야 하므로, 이미 했던 계산을 계속 반복하게 된다. 이것이 Decode에서 KV Cache가 중요한 이유다.</p>
<hr>
<h3 id="2-4-kv-cache는-정확히-무엇을-줄이는가">2-4. KV Cache는 정확히 무엇을 줄이는가</h3>
<p>여기서 정확히 짚어야 할 점이 있다. KV Cache가 Attention의 모든 계산을 $O(LD)$로 만들어버리는 것은 아니다. 새로운 Token 하나를 생성할 때도 현재까지 존재하는 모든 Key와 새로운 Query의 관계는 계산해야 하므로, 해당 Decode Step의 Attention 계산량은 대략</p>
<p>$$
O(LD)
$$</p>
<p>이다. KV Cache가 줄이는 것은 <strong>이전 Token들의 K/V를 다시 계산하는 반복 작업</strong>이다.</p>
<pre><code class="language-text">KV Cache 없음 → 이전 Token들의 K/V를 반복 계산
KV Cache 있음 → 이전 K/V는 저장된 값을 재사용, 새 Token의 K/V만 계산</code></pre>
<p>따라서 생성 과정 전체에서 불필요한 반복 계산을 크게 줄일 수 있다.</p>
<hr>
<h3 id="2-5-실제로-얼마나-차이가-날까">2-5. 실제로 얼마나 차이가 날까</h3>
<p>이론적으로 KV Cache가 반복적인 K/V 계산을 줄인다는 것을 확인했다. 그렇다면 실제 Inference에서는 어느 정도의 차이가 발생할까?</p>
<p>간단한 실험을 통해 확인해보자.</p>
<p>실험에서는 모델, GPU, Prompt를 동일하게 유지하고 use_cache 옵션만 변경한다.</p>
<blockquote>
<p>고정 조건 : 모델, GPU, Prompt, Sampling 방식
변경 조건 : use_cache = False ↔ True
측정 방법 : 전체 Generation Wall-clock Time</p>
</blockquote>
<p>여기서 생성 Token 수를 여러 단계로 변경해보면 KV Cache의 효과가 생성 길이에 따라 어떻게 달라지는지도 확인할 수 있다.</p>
<p><strong>실험 환경</strong></p>
<p>GPU가 없어도 Google Colab의 GPU Runtime을 사용하면 간단하게 테스트할 수 있다. 실험용 모델로는 Qwen/Qwen2.5-0.5B-Instruct를 사용한다.</p>
<p>Colab에서 Runtime → Change runtime type → GPU를 선택한 뒤 다음 코드를 실행한다.</p>
<p>먼저 GPU와 필요한 라이브러리를 확인한다.</p>
<pre><code class="language-python">!nvidia-smi
!pip install -q -U transformers accelerate</code></pre>
<p>이후 모델을 불러온다.</p>
<pre><code class="language-python">import torch
import time
import pandas as pd
import matplotlib.pyplot as plt

from transformers import AutoTokenizer, AutoModelForCausalLM

MODEL_NAME = &quot;Qwen/Qwen2.5-1.5B-Instruct&quot;

device = &quot;cuda&quot; if torch.cuda.is_available() else &quot;cpu&quot;

print(&quot;Device:&quot;, device)

if device == &quot;cuda&quot;:
    print(&quot;GPU:&quot;, torch.cuda.get_device_name(0))

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    torch_dtype=torch.float16 if device == &quot;cuda&quot; else torch.float32,
)

model = model.to(device)
model.eval()

print(&quot;Model loaded.&quot;)</code></pre>
<p>실험에 사용할 prompt도 고정한다.</p>
<pre><code class="language-python">prompt = &quot;&quot;&quot;
Explain why GPUs are particularly suitable for Transformer-based
large language model inference. Focus on matrix multiplication,
parallelism, and memory usage.
&quot;&quot;&quot;

inputs = tokenizer(
    prompt,
    return_tensors=&quot;pt&quot;
).to(device)

input_tokens = inputs[&quot;input_ids&quot;].shape[1]

print(&quot;Input tokens:&quot;, input_tokens)</code></pre>
<p>GPU에서는 실제 연산이 비동기적으로 실행될 수 있기 때문에, 정확한 시간을 측정하려면 GPU 연산이 끝날 때까지 기다린 후 시간을 측정해야 한다. 따라서 torch.cuda.synchronize()를 사용한다.</p>
<p>또한 첫 실행에는 CUDA 초기화 등의 오버헤드가 포함될 수 있으므로 먼저 Warm-up을 수행한다.</p>
<pre><code class="language-python">print(&quot;Warm-up...&quot;)

with torch.no_grad():
    _ = model.generate(
        **inputs,
        max_new_tokens=20,
        do_sample=False,
        use_cache=True,
    )

if device == &quot;cuda&quot;:
    torch.cuda.synchronize()

print(&quot;Warm-up complete.&quot;)</code></pre>
<p>이제 use_cache=False와 use_cache=True의 실행 시간을 측정하는 함수를 만든다.</p>
<pre><code class="language-python">def benchmark(use_cache, max_new_tokens, runs=3):

    times = []
    generated_tokens = None

    for _ in range(runs):

        if device == &quot;cuda&quot;:
            torch.cuda.synchronize()

        start = time.perf_counter()

        with torch.no_grad():
            output = model.generate(
                **inputs,
                max_new_tokens=max_new_tokens,
                do_sample=False,
                use_cache=use_cache,
            )

        if device == &quot;cuda&quot;:
            torch.cuda.synchronize()

        end = time.perf_counter()

        times.append(end - start)

        generated_tokens = (
            output.shape[1] - inputs[&quot;input_ids&quot;].shape[1]
        )

    avg_time = sum(times) / len(times)

    return {
        &quot;use_cache&quot;: use_cache,
        &quot;max_new_tokens&quot;: max_new_tokens,
        &quot;generated_tokens&quot;: generated_tokens,
        &quot;time_sec&quot;: avg_time,
        &quot;tokens_per_sec&quot;: generated_tokens / avg_time,
    }</code></pre>
<p>이제 생성 Token 수를 20, 50, 100, 200, 400으로 바꿔가며 각각 측정한다.</p>
<pre><code class="language-python">results = []

token_lengths = [20, 50, 100, 200, 400]

for token_length in token_lengths:

    print(f&quot;\n===== {token_length} tokens =====&quot;)

    result_no_cache = benchmark(
        use_cache=False,
        max_new_tokens=token_length,
        runs=3,
    )

    result_cache = benchmark(
        use_cache=True,
        max_new_tokens=token_length,
        runs=3,
    )

    results.append(result_no_cache)
    results.append(result_cache)

    print(
        f&quot;No Cache : &quot;
        f&quot;{result_no_cache[&#39;time_sec&#39;]:.3f}s&quot;
    )

    print(
        f&quot;Cache    : &quot;
        f&quot;{result_cache[&#39;time_sec&#39;]:.3f}s&quot;
    )

    print(
        f&quot;Speedup  : &quot;
        f&quot;{result_no_cache[&#39;time_sec&#39;] / result_cache[&#39;time_sec&#39;]:.2f}x&quot;
    )</code></pre>
<p>생성 Token 수와 KV cache 유무에 따른 실행 시간을 확인하면 다음과 같다.</p>
<table>
<thead>
<tr>
<th>index</th>
<th>Generated Tokens</th>
<th>No Cache (sec)</th>
<th>KV Cache (sec)</th>
<th>Speedup</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>20</td>
<td>1.9379192629999882</td>
<td>1.4329646596665953</td>
<td>1.352384547606973</td>
</tr>
<tr>
<td>1</td>
<td>50</td>
<td>2.2016549980000186</td>
<td>2.2275918716666183</td>
<td>0.9883565414309066</td>
</tr>
<tr>
<td>2</td>
<td>100</td>
<td>4.5284888993333725</td>
<td>4.109722240666618</td>
<td>1.1018965842807977</td>
</tr>
<tr>
<td>3</td>
<td>200</td>
<td>9.226087766000015</td>
<td>8.248578985333324</td>
<td>1.1185063248354394</td>
</tr>
<tr>
<td>4</td>
<td>400</td>
<td>24.924119704000002</td>
<td>16.141505910333308</td>
<td>1.5441012655482367</td>
</tr>
</tbody></table>
<p>결과를 통해 확인할 수 있듯이 <code>use_cache=False</code>에서는 Decode 과정에서 이전 Token들의 K/V를 반복적으로 계산해야 하므로 생성 길이가 길어질수록 불필요한 계산이 누적된다.
<code>use_cache=True</code>에서는 이전 Token의 K/V를 재사용하기 때문에 생성 길이가 증가해도 K/V를 매번 처음부터 계산하는 비용을 피할 수 있다.</p>
<p>다만 해당 실험은 <code>1.5B</code>의 작은 모델로 테스트를 수행했다.
모델 규모가 작거나 GPU에서 수행되는 다른 연산의 비중이 큰 경우에는 KV Cache가 줄여주는 계산량이 전체 실행 시간에서 차지하는 비중이 작을 수 있어, 실험 결과는 GPU와 모델에 따라 달라질 수 있다.
또한 이 실험에서 측정한 전체 시간에는 K/V 계산 외에도 Attention, Feed Forward Network, GPU Kernel 실행 등의 시간이 모두 포함되어 있다. 따라서 측정된 전체 시간의 차이를 모두 KV 계산 비용이라고 해석해서는 안 된다. </p>
<hr>
<h3 id="2-6-kv-cache에도-대가가-있다">2-6. KV Cache에도 대가가 있다</h3>
<p>KV cache는 계산을 줄이는 대신 메모리를 사용한다. 각 Transformer Layer에서 계산된 Key와 Value를 저장해야 하기 때문이다. KV Cache가 사용하는 메모리는 대략 다음과 같이 표현할 수 있다.</p>
<p>$$
Memory_{KV}
=
2 \times N_{layers} \times N_{KV\text{-}heads} \times D_{head} \times L \times B \times Bytes
$$</p>
<p>각 항은 다음을 의미한다.</p>
<ul>
<li>$2$ : Key와 Value</li>
<li>$N_{layers}$ : Transformer Layer 수</li>
<li>$N_{KV\text{-}heads}$ : K/V Head 수</li>
<li>$D_{head}$ : Head Dimension</li>
<li>$L$ : Sequence Length</li>
<li>$B$ : Batch Size</li>
<li>$Bytes$ : 데이터 정밀도에 따른 바이트 수</li>
</ul>
<p>여기서 중요한 것은</p>
<p>$$
Memory_{KV} \propto L \times B
$$</p>
<p>라는 점이다. Context가 길어지고 동시에 처리하는 요청이 많아질수록 KV Cache는 빠르게 커진다. 실제 GPU Memory에는</p>
<pre><code class="language-text">Model Weights + KV Cache + Activation + Runtime / Framework Memory</code></pre>
<p>등이 함께 존재한다. 따라서 LLM Serving에서 GPU Memory를 계산할 때는 모델의 파라미터 크기만 봐서는 안 된다.</p>
<hr>
<h2 id="3-compute-bound-vs-memory-bound">3. Compute-bound vs Memory-bound</h2>
<h3 id="3-1-prefill은-compute-bound-decode는-memory-bound">3-1. Prefill은 Compute-bound, Decode는 Memory-bound</h3>
<p>앞서 KV cache 유무를 통해 확인한 계산량 차이를 그대로 병목에 대응시켜보면, 왜 Prefill과 Decode가 서로 다른 자원에서 막히는지가 자연스럽게 드러난다.</p>
<p>Prefill은 한 번에 많은 Token($L$개)을 처리하며 $O(L^2D)$의 Matrix Multiplication을 대규모로 수행한다. GPU의 연산 유닛을 높은 수준으로 활용할 수 있는 구조이므로, Prefill은 일반적으로 <strong>Compute-bound</strong>다. 이 구간에서는 GPU의 FLOPS와 Kernel 효율이 중요한 요소가 된다.</p>
<pre><code class="language-text">New Token
    ↓
    Q
    │
    └──────→ KV Cache
              ├ K1 V1
              ├ K2 V2
              ├ ...
              └ Kn Vn</code></pre>
<p>반면 Decode는 Step마다 새로 계산하는 Token이 하나뿐이라 연산량은 $O(LD)$로 작다. 하지만 그 하나를 계산하기 위해 지금까지 쌓인 KV Cache 전체를 GPU Memory에서 읽어와야 한다. 즉</p>
<pre><code class="language-text">작은 Compute(새 Token 1개) + 큰 Memory Access(KV Cache 전체) → Memory-bound</code></pre>
<p>Context가 길어질수록 읽어야 하는 KV Cache는 계속 커지는데, 새로 계산하는 연산량은 그대로이므로 병목은 Compute가 아니라 <strong>Memory Bandwidth와 Memory Access</strong>로 옮겨간다. 이것이</p>
<blockquote>
<p><strong>Prefill = Compute-bound</strong>
<strong>Decode = Memory-bound</strong></p>
</blockquote>
<p>라고 말하는 이유다.</p>
<hr>
<h3 id="3-2-왜-이-구분이-중요한가--병목을-어떻게-진단할까">3-2. 왜 이 구분이 중요한가 — 병목을 어떻게 진단할까</h3>
<p>병목이 다르면 최적화 방법도 달라진다. Prefill이 병목이라면 GPU Compute를 효율적으로 쓰는 것이 중요하다. Decode가 병목이라면 단순히 Compute 성능을 높이는 것만으로는 충분하지 않고,</p>
<pre><code class="language-text">KV Cache 메모리 효율
Memory Bandwidth
Quantization
Attention Kernel
Batching</code></pre>
<p>등을 함께 고려해야 한다.</p>
<blockquote>
<p><strong>LLM Serving에서 GPU를 더 많이 쓰는 것이 항상 성능 향상으로 이어지지는 않는다.</strong></p>
</blockquote>
<p>그렇다면 실제 서비스에서 지금 어느 쪽이 병목인지는 어떻게 확인할까? 3-1편에서 다룬 TTFT/TPOT를 이 구분에 그대로 대응시키면 된다.</p>
<p><strong>첫 번째 Token이 늦게 나온다면?</strong> TTFT를 확인한다. 긴 Prompt를 처리하는 Prefill이 오래 걸릴 수도 있고, Queue에서 요청이 오래 기다리고 있을 수도 있다.</p>
<p><strong>첫 번째 Token은 빠른데 이후 생성이 느리다면?</strong> TPOT를 확인한다. Decode 단계의 KV Cache 접근이나 Memory Bandwidth가 병목일 수 있다.</p>
<p><strong>전체 처리량이 낮다면?</strong> 다음 요소를 함께 확인한다.</p>
<pre><code class="language-text">Batch Size
Continuous Batching
KV Cache Memory
GPU Memory Bandwidth
Quantization
Scheduler</code></pre>
<p>성능 문제를 해결하려면 먼저 <strong>어느 단계가 병목인지 분리해서 생각해야 한다.</strong></p>
<hr>
<h2 id="4-kv-cache-메모리-관리--문제와-해결">4. KV Cache 메모리 관리 — 문제와 해결</h2>
<h3 id="4-1-fragmentation--kv-cache가-커지면서-생기는-또-다른-메모리-문제">4-1. Fragmentation — KV Cache가 커지면서 생기는 또 다른 메모리 문제</h3>
<p>지금까지 다룬 Memory 문제는 &quot;데이터를 얼마나 빠르게 읽어오는가&quot;, 즉 Bandwidth였다. 그런데 여러 요청을 동시에 처리하는 Serving 환경에서는 &quot;그 데이터를 GPU Memory 어디에 저장할 것인가&quot;라는 전혀 다른 메모리 문제가 하나 더 있다. KV Cache는 요청마다 별도로 존재한다.</p>
<pre><code class="language-text">Request A → KV Cache A
Request B → KV Cache B
Request C → KV Cache C</code></pre>
<p>문제는 요청마다 길이가 다르다는 것이다.</p>
<pre><code class="language-text">A → 100 tokens
B → 1000 tokens
C → 5000 tokens</code></pre>
<p>그래서 KV Cache를 GPU Memory에 효율적으로 배치하기 어렵다. 특히 요청마다 필요한 메모리 크기가 계속 변하면 메모리 공간에 빈틈이 생길 수 있다. 이를 <strong>Memory Fragmentation</strong>이라고 한다. 결국 GPU Memory에 충분한 총 공간이 남아 있어도, 필요한 형태로 메모리를 할당하지 못해 새로운 요청을 받지 못하는 상황이 생길 수 있다.</p>
<hr>
<h3 id="4-2-pagedattention">4-2. PagedAttention</h3>
<p>이 문제를 해결하기 위한 대표적인 아이디어가 <strong>PagedAttention</strong>이다. PagedAttention은 KV Cache를 하나의 큰 연속된 메모리 공간이 아니라 작은 Block 단위로 나누어 관리한다.</p>
<pre><code class="language-text">Logical KV Cache (Request A)
┌────┬────┬────┬────┐
│ A1 │ A2 │ A3 │ A4 │
└────┴────┴────┴────┘</code></pre>
<p>이 Block들이 GPU Memory에서 반드시 연속적으로 존재할 필요는 없다.</p>
<pre><code class="language-text">GPU Memory
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ A2 │ B1 │ A4 │ C1 │ B3 │ A1 │ B2 │ A3 │
└────┴────┴────┴────┴────┴────┴────┴────┘</code></pre>
<p>Logical Block과 Physical Memory Block의 매핑을 관리하기 때문에 GPU Memory를 더 유연하게 쓸 수 있다. 결과적으로 메모리 낭비를 줄이고 더 많은 요청을 동시에 처리할 수 있다.</p>
<hr>
<h2 id="5-kv-cache를-줄이는-최적화-기법들">5. KV Cache를 줄이는 최적화 기법들</h2>
<h3 id="5-1-kv-cache를-줄이는-아키텍처--mqa-gqa">5-1. KV Cache를 줄이는 아키텍처 — MQA, GQA</h3>
<p>KV Cache가 너무 커진다면 Serving 단계에서만 해결할 필요는 없다. 모델의 Attention 구조 자체를 바꾸는 방법도 있다. 대표적인 것이 <strong>Multi-Query Attention(MQA)</strong>과 <strong>Grouped-Query Attention(GQA)</strong>이다.</p>
<p>일반적인 Multi-Head Attention에서는 각 Query Head마다 대응하는 K/V Head가 존재한다.</p>
<pre><code class="language-text">MHA
Q1 → K1 V1
Q2 → K2 V2
Q3 → K3 V3
Q4 → K4 V4</code></pre>
<p>MQA에서는 여러 Query Head가 K/V를 공유한다.</p>
<pre><code class="language-text">Q1 ─┐
Q2 ─┤
Q3 ─┼→ K V
Q4 ─┘</code></pre>
<p>저장해야 하는 K/V의 수가 줄어든다. GQA는 MHA와 MQA의 중간 형태로, 여러 Query Head가 하나의 K/V Group을 공유한다.</p>
<pre><code class="language-text">Q1 Q2 → K1 V1
Q3 Q4 → K2 V2</code></pre>
<blockquote>
<p><strong>KV Cache 문제는 Serving Engine뿐 아니라 모델 아키텍처에서도 해결할 수 있다.</strong></p>
</blockquote>
<hr>
<h3 id="5-2-flashattention--memory-access-자체를-최적화하다">5-2. FlashAttention — Memory Access 자체를 최적화하다</h3>
<p>Attention의 문제는 연산량($O(L^2D)$)만이 아니다. 1편에서 CPU를 다루며 봤듯, 메모리는 항상 &quot;빠르지만 작은 것&quot;과 &quot;느리지만 큰 것&quot;의 계층으로 나뉜다(Register → Cache → DRAM). GPU도 마찬가지다. SM 안의 Register/Shared Memory/L1 Cache처럼 아주 빠르지만 용량이 작은 <strong>SRAM(On-chip Memory)</strong>이 있고, 그 바깥에 모델 Weight와 KV Cache 같은 큰 데이터가 실제로 저장되는 크지만 상대적으로 느린 <strong>HBM(GPU Memory)</strong>이 있다. 실제 성능은 이 둘 사이에서 데이터를 얼마나 적게, 효율적으로 옮기느냐에 좌우된다.</p>
<h4 id="문제--naive-attention은-hbm을-몇-번이나-오간다">문제 — Naive Attention은 HBM을 몇 번이나 오간다</h4>
<p>일반적인(Naive) Attention 구현은 수식 그대로 단계별로 계산하면서, 각 단계의 중간 결과를 매번 HBM에 썼다가 다시 읽는다.</p>
<pre><code class="language-text">Q, K, V (HBM)
     │
     ▼  read
S = QK^T          ── L×L 행렬 ──▶  write HBM
     │
     ▼  read
P = softmax(S)    ── L×L 행렬 ──▶  write HBM
     │
     ▼  read
O = PV                          ▶  write HBM</code></pre>
<p>여기서 $S$, $P$는 모두 $L \times L$ 크기의 행렬이다. Sequence가 길어질수록 이 행렬을 HBM에 쓰고 다시 읽는 비용 자체가 커진다. 즉 Attention은 연산량뿐 아니라 <strong>HBM Read/Write 횟수</strong> 때문에도 느려질 수 있다.</p>
<h4 id="해결--tiling과-online-softmax">해결 — Tiling과 Online Softmax</h4>
<p>FlashAttention은 $S$, $P$ 같은 $L \times L$ 크기의 중간 행렬을 <strong>한 번도 HBM에 만들지 않는다.</strong> 대신 Q, K, V를 작은 Block으로 잘라 SRAM에 올리고, 그 Block 안에서 Attention 계산을 전부 끝낸 뒤 결과만 HBM에 쓴다.</p>
<pre><code class="language-text">Q ─▶ [Q1][Q2][Q3]...
K ─▶ [K1][K2][K3]...
V ─▶ [V1][V2][V3]...

for Q_i (SRAM):
    for K_j, V_j (SRAM):
        S_ij  = Q_i K_j^T           ← SRAM 안에서만 계산
        누적 max, 누적 sum 갱신      ← Online Softmax
        O_i  += 부분 Attention 결과  ← SRAM에서 누적
    O_i 최종값만 HBM에 write</code></pre>
<p>핵심은 <strong>Online Softmax</strong>다. Softmax는 전체 $L$개의 Key에 대한 값을 한 번에 봐야 정규화할 수 있는데, FlashAttention은 이를 Block 단위로 나눠 계산하면서 지금까지 본 Block들의 최댓값과 합을 계속 보정(rescale)해 나간다. 이렇게 하면 전체 Score 행렬을 동시에 들고 있지 않아도 수학적으로 동일한 Softmax 결과를 만들 수 있다.</p>
<pre><code class="language-text">Naive        : S, P를 HBM에 전부 저장 → 여러 번 read/write
FlashAttention: Block 단위로 SRAM에서 계산 → 최종 O만 HBM에 write</code></pre>
<blockquote>
<p><strong>FlashAttention은 연산량(FLOPs)을 줄이는 최적화가 아니라, 같은 연산을 하면서 HBM Read/Write 횟수를 줄이는 최적화다.</strong></p>
</blockquote>
<p>이는 1편에서 살펴봤던 <strong>Compute와 Memory 사이의 데이터 이동</strong> 문제, 그리고 이번 편에서 계속 강조한 &quot;병목은 연산량만이 아니라 어디서 데이터를 읽어오는가&quot;라는 관점과 정확히 같은 맥락이다. Prefill에서는 거대한 $L \times L$ 행렬의 HBM 왕복을 줄여주고, Decode에서는 매 Step 읽어야 하는 KV Cache를 Block 단위로 스트리밍하듯 처리해 Memory Access 패턴을 개선한다.</p>
<hr>
<h3 id="5-3-chunked-prefill">5-3. Chunked Prefill</h3>
<p>긴 Prompt가 들어오면 Prefill이 GPU Compute 자원을 오랫동안 점유할 수 있다. 그동안 Decode 중인 다른 요청이 기다려야 한다면 전체 서비스의 Latency가 나빠질 수 있다. 이를 해결하기 위해 Prefill을 작은 Chunk로 나누어 처리하고 Decode 요청과 적절히 섞는 <strong>Chunked Prefill</strong>이 쓰인다.</p>
<h4 id="무엇을-기준으로-나누는가">무엇을 기준으로 나누는가</h4>
<p>2편에서 살펴본 Decoder-only Transformer의 구조를 다시 떠올려보자.</p>
<pre><code class="language-text">Token들 → Embedding → Positional Encoding → [Self-Attention → FFN] × N Layer → 다음 Token 예측</code></pre>
<p>Chunked Prefill이 나누는 것은 이 <strong>Layer 스택이 아니다.</strong> Layer 수를 줄이거나 일부 Layer만 먼저 계산하는 것이 아니라, <strong>한 번의 GPU 실행(Iteration)에 몇 개의 Token을 태울 것인가</strong>, 즉 입력 Token 시퀀스($L$) 축을 기준으로 나눈다.</p>
<pre><code class="language-text">전체 Prompt (1000 Tokens)
┌─────────────────────────────────────┐
│ Chunk 1(256) │ Chunk 2(256) │ Chunk 3(256) │ Chunk 4(232) │
└─────────────────────────────────────┘</code></pre>
<p>각 Chunk는 그 자체로 <strong>작은 Prefill 하나</strong>다. Chunk 1이 Embedding부터 마지막 Layer까지 전체 스택을 통과하고, 그다음 Chunk 2가 다시 Embedding부터 마지막 Layer까지 전체 스택을 통과하는 식이다. 즉 한 Chunk는 반드시 모든 Layer를 다 거친 뒤에야 다음 Chunk로 넘어간다 — Layer 축이 아니라 Token 축으로 나뉘는 것이다.</p>
<h4 id="나누는-기준--token-budget">나누는 기준 — Token Budget</h4>
<p>Chunk의 크기는 임의로 정하는 것이 아니라, Scheduler가 한 Iteration에서 GPU에 태울 수 있는 <strong>Token Budget</strong>(예: Iteration당 최대 512 Token)으로 결정된다. 이 예산 안에서 Prefill Chunk와, 그 사이에 끼어드는 다른 요청들의 Decode Token(각 요청당 1개씩)을 함께 채워 하나의 Batch로 실행한다.</p>
<pre><code class="language-text">Iteration N   : [Prefill Chunk 1(256)] + [Decode A(1)] + [Decode B(1)] + ...
Iteration N+1 : [Prefill Chunk 2(256)] + [Decode A(1)] + [Decode B(1)] + ...</code></pre>
<p>Chunk를 크게 잡으면 GPU Compute 활용은 좋아지지만 그만큼 다른 요청의 Decode가 한 Iteration 동안 밀린다. Chunk를 작게 잡으면 Decode 요청들의 TPOT는 안정적으로 유지되지만 Prefill 자체는 더 여러 Iteration에 걸쳐 끝난다. 즉 Chunk Size는 <strong>Prefill 처리량과 동시 요청들의 Decode Latency 사이의 Trade-off</strong>를 조절하는 값이다.</p>
<h4 id="어디서-다시-합쳐지는가--kv-cache">어디서 다시 합쳐지는가 — KV Cache</h4>
<p>Chunk로 나눠 계산해도 결과가 흩어지는 것은 아니다. Chunk 1을 처리하면서 각 Layer에서 계산된 K/V는 그 요청의 KV Cache에 그대로 Append된다. Chunk 2의 Attention은 &quot;Chunk 1이 만들어 KV Cache에 저장해 둔 K/V&quot; + &quot;Chunk 2 자신의 K/V&quot;를 함께 참조하며 계산되고, 그 결과 역시 같은 KV Cache에 이어 붙는다.</p>
<pre><code class="language-text">Chunk 1 처리 → KV Cache: [K/V(1~256)]
Chunk 2 처리 → KV Cache: [K/V(1~256)] + [K/V(257~512)]
Chunk 3 처리 → KV Cache: [K/V(1~512)] + [K/V(513~768)]
...
마지막 Chunk 처리 완료 → KV Cache: [K/V(1~1000)]  ← 한 번에 Prefill한 것과 동일</code></pre>
<p>즉 나눠지는 지점은 &quot;Token을 몇 개씩 모델에 넣을 것인가&quot;이고, 합쳐지는 지점은 Transformer 내부 연산이 아니라 <strong>KV Cache</strong>다. 모든 Chunk가 끝나고 나면 KV Cache에는 마치 Prompt 전체를 한 번에 Prefill한 것과 동일한 상태가 만들어지고, 그 이후부터는 평소와 같은 Decode가 시작된다.</p>
<blockquote>
<p><strong>Chunked Prefill이 나누는 것은 모델 구조가 아니라 한 Iteration에 태우는 Token 수이고, 나뉘어 처리된 결과가 다시 만나는 곳은 KV Cache다.</strong></p>
</blockquote>
<hr>
<h3 id="5-4-disaggregated-prefill--decode">5-4. Disaggregated Prefill / Decode</h3>
<p>더 나아가면 Prefill과 Decode 자체를 서로 다른 서버나 GPU 자원에서 처리하는 구조도 생각할 수 있다. 3-1에서 확인했듯 Prefill은 Compute-bound, Decode는 Memory-bound이므로, 애초에 <strong>필요한 자원의 성격이 다른 두 작업을 같은 GPU에 억지로 함께 두지 않는다</strong>는 것이 핵심 아이디어다.</p>
<pre><code class="language-text">User Request
     │
     ▼
┌───────────────┐        KV Cache        ┌───────────────┐
│ Prefill Server │ ──── (NVLink/RDMA) ───▶│ Decode Server  │──▶ Streaming Response
└───────────────┘                        └───────────────┘
  Compute-heavy GPU                        Memory-heavy GPU</code></pre>
<h4 id="각-구성-요소는-어떤-자원이-맞는가">각 구성 요소는 어떤 자원이 맞는가</h4>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>필요한 특성</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>Prefill Server (GPU)</td>
<td>높은 FLOPS(연산 성능)</td>
<td>대규모 Matrix Multiplication을 수행하는 Compute-bound 작업이므로, Tensor Core 연산 성능이 좋은 GPU가 유리하다</td>
</tr>
<tr>
<td>Decode Server (GPU)</td>
<td>큰 Memory 용량 + 높은 Memory Bandwidth</td>
<td>여러 요청의 KV Cache를 동시에 들고 있어야 하고, Step마다 이를 빠르게 읽어야 하는 Memory-bound 작업이기 때문이다</td>
</tr>
<tr>
<td>Router / Scheduler (CPU)</td>
<td>낮은 지연, 가벼운 연산</td>
<td>어떤 요청을 Prefill Server로, 어떤 요청을 Decode Server로 보낼지 결정하고 KV Cache 전송 시점을 관리한다. GPU 연산이 필요 없는 경량 작업이라 CPU에서 처리한다</td>
</tr>
<tr>
<td>Tokenizer / Detokenizer (CPU)</td>
<td>낮은 지연</td>
<td>문자열 ↔ Token ID 변환은 GPU 연산이 아니라 CPU에서 수행하는 것이 일반적이다</td>
</tr>
<tr>
<td>KV Cache 전송 경로</td>
<td>빠른 Interconnect</td>
<td>Prefill Server가 만든 KV Cache(요청·길이에 따라 수백 MB~수 GB)를 Decode Server로 옮겨야 하므로, 같은 노드라면 NVLink, 다른 노드라면 InfiniBand/RDMA처럼 빠른 연결이 필요하다. 이 전송 자체가 느리면 새로운 병목이 된다</td>
</tr>
</tbody></table>
<h4 id="구체적인-예시">구체적인 예시</h4>
<p>예를 들어 8,000 Token 분량의 문서를 요약해달라는 요청이 들어왔다고 하자.</p>
<pre><code class="language-text">1. Router가 요청을 받아 Prefill Server로 전달
2. Prefill Server(FLOPS가 높은 GPU, 예: H100 여러 장을 Tensor Parallel로 구성)가
   8,000 Token 전체를 한 번에(또는 Chunked Prefill로) 처리 → KV Cache 생성
3. 생성된 KV Cache를 NVLink/RDMA로 Decode Server에 전송
4. Decode Server(Memory 용량·대역폭이 큰 GPU)가 이 요청의 Decode를
   다른 요청들과 Continuous Batching으로 묶어 함께 처리
5. 생성되는 Token을 Streaming으로 사용자에게 전달</code></pre>
<p>이렇게 나누면 Prefill Server는 짧은 시간에 최대한 많은 연산을 처리하는 데 집중하고, Decode Server는 동시에 얼마나 많은 요청의 KV Cache를 붙들고 있을 수 있는지에 집중할 수 있다. 실제로 이런 구조는 vLLM의 Prefill/Decode Disaggregation, DistServe, Splitwise 같은 이름으로 프로덕션 Serving 시스템에 적용되고 있다.</p>
<blockquote>
<p><strong>Prefill과 Decode는 병목의 성격이 다르므로, 굳이 같은 GPU에서 처리할 필요가 없다. 서로 다른 자원을 붙이고, 그 사이는 빠른 Interconnect로 KV Cache를 옮겨 연결한다.</strong></p>
</blockquote>
<hr>
<h2 id="6-vllm으로-보는-실전-시스템">6. vLLM으로 보는 실전 시스템</h2>
<h3 id="6-1-pagedattention과-vllm--그리고-아직-남은-문제">6-1. PagedAttention과 vLLM — 그리고 아직 남은 문제</h3>
<p>지금까지 KV Cache의 크기를 줄이거나(MQA/GQA), 접근 방식을 최적화하거나(FlashAttention), 처리 시점을 조절하거나(Chunked Prefill), 아예 물리적으로 분리하는(Disaggregated Prefill/Decode) 여러 방법을 봤다. 이 기법들이 실제 Serving Engine 안에서 어떻게 하나로 합쳐지는지 살펴보자.</p>
<p>PagedAttention을 대표적으로 활용하는 Serving Engine이 <strong>vLLM</strong>이다. vLLM의 핵심은 단순히 Transformer 모델을 실행하는 것이 아니라, <strong>Inference 과정에서 발생하는 GPU Memory와 KV Cache를 효율적으로 관리하고, 여러 요청을 높은 Throughput으로 처리하는 것</strong>이다.</p>
<pre><code class="language-text">Transformer → 모델의 구조
vLLM        → 모델을 효율적으로 Serving하기 위한 시스템</code></pre>
<p>그런데 PagedAttention은 &quot;이미 들어온 KV Cache를 메모리에 어떻게 배치할 것인가&quot;라는 <strong>공간 배치</strong> 문제를 푼 것이지, &quot;어떤 요청을 지금 GPU에 올려 실행할 것인가&quot;라는 <strong>시간(스케줄링)</strong> 문제까지 푼 것은 아니다. 
vLLM이 실제로 높은 Throughput을 내려면 이 두 번째 문제도 함께 풀어야 한다. </p>
<hr>
<h3 id="6-2-continuous-batching과-scheduler--시간-축의-문제">6-2. Continuous Batching과 Scheduler — 시간 축의 문제</h3>
<p>앞서 남겨둔 스케줄링 문제가 바로 3-1편에서 살펴본 <strong>Continuous Batching</strong>이다. 이번엔 Prefill/Decode 관점에서 다시 보자. 
Decode는 한 번에 하나의 Token을 생성하기 때문에 요청마다 생성 과정이 계속 진행되고, 요청마다 종료 시점도 다르다. 
Continuous Batching은 실행 중인 요청이 끝났을 때 새로운 요청을 동적으로 추가해, GPU가 특정 요청의 종료를 기다리는 시간을 줄이고 더 많은 요청을 지속적으로 처리한다.</p>
<p>여기서 핵심은 <strong>Scheduler</strong>다. Scheduler는 어떤 요청을 Batch에 넣고, 언제 제거하고, GPU에 어떤 작업을 실행할지 결정한다. PagedAttention이 메모리 공간을, Continuous Batching이 실행 시점을 관리한다는 점에서 둘은 vLLM 안에서 서로 다른 축을 담당하는 짝이다. 이것이 이후 편에서 직접 Serving Service를 구현할 때 중요한 부분이 된다.</p>
<hr>
<h3 id="6-3-streaming은-어떻게-연결되는가">6-3. Streaming은 어떻게 연결되는가</h3>
<p>LLM은 Token을 하나씩 생성하기 때문에 생성할 때마다 사용자에게 전달할 수 있다.</p>
<pre><code class="language-text">Model → Token 1 → User
      → Token 2 → User
      → Token 3 → User</code></pre>
<p>Streaming은 모델의 전체 계산량을 줄이는 것은 아니다. 하지만 사용자가 첫 번째 결과를 빠르게 볼 수 있기 때문에 <strong>체감 Latency를 낮추는 데 중요하다.</strong> Serving System에서는 모델 계산 속도, Token 생성 속도, 전송 방식을 함께 고려해야 한다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<ul>
<li><strong>Self-Attention</strong> : 모든 Token 쌍의 관계를 계산하므로 $O(L^2D)$의 계산량을 가진다.</li>
<li><strong>KV Cache</strong> : 이전 Token의 K/V를 저장해 다시 계산하지 않는다. Decode에서 반복되는 K/V 계산을 없앤다.</li>
<li><strong>Prefill</strong> : 많은 Token을 병렬로 처리 → <strong>Compute-bound</strong></li>
<li><strong>Decode</strong> : 새로운 Token은 하나지만 이전 Token의 KV Cache를 계속 읽어야 함 → <strong>Memory-bound</strong></li>
<li><strong>PagedAttention</strong> : KV Cache를 Block 단위로 관리해 GPU Memory를 효율적으로 사용한다.</li>
<li><strong>Continuous Batching</strong> : 요청의 종료 시점이 달라도 실행 중인 Batch에 새 요청을 계속 추가해 GPU 활용률과 Throughput을 높인다.</li>
<li><strong>MQA / GQA</strong> : 여러 Query Head가 K/V Head를 공유하도록 모델 아키텍처 자체를 바꿔 KV Cache 크기를 줄인다.</li>
<li><strong>FlashAttention</strong> : 연산량이 아니라 HBM Read/Write 횟수를 줄여, Attention 계산이 GPU Kernel 레벨에서 Memory에 덜 의존하게 만든다.</li>
<li><strong>Chunked Prefill</strong> : 긴 Prefill을 Token 축으로 잘라 Decode 요청과 한 Iteration 안에서 함께 실행해, Prefill이 Decode의 Latency를 밀어내지 않게 한다.</li>
<li><strong>Disaggregated Prefill/Decode</strong> : Compute-bound인 Prefill과 Memory-bound인 Decode를 아예 다른 서버로 분리하고, 그 사이를 빠른 Interconnect로 연결해 KV Cache를 전달한다.</li>
</ul>
<p>1편에서는 GPU의 구조를 살펴봤다. GPU는 많은 연산을 병렬로 수행하기 때문에 Matrix 연산에 강하다.</p>
<p>2편에서는 Transformer를 살펴봤다. Attention과 FFN은 GPU가 잘 처리할 수 있는 대규모 연산을 수행한다.</p>
<p>3-1편에서는 이 모델을 실제 서비스로 가져왔다.</p>
<pre><code class="language-text">Request → Tokenization → Scheduling → Batching → GPU Inference → Streaming</code></pre>
<p>그리고 3-2편에서는 그 내부에서 실제로 어떤 일이 일어나는지 살펴봤다.</p>
<pre><code class="language-text">Prompt → Prefill → First Token → Decode → KV Cache → Next Token → ...</code></pre>
<p>여기서 중요한 사실이 하나 드러난다.</p>
<p><strong>LLM Serving의 병목은 항상 Compute가 아니다.</strong> Prefill에서는 GPU Compute가 중요하지만, Decode에서는 KV Cache와 Memory Bandwidth가 중요하다. 따라서 좋은 Serving System은 단순히 더 강한 GPU를 쓰는 것이 아니라,</p>
<blockquote>
<p><strong>현재 어떤 단계가 병목인지 파악하고, 그 병목에 맞는 최적화를 적용해야 한다.</strong></p>
</blockquote>
<p>여기까지 이해했다면 이제 이론을 실제 시스템으로 옮겨볼 차례다.</p>
<p>다음편에서는 Serving System을 직접 만든다면 어떻게 구현해야 할지 확인해본다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[3-1편. LLM 모델 서빙과 최적화]]></title>
            <link>https://velog.io/@_gyullbb/3-1%ED%8E%B8.-LLM-%EB%AA%A8%EB%8D%B8-%EC%84%9C%EB%B9%99%EA%B3%BC-%EC%B5%9C%EC%A0%81%ED%99%94-86ckpimw</link>
            <guid>https://velog.io/@_gyullbb/3-1%ED%8E%B8.-LLM-%EB%AA%A8%EB%8D%B8-%EC%84%9C%EB%B9%99%EA%B3%BC-%EC%B5%9C%EC%A0%81%ED%99%94-86ckpimw</guid>
            <pubDate>Sat, 15 Aug 2026 08:52:00 GMT</pubDate>
            <description><![CDATA[<p>1편에서는 CPU와 GPU의 구조를, 2편에서는 그 GPU 위에서 Transformer가 어떻게 동작하는지 살펴봤다.</p>
<p>Token은 Embedding과 Positional Encoding을 거쳐 Self-Attention과 FFN을 통과하고, 이 과정을 여러 Transformer Block에 걸쳐 반복하며 문맥을 이해한다. Decoder-only Transformer는 이렇게 만들어진 표현을 바탕으로 다음 Token을 하나씩 예측한다.</p>
<p>그렇다면 이제 이런 질문이 생긴다.</p>
<blockquote>
<p><strong>이 모델을 실제 서비스에서 수천, 수만 명이 동시에 사용한다면 어떻게 될까?</strong></p>
</blockquote>
<p>모델을 한 번 실행하는 것과, 모델을 서비스로 운영하는 것은 완전히 다른 문제다.</p>
<p>정확도가 아무리 높아도 응답이 느리거나, GPU 메모리가 부족하거나, GPU를 많이 쓰면서도 처리량이 나오지 않는다면 실제 서비스에서는 쓰기 어렵다.</p>
<p>그래서 LLM 시스템에서는 모델의 성능만큼이나 <strong>모델을 얼마나 빠르고 효율적으로 서비스할 수 있는가</strong>가 중요하다.</p>
<p>이번 편에서는 학습된 LLM을 실제 서비스로 실행하는 <strong>Inference와 Serving</strong>의 전체 구조를 살펴보고, 어떤 지점에서 병목이 생기는지, 그리고 이를 해결하기 위해 어떤 최적화 기법들이 등장했는지 전체 지도를 그려본다.</p>
<p>이 중 가장 중요한 두 축인 <strong>Prefill, Decode, KV Cache</strong>는 실제 수치와 함께 3-2편에서 깊이 다룬다.</p>
<hr>
<h2 id="1-training과-inference-그리고-serving">1. Training과 Inference, 그리고 Serving</h2>
<p>Transformer를 실제 사용자에게 어떻게 빠르고 효율적으로 제공할 수 있을까?</p>
<p>앞서 학습과 추론에 대한 개념을 구분해보았다.</p>
<p><strong>학습(Training)</strong>은 모델의 파라미터를 학습시키는 과정이다. 대규모 데이터를 모델에 입력하고 예측과 정답의 차이를 계산한 뒤, Backpropagation으로 파라미터를 업데이트한다.</p>
<blockquote>
<p>Training = 모델을 학습시키는 과정</p>
</blockquote>
<p><strong>추론(Inference)</strong>는 이미 학습된 모델을 이용해 결과를 생성하는 과정이다.</p>
<blockquote>
<p>Inference = 학습된 모델을 이용해 결과를 생성하는 과정</p>
</blockquote>
<p>실제 서비스에서는 이 Inference를 사용자 요청에 맞춰 반복적으로 수행해야 한다. 그래서 <strong>Serving</strong>은 단순히 모델을 실행하는 것보다 더 넓은 개념이다. 요청을 받고, 토큰화하고, 요청을 관리하고, GPU에서 모델을 실행하고, 결과를 다시 사용자에게 전달하는 전체 시스템을 포함한다.</p>
<hr>
<h2 id="2-llm은-실제-서비스에서-어떻게-실행되는가">2. LLM은 실제 서비스에서 어떻게 실행되는가</h2>
<p>사용자가 다음과 같이 질문했다고 해보자.</p>
<blockquote>
<p>&quot;Transformer가 무엇인지 설명해줘.&quot;</p>
</blockquote>
<p>이 요청이 곧바로 GPU로 전달되지는 않는다. 대략 다음과 같은 과정을 거친다.</p>
<pre><code class="language-text">사용자 요청
    ↓
Request Server
    ↓
Tokenizer
    ↓
Queue / Scheduler
    ↓
Batching
    ↓
Inference Engine
    ↓
GPU
    ↓
Detokenization
    ↓
Streaming Response
    ↓
사용자</code></pre>
<h3 id="1-request">(1) Request</h3>
<p>사용자가 텍스트를 입력하면 서버가 이 요청을 받는다.</p>
<h3 id="2-tokenization">(2) Tokenization</h3>
<p>LLM은 문자열을 그대로 입력받지 않는다. Tokenizer가 텍스트를 Token ID로 변환한다. 어떻게 나뉘는지는 2편에서 다룬 것처럼 사용하는 Tokenizer에 따라 달라진다.</p>
<h3 id="3-queue--scheduler">(3) Queue / Scheduler</h3>
<p>동시에 여러 요청이 들어오면, 하나씩 처리하는 것보다 여러 요청을 효율적으로 묶어 GPU에서 처리하는 것이 유리하다. 그래서 Serving System에는 어떤 요청을 언제 GPU에서 실행할지 결정하는 Scheduler가 필요하다.</p>
<h3 id="4-batching">(4) Batching</h3>
<p>여러 요청을 묶어 GPU에 전달한다.</p>
<pre><code class="language-text">Request A ─┐
Request B ─┼→ Batch → GPU
Request C ─┘</code></pre>
<p>GPU는 대규모 병렬 연산에 강하기 때문에 여러 요청을 함께 처리하면 자원을 더 효율적으로 쓸 수 있다. 다만 LLM은 요청마다 입력 길이와 생성 길이가 다르기 때문에, 단순히 Batch Size만 키우는 것으로는 충분하지 않다. 이 문제는 뒤에서 다시 다룬다.</p>
<h3 id="5-inference">(5) Inference</h3>
<p>Embedding → Transformer Block(Attention, FFN) → Next Token 순으로 2편에서 살펴본 계산이 GPU에서 실행된다.</p>
<h3 id="6-detokenization">(6) Detokenization</h3>
<p>모델이 생성하는 것은 문자열이 아니라 Token ID다. 이를 다시 사람이 읽을 수 있는 텍스트로 변환한다.</p>
<h3 id="7-streaming">(7) Streaming</h3>
<p>LLM은 답변 전체를 한 번에 생성하지 않고 Token을 하나씩 생성한다. 그래서 생성된 Token을 바로바로 사용자에게 전달하는 <strong>Streaming</strong> 방식이 널리 쓰인다.</p>
<pre><code class="language-text">Token 1 → 사용자
Token 2 → 사용자
Token 3 → 사용자
...</code></pre>
<hr>
<h2 id="3-어디가-병목이-되는가">3. 어디가 병목이 되는가</h2>
<p>전체 파이프라인을 보면 LLM Serving의 병목이 GPU 하나에만 있는 것이 아니라는 것을 알 수 있다.</p>
<pre><code class="language-text">Request → Tokenizer → Queue/Scheduler → Batching → Inference → GPU Memory → Detokenization → Network</code></pre>
<p>요청이 몰리면 Queue에서 대기 시간이 길어질 수 있고, GPU 메모리가 부족하면 동시에 처리할 수 있는 요청 수가 줄어든다. 모델의 연산량이 많으면 GPU Compute가 병목이 될 수 있고, 반대로 연산량보다 데이터를 GPU Memory에서 읽어오는 시간이 더 크다면 Memory Bandwidth가 병목이 될 수도 있다.</p>
<p>따라서 중요한 질문은</p>
<blockquote>
<p>&quot;GPU가 느린가?&quot;</p>
</blockquote>
<p>가 아니라</p>
<blockquote>
<p>&quot;현재 시스템에서 어느 부분이 병목인가?&quot;</p>
</blockquote>
<p>이다. 이 질문에 대한 답에 따라 최적화 방법도 달라진다.</p>
<hr>
<h2 id="4-성능은-무엇으로-측정하는가--latency">4. 성능은 무엇으로 측정하는가 — Latency</h2>
<p>LLM Serving에서는 <strong>Latency</strong>와 <strong>Throughput</strong>이 대표적인 성능 지표다.</p>
<p>Latency는 요청 하나를 처리하는 데 걸리는 시간이다. LLM에서는 특히 <strong>TTFT</strong>와 <strong>TPOT</strong>가 중요하다.</p>
<h3 id="ttft--time-to-first-token">TTFT — Time To First Token</h3>
<p>요청을 보낸 뒤 첫 번째 Token이 생성될 때까지 걸리는 시간이다.</p>
<pre><code class="language-text">Request
   │
   ├────── TTFT ──────┤
                        ↓
                   First Token</code></pre>
<p>TTFT가 짧을수록 사용자는 응답이 빠르게 시작된다고 느낀다.</p>
<h3 id="tpot--time-per-output-token">TPOT — Time Per Output Token</h3>
<p>첫 Token 이후 모델은 다음 Token을 하나씩 생성한다. TPOT는 출력 Token 하나를 생성하는 데 걸리는 시간이다. TPOT가 낮을수록 답변이 빠르게 출력된다.</p>
<hr>
<h2 id="5-throughput-그리고-latency와의-균형">5. Throughput, 그리고 Latency와의 균형</h2>
<p>Throughput은 일정 시간 동안 처리할 수 있는 작업량이다. LLM에서는 requests/sec, tokens/sec 등으로 측정한다. 동시에 많은 사용자가 요청하는 서비스라면, 한 요청의 Latency만 낮추는 것보다 <strong>전체적으로 얼마나 많은 Token을 처리할 수 있는가</strong>가 중요할 수 있다.</p>
<p>여기서 중요한 점이 있다.</p>
<blockquote>
<p><strong>Latency와 Throughput은 항상 동시에 최적화되지 않는다.</strong></p>
</blockquote>
<p>많은 요청을 Batch로 묶으면 GPU 활용률이 높아져 Throughput은 증가할 수 있다. 하지만 특정 요청은 다른 요청이 끝날 때까지 기다려야 하므로 Latency가 늘어날 수도 있다. 그래서 Serving System은 <strong>한 요청의 빠른 응답과 전체 처리량 사이에서 균형을 찾아야 한다.</strong></p>
<hr>
<h2 id="6-llm-serving-최적화">6. LLM Serving 최적화</h2>
<p>LLM Serving에는 다양한 최적화 기법이 존재한다. 이번 편에서는 각각의 원리를 깊게 파고들기보다, <strong>어떤 문제가 있고 어떤 해결책이 존재하는지</strong> 전체 지도를 그려본다.</p>
<table>
<thead>
<tr>
<th>기법</th>
<th>목적</th>
<th>핵심 아이디어</th>
</tr>
</thead>
<tbody><tr>
<td>Quantization</td>
<td>메모리 절감, 연산 효율 향상</td>
<td>낮은 정밀도로 모델 표현</td>
</tr>
<tr>
<td>Pruning</td>
<td>파라미터 감소</td>
<td>불필요한 파라미터 제거</td>
</tr>
<tr>
<td>Knowledge Distillation</td>
<td>모델 경량화</td>
<td>큰 모델의 지식을 작은 모델로 전달</td>
</tr>
<tr>
<td>LoRA / PEFT</td>
<td>효율적인 Fine-tuning</td>
<td>일부 파라미터만 학습</td>
</tr>
<tr>
<td>Continuous Batching</td>
<td>Throughput 향상</td>
<td>실행 중인 요청을 동적으로 관리</td>
</tr>
<tr>
<td>Speculative Decoding</td>
<td>Decode Latency 감소</td>
<td>작은 모델이 후보를 만들고 큰 모델이 검증</td>
</tr>
<tr>
<td>KV Cache</td>
<td>반복 계산 감소</td>
<td>이전 Token의 K/V를 저장하고 재사용</td>
</tr>
<tr>
<td>PagedAttention</td>
<td>KV Cache 메모리 효율화</td>
<td>KV Cache를 Block 단위로 관리</td>
</tr>
</tbody></table>
<p>이제 각각이 어떤 문제를 해결하는지 간단히 살펴보자.</p>
<hr>
<h3 id="6-1-quantization">6-1. Quantization</h3>
<p>모델의 파라미터나 연산을 낮은 정밀도로 표현하는 방법이다. 예를 들어 FP16 대신 INT8이나 INT4를 사용한다.</p>
<pre><code class="language-text">FP16 → INT8 → INT4</code></pre>
<p>정밀도가 낮아지면 모델이 차지하는 메모리가 줄어든다. 따라서 GPU Memory 사용량을 줄이고 Memory Bandwidth 부담을 낮추는 데 도움이 될 수 있다. 대표적인 방법으로 GPTQ, AWQ 등이 있다.</p>
<p>다만 정밀도를 낮추면 정확도 손실이 발생할 수 있고, 실제 속도 향상은 하드웨어와 Kernel이 해당 저정밀 연산을 얼마나 잘 지원하는지에 따라 달라진다.</p>
<blockquote>
<p><strong>Quantization은 단순히 모델 크기를 줄이는 것이 아니라, 정확도와 하드웨어 지원까지 함께 고려해야 하는 최적화다.</strong></p>
</blockquote>
<hr>
<h3 id="6-2-pruning">6-2. Pruning</h3>
<p>모델에서 중요하지 않은 파라미터를 제거하는 방법이다. 하지만 파라미터를 제거했다고 해서 실제 GPU 연산량이 반드시 줄어드는 것은 아니다.</p>
<p><strong>Unstructured Pruning</strong>은 개별 Weight를 제거한다. 이론적으로는 많은 파라미터를 제거할 수 있지만, 일반적인 GPU의 Dense Matrix 연산에서는 제거된 부분을 건너뛰는 것이 오히려 복잡해질 수 있다.</p>
<p><strong>Structured Pruning</strong>은 행, 열, Head 같은 구조적 단위로 제거한다. GPU가 처리하기 쉬운 형태로 모델 구조를 단순화할 수 있어 실제 하드웨어에서 더 유리할 수 있다.</p>
<blockquote>
<p><strong>Pruning에서는 이론적인 연산량 감소와 실제 성능 향상이 다를 수 있다.</strong></p>
</blockquote>
<hr>
<h3 id="6-3-knowledge-distillation">6-3. Knowledge Distillation</h3>
<p>큰 모델(Teacher)의 지식을 작은 모델(Student)에게 전달하는 방법이다.</p>
<pre><code class="language-text">Teacher Model → Knowledge → Student Model</code></pre>
<p>목표는 <strong>더 작은 모델로 원래 모델과 비슷한 성능을 얻는 것</strong>이다. 모델 자체가 작아지기 때문에 추론에 필요한 Compute와 Memory를 함께 줄일 수 있다. 다만 별도의 학습 과정이 필요하기 때문에, <strong>학습 비용을 추가로 지불해 서빙 비용을 줄이는 방법</strong>이라고 볼 수 있다.</p>
<hr>
<h3 id="6-4-lora--peft">6-4. LoRA / PEFT</h3>
<p><strong>PEFT(Parameter-Efficient Fine-Tuning)</strong>는 모델의 전체 파라미터가 아니라 <strong>일부 파라미터만 학습해 Fine-tuning 비용을 줄이는 방법론</strong>을 통칭하는 개념이다.</p>
<pre><code class="language-text">Full Fine-tuning → 전체 파라미터 업데이트
PEFT            → 일부 파라미터만 업데이트</code></pre>
<p>기존의 Full Fine-tuning은 모델의 모든 Weight를 업데이트하기 때문에, 모델 크기만큼의 Gradient와 Optimizer State를 GPU Memory에 올려야 한다. 모델이 클수록 이 비용은 감당하기 어려워진다. PEFT는 이 문제를 해결하기 위해 대부분의 파라미터는 고정(Freeze)한 채, 작은 일부 파라미터만 학습한다.</p>
<p>PEFT는 하나의 특정 기법이 아니라 <strong>접근 방식의 범주</strong>이며, 그 안에 여러 구체적인 기법이 존재한다.</p>
<table>
<thead>
<tr>
<th>기법</th>
<th>핵심 아이디어</th>
</tr>
</thead>
<tbody><tr>
<td>LoRA</td>
<td>기존 Weight는 고정하고, 저차원 Adapter 행렬만 학습</td>
</tr>
<tr>
<td>Prefix / Prompt Tuning</td>
<td>입력 앞에 학습 가능한 Token(Embedding)을 추가</td>
</tr>
<tr>
<td>Adapter Layers</td>
<td>Transformer Block 사이에 작은 모듈을 삽입해 그 부분만 학습</td>
</tr>
<tr>
<td>IA³</td>
<td>기존 Activation에 곱해지는 작은 Scale 파라미터만 학습</td>
</tr>
</tbody></table>
<p>이 중 실무에서 가장 널리 쓰이는 것이 <strong>LoRA</strong>다.</p>
<p>주로 Fine-tuning 비용을 줄이기 위한 방법이다. 기존에는 모델의 전체 파라미터를 업데이트해야 했지만, LoRA는 기존 Weight를 유지한 채 작은 Adapter를 추가해 학습한다.</p>
<pre><code class="language-text">Base Model + LoRA Adapter → Fine-tuned Model</code></pre>
<p>Fine-tuning에 필요한 GPU Memory와 학습 비용을 줄일 수 있다. 다만 이것은 기본적으로 <strong>학습 단계의 최적화</strong>다. 서빙에서는 Adapter를 Merge할지 별도로 유지할지에 따라 실행 방식이 달라진다.</p>
<blockquote>
<p><strong>LoRA는 서빙 자체를 빠르게 만드는 기법이라기보다, 모델을 효율적으로 커스터마이징하기 위한 방법으로 이해하는 것이 좋다.</strong></p>
</blockquote>
<hr>
<h3 id="6-5-continuous-batching">6-5. Continuous Batching</h3>
<p>일반적인 Batch는 여러 요청을 한 번에 묶어 처리한다. 그런데 LLM은 요청마다 생성 길이가 다르다.</p>
<pre><code class="language-text">A → 10 tokens
B → 100 tokens
C → 30 tokens</code></pre>
<p>A와 C의 생성이 끝났는데 B 때문에 Batch가 계속 유지된다면 GPU 자원이 비효율적으로 쓰인다. <strong>Continuous Batching</strong>은 실행 중인 요청이 완료되면 새로운 요청을 동적으로 Batch에 추가한다.</p>
<pre><code class="language-text">시간 →

A █████████
B ███████████████████
C ███████
D       ███████████
E            █████████</code></pre>
<p>덕분에 GPU Idle Time을 줄이고 전체 Throughput을 높일 수 있다. 다만 이 과정에서는 요청별 상태를 관리하는 <strong>Scheduler</strong>가 중요하다. 이 개념은 3-2편에서 Prefill/Decode와 함께, 그리고 이후 편에서 직접 Serving Service를 구현하며 다시 살펴본다.</p>
<hr>
<h3 id="6-6-speculative-decoding">6-6. Speculative Decoding</h3>
<p>LLM의 Decode는 한 번에 하나의 Token만 생성해야 한다.</p>
<pre><code class="language-text">Token 1 → Token 2 → Token 3 → Token 4</code></pre>
<p><strong>Speculative Decoding</strong>은 이 순차적인 과정을 줄이기 위해 작은 Draft Model로 여러 후보 Token을 먼저 만들고, 큰 Target Model이 이 후보들을 한 번에 검증한다.</p>
<pre><code class="language-text">Draft Model  → Token A → Token B → Token C → Token D
Target Model → Accept / Reject</code></pre>
<p>작은 모델로 후보를 빠르게 만들고 큰 모델이 검증함으로써, 큰 모델의 순차적인 Decode 횟수를 줄이는 방식이다.</p>
<blockquote>
<p><strong>추가적인 Compute를 사용해 Decode의 순차성을 줄이는 접근이다.</strong></p>
</blockquote>
<hr>
<h3 id="6-7-kv-cache">6-7. KV Cache</h3>
<p>이번 편에서 소개한 기법 중 가장 중요한 것이 <strong>KV Cache</strong>다.</p>
<p>LLM은 Token을 하나씩 생성하기 때문에, 새로운 Token을 만들 때마다 이전 Token들과의 Attention을 계산해야 한다. 그런데 이전 Token의 Key와 Value는 이미 계산한 값이다. <strong>그 계산 결과를 저장해두고 재사용하면</strong> 반복 계산을 없앨 수 있다. 이것이 KV Cache다.</p>
<p>KV Cache는 반복적인 K/V 계산을 없애주지만, 그 대신 큰 GPU 메모리를 사용한다.</p>
<blockquote>
<p><strong>계산을 줄이는 대신 메모리를 사용한다.</strong></p>
</blockquote>
<p>바로 이 Trade-off에서 LLM Serving의 핵심 문제가 시작된다. 다음편에서는 이 Trade-off를 중심으로 Self-Attention의 비용, Prefill/Decode, PagedAttention 개념등을 알아본다.</p>
<hr>
<h2 id="14-왜-인프라-엔지니어가-이걸-알아야-하는가">14. 왜 인프라 엔지니어가 이걸 알아야 하는가</h2>
<p>지금까지의 내용을 보면 모델의 정확도와 서빙 성능은 서로 다른 문제라는 것을 알 수 있다.</p>
<p>큰 모델은 더 좋은 결과를 낼 수 있지만</p>
<pre><code class="language-text">Model Size ↑ → GPU Memory ↑ → 동시 처리 요청 ↓ → Cost ↑</code></pre>
<p>가 될 수 있다. 반대로 Quantization을 적용하면</p>
<pre><code class="language-text">Precision ↓ → Memory ↓ → Throughput ↑</code></pre>
<p>가 될 수 있지만 정확도 손실이 발생할 수 있다.</p>
<p>즉 실제 서비스에서는 항상 Trade-off가 존재한다. 그래서 인프라 엔지니어가 알아야 할 것은 단순히 &quot;어떤 GPU를 사용할 것인가?&quot;가 아니다. 더 중요한 질문은</p>
<blockquote>
<p><strong>&quot;현재 서비스의 병목은 Compute인가, Memory인가, 아니면 Scheduling인가?&quot;</strong></p>
</blockquote>
<p>이다. 병목을 알지 못한 채 GPU만 추가하면 비용만 늘고 원하는 성능은 얻지 못할 수도 있다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이번 편의 목적은 모든 최적화 기법을 깊게 배우는 것이 아니라, <strong>LLM Serving이라는 문제의 전체 지도를 그리는 것</strong>이다.</p>
<pre><code class="language-text">                 LLM Serving
                      │
       ┌──────────────┼──────────────┐
       ↓              ↓              ↓
    Compute         Memory       Scheduling
       │              │              │
    Prefill        KV Cache       Batching
    Attention      Weights        Continuous
    FFN            Memory
       │              │
       └───────┬──────┘
               ↓
          Optimization</code></pre>
<p>다음편에서는 왜 Attention은 시퀀스 길이가 길어질수록 비싸지는지, KV Cache는 정확히 무엇을 저장하는지, 그리고 왜 Prefill과 Decode의 병목이 서로 다른지 직접 확인해보자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[2편. Transformer 구조 ]]></title>
            <link>https://velog.io/@_gyullbb/2%ED%8E%B8.-Transformer-%EA%B5%AC%EC%A1%B0</link>
            <guid>https://velog.io/@_gyullbb/2%ED%8E%B8.-Transformer-%EA%B5%AC%EC%A1%B0</guid>
            <pubDate>Fri, 07 Aug 2026 17:31:50 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>&quot;GPU는 행렬곱을 가장 잘하는 프로세서다.</p>
<p>그렇다면 GPU는 실제로 무엇을 계산하고 있을까?&quot;</p>
</blockquote>
<p>1편에서는 CPU와 GPU의 구조를 살펴보며 왜 AI가 GPU 위에서 동작하는지 알아보았다.</p>
<p>CPU는 복잡한 제어를 빠르게 수행하도록 설계되었고,</p>
<p>GPU는 수천 개의 연산 유닛을 이용해 같은 계산을 동시에 수행하도록 설계되었다.</p>
<p>그렇다면 자연스럽게 이런 질문이 생긴다.</p>
<blockquote>
<p><strong>GPU는 실제로 무엇을 계산하기에 그렇게 빠를까?</strong></p>
</blockquote>
<p>오늘날 ChatGPT와 같은 대부분의 대형 언어 모델(LLM)은 <strong>Transformer</strong>라는 구조를 사용한다.</p>
<p>Transformer는 GPU를 위해 만들어진 모델은 아니다.</p>
<p>하지만 결과적으로 GPU가 가장 잘하는 계산인 <strong>행렬곱(Matrix Multiplication)</strong>을 거의 모든 단계에서 사용한다.</p>
<p>그래서 GPU의 성능이 좋아질수록 Transformer 역시 더 빠르게 학습하고 더 빠르게 추론할 수 있다.</p>
<p>이번 글에서는 LLM이 문장 하나를 생성하는 과정을 따라가며 Transformer가 어떻게 동작하는지 살펴본다.</p>
<hr>
<h1 id="llm은-어떻게-답을-만들까">LLM은 어떻게 답을 만들까?</h1>
<p>예를 들어 사용자가 ChatGPT에게 다음과 같이 질문했다고 해보자.</p>
<blockquote>
<p> The weather</p>
</blockquote>
<p>우리는 자연스럽게 다음 단어로</p>
<blockquote>
<p> is</p>
</blockquote>
<p>또는</p>
<blockquote>
<p> today</p>
</blockquote>
<p>같은 단어를 떠올릴 것이다.</p>
<p>LLM도 겉보기에는 비슷한 일을 하는 것처럼 보인다. 하지만 내부 동작은 사람과 전혀 다르다.</p>
<p>사람은 문장의 의미를 이해한 뒤 다음 말을 생각하지만, LLM은 입력 문장을 숫자로 변환하고 수많은 행렬 연산을 수행하여 가장 가능성이 높은 다음 Token을 계산한다.</p>
<p>LLM 실제 계산의 대부분은 Transformer Block 안에서 이루어진다.</p>
<p>하지만 Transformer를 이해하기 전에 먼저 컴퓨터가 문장을 어떻게 숫자로 바꾸는지부터 살펴보자.</p>
<hr>
<h1 id="tokenizer">Tokenizer</h1>
<p>컴퓨터는 사람이 사용하는 언어를 그대로 이해하지 못한다.</p>
<p>따라서 가장 먼저 해야 하는 일은 문장을 Token이라는 작은 단위로 분리하는 것이다.</p>
<blockquote>
<p>The weather is nice</p>
</blockquote>
<p>라는 문장은 </p>
<p>&quot;The&quot;
&quot;weather&quot;
&quot;is&quot;
&quot;nice&quot;
처럼 여러 개의 Token으로 나뉜다.</p>
<p>여기서 중요한 점은 Token이 반드시 단어 하나를 의미하는 것은 아니라는 것이다.</p>
<p>예를 들어</p>
<blockquote>
<p>playing</p>
</blockquote>
<p>이라는 단어는
&quot;play&quot;
&quot;ing&quot;
처럼 두 개의 Token 으로 분리될 수도 있다.</p>
<p>즉, Tokenizer는 언어를 가장 효율적으로 표현할 수 있도록 문장을 분해하는 과정이다.</p>
<p>Tokenizer는 이렇게 분해한 각 Token에 고유한 번호(ID)를 부여한다.</p>
<blockquote>
<p>&quot;The&quot; -&gt; id 464</p>
<p>&quot;weather&quot; -&gt; id 6193</p>
</blockquote>
<p>이제 컴퓨터는 문장을 숫자로 표현할 수 있게 되었다.</p>
<p>하지만 아직 문제가 하나 남아 있다.</p>
<p>숫자 464를 보고 컴퓨터는 이것이 &quot;The&quot;라는 단어인지, &quot;weather&quot;와 어떤 관계가 있는지 전혀 알지 못한다.</p>
<p>이 문제를 해결하기 위해 사용하는 것이 Embedding이다.</p>
<hr>
<h1 id="embedding">Embedding</h1>
<p>Embedding은 Token ID를 의미를 가진 벡터(Vector)로 변환하는 과정이다.</p>
<p>Transformer는 Token ID를 그대로 사용하는 대신, 각각의 Token을 수백 또는 수천 개의 숫자로 이루어진 벡터로 표현한다.</p>
<p>예를 들어 Hidden Size가 768인 모델이라면 하나의 Token은 다음과 같이 표현된다.</p>
<blockquote>
<p>&quot;The&quot; -&gt; [0.82, -1.37, 0.19, ..., 0.45]</p>
</blockquote>
<p>이 벡터 하나가 바로 &quot;The&quot;라는 단어를 표현하는 새로운 방식이다.</p>
<h2 id="embedding은-어떻게-만들어질까">Embedding은 어떻게 만들어질까?</h2>
<p>LLM 내부에는 Embedding Matrix라는 거대한 테이블이 하나 존재한다.</p>
<p>Embedding Matrix (Vocabulary Size × Embedding Dimension)</p>
<h2 id="id------vector">ID      Vector</h2>
<p>0     → [ 0.11, -0.54,  0.92, ... ]
1     → [-0.38,  1.25, -0.73, ... ]
2     → [ 0.66,  0.17,  0.44, ... ]
...
464   → [ 0.82, -1.37,  0.19, ... ]
...
6193  → [-0.51,  0.84, -0.11, ... ]
...</p>
<p>예를 들어 &quot;The&quot;의 Token ID가 464라면 Embedding Layer는 Embedding Matrix의 464번째 행(Row)을 그대로 가져온다.</p>
<blockquote>
<p>Embedding Matrix의 464번째 Row 조회 -&gt; [0.82, -1.37, 0.19, ..., 0.45]</p>
</blockquote>
<p>이렇게 얻어진 벡터가 바로 &quot;The&quot;의 Embedding이다.</p>
<p>즉, Embedding Layer는 새로운 벡터를 계산하는 것이 아니라 미리 학습된 벡터를 조회(Lookup) 하는 과정이다.</p>
<h2 id="그렇다면-이-벡터는-누가-만들었을까">그렇다면 이 벡터는 누가 만들었을까?</h2>
<p>처음에는 모든 벡터가 무작위(Random) 값으로 초기화된다. 
하지만 모델이 수십억 개의 문장을 학습하면서 역전파(Backpropagation)를 통해 조금씩 수정된다.</p>
<p>예를 들어</p>
<ul>
<li>weather</li>
<li>rain</li>
<li>cloud</li>
<li>temperature</li>
</ul>
<p>처럼 자주 함께 등장하는 단어들은 점점 가까운 위치로 이동하고, 관련이 적은 단어들은 멀어지게 된다.</p>
<p>결국 학습이 끝나면 단순한 숫자였던 Token ID는 의미를 담은 좌표가 된다.</p>
<p>중요한 점은 절대적인 좌표가 아니라 벡터들 사이의 거리와 방향이다.</p>
<p>그래서 의미가 비슷한 단어일수록 벡터 공간에서도 가까운 위치에 존재하게 된다.</p>
<p>서로 다른 모델은 학습 데이터와 구조가 다르기 때문에 같은 &quot;The&quot;라도 모델에 따라 서로 다른 Embedding 벡터를 갖는다.</p>
<p>여기까지 오면 컴퓨터는 각 단어의 의미는 표현할 수 있게 되었다.</p>
<p>하지만 아직도 중요한 정보 하나가 부족하다.</p>
<p>바로 단어의 순서다.</p>
<hr>
<h1 id="position-encoding">Position Encoding</h1>
<p>다음 두 문장을 비교해보자.</p>
<blockquote>
<p>I love you</p>
</blockquote>
<blockquote>
<p>You love I</p>
</blockquote>
<p>두 문장은 같은 단어를 사용하지만 의미는 완전히 다르다.</p>
<p>Embedding만 사용한다면 Transformer는 두 문장을 거의 같은 단어 집합으로 인식한다.</p>
<p>왜냐하면 각 단어의 의미는 알 수 있지만 어느 단어가 먼저 등장했는지는 알 수 없기 때문이다.</p>
<p>그래서 Transformer는 각 Token에 위치 정보(Position) 를 함께 전달한다.</p>
<p>이를 <strong>Positional Encoding</strong>이라고 한다.</p>
<p>동작 방식은 생각보다 단순하다. 각 Token은 이미 Embedding을 통해 의미를 표현하는 벡터를 가지고 있다.
여기에 &quot;몇 번째 단어인지&quot; 를 나타내는 위치 벡터를 더한다.</p>
<blockquote>
<p>&quot;The&quot; (1번째 단어)</p>
</blockquote>
<p>Embedding [0.82, -1.37, 0.19, ...] + Position Encoding [0.00, 1.00, 0.00, ...]</p>
<blockquote>
</blockquote>
<p>= Transformer에 입력되는 값 [0.82, -0.37, 0.19, ...]</p>
<p>즉, Transformer에 입력되는 값은</p>
<blockquote>
<p>단어의 의미 + 단어의 위치</p>
</blockquote>
<p>를 함께 표현하는 하나의 벡터이다. 
이렇게 하면 같은 &quot;love&quot;라는 단어라도 문장의 첫 번째에 등장하는지, 세 번째에 등장하는지에 따라 서로 다른 표현을 갖게 되고, 모델은 단어의 의미뿐 아니라 순서까지 함께 이해할 수 있다.</p>
<p>그렇다면 위치 벡터는 어떻게 만들까? Transformer 논문에서는 각 위치마다 고유한 패턴을 만들기 위해 사인(Sine) 과 ​코사인(Cosine) 함수를 사용했다. 
위치 번호(Position)를 입력하면 항상 같은 위치 벡터가 생성되며, 가까운 위치는 비슷한 값을, 먼 위치는 다른 값을 갖도록 설계되어 모델이 단어들의 상대적인 위치 관계까지 학습할 수 있다.</p>
<p>수식은 다음과 같다.</p>
<p>$$
\begin{aligned}
PE(pos,2i)
&amp;=\sin\left(\frac{pos}{10000^{\frac{2i}{d}}}\right) \[1.2em]
PE(pos,2i+1)
&amp;=\cos\left(\frac{pos}{10000^{\frac{2i}{d}}}\right)
\end{aligned}
$$</p>
<blockquote>
<p>pos : 현재 토큰의 위치 (0, 1, 2, ...)
i   : 벡터의 차원 인덱스
d   : Embedding 차원 (예: 512, 768, 4096 ...)</p>
</blockquote>
<p>해당 수식으로 <strong>각 위치마다 고유한 벡터</strong>를 만들어 Embedding에 더해 줌으로써 이제 Transformer는 단어의 의미뿐 아니라 문장 속 순서까지 함께 표현할 수 있게 된다.</p>
<p>각 Token은 의미를 담은 Embedding 벡터와 순서를 나타내는 Position 정보를 함께 갖게 된다. 
하지만 이것만으로는 아직 다음 단어를 예측할 수 없다.</p>
<p>Transformer의 핵심인 Transformer Block 안으로 들어가, 문장 속 단어들이 서로 어떤 관계를 가지는지 계산하기 시작한다.</p>
<p>그 중심에 있는 것이 바로 Self-Attention이다.</p>
<hr>
<h1 id="self-attention">Self-Attention</h1>
<p>Transformer Block 내부 과정을 이해하기 위해서는 Self-Attention에 대한 이해는 필수적이다.</p>
<p>Self-Attention을 간단하게 표현하면 <strong>문장 안의 모든 단어를 동시에 바라보며</strong> 어떤 단어가 자신과 가장 관련이 있는지 계산하는 과정이다.</p>
<p>이 과정을 위해 등장하는 개념이 바로 Query(Q), Key(K), Value(V) 이다.</p>
<h1 id="query-key-value">Query, Key, Value</h1>
<p>Transformer는 입력으로 받은 Embedding을 그대로 비교하지 않는다.</p>
<p>대신 하나의 Embedding으로부터 서로 다른 역할을 가진 세 개의 벡터를 만든다.</p>
<ul>
<li>Query(Q)</li>
<li>Key(K)</li>
<li>Value(V)</li>
</ul>
<p>왜 굳이 하나를 세 개로 나눌까?</p>
<p>그 이유는 하나의 단어가 문장 안에서 동시에 여러 역할을 수행하기 때문이다.</p>
<p>예를 들어</p>
<blockquote>
<p>The weather is nice.</p>
</blockquote>
<p>라는 문장에서 &quot;weather&quot;는</p>
<ul>
<li>다른 단어를 찾기 위한 기준이 되기도 하고,</li>
<li>다른 단어가 자신을 참고하는 대상이 되기도 하며,</li>
<li>최종적으로 전달할 의미를 담기도 한다.</li>
</ul>
<p>이 세 가지 역할을 하나의 벡터가 모두 수행하도록 하면 서로 다른 목적이 뒤섞이게 된다.</p>
<p>그래서 Transformer는 역할을 분리한다.</p>
<p>각각을 다음처럼 생각하면 이해하기 쉽다.</p>
<ul>
<li>Query : 나는 무엇을 찾고 싶은가?</li>
<li>Key : 나는 어떤 특징을 가지고 있는가?</li>
<li>Value : 나는 어떤 정보를 전달할 것인가?</li>
</ul>
<p>즉, Query는 질문, Key는 검색 키, Value는 실제 데이터라고 생각하면 된다.</p>
<p>이처럼 역할을 분리하면 비교는 Query와 Key가 담당하고, 실제 정보 전달은 Value가 담당하게 되어 각 역할을 독립적으로 학습할 수 있다.</p>
<p>여기서 한 가지 질문이 생긴다.</p>
<blockquote>
<p>Query, Key, Value는 누가 만드는 걸까?</p>
</blockquote>
<p>이 질문에 답하려면 먼저 학습(Training) 과 추론(Inference) 을 구분해야 한다.</p>
<hr>
<h1 id="학습training과-추론inference의-차이">학습(Training)과 추론(Inference)의 차이</h1>
<p>Transformer의 동작을 이해하기 전에 반드시 구분해야 하는 개념이 있다. 바로 <strong>학습</strong>과 <strong>추론</strong>이다. 
이 두 개념을 분리하여 이해하여야 Transformer에 대한 이해가 쉬워진다.</p>
<p>ChatGPT와 같은 LLM을 사용할 때, 내가 질문을 하면 ChatGPT가 그 자리에서 계속 학습하는 것이 아닐까? 라는 생각을 할 수 있다.
하지만 Transformer에서 학습과 추론은 <strong>목적과 동작 방식이 서로 다른 두 과정</strong>이다.</p>
<h2 id="학습training">학습(Training)</h2>
<p>학습은 모델이 언어를 배우는 과정이다.
문장이 주어지면 모델은 현재까지의 문맥을 바탕으로 다음 토큰을 예측하는 문제를 반복해서 푼다.</p>
<p>예를 들어</p>
<blockquote>
<p>나는 오늘 밥을</p>
</blockquote>
<p>이라는 문장이 주어졌을 때 모델이</p>
<blockquote>
<p>침대</p>
</blockquote>
<p>라고 예측했다고 해보자.</p>
<p>실제 정답은</p>
<blockquote>
<p>먹었다</p>
</blockquote>
<p>이다.</p>
<p>모델은 자신의 예측과 정답을 비교하여 얼마나 틀렸는지를 계산한다.</p>
<p>이 값을 Loss라고 한다.</p>
<p>Loss는 모델의 예측이 실제 정답과 얼마나 다른지를 수치로 나타낸 값이다. 모델의 목표는 Loss를 최대한 작게 만드는 것이다.</p>
<p>Loss가 계산되면 &quot;어떤 가중치 때문에 이런 실수가 발생했는가?&quot; 를 역전파(Backpropagation)를 통해 계산하고, 모델 내부의 모든 Weight를 조금씩 수정한다.</p>
<p>이 과정을 수십억 개의 문장에 대해 반복하면서 Embedding, Q를 만드는 Weight, K를 만드는 Weight, V를 만드는 Weight, 그리고 Transformer의 모든 Weight가 조금씩 최적화된다.</p>
<p>즉, Query, Key, Value를 만드는 방법도 모두 학습을 통해 결정된다.</p>
<hr>
<h2 id="추론inference">추론(Inference)</h2>
<p>우리가 ChatGPT를 사용할 때는 학습의 과정을 통해 얻은 모델을 사용하게 된다. 이 과정을 <strong>추론</strong>이라고 한다.</p>
<p>추론에서는 이미 학습이 끝난 Weight를 그대로 사용한다. 새로운 문장이 들어오더라도 Weight는 변하지 않는다.</p>
<p>그런데도 입력 문장에 따라 Self-Attention 결과는 계속 달라진다.</p>
<p>Weight는 고정되어 있지만 입력되는 Embedding은 매번 달라지기 때문이다.</p>
<p>같은 Weight를 사용하더라도 입력되는 Embedding이 달라지면 생성되는 Query, Key, Value 역시 달라진다.</p>
<p>즉, <strong>학습</strong>은 <strong>Weight를 최적화</strong>하는 과정이고, <strong>추론</strong>은 학습된 Weight를 이용해 <strong>새로운 입력에 대한 결과를 계산하는 과정</strong>이다.</p>
<p>중요한 점은 Transformer는 추론 중에도 Query, Key, Value를 새롭게 계산한다는 것이다.</p>
<p>바뀌지 않는 것은 Weight이고, 매 입력마다 새롭게 계산되는 것은 Embedding과 Query, Key, Value 그리고 Self-Attention 결과이다.</p>
<p>즉, 모델이 새로운 문장을 이해하는 이유는 Weight가 바뀌기 때문이 아니라, 같은 Weight를 이용해 입력에 맞는 새로운 표현을 매번 계산하기 때문이다.</p>
<hr>
<h2 id="query-key-value-계산">Query, Key, Value 계산</h2>
<p>이제 실제 계산을 살펴보자.</p>
<p>Embedding 하나는 각각 다른 Weight를 이용하여 세 번의 선형 변환을 수행한다.</p>
<pre><code class="language-text">Embedding
     │
     ├────────► Q = XWQ + bQ
     │
     ├────────► K = XWK + bK
     │
     └────────► V = XWV + bV</code></pre>
<ul>
<li><strong>X</strong> : Embedding 벡터</li>
<li><strong>WQ, WK, WV</strong> : Query, Key, Value를 만들기 위한 학습된 Weight</li>
<li><strong>bQ, bK, bV</strong> : Bias</li>
</ul>
<p>LLM에서 하나의 Token은 여러 개의 숫자로 이루어진 벡터로 표현된다. 이 벡터의 길이를 Hidden Size라고 하며, 모델을 설계할 때 미리 정해진다.</p>
<p>예를 들어 BERT Base의 Hidden Size는 768이다. 즉, 하나의 Token은 768개의 숫자로 표현된다.</p>
<p>&quot;The weather&quot;라는 문장은 Token이 두 개이므로 Embedding 행렬의 크기는 다음과 같다.</p>
<blockquote>
<p>(2, 768)</p>
</blockquote>
<ul>
<li>2   : Token의 개수</li>
<li>768 : Hidden Size</li>
</ul>
<p>앞에서 본 수식대로라면 Query, Key, Value를 각각 계산해야 하므로 세 번의 행렬곱이 필요해 보인다.</p>
<p>하지만 실제 구현은 조금 다르다.</p>
<p>GPU는 작은 행렬을 여러 번 곱하는 것보다 큰 행렬 하나를 한 번에 계산하는 것이 메모리 접근과 병렬 처리 측면에서 훨씬 효율적이다. 
따라서 대부분의 Transformer 구현은 Query, Key, Value를 위한 <strong>세 개의 Weight를 하나의 큰 Weight로 합쳐</strong> 한 번의 행렬곱으로 계산한다.</p>
<p>Hidden Size가 768이라면 세 개의 Weight를 이어 붙인 하나의 Weight 행렬의 크기는 다음과 같다.</p>
<blockquote>
<p>(768, 2304)</p>
</blockquote>
<p>실제 계산은 다음과 같이 한 번의 행렬곱으로 수행된다.</p>
<blockquote>
<p>(2, 768) × (768, 2304) = (2, 2304)</p>
</blockquote>
<p>계산이 끝난 뒤 생성된 (2, 2304) 행렬은 가로 방향으로 세 등분되어 각각 (2, 768) 크기의 Query, Key, Value 행렬로 분리된다.</p>
<pre><code>       (2, 2304)
            │
 ┌──────────┼──────────┐
 ▼          ▼          ▼</code></pre><p> Q (2,768)  K (2,768)  V (2,768)</p>
<p>수학적으로는 Query, Key, Value를 각각 계산하는 것으로 표현하지만, 실제 구현에서는 GPU의 병렬 연산 성능을 최대한 활용하기 위해 한 번의 큰 행렬곱으로 계산한 뒤 다시 세 개의 행렬로 분리하는 방식을 사용한다.</p>
<p>이제 모든 Token에 대한 Query, Key, Value가 준비되었다. 다음 단계에서는 각 Token이 서로 얼마나 관련 있는지를 계산한다.</p>
<hr>
<h2 id="self-attention-계산">Self-Attention 계산</h2>
<p>앞에서 모든 Token에 대한 Query(Q), Key(K), Value(V)를 준비했다.</p>
<p>이제 Transformer는 이 세 가지 정보를 이용해 각 Token이 문장 속 어떤 단어를 얼마나 참고해야 하는지 계산한다.
논문에서는 이를 다음과 같은 수식으로 정의한다.</p>
<p>$$
Attention(Q,K,V)
=
Softmax
\left(
\frac{QK^T}{\sqrt{d_k}}
\right)
V
$$</p>
<p>이 수식은 크게 네 단계로 나누어 볼 수 있다.</p>
<h3 id="1-query와-key를-비교한다">1. Query와 Key를 비교한다.</h3>
<p>가장 먼저 Query와 모든 Key를 비교하여 단어들 사이의 관련성을 계산한다.</p>
<p>이때 사용하는 연산이 내적(Dot Product) 이다.</p>
<p>내적은 두 벡터가 얼마나 비슷한 방향을 바라보고 있는지를 나타내는 연산으로, 값이 클수록 두 벡터가 더 비슷하다는 의미이다.</p>
<p>예를 들어 &quot;weather&quot;를 처리하고 있다면 &quot;weather&quot;의 Query는 문장 속 모든 Key와 비교된다.</p>
<p>weather(Q)
    │
    ├──► The(K)
    ├──► weather(K)
    ├──► is(K)
    └──► nice(K)</p>
<p>이 과정을 거치면 &quot;weather&quot;와 다른 단어들 사이의 관련성이 숫자로 계산된다.</p>
<p>이 숫자를 <strong>Attention Score</strong>라고 한다.</p>
<p>중요한 점은 &quot;weather&quot;만 계산하는 것이 아니라 모든 Token이 동시에 같은 계산을 수행한다는 것이다.</p>
<p>Transformer가 문장을 한 번에 처리할 수 있는 이유도 여기에 있다.</p>
<hr>
<h3 id="2-왜-√dₖ로-나눌까">2. 왜 √dₖ로 나눌까?</h3>
<p>Query와 Key를 내적하면 차원이 커질수록 결과도 함께 커지는 경향이 있다.</p>
<p>예를 들어 Hidden Size가 64일 때보다 4096일 때는 내적 값이 훨씬 큰 숫자가 된다.</p>
<p>이 상태에서 바로 Softmax를 적용하면 문제가 발생한다.</p>
<p>예를 들어 다음과 같은 결과가 나올 수 있다.</p>
<blockquote>
<p>0.999999
0.000001
0.000000</p>
</blockquote>
<p>모델이 하나의 단어만 지나치게 선택하고 나머지 단어는 거의 무시하게 되는 것이다.</p>
<p>이런 현상을 방지하기 위해 논문에서는 내적 값을</p>
<p>$$
\sqrt{d_k}
$$</p>
<p>로 나누어 값의 크기를 적절하게 조절한다.</p>
<p>이 과정을 포함한 Attention을 <strong>Scaled Dot-Product Attention</strong>이라고 부른다.</p>
<hr>
<h3 id="3-softmax로-중요도를-계산한다">3. Softmax로 중요도를 계산한다.</h3>
<p>정규화된 Attention Score는 Softmax를 거쳐 확률 형태의 가중치로 변환된다.
예를 들어 &quot;weather&quot;를 처리한 결과가 다음과 같다고 해보자.</p>
<table>
<thead>
<tr>
<th>단어</th>
<th align="right">Attention Weight</th>
</tr>
</thead>
<tbody><tr>
<td>The</td>
<td align="right">0.05</td>
</tr>
<tr>
<td>weather</td>
<td align="right">0.65</td>
</tr>
<tr>
<td>is</td>
<td align="right">0.20</td>
</tr>
<tr>
<td>nice</td>
<td align="right">0.10</td>
</tr>
</tbody></table>
<p>위와 같은 결과가 나왔다면 Transformer는 <code>&quot;weather&quot;</code>를 가장 중요하게 참고하고, <code>&quot;the&quot;</code>와<code>&quot;is&quot;</code> 그리고 <code>&quot;nice&quot;</code>는 그보다 적게 참고한다는 의미이다.</p>
<p>이 가중치는 단순히 가장 큰 값을 선택하는 것이 아니라, 문맥에 따라 각 단어를 얼마나 반영할지를 결정하는 비율이다.</p>
<hr>
<h3 id="4-value를-이용해-새로운-표현을-만든다">4. Value를 이용해 새로운 표현을 만든다.</h3>
<p>이제 계산된 Attention Weight를 각 Token의 Value에 곱한 뒤 모두 더한다.</p>
<p>$$
Output
=
Attention\ Weights
\times V
$$</p>
<p>즉, 관련성이 높은 단어의 정보는 더 많이 반영하고, 관련성이 낮은 단어의 정보는 적게 반영한다.</p>
<p>결과적으로 &quot;weather&quot;는 처음의 Embedding과는 다른 벡터가 된다.</p>
<p>이 벡터는 이제 단순히 &quot;weather&quot;라는 단어를 표현하는 것이 아니라,</p>
<p>문장 전체의 문맥이 반영된 새로운 표현(Contextual Representation)이 된다.</p>
<p>이것이 바로 Self-Attention의 핵심이다.</p>
<hr>
<h1 id="multi-head-attention">Multi-Head Attention</h1>
<p>지금까지는 Attention을 하나만 사용하는 경우를 살펴봤다.</p>
<p>하지만 실제 Transformer는 Attention을 하나만 사용하지 않고 여러 개의 Attention을 동시에 계산한다.</p>
<p>이는 <strong>문장을 이해하는 방법은 하나가 아니기</strong> 때문이다.</p>
<p>하나의 문장에서 어떤 Head는 문법적 관계를, 또 다른 Head는 동사의 의미를, 또 다른 Head는 문장 전체의 흐름을 학습할 수 있다.</p>
<p>각 Head는 서로 다른 Weight를 사용하므로 같은 문장을 보더라도 서로 다른 특징을 학습하게 된다.</p>
<p>모든 Head의 계산이 끝나면 결과를 하나로 합쳐 다음 단계로 전달한다.</p>
<p>이 과정을 Multi-Head Attention이라고 한다.</p>
<p>덕분에 Transformer는 하나의 관점이 아니라 여러 관점을 동시에 고려하여 문맥을 이해할 수 있다.</p>
<p>여러 Head의 결과는 하나로 합쳐져 다음 단계인 Feed Forward Network로 전달된다.</p>
<hr>
<h1 id="feed-forward-network">Feed Forward Network</h1>
<p>Self-Attention을 거치면 각 Token은 문맥 정보를 반영한 새로운 표현을 갖게 된다.</p>
<p>하지만 이것만으로는 충분하지 않다.</p>
<p>문맥을 이해한 뒤에는 그 정보를 이용해 각 Token 자체를 더 풍부하게 표현해야 한다.</p>
<p>이 역할을 수행하는 것이 Feed Forward Network(FFN) 이다.</p>
<p>쉽게 말하면,</p>
<p>Self-Attention이 어떤 단어를 참고할지 결정한다면,
FFN은 그 정보를 이용해 Token의 표현 자체를 업그레이드하는 과정이다.</p>
<h2 id="ffn의-구조">FFN의 구조</h2>
<p>FFN은 두 개의 Linear Layer와 하나의 활성화 함수(GELU)로 이루어진다.</p>
<p>$$
h = GELU(XW_1+b_1)
$$</p>
<p>$$
Output = hW_2+b_2
$$</p>
<ul>
<li><strong>W₁</strong> : 차원을 확장하는 Weight</li>
<li><strong>W₂</strong> : 다시 원래 크기로 줄이는 Weight</li>
</ul>
<p>첫 번째 Linear Layer는 Hidden Size를 크게 확장하고, 두 번째 Linear Layer는 다시 원래 크기로 축소한다.</p>
<p>예를 들어 BERT Base의 Hidden Size는 768이지만, FFN 내부에서는 먼저 3072(768 × 4) 차원으로 확장한 뒤 다시 768 차원으로 줄인다.</p>
<p>이는 Token의 개수는 그대로 유지한 채, 각 Token을 표현하는 Feature의 개수를 768개에서 3072개로 확장했다가 다시 압축하는 것을 의미한다.</p>
<p>여기서 차원을 늘린다는 것은 단순히 768개의 숫자를 3072개로 복사하는 것이 아니다.</p>
<p>첫 번째 Linear Layer는 768개의 입력 Feature를 3072개의 새로운 Feature로 변환하며, 이때 생성되는 3072개의 출력 각각을 하나의 뉴런(Neuron)이라고 생각하면 이해하기 쉽다.</p>
<p>각 뉴런은 입력 Feature 전체를 <strong>자신만의 Weight로 조합하여 하나의 새로운 값을 계산</strong>하는 계산기이다. 
따라서 3072개의 뉴런은 모두 서로 다른 Weight를 사용해 동일한 입력을 각자의 방식으로 계산한다.</p>
<p>결과적으로 하나의 Token은 3072개의 새로운 Feature로 표현되며, 이는 하나의 Token을 <strong>3072개의 서로 다른 관점에서 분석하는 과정</strong>이라고 이해하면 된다.</p>
<h2 id="gelu">GELU</h2>
<p>여기서 또 하나의 궁금한 점이 생긴다.</p>
<p>Linear Layer 두 번 사용하는데, 왜 그 사이에 GELU를 넣는 걸까?</p>
<p>만약 활성화 함수가 없다면 Linear → Linear는 결국 하나의 큰 Linear Layer와 완전히 동일한 계산이 된다.</p>
<p>즉, 아무리 Layer를 많이 쌓아도 복잡한 패턴을 학습할 수 없다.</p>
<p>이를 해결하기 위해 첫 번째 Linear Layer와 두 번째 Linear Layer 사이에 GELU(Gaussian Error Linear Unit) 라는 활성화 함수를 넣는다.</p>
<p>GELU는 입력값을 단순히 통과시키는 것이 아니라 <strong>값의 크기에 따라 출력을 부드럽게 조절하는 비선형 함수</strong>이다.</p>
<p>큰 값은 거의 그대로 유지하고, 작은 값이나 중요하지 않은 값은 자연스럽게 줄여 준다.</p>
<blockquote>
<p>입력
  │
  ├── 큰 값  ────────► 거의 그대로 통과
  │
  └── 작은 값 ───────► 부드럽게 감소</p>
</blockquote>
<p>ReLU처럼 일정 기준 아래의 값을 모두 0으로 만드는 것이 아니라,연속적으로 값을 조절하기 때문에 정보 손실이 적고 학습도 더욱 안정적이다.</p>
<h2 id="ffn의-목적">FFN의 목적</h2>
<p>FFN을 거치면 3072개의 다양한 특징 가운데 중요한 정보는 더욱 강조되고, 덜 중요한 정보는 자연스럽게 약해진다.</p>
<p>마지막 Linear Layer는 이를 다시 원래 Hidden Size로 압축하여 다음 Block으로 전달한다.</p>
<p>처음 Transformer Block에 들어왔을 때의 벡터는 단순히 단어의 의미와 위치만 담고 있었다.</p>
<p>하지만 Self-Attention과 FFN을 거친 후에는 <strong>문맥과 다양한 특징이 모두 반영된 새로운 표현</strong>으로 변하게 된다.</p>
<p>이 표현은 다음 Transformer Block으로 전달되고,</p>
<p>같은 과정이 반복되면서 문장의 의미는 점점 더 풍부하게 다듬어진다.</p>
<hr>
<h1 id="residual-connection">Residual Connection</h1>
<p>지금까지 살펴본 Self-Attention과 Feed Forward Network는 입력된 Token을 새로운 표현으로 변환하는 역할을 한다.</p>
<p>하지만 Transformer는 이러한 Block을 하나만 사용하는 것이 아니다.</p>
<p>Transformer가 수십 개, 많게는 수백 개의 Block을 거치게 되면 문제가 생긴다.</p>
<p>매 Block마다 입력을 계속 새로운 값으로 바꾸다 보면 원래 가지고 있던 정보가 점점 희미해질 수 있다.</p>
<p>이 문제를 해결하기 위해 사용하는 것이 Residual Connection(잔차 연결) 이다.</p>
<p>Residual Connection은 새로운 계산 결과만 사용하는 것이 아니라, 원래 입력을 함께 더해주는 방식이다.</p>
<p>수식으로 표현하면 다음과 같다.</p>
<blockquote>
<p>Output=X+F(X)</p>
</blockquote>
<ul>
<li>X : Block의 입력</li>
<li>F(X) : Self-Attention 또는 FFN의 출력</li>
</ul>
<p>즉, Transformer는 새롭게 학습한 정보와 기존 정보를 함께 유지하도록 설계되어 있다.</p>
<p>덕분에 Block이 아무리 깊어져도 중요한 정보가 쉽게 사라지지 않으며, 역전파 과정에서도 Gradient가 안정적으로 전달된다.</p>
<p>이러한 구조 덕분에 수십, 수백 개의 Layer를 가진 매우 깊은 모델을 안정적으로 학습할 수 있다.</p>
<hr>
<h1 id="layer-normalization">Layer Normalization</h1>
<p>Residual Connection으로 입력 정보를 유지했다면, 이제는 계산 결과를 안정적으로 만들어야 한다.</p>
<p>Transformer는 Block을 반복해서 거치는 동안 벡터 안의 값이 점점 커지거나 작아질 수 있다.</p>
<p>예를 들어 어떤 Block에서는</p>
<blockquote>
<p>[ 0.8, -0.5, 1.2 ]</p>
</blockquote>
<p>가 나오고,</p>
<p>다음 Block에서는</p>
<blockquote>
<p>[ 130, -85, 240 ]</p>
</blockquote>
<p>처럼 값의 크기가 크게 변할 수도 있다.</p>
<p>이러한 변화가 계속 누적되면 학습이 불안정해지고, 모델의 성능도 떨어질 수 있다.</p>
<p>이를 방지하기 위해 사용하는 것이 Layer Normalization(LayerNorm) 이다.</p>
<p>LayerNorm은 벡터의 평균과 분산을 이용해 값의 분포를 일정한 범위로 맞춰준다.</p>
<p>여기서 중요한 점은 값의 크기를 조정하는 것이지, 의미를 없애는 것은 아니라는 것이다.</p>
<p>각 Feature가 가진 상대적인 관계는 그대로 유지하면서, 계산하기 좋은 범위로 정리해 주는 역할을 한다.</p>
<p>Residual Connection이 정보를 유지한다면, LayerNorm은 그 정보를 안정적으로 다음 Layer에 전달하는 역할을 수행한다고 이해하면 된다.</p>
<hr>
<h1 id="transformer-block">Transformer Block</h1>
<p>이제 지금까지 살펴본 내용을 하나로 정리해 보자.</p>
<p>Transformer Block 내부에서는 다음과 같은 순서로 계산이 이루어진다.</p>
<blockquote>
<p>Input
  │
  ▼
Multi-Head Attention
  │
  ▼
Add (Residual)
  │
  ▼
Layer Normalization
  │
  ▼
Feed Forward Network
  │
  ▼
Add (Residual)
  │
  ▼
Layer Normalization
  │
  ▼
Output</p>
</blockquote>
<p>먼저 Multi-Head Attention이 문장 속 단어들의 관계를 계산하고,</p>
<p>Feed Forward Network가 각 Token의 표현을 더욱 풍부하게 만든다.</p>
<p>그리고 Residual Connection과 LayerNorm이 학습을 안정적으로 유지하면서 다음 Block으로 정보를 전달한다.</p>
<p>Transformer는 이러한 Block을 하나만 사용하는 것이 아니라 수십 개에서 많게는 수백 개까지 반복해서 쌓는다.</p>
<p>각 Block을 지날 때마다 Token은 새로운 문맥 정보를 계속 반영하게 되고, 처음에는 단순히 단어의 의미를 나타내던 Embedding이 점차 문장 전체를 이해한 표현(Contextual Representation) 으로 발전한다.</p>
<p>우리가 질문을 입력하면, 모델은 Transformer Block을 반복적으로 통과시키며 문장의 의미를 단계적으로 다듬고, 가장 가능성이 높은 다음 Token을 예측하는 것이다.</p>
<hr>
<h1 id="마무리">마무리</h1>
<p>이번 글에서는 Transformer가 문장을 처리하는 전체 과정을 순서대로 따라가 보았다.</p>
<p>먼저 Tokenizer가 문장을 Token으로 분리하고, Embedding이 각 Token을 의미를 가진 벡터로 변환했다.</p>
<p>여기에 Positional Encoding을 더해 단어의 순서까지 함께 표현한 뒤, Transformer Block 안에서 Self-Attention을 통해 문장 속 단어들의 관계를 계산했다.</p>
<p>이후 Multi-Head Attention은 여러 관점에서 문맥을 분석하고, Feed Forward Network는 각 Token의 표현을 더욱 풍부하게 만들었다.</p>
<p>마지막으로 Residual Connection과 LayerNorm은 깊은 네트워크에서도 학습이 안정적으로 이루어질 수 있도록 도와주었다.</p>
<p>이처럼 Transformer의 대부분의 연산은 행렬곱(Matrix Multiplication) 을 기반으로 수행된다.</p>
<p>그래서 GPU가 가진 대규모 병렬 연산 능력을 가장 효과적으로 활용할 수 있었고, Transformer는 오늘날 LLM의 표준 구조가 되었다.</p>
<p>다음 편에서는 모델 서빙(Model Serving) 의 관점에서 LLM이 실제 서비스 환경에서 어떻게 실행되는지 살펴본다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[1편. CPU vs GPU 구조 — 왜 딥러닝은 GPU에서 동작할까?]]></title>
            <link>https://velog.io/@_gyullbb/1%ED%8E%B8.-CPU-vs-GPU-%EA%B5%AC%EC%A1%B0-%EC%99%9C-%EB%94%A5%EB%9F%AC%EB%8B%9D%EC%9D%80-GPU%EC%97%90%EC%84%9C-%EB%8F%99%EC%9E%91%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@_gyullbb/1%ED%8E%B8.-CPU-vs-GPU-%EA%B5%AC%EC%A1%B0-%EC%99%9C-%EB%94%A5%EB%9F%AC%EB%8B%9D%EC%9D%80-GPU%EC%97%90%EC%84%9C-%EB%8F%99%EC%9E%91%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Fri, 07 Aug 2026 03:13:00 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>&quot;같은 행렬곱인데 왜 CPU는 느리고 GPU는 빠를까?&quot;</strong></p>
</blockquote>
<p>ChatGPT에게 질문을 보내면 GPU 수천 개가 동시에 계산을 수행하며 답을 만들어낸다.</p>
<p>그렇다면 이런 궁금증이 생긴다.</p>
<blockquote>
<p><strong>CPU도 계산을 하는데, 왜 AI는 굳이 GPU를 사용할까?</strong></p>
</blockquote>
<p>많은 사람들이</p>
<blockquote>
<p>&quot;GPU는 병렬 처리를 잘하니까.&quot;</p>
</blockquote>
<p>라고 설명한다.</p>
<p>물론 맞는 말이다.</p>
<p>하지만 이것은 <strong>결과</strong>일 뿐이다.</p>
<p>진짜 이유는 CPU와 GPU가 <strong>애초에 다른 목적을 가지고 설계되었기 때문</strong>이다.</p>
<p>이번 글에서는 CPU와 GPU의 내부 구조를 살펴보며, 왜 Transformer와 같은 딥러닝 모델이 GPU에서 압도적인 성능을 내는지 이해해보자.</p>
<hr>
<h1 id="cpu와-gpu는-처음부터-목적이-달랐다">CPU와 GPU는 처음부터 목적이 달랐다</h1>
<p>컴퓨터가 수행하는 작업은 크게 두 가지로 나눌 수 있다.</p>
<p>첫 번째는 <strong>복잡한 판단</strong>이다.</p>
<p>예를 들어,</p>
<ul>
<li>운영체제 실행</li>
<li>웹 브라우저</li>
<li>게임 로직</li>
<li><code>if</code>, <code>for</code>, <code>while</code></li>
<li>함수 호출</li>
</ul>
<p>처럼 다음에 무엇을 할지 계속 결정해야 하는 작업이다.</p>
<p>두 번째는 <strong>같은 계산을 엄청나게 많이 반복하는 작업</strong>이다.</p>
<p>CPU는 첫 번째 작업을 잘하도록 만들어졌고, GPU는 두 번째 작업을 잘하도록 만들어졌다.</p>
<p>이것이 두 프로세서를 이해하는 가장 중요한 출발점이다.</p>
<hr>
<h1 id="cpu는-어떤-일을-하는-프로세서일까">CPU는 어떤 일을 하는 프로세서일까?</h1>
<p>우리가 컴퓨터를 사용할 때 CPU가 처리하는 작업을 떠올려 보자.</p>
<ul>
<li>운영체제 실행</li>
<li>웹 브라우저 실행</li>
<li>게임 로직 처리</li>
<li>프로그램 실행</li>
<li>파일 복사</li>
<li>데이터베이스 조회</li>
</ul>
<p>이 작업들의 공통점은 무엇일까?</p>
<p>계속해서 <strong>다음에 무엇을 해야 하는지 판단해야 한다</strong>는 것이다.</p>
<p>예를 들어 아주 간단한 코드 하나를 보자.</p>
<pre><code>if (score &gt;= 90)
    grade = &quot;A&quot;;
else
    grade = &quot;B&quot;;</code></pre><p>이 프로그램은 먼저 <code>score &gt;= 90</code>이라는 조건의 결과를 알아야만 다음 명령을 실행할 수 있다.</p>
<p>이렇듯 CPU가 처리하는 대부분의 프로그램은 판단 =&gt; 다음 명령 결정 =&gt; 실행의 과정을 수십억 번 반복한다.</p>
<p>그래서 CPU는 단순히 계산을 잘하는 프로세서가 아니라, <strong>복잡한 판단을 빠르게 수행하는 프로세서</strong>라고 이해하는 것이 맞다.</p>
<hr>
<h1 id="cpu는-어떻게-구성되어-있을까">CPU는 어떻게 구성되어 있을까?</h1>
<p>CPU 내부에는 수많은 회로가 있지만, 가장 중요한 구성 요소는 크게 네 가지다.</p>
<pre><code>                CPU Core

        ┌─────────────────┐
        │ Control Unit    │
        ├─────────────────┤
        │ ALU             │
        ├─────────────────┤
        │ Register        │
        ├─────────────────┤
        │ L1 Cache        │
        └─────────────────┘

                ↓

          L2 / L3 Cache

                ↓

              DRAM</code></pre><p>CPU에서 가장 중요한 구성 요소는 다음 네 가지다.</p>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Control Unit</strong></td>
<td>명령어 해석 및 실행 순서 결정</td>
</tr>
<tr>
<td><strong>ALU</strong></td>
<td>덧셈, 곱셈, 비교 등 실제 계산 수행</td>
</tr>
<tr>
<td><strong>Register</strong></td>
<td>현재 계산 중인 데이터를 저장하는 가장 빠른 메모리</td>
</tr>
<tr>
<td><strong>Cache</strong></td>
<td>메모리 병목을 줄이기 위한 고속 메모리</td>
</tr>
</tbody></table>
<h2 id="1-control-unit-제어장치">1. Control Unit (제어장치)</h2>
<p>Control Unit은 CPU의 <strong>지휘자</strong>다.</p>
<p>프로그램의 명령어를 읽고,</p>
<ul>
<li>무엇을 계산할지</li>
<li>어떤 데이터를 가져올지</li>
<li>다음에 어떤 명령을 실행할지</li>
</ul>
<p>를 결정한다.</p>
<p>예를 들어</p>
<pre><code>a = b + c;</code></pre><p>라는 코드가 실행되면 Control Unit은</p>
<pre><code>명령어 읽기

↓

b와 c를 Register로 가져오기

↓

ALU에게 덧셈 요청

↓

결과 저장</code></pre><p>이라는 흐름을 관리한다.</p>
<p>실제 계산은 ALU가 하지만,</p>
<p>무엇을 계산할지는 Control Unit이 결정한다.</p>
<hr>
<h2 id="2-alu-arithmetic-logic-unit">2. ALU (Arithmetic Logic Unit)</h2>
<p>ALU는 CPU에서 실제 계산을 수행하는 장치다.</p>
<p>덧셈, 뺄셈, 비교, 논리 연산 등이 모두 여기에서 이루어진다.</p>
<p>쉽게 말하면 CPU 안의 계산기다.</p>
<hr>
<h2 id="3-register">3. Register</h2>
<p>Register는 CPU 안에서 가장 빠른 저장 공간이다.</p>
<p>현재 계산 중인 데이터를 잠시 저장한다.</p>
<p>예를 들어</p>
<pre><code>result = a + b;</code></pre><p>를 계산한다고 해보자.</p>
<p>만약 <code>a</code>와 <code>b</code>를 매번 RAM에서 읽어온다면 계산보다 데이터를 가져오는 시간이 훨씬 오래 걸린다.</p>
<p>그래서 CPU는 계산에 필요한 값을 Register에 올려놓고 연산을 수행한다.</p>
<pre><code>RAM

↓

Register

↓

ALU 계산

↓

Register

↓

RAM</code></pre><p>CPU는 가능한 한 Register 안에서 계산을 끝내려고 한다.</p>
<hr>
<h2 id="4-cache">4. Cache</h2>
<p>Register는 매우 빠르지만 용량이 작다.</p>
<p>반대로 DRAM은 용량은 크지만 속도가 느리다.</p>
<p>그래서 CPU는 둘 사이에 Cache를 둔다.</p>
<pre><code>CPU

↓

Register
(가장 빠름)

↓

L1 Cache

↓

L2 Cache

↓

L3 Cache

↓

DRAM
(가장 느림)</code></pre><p>CPU는 자주 사용하는 데이터를 Cache에 저장해 느린 메모리에 접근하는 횟수를 줄인다.</p>
<p>Cache는 CPU 성능을 결정하는 가장 중요한 요소 중 하나다.</p>
<p>여기까지 보면 CPU도 성능을 높이기 위해서는 <strong>GPU처럼 코어를 수천개 넣으면 되는 것</strong> 아닌가? 라는 의문이 생길 수 있다.</p>
<p>문제는 앞서 말한 것 처럼 CPU가 처리하는 작업은 대부분 <strong>순서가 중요하다</strong>는 것이다. </p>
<p>이런 작업에서는 코어를 무한히 늘려도 성능이 크게 향상되지 않는다.</p>
<p>그래서 CPU는 <strong>코어를 늘리는 대신 하나의 코어를 더 효율적으로 만드는 방향</strong>으로 발전했다. </p>
<hr>
<h1 id="cpu는-계산을-어떻게-빠르게-처리할까">CPU는 계산을 어떻게 빠르게 처리할까?</h1>
<p>트랜지스터는 CPU를 구성하는 가장 작은 전자 스위치다.</p>
<p>CPU 설계자는 트랜지스터를 하나씩 배치하는 것이 아니라, 수십억 개의 트랜지스터를 조합해 더 큰 기능을 가진 회로를 만든다.</p>
<pre><code>Transistor
      │
      ▼
Logic Gate
(AND / OR / NOT)

      │
      ▼
Adder
(가산기)

      │
      ▼
ALU

      │
      ▼
Execution Unit

      │
      ▼
Pipeline

      │
      ▼
CPU Core</code></pre><p>이런 트랜지스터 조합을 통해 CPU 설계자는 고성능 계산과, 계산이 효율적으로 관리하는 기술 즉,</p>
<ul>
<li>앞으로 무엇을 실행할지 예측하고(Branch Predictor),</li>
<li>기다리는 시간을 줄이고(Out-of-Order Execution &amp; Register Renaming),</li>
<li>실행 순서를 조정하고(Reorder Buffer),</li>
<li>메모리 접근을 최소화하는 데(대용량 Cache) </li>
</ul>
<p>막대한 트랜지스터를 투자한다.</p>
<p>즉 CPU 성능은 단순히 ALU 개수보다 <strong>복잡한 제어 회로를 얼마나 잘 설계했는가</strong>에 더 큰 영향을 받는다.</p>
<p>CPU는 &quot;계산을 많이 하는 프로세서&quot; 보다는 <strong>&quot;계산이 멈추지 않도록 끊임없이 판단하는 프로세서&quot;</strong> 라고 이해하는 편이 더 정확하다.</p>
<hr>
<h1 id="gpu는-왜-만들어졌을까">GPU는 왜 만들어졌을까?</h1>
<p>GPU(Graphics Processing Unit)는 원래 AI를 위해 만들어진 프로세서가 아니다.</p>
<p>처음의 목적은 그래픽을 빠르게 렌더링하는 것이었다.</p>
<p>예를 들어 Full HD 화면은 1920 × 1080, 약 200만 개의 픽셀로 이루어져 있다.</p>
<p>화면을 그리려면 각 픽셀의 색상을 계산해야 하는데, 대부분의 픽셀은 서로 독립적이므로 여러 픽셀을 동시에 계산해도 문제가 없다.</p>
<p>이를 위해 GPU는 <strong>수많은 픽셀에 대해 동일한 연산을 병렬로 수행</strong>할 수 있도록 설계되었다.</p>
<p>CPU의 목적은 하나의 작업을 최대한 빠르고 효율적으로 처리하는 것이기 때문에 복잡한 제어 기능을 발전시킨 반면,</p>
<p>GPU는 <strong>수백만 개의 동일하거나 유사한 계산을 동시에 처리하는 것</strong>이 핵심이므로, 복잡한 제어 회로를 최소화하는 대신 <strong>수많은 산술연산장치(ALU)를 배치하여 병렬 연산 성능을 극대화하는 방향</strong>으로 발전했다.</p>
<hr>
<h1 id="gpu-구조">GPU 구조</h1>
<p>GPU는 하나의 거대한 코어가 아니다.</p>
<p>여러 개의 계산 블록으로 구성된다.</p>
<p>NVIDIA GPU에서는 이를 <strong>SM(Streaming Multiprocessor)</strong> 이라고 부른다.</p>
<pre><code>                  GPU

      ┌─────┐  ┌─────┐  ┌─────┐
      │ SM  │  │ SM  │  │ SM  │
      └─────┘  └─────┘  └─────┘

      ┌─────┐  ┌─────┐  ┌─────┐
      │ SM  │  │ SM  │  │ SM  │
      └─────┘  └─────┘  └─────┘</code></pre><p>SM 하나를 열어보면</p>
<pre><code>                  SM

┌────────────────────────────┐
│ Warp Scheduler             │
├────────────────────────────┤
│ 연산 유닛                  │
│ CUDA Core / Tensor Core    │
│ CUDA Core / Tensor Core    │
│ CUDA Core / Tensor Core    │
├────────────────────────────┤
│ Register File              │
├────────────────────────────┤
│ Shared Memory              │
├────────────────────────────┤
│ L1 Cache                   │
└────────────────────────────┘</code></pre><p>CPU와 비슷하게 Register와 Cache도 존재한다.</p>
<p>하지만 CPU처럼</p>
<ul>
<li>Branch Predictor</li>
<li>Out-of-Order Execution</li>
<li>Reorder Buffer</li>
<li>Register Renaming</li>
</ul>
<p>같은 복잡한 회로는 거의 사용하지 않는다.</p>
<p>대신</p>
<pre><code>                 GPU

┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ ALU  │ │ ALU  │ │ ALU  │ │ ALU  │
├──────┤ ├──────┤ ├──────┤ ├──────┤
│ ALU  │ │ ALU  │ │ ALU  │ │ ALU  │
├──────┤ ├──────┤ ├──────┤ ├──────┤
│ ALU  │ │ ALU  │ │ ALU  │ │ ALU  │
└──────┘ └──────┘ └──────┘ └──────┘</code></pre><p>처럼 <strong>수많은 연산 유닛을 반복적으로 배치</strong>한다.</p>
<p>이것이 GPU가 같은 계산을 동시에 엄청나게 많이 수행할 수 있는 이유다.</p>
<hr>
<h1 id="gpu는-어떻게-수백만-개의-계산을-동시에-처리할까">GPU는 어떻게 수백만 개의 계산을 동시에 처리할까?</h1>
<p>GPU는 큰 작업을 아주 작은 작업으로 나눈다.</p>
<p>4096 × 4096 행렬곱을 생각해 보자. 결과 행렬에는 16,777,216 개의 원소가 존재한다. </p>
<p>GPU는 이 엄청나게 많은 작업을 다음과 같은 계층으로 관리한다.</p>
<hr>
<h3 id="thread">Thread</h3>
<p>가장 작은 작업 단위로, 결과 행렬의 원소 하나를 계산한다.</p>
<p>문제는 행렬 하나만 계산해도 Thread가 수천만 개 생긴다.</p>
<p>이걸 하나씩 관리하면 관리 비용이 너무 커진다.</p>
<p>그래서 GPU는 여러개의 Thread를 Warp라는 단위로 묶는다.</p>
<hr>
<h3 id="warp">Warp</h3>
<p>NVIDIA GPU의 경우 Thread 32개를 하나의 Warp로 GPU가 자동으로 묶는다.</p>
<p>Warp 안의 Thread는 같은 명령어를 동시에 실행한다.</p>
<p>이 방식이 바로 <strong>SIMT(Single Instruction Multiple Thread)</strong> 다.</p>
<hr>
<h3 id="block">Block</h3>
<p>Warp 여러 개를 하나로 묶으면 Block이 된다.</p>
<p>Block은 GPU의 한 개 SM에 배정된다.</p>
<p>Block 내부 Thread들은 Shared Memory를 함께 사용할 수 있다.</p>
<p>그래서 같은 데이터를 여러 번 읽지 않아도 된다.</p>
<hr>
<h3 id="grid">Grid</h3>
<p>마지막으로 Block들을 모두 합친 것이 Grid다. 이는 GPU에 전달되는 하나의 작업 전체를 의미한다.</p>
<p>즉 GPU는 거대한 계산 하나를 수백만 개의 Thread로 나눈 뒤 Warp -&gt; Block -&gt; SM 순으로 배치해 실행한다.</p>
<hr>
<h1 id="gpu는-thread를-어떻게-실행할까">GPU는 Thread를 어떻게 실행할까?</h1>
<p>Thread를 만드는 것은 GPU가 아니다.</p>
<p>CUDA와 같은 프로그래밍 플랫폼에서 개발자가 커널(Kernel)을 실행할 때 <strong>Grid와 Block의 크기를 지정</strong>하면, CUDA Runtime이 그에 맞는 Thread들을 생성한다.</p>
<p>생성된 Thread들은 GPU에 전달되고, GPU는 이를 32개씩 하나의 Warp(NVIDIA 기준)로 자동으로 묶는다. </p>
<p>이후 여러 Warp를 포함한 Block이 하나의 SM에 할당된다.</p>
<p>SM 내부의 <strong>Warp Scheduler</strong>는 실행 가능한 Warp를 선택해 <strong>CUDA Core</strong>에 배정한다. CUDA Core는 Thread를 실행하는 실제 연산 하드웨어(ALU)로, Thread가 수행해야 하는 계산을 처리한다.</p>
<p>또한 GPU는 어떤 Warp가 메모리 접근 등으로 인해 실행을 잠시 기다리게 되면, 해당 Warp를 기다리지 않고 <strong>즉시 다른 준비된 Warp로 전환</strong>하여 CUDA Core를 계속 동작시킨다. 이러한 빠른 Warp 전환 덕분에 GPU는 연산 장치가 쉬는 시간을 최소화하며 매우 높은 처리량을 달성할 수 있다.</p>
<hr>
<h1 id="결국-cpu와-gpu의-차이는-무엇일까">결국 CPU와 GPU의 차이는 무엇일까?</h1>
<p>한 장으로 정리하면 이렇다.</p>
<table>
<thead>
<tr>
<th>CPU</th>
<th>GPU</th>
</tr>
</thead>
<tbody><tr>
<td>복잡한 제어에 많은 트랜지스터 사용</td>
<td>연산 유닛에 많은 트랜지스터 사용</td>
</tr>
<tr>
<td>적은 수의 강력한 코어</td>
<td>많은 수의 단순한 연산 유닛</td>
</tr>
<tr>
<td>지연 시간(Latency) 최소화</td>
<td>처리량(Throughput) 최대화</td>
</tr>
<tr>
<td>운영체제, 웹 브라우저, 게임</td>
<td>그래픽, AI, 과학 계산</td>
</tr>
</tbody></table>
<p>CPU는 <strong>복잡한 문제 하나를 빠르게 해결</strong>하도록 설계되었고,</p>
<p>GPU는 <strong>단순한 문제 수백만 개를 동시에 해결</strong>하도록 설계되었다.</p>
<hr>
<h1 id="마무리">마무리</h1>
<p>이제 왜 딥러닝이 GPU에서 동작하는지 이해할 수 있다.</p>
<p>Transformer의 핵심 연산은 대부분 <strong>거대한 행렬곱</strong>이다.</p>
<p>행렬곱은 다시 수많은 독립적인 곱셈과 덧셈으로 나눌 수 있다.</p>
<p>GPU는 이 계산을 <strong>Thread → Warp → Block → SM</strong> 구조로 병렬 실행하며, 수천 개의 연산 유닛을 동시에 활용한다.</p>
<p>다음 편에서는 이 GPU 위에서 실제로 어떤 연산이 수행되는지, 그리고 왜 <strong>Transformer가 RNN을 대체하게 되었는지</strong>를 살펴보겠다.</p>
<hr>
<h1 id="참고문헌">참고문헌</h1>
<ul>
<li><a href="https://www.youtube.com/watch?v=Fg00LN30Ezg&amp;t=795s">CPU는 어떻게 작동할까?</a></li>
<li><a href="https://www.youtube.com/watch?v=ZdITviTD3VM">GPU는 어떻게 작동할까?</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Cilium - Cilium Security]]></title>
            <link>https://velog.io/@_gyullbb/Cilium-Cilium-Security</link>
            <guid>https://velog.io/@_gyullbb/Cilium-Cilium-Security</guid>
            <pubDate>Sat, 06 Sep 2025 13:06:36 GMT</pubDate>
            <description><![CDATA[<p>Cilium은 여러 수준에서 보안을 제공하는데, 각 수준은 개별적으로 사용될 수도 있고, 함께 결합하여 사용할 수도 있다.
각 수준에 대해 알아본다.</p>
<h1 id="layer-3-identity-based">Layer 3 (Identity-Based)</h1>
<p>엔드포인트간 연결 정책을 정의하는 방식이다. 
전통적인 Kubernetes에서 사용하는 IP 기반 보안 모델은 파드가 생성·삭제될 때마다 모든 노드의 보안 규칙을 갱신해야 하기에 대규모 환경에서 한계가 존재하였다. 
Cilium은 이러한 한계를 극복하기 위해 Identity를 도입하여, 보안을 IP와 완전히 분리하고 Identity 기반으로 통신 규칙을 정의하는 방안을 도입하였다.</p>
<p><strong>전통적인 Kubernetes</strong></p>
<ul>
<li>파드마다 할당된 IP 주소 기반으로 보안 정책 적용</li>
<li>파드가 생성·삭제될 때마다 모든 노드의 보안 규칙(IP 필터)을 갱신해야 함</li>
<li>대규모 환경에서 확장성·유연성에 한계 존재</li>
</ul>
<p><strong>Cilium</strong></p>
<ul>
<li>라벨 기반 아이덴티티(Identity) 를 도입해 보안 정책 적용</li>
<li>파드의 생성·삭제 시 보안 규칙 재설정 불필요, 아이덴티티 해석만 수행</li>
<li>확장성과 관리 효율성 향상</li>
</ul>
<h2 id="identity">Identity</h2>
<p>Identity란 모든 엔드포인트에 할당되는 객체로, 엔드포인트 간 기본 연결성을 강제하며 Layer 3 수준의 보안 적용에 사용된다.</p>
<p>Cilium은 엔드포인트의 보안 관련 라벨(Security Relevant Labels)에 따라, 각 엔드포인트에 클러스터 전체에서 고유한 식별자를 부여한다. 
동일한 라벨 집합을 가진 엔드포인트는 동일한 Identity를 공유하게 된다. 이 Identity 개념을 통해 application을 확장하더라도 동일한 보안 라벨 집합에 속하는 엔드포인트는 모두 동일한 Identity를 공유하기 때문에, 정책 적용을 매우 큰 규모로 확장할 수 있다.</p>
<p>파드나 컨테이너의 라벨이 변경되면 아이덴티티도 다시 확인되며, 필요 시 자동으로 수정된다.</p>
<h3 id="cilium-code">Cilium Code</h3>
<p>코드와 함께 Identity가 엔드포인트에 할당되는 과정을 확인해본다.</p>
<p>아래 코드는 엔드포인트 보안 라벨 변경을 확인하고, Identity를 할당하는 코드이다.
Identity에는 Local Identity와 Global Identity가 있는데, Local Identity는 단일 노드 내에서만 고유한 ID를 부여받는  엔드포인트이며, Global Identity는 클러스터 전체에서 고유한 ID를 부여받는 엔드포인트이다.
Local Identity의 경우, 컨트롤러 동기화 및 KV Store 확인 없이 노드 내에서 바로 Identity를 할당한다.
Global Identity의 경우, 컨트롤러 동기화를 수행하며, 컨트롤러에서는 Identity 할당을 진행한다.</p>
<pre><code class="language-go">//cilium/pkg/endpoint/endpoint.go
func (e *Endpoint) runIdentityResolver(ctx context.Context, blocking bool, updateJitter time.Duration) (regenTriggered bool) {
...
    newLabels := e.labels.IdentityLabels()
    e.runlock()
...
  //Local Identity의 경우, 컨트롤러 동기화 및 KV Store 확인 없이 노드 내에서 바로 Identity를 할당한다.
    if blocking || identity.IdentityAllocationIsLocal(newLabels) {
    scopedLog.Info(&quot;Resolving identity labels (blocking)&quot;)
        regenTriggered, err = e.identityLabelsChanged(ctx)
    ...
  }
  ...
  ctrlName := resolveIdentity + &quot;-&quot; + strconv.FormatUint(uint64(e.ID), 10)
  //Global Identity의 경우, 컨트롤러 동기화를 수행하며, 컨트롤러에서는 Identity 할당을 진행한다.
    e.controllers.UpdateController(ctrlName,
        controller.ControllerParams{
            Group: resolveIdentityControllerGroup,
            DoFunc: func(ctx context.Context) error {
                _, err := e.identityLabelsChanged(ctx)
...
            },
...
        },
    )
}</code></pre>
<p>보안 라벨이 변경되었거나, 신규로 필요할 경우, 신규 Identity를 할당한다.</p>
<pre><code class="language-go">//cilium/pkg/endpoint/endpoint.go
func (e *Endpoint) identityLabelsChanged(ctx context.Context) (regenTriggered bool, err error) {
  ...
    allocatedIdentity, _, err := e.allocator.AllocateIdentity(ctx, newLabels, notifySelectorCache, identity.InvalidIdentity)
}</code></pre>
<p>Global Identity의 경우, Controller에 의해 KV store에서 idPool 중 사용 가능한 ID를 할당받는다.</p>
<pre><code class="language-go">//cilium/pkg/identity/cache/allocator.go
func (m *CachingIdentityAllocator) AllocateIdentity(ctx context.Context, lbls labels.Labels, notifyOwner bool, oldNID identity.NumericIdentity) (id *identity.Identity, allocated bool, err error) {
      idp, allocated, isNewLocally, err := m.IdentityAllocator.Allocate(ctx, &amp;key.GlobalIdentity{LabelArray: lbls.LabelArray()})
}
//cilium/pkg/allocator/allocator.go
func (a *Allocator) Allocate(ctx context.Context, key AllocatorKey) (idpool.ID, bool, bool, error) {
  ...
    //KV store에서 idPool 중 사용 가능한 ID를 할당받는다.
      kvstore.Trace(a.logger, &quot;Allocating from kvstore&quot;, fieldKey, key)
        value, isNew, firstUse, err = a.lockedAllocate(ctx, key)
    if err == nil {
            a.mainCache.insert(key, value)
    }
...
}</code></pre>
<p>Local Identity의 경우, Reserved Identity 여부 및 Well-Known Identity를 먼저 확인한다.
Reserved Identity인 경우 추가 할당 없이 예약된 Identity를 그대로 반환한다.</p>
<pre><code class="language-go">//cilium/pkg/identity/cache/allocator.go
func (m *CachingIdentityAllocator) AllocateLocalIdentity(lbls labels.Labels, notifyOwner bool, oldNID identity.NumericIdentity) (id *identity.Identity, allocated bool, err error) {

    // If this is a reserved, pre-allocated identity, just return that and be done
    if reservedIdentity := identity.LookupReservedIdentityByLabels(lbls); reservedIdentity != nil {
        m.logger.Debug(
            &quot;Resolving reserved identity&quot;,
            logfields.Identity, reservedIdentity.ID,
            logfields.IdentityLabels, lbls,
            logfields.New, false,
        )
        return reservedIdentity, false, nil
    }</code></pre>
<h3 id="reserved-identity">Reserved Identity</h3>
<p>위 코드에서 보듯이, Cilium에는 Reserved Identity가 존재한다.
이는 Cilium이 네트워크 통신을 수행할 때 반드시 필요하거나, 보안 신원이 명확히 정의된 잘 알려진 엔드포인트에 할당된다.
Reserved Identity의 Numeric ID는 아래 코드에 정의되어 있다.</p>
<pre><code class="language-go">//cilium/pkg/identity/numericidentity.go
const (
    // IdentityUnknown represents an unknown identity
    IdentityUnknown NumericIdentity = iota

    // ReservedIdentityHost represents the local host
    ReservedIdentityHost

    // ReservedIdentityWorld represents any endpoint outside of the cluster
    ReservedIdentityWorld

    // ReservedIdentityUnmanaged represents unmanaged endpoints.
    ReservedIdentityUnmanaged

    // ReservedIdentityHealth represents the local cilium-health endpoint
    ReservedIdentityHealth

    // ReservedIdentityInit is the identity given to endpoints that have not
    // received any labels yet.
    ReservedIdentityInit

    // ReservedIdentityRemoteNode is the identity given to all nodes in
    // local and remote clusters except for the local node.
    ReservedIdentityRemoteNode

    // ReservedIdentityKubeAPIServer is the identity given to remote node(s) which
    // have backend(s) serving the kube-apiserver running.
    ReservedIdentityKubeAPIServer

    // ReservedIdentityIngress is the identity given to the IP used as the source
    // address for connections from Ingress proxies.
    ReservedIdentityIngress

    // ReservedIdentityWorldIPv4 represents any endpoint outside of the cluster
    // for IPv4 address only.
    ReservedIdentityWorldIPv4

    // ReservedIdentityWorldIPv6 represents any endpoint outside of the cluster
    // for IPv6 address only.
    ReservedIdentityWorldIPv6

    // ReservedEncryptedOverlay represents overlay traffic which must be IPSec
    // encrypted before it leaves the host
    ReservedEncryptedOverlay
)

// Special identities for well-known cluster components
// Each component has two identities. The first one is used for Kubernetes &lt;1.21
// or when the NamespaceDefaultLabelName feature gate is disabled. The second
// one is used for Kubernetes &gt;= 1.21 and when the NamespaceDefaultLabelName is
// enabled.
const (
    DeprecatedETCDOperator NumericIdentity = iota + 100
    DeprecatedCiliumKVStore

    // ReservedKubeDNS is the reserved identity used for kube-dns.
    ReservedKubeDNS

    // ReservedEKSKubeDNS is the reserved identity used for kube-dns on EKS
    ReservedEKSKubeDNS

    // ReservedCoreDNS is the reserved identity used for CoreDNS
    ReservedCoreDNS

    // ReservedCiliumOperator is the reserved identity used for the Cilium operator
    ReservedCiliumOperator

    // ReservedEKSCoreDNS is the reserved identity used for CoreDNS on EKS
    ReservedEKSCoreDNS

    DeprecatedCiliumEtcdOperator

    // Second identities for all above components
    DeprecatedETCDOperator2
    DeprecatedCiliumKVStore2
    ReservedKubeDNS2
    ReservedEKSKubeDNS2
    ReservedCoreDNS2
    ReservedCiliumOperator2
    ReservedEKSCoreDNS2
    DeprecatedCiliumEtcdOperator2
)</code></pre>
<table>
<thead>
<tr>
<th>Reserved Identity</th>
<th>Numeric ID</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>IdentityUnknown</td>
<td>0</td>
<td>알 수 없는 Identity</td>
</tr>
<tr>
<td>ReservedIdentityHost</td>
<td>1</td>
<td>로컬 호스트</td>
</tr>
<tr>
<td>ReservedIdentityWorld</td>
<td>2</td>
<td>클러스터 외부 엔드포인트</td>
</tr>
<tr>
<td>ReservedIdentityUnmanaged</td>
<td>3</td>
<td>Cilium이 관리하지 않는 엔드포인트</td>
</tr>
<tr>
<td>ReservedIdentityHealth</td>
<td>4</td>
<td>로컬 cilium-health 엔드포인트</td>
</tr>
<tr>
<td>ReservedIdentityInit</td>
<td>5</td>
<td>아직 라벨을 받지 않은 엔드포인트</td>
</tr>
<tr>
<td>ReservedIdentityRemoteNode</td>
<td>6</td>
<td>로컬 노드를 제외한 모든 노드</td>
</tr>
<tr>
<td>ReservedIdentityKubeAPIServer</td>
<td>7</td>
<td>kube-apiserver 백엔드가 있는 원격 노드</td>
</tr>
<tr>
<td>ReservedIdentityIngress</td>
<td>8</td>
<td>Ingress 프록시에서 소스 IP로 사용되는 엔드포인트</td>
</tr>
<tr>
<td>ReservedIdentityWorldIPv4</td>
<td>9</td>
<td>IPv4 클러스터 외부 엔드포인트</td>
</tr>
<tr>
<td>ReservedIdentityWorldIPv6</td>
<td>10</td>
<td>IPv6 클러스터 외부 엔드포인트</td>
</tr>
<tr>
<td>ReservedEncryptedOverlay</td>
<td>11</td>
<td>호스트를 떠나기 전에 IPSec으로 암호화해야 하는 오버레이 트래픽</td>
</tr>
</tbody></table>
<br>

<table>
<thead>
<tr>
<th>Component Identity</th>
<th>Numeric ID</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>ReservedKubeDNS</td>
<td>102</td>
<td>kube-dns</td>
</tr>
<tr>
<td>ReservedEKSKubeDNS</td>
<td>103</td>
<td>EKS kube-dns</td>
</tr>
<tr>
<td>ReservedCoreDNS</td>
<td>104</td>
<td>CoreDNS</td>
</tr>
<tr>
<td>ReservedCiliumOperator</td>
<td>105</td>
<td>Cilium Operator</td>
</tr>
<tr>
<td>ReservedEKSCoreDNS</td>
<td>106</td>
<td>EKS CoreDNS</td>
</tr>
</tbody></table>
<h2 id="layer-3-정책-메소드">Layer 3 정책 메소드</h2>
<p>Layer 3은 여러 방식으로 통신 연결 규칙을 설정할 수 있다.</p>
<h3 id="endpoints-based">Endpoints based</h3>
<p>Cilium이 관리하는 두 엔드포인트의 라벨을 기반으로 통신 관계를 정의한다.</p>
<h4 id="목표--role-frontend-→-role-backend-통신-허용">목표 : <code>role: frontend</code> → <code>role: backend</code> 통신 허용</h4>
<p><strong>배포 yaml</strong></p>
<pre><code class="language-yaml"># frontend pod
apiVersion: v1
kind: Pod
metadata:
  name: frontend
  labels:
    role: frontend
spec:
  containers:
  - name: nginx
    image: nginx
    ports:
    - containerPort: 80
---
# other pod
apiVersion: v1
kind: Pod
metadata:
  name: other
  labels:
    role: other
spec:
  containers:
  - name: nginx
    image: nginx
    ports:
    - containerPort: 80
---
# backend pod
apiVersion: v1
kind: Pod
metadata:
  name: backend
  labels:
    role: backend
spec:
  containers:
  - name: backend
    image: nginx
    ports:
    - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: backend
spec:
  selector:
    role: backend
  ports:
    - port: 80
      targetPort: 80
---
#NetworkPolicy
apiVersion: &quot;cilium.io/v2&quot;
kind: CiliumNetworkPolicy
metadata:
  name: allow-frontend-to-backend
spec:
  endpointSelector:
    matchLabels:
      role: backend
  ingress:
  - fromEndpoints:
    - matchLabels:
        role: frontend</code></pre>
<p><strong>정책 적용 확인</strong></p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl -n kube-system exec ds/cilium -- cilium policy get
[
  {
    &quot;endpointSelector&quot;: {
      &quot;matchLabels&quot;: {
        &quot;any:role&quot;: &quot;backend&quot;,
        &quot;k8s:io.kubernetes.pod.namespace&quot;: &quot;default&quot;
      }
    },
    &quot;ingress&quot;: [
      {
        &quot;fromEndpoints&quot;: [
          {
            &quot;matchLabels&quot;: {
              &quot;any:role&quot;: &quot;frontend&quot;,
              &quot;k8s:io.kubernetes.pod.namespace&quot;: &quot;default&quot;
            }
          }
        ]
      }
    ],
    ...</code></pre>
<p><strong>통신 확인</strong>
frontend -&gt; backend 통신은 가능하나, other -&gt; backend 통신은 불가능한 것을 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it frontend -- curl -s http://backend
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Welcome to nginx!&lt;/title&gt;
&lt;style&gt;
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;Welcome to nginx!&lt;/h1&gt;
&lt;p&gt;If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.&lt;/p&gt;

&lt;p&gt;For online documentation and support please refer to
&lt;a href=&quot;http://nginx.org/&quot;&gt;nginx.org&lt;/a&gt;.&lt;br/&gt;
Commercial support is available at
&lt;a href=&quot;http://nginx.com/&quot;&gt;nginx.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thank you for using nginx.&lt;/em&gt;&lt;/p&gt;
&lt;/body&gt;
&lt;/html&gt;

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it other -- curl -sv http://backend
*   Trying 10.96.27.61:80...

^Ccommand terminated with exit code 130</code></pre>
<h3 id="services-based">Services based</h3>
<p>Service 개념을 활용하여 통신 관계를 정의한다.</p>
<h4 id="목표--role-frontend-→-service-backend-통신-허용">목표 : <code>role: frontend</code> → <code>Service: backend</code> 통신 허용</h4>
<p><code>role: frontend</code> 파드에서 backend라는 이름을 가진 Service와 통신을 할 수 있는 CiliumNetworkPolicy를 배포한다.
이 때, 주의할 점은 CiliumNetworkPolicy에 backend서비스만 지정을 해서 배포하면 coredns통신이 되지 않는다는 점이다.
kube-dns로도 통신을 할 수 있도록 설정을 해야지 정상적인 backend서비스로의 통신이 가능하다.</p>
<p><strong>배포 yaml</strong></p>
<pre><code class="language-yaml">#NetworkPolicy (Service based)
apiVersion: &quot;cilium.io/v2&quot;
kind: CiliumNetworkPolicy
metadata:
  name: allow-frontend-to-backend-service
spec:
  endpointSelector:
    matchLabels:
      role: frontend
  egress:
  - toServices:
    - k8sService:
        serviceName: backend
        namespace: default
    - k8sService:
        serviceName: kube-dns
        namespace: kube-system</code></pre>
<p><strong>정책 적용 확인</strong></p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl -n kube-system exec ds/cilium -- cilium policy get
[
[
  {
    &quot;endpointSelector&quot;: {
      &quot;matchLabels&quot;: {
        &quot;any:role&quot;: &quot;backend&quot;,
        &quot;k8s:io.kubernetes.pod.namespace&quot;: &quot;default&quot;
      }
    },
    &quot;ingress&quot;: [
      {
        &quot;fromEndpoints&quot;: [
          {
            &quot;matchLabels&quot;: {
              &quot;any:role&quot;: &quot;frontend&quot;,
              &quot;k8s:io.kubernetes.pod.namespace&quot;: &quot;default&quot;
            }
          }
        ]
      }
    ],
...
  {
    &quot;endpointSelector&quot;: {
      &quot;matchLabels&quot;: {
        &quot;any:role&quot;: &quot;frontend&quot;,
        &quot;k8s:io.kubernetes.pod.namespace&quot;: &quot;default&quot;
      }
    },
    &quot;egress&quot;: [
      {
        &quot;toEndpoints&quot;: [
          {
            &quot;matchLabels&quot;: {
              &quot;any:role&quot;: &quot;backend&quot;,
              &quot;k8s:io.kubernetes.pod.namespace&quot;: &quot;default&quot;
            }
          },
          {
            &quot;matchLabels&quot;: {
              &quot;any:k8s-app&quot;: &quot;kube-dns&quot;,
              &quot;k8s:io.kubernetes.pod.namespace&quot;: &quot;kube-system&quot;
            }
          }
        ],
        &quot;toServices&quot;: [
          {
            &quot;k8sService&quot;: {
              &quot;serviceName&quot;: &quot;backend&quot;,
              &quot;namespace&quot;: &quot;default&quot;
            }
          },
          {
            &quot;k8sService&quot;: {
              &quot;serviceName&quot;: &quot;kube-dns&quot;,
              &quot;namespace&quot;: &quot;kube-system&quot;
            }
          }
        ]
      }
    ],</code></pre>
<p><strong>통신 확인</strong>
frontend -&gt; backend 서비스 통신은 가능하나, other -&gt; backend 서비스 통신은 불가능한 것을 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it frontend -- curl -sv http://backend
*   Trying 10.96.65.95:80...
* Connected to backend (10.96.65.95) port 80 (#0)
&gt; GET / HTTP/1.1
&gt; Host: backend
&gt; User-Agent: curl/7.88.1
&gt; Accept: */*
&gt;
&lt; HTTP/1.1 200 OK

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it other -- curl -sv http://backend
*   Trying 10.96.65.95:80...
^Ccommand terminated with exit code 130</code></pre>
<h3 id="entities-based">Entities based</h3>
<p>fromEntities와 toEntities를 사용하여 Pod 통신을 실제 IP 대신 엔티티(논리 그룹) 단위로 제어할 수 있다.</p>
<table>
<thead>
<tr>
<th>Entity</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>host</code></td>
<td>로컬 호스트(노드) 및 hostNetwork 모드 컨테이너</td>
</tr>
<tr>
<td><code>remote-node</code></td>
<td>다른 노드 및 원격 노드의 hostNetwork 모드 컨테이너</td>
</tr>
<tr>
<td><code>kube-apiserver</code></td>
<td>Kubernetes API 서버 (내부/외부 배포 모두 포함)</td>
</tr>
<tr>
<td><code>ingress</code></td>
<td>Cilium Envoy 인그레스 프록시 (Pod-to-Pod hairpin 포함)</td>
</tr>
<tr>
<td><code>cluster</code></td>
<td>클러스터 내 모든 엔드포인트 (host, remote-node, init 포함)</td>
</tr>
<tr>
<td><code>init</code></td>
<td>identity 미할당 초기 부트스트랩 단계 엔드포인트</td>
</tr>
<tr>
<td><code>health</code></td>
<td>Cilium health check 엔드포인트</td>
</tr>
<tr>
<td><code>unmanaged</code></td>
<td>Cilium이 관리하지 않는 엔드포인트 (cluster 엔티티에도 포함됨)</td>
</tr>
<tr>
<td><code>world</code></td>
<td>클러스터 외부(인터넷 포함, CIDR 0.0.0.0/0과 동일)</td>
</tr>
<tr>
<td><code>all</code></td>
<td>cluster + world 모든 엔드포인트 전체</td>
</tr>
</tbody></table>
<h4 id="목표--pod에서-로컬-호스트-및-hostnetwork-모드-컨테이너에만-접근-가능">목표 : pod에서 로컬 호스트 및 hostNetwork 모드 컨테이너에만 접근 가능</h4>
<p><strong>배포 yaml</strong></p>
<pre><code class="language-yaml"># dev Pod
apiVersion: v1
kind: Pod
metadata:
  name: dev-pod
  labels:
    role: dev
spec:
  containers:
  - name: curl
    image: curlimages/curl
    command: [&quot;sleep&quot;, &quot;3600&quot;]
---
# Allow dev → host
apiVersion: &quot;cilium.io/v2&quot;
kind: CiliumNetworkPolicy
metadata:
  name: dev-to-host
spec:
  endpointSelector:
    matchLabels:
      role: dev
  egress:
    - toEntities:
      - host
---
# same-pod (nginx, hostNetwork 사용, k8s-w1에 고정)
apiVersion: v1
kind: Pod
metadata:
  name: same-pod
  labels:
    app: same
spec:
  nodeName: k8s-w1       # 특정 노드에 고정
  hostNetwork: true      # hostNetwork 모드 활성화
  dnsPolicy: ClusterFirstWithHostNet
  containers:
  - name: nginx
    image: nginx:1.25
    ports:
    - containerPort: 80 
---
# same-pod (nginx, hostNetwork 사용, k8s-w2에 고정)
apiVersion: v1
kind: Pod
metadata:
  name: diff-pod
  labels:
    app: same
spec:
  nodeName: k8s-w2       # 특정 노드에 고정
  hostNetwork: true      # hostNetwork 모드 활성화
  dnsPolicy: ClusterFirstWithHostNet
  containers:
  - name: nginx
    image: nginx:1.25
    ports:
    - containerPort: 80  </code></pre>
<p><strong>통신 확인</strong>
통신을 확인해보면, 동일한 노드에 hostNetwork로 뜬 Pod와는 정상통신이 되고, 타 노드에 hostNetwork로 뜬 Pod와는 정상통신이 되지 않음을 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -o wide
NAME       READY   STATUS    RESTARTS   AGE   IP               NODE     NOMINATED NODE   READINESS GATES
dev-pod    1/1     Running   0          5s    172.20.2.232     k8s-w2   &lt;none&gt;           &lt;none&gt;
diff-pod   1/1     Running   0          44s   192.168.10.102   k8s-w2   &lt;none&gt;           &lt;none&gt;
same-pod   1/1     Running   0          44s   192.168.10.101   k8s-w1   &lt;none&gt;           &lt;none&gt;

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it dev-pod -- curl http://192.168.10.102
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Welcome to nginx!&lt;/title&gt;
&lt;style&gt;
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;Welcome to nginx!&lt;/h1&gt;

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it dev-pod -- curl http://192.168.10.101

^Ccommand terminated with exit code 130</code></pre>
<h3 id="node-based">Node based</h3>
<p>특정 노드만 허용하거나 차단하도록 통신 관계를 정의한다.</p>
<h3 id="ipcidr-based">IP/CIDR based</h3>
<p>외부 서비스와의 통신 관계를 IP 주소나 서브넷을 이용해 정의한다.</p>
<h3 id="dns-based">DNS based</h3>
<p>DNS 이름을 통해 IP로 변환하여 클러스터 외부 peer와의 통신 관계를 정의한다.</p>
<h1 id="layer-4-restriction-of-accessible-ports">Layer 4 (Restriction of accessible ports)</h1>
<p>엔드포인트가 접근할 수 있는 포트 범위와 프로토콜(TCP/UDP 등)을 제한하는 정책이다.
L3 정책과 함께 적용되어, L3 정책으로 통신 가능 여부를 결정한 이후, 포트 단위로 더 세밀하게 제어할 때 사용된다.</p>
<h1 id="layer-7-application-protocol-level">Layer 7 (Application protocol level)</h1>
<p>애플리케이션 프로토콜 단위로 트래픽을 제어하는 정책이다.
L3(Layer3)와 L4(Layer4) 정책으로 허용된 트래픽 중, 애플리케이션 레벨에서 세밀한 접근 제어를 수행할 때 사용된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Cilium - SR-IOV + Multus]]></title>
            <link>https://velog.io/@_gyullbb/Cilium-SR-IOV-Multus</link>
            <guid>https://velog.io/@_gyullbb/Cilium-SR-IOV-Multus</guid>
            <pubDate>Sat, 30 Aug 2025 16:40:04 GMT</pubDate>
            <description><![CDATA[<h1 id="gpu-환경에서의-네트워크-최적화-cilium--sr-iov--multus">GPU 환경에서의 네트워크 최적화: Cilium + SR-IOV + Multus</h1>
<p>GPU 워크로드는 대규모 연산과 빠른 데이터 전송을 동시에 요구한다. 
특히 분산 학습이나 대규모 데이터셋을 다루는 환경에서는 네트워크 지연(latency)과 대역폭(bandwidth)이 성능의 핵심 요소가 된다. 
Kubernetes 환경에서 이러한 요구를 충족하기 위해서는 SR-IOV, Multus, Cilium을 함께 사용하는 구성이 효과적이다.</p>
<p>이번 글에서는 각 기술에 대한 소개와 구성 실습을 진행한다.</p>
<h1 id="sr-iov">SR-IOV</h1>
<p>GPU 환경에서 네트워크 성능을 최적화하기 위해서는 RDMA(Remote Direct Memory Access) 기술을 활용하는 것이 중요하다. 
RDMA란 CPU 개입 없이 네트워크를 통해 직접 메모리에 접근할 수 있도록 하는 기술로, 지연을 최소화하고 대역폭 활용을 극대화할 수 있다.</p>
<p>SR-IOV는 이러한 RDMA 기술을 Pod 단위에서 활용할 수 있도록, 물리 NIC의 가상 함수(VF)를 Pod에 직접 할당한다. 
이 때 RDMA 통신은 일반적으로 RoCE 프로토콜을 통해 이루어진다.</p>
<h2 id="roce-rdma-over-converged-ethernet">RoCE (RDMA over Converged Ethernet)</h2>
<p>RoCE는 이더넷 기반 네트워크 상에서 RDMA를 지원하는 기술이다. 
기존 RDMA가 InfiniBand와 같은 특수 네트워크에서만 가능했던 것과 달리, RoCE를 활용하면 표준 이더넷 환경에서도 RDMA 성능을 활용할 수 있다.</p>
<p>RoCE는 두 가지 버전이 존재한다.</p>
<h3 id="1roce-v1">1.RoCE v1</h3>
<p><strong>특징</strong></p>
<ul>
<li>Ethernet Layer 2에서 동작한다.</li>
<li>동일 브로드캐스트 도메인 내에서만 통신이 가능하다.</li>
<li>L2 프레임 기반이므로 라우팅이 불가능하다.
장점: 간단한 구성으로 낮은 지연을 제공한다.
제약: 대규모 클러스터 또는 다른 네트워크 세그먼트를 넘는 통신에는 부적합하다.</li>
</ul>
<h3 id="2roce-v2">2.RoCE v2</h3>
<p><strong>특징</strong></p>
<ul>
<li>UDP/IP 위에서 동작하는 RDMA 프로토콜이다.</li>
<li>L3 라우팅이 가능하여, 서로 다른 네트워크 서브넷 간에도 RDMA 통신을 지원한다.</li>
<li>QoS 및 Congestion Control 메커니즘과 결합하여 대규모 클러스터 환경에 적합하다.
장점: 대규모 데이터센터 환경에서 확장성이 뛰어나다.
제약: RoCE v1보다 상대적으로 복잡하며, 네트워크 설정(QoS, PFC 등)이 필요하다.</li>
</ul>
<p>정리를 하자면 RoCE v1은 동일 노드 또는 동일 네트워크 세그먼트에서 GPU 간 저지연 통신이 필요한 경우 적합하다.
RoCE v2는 대규모 GPU 클러스터에서 서로 다른 노드 간 학습 데이터를 주고받을 때 사용한다.
SR-IOV를 통해 Pod에 VF를 할당하고, 해당 VF를 RoCE 지원 네트워크 인터페이스로 구성하면 Kubernetes GPU 워크로드에서도 RDMA 성능을 활용할 수 있다.</p>
<h1 id="multus">Multus</h1>
<p>Kubernetes는 기본적으로 Pod에 하나의 네트워크 인터페이스만을 제공하며, CNI 플러그인(Cilium, Calico 등)에 의해 관리된다. 해당 인터페이스는 주로 Kubernetes 서비스 트래픽과 Pod 간 기본 통신에 사용된다. 
그러나 GPU 환경에서는 단일 네트워크인터페이스 만으로는 서비스 트래픽과 고속 데이터 전송을 동시에 처리하기 어렵기 때문에 멀티 네트워크 인터페이스를 통해 트래픽을 분리하고 최적화해야 한다.</p>
<p>Multus CNI는 Kubernetes에서 Pod에 다중 네트워크 인터페이스를 부여할 수 있도록 해주는 메타 플러그인이다. 
즉, 하나의 Pod가 여러 CNI 플러그인을 동시에 사용할 수 있도록 한다.</p>
<h2 id="multus-역할">Multus 역할</h2>
<h3 id="1데이터-경로와-제어-경로의-분리">1.데이터 경로와 제어 경로의 분리</h3>
<p>eth0은 Kubernetes 서비스 및 관리 트래픽을 담당한다.
net1 같은 추가 인터페이스는 SR-IOV 기반 고성능 RDMA/RoCE 트래픽을 담당한다.
이를 통해 서비스 안정성과 데이터 성능을 동시에 보장한다.</p>
<h3 id="2특화된-네트워크-할당">2.특화된 네트워크 할당</h3>
<p>GPU 학습 워크로드는 고성능 RDMA 네트워크가 필요하다.
Multus를 통해 특정 Pod에만 SR-IOV NIC의 VF를 붙여주어, 필요할 때만 성능 최적화 네트워크를 사용할 수 있다.</p>
<h2 id="multus-cni-plugin">Multus CNI Plugin</h2>
<p>Multus CNI는 다른 CNI를 호출하여 Pod에 여러 네트워크를 붙여주는 메타 플러그인으로 Multus CNI 자체가 네트워크를 직접 구성하지는 않는다.
Multus를 통해 여러 네트워크를 추가할 때는 아래와 같은 CNI 플러그인들을 조합할 수 있다.</p>
<table>
<thead>
<tr>
<th>플러그인 종류</th>
<th>설명</th>
<th>활용 사례</th>
</tr>
</thead>
<tbody><tr>
<td><strong>SR-IOV CNI</strong></td>
<td>물리 NIC의 SR-IOV 기능을 이용해 Pod에 가상 함수(VF)를 직접 할당한다.</td>
<td>GPU/HPC 환경에서 RoCE·RDMA 전용 네트워크 제공</td>
</tr>
<tr>
<td><strong>macvlan CNI</strong></td>
<td>Pod에 별도 MAC 주소를 부여하여 물리 네트워크에 직접 연결한다.</td>
<td>Pod이 외부 네트워크와 직접 통신해야 할 때</td>
</tr>
<tr>
<td><strong>ipvlan CNI</strong></td>
<td>호스트 NIC의 MAC을 공유하며 Pod에 IP만 부여한다.</td>
<td>MAC 주소 제한이 있는 대규모 Pod 환경</td>
</tr>
<tr>
<td><strong>bridge CNI</strong></td>
<td>Pod 인터페이스를 호스트 브리지에 연결한다.</td>
<td>테스트/개발 환경에서 간단히 브리지 네트워크 활용</td>
</tr>
<tr>
<td><strong>vlan CNI</strong></td>
<td>Pod 트래픽에 VLAN 태그를 붙여 네트워크를 분리한다.</td>
<td>멀티 테넌트 환경에서 네트워크 격리</td>
</tr>
<tr>
<td><strong>host-device CNI</strong></td>
<td>노드의 물리 NIC을 Pod에 직접 할당한다.</td>
<td>특정 Pod에 물리 NIC 리소스를 100% 전담시킬 때</td>
</tr>
<tr>
<td><strong>loopback CNI</strong></td>
<td>Pod 내부 <code>lo</code> 인터페이스를 생성한다.</td>
<td>모든 Pod에 기본 제공, 내부 통신용</td>
</tr>
</tbody></table>
<h2 id="ipam-plugin">IPAM Plugin</h2>
<p>IPAM(IP Address Management) 플러그인은 각 네트워크 인터페이스에 IP 주소를 어떻게 할당할지를 결정한다.
CNI 플러그인이 인터페이스를 생성하면, IPAM 플러그인이 호출되어 해당 인터페이스에 붙을 IP, Gateway, Route 등을 결정한다.
즉, “네트워크 인터페이스 생성”과 “IP 할당”을 분리하여 관리할 수 있다.</p>
<p>IPAM Plugin 종류는 다음과 같다.</p>
<ul>
<li>host-local: 노드 로컬에 IPAM 상태를 저장하고 IP를 순차적으로 할당한다.</li>
<li>static: 설정 파일에 미리 정의한 IP를 할당한다.</li>
<li>dhcp: 외부 DHCP 서버에서 IP를 받아온다.</li>
</ul>
<h1 id="실습">실습</h1>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/e275c466-5a33-4bc2-af6b-e18ec37ff855/image.png" alt=""></p>
<p>*현재 통신 이슈를 확인하는 단계로 통신 이슈 해결 시 업데이트 예정이다.</p>
<h2 id="구성-설명">구성 설명</h2>
<h3 id="sr-iov-1">SR-IOV</h3>
<p>각 노드의 NIC에서 VLAN100을 Pod의 net1으로 직접 할당
Pod가 NIC VF를 통해 RoCE v2 트래픽을 바로 전송 가능</p>
<h3 id="multus-1">Multus</h3>
<p>Pod에 net1 인터페이스를 붙여줌</p>
<h3 id="roce-v2">RoCE v2</h3>
<p>net1을 통해 UDP/IP 기반 RDMA 통신 수행
서로 다른 노드의 Pod 간 라우팅으로 데이터 전송</p>
<h3 id="pod-통신">Pod 통신</h3>
<p>ping 및 RDMA 테스트 시, net1(VLAN100) 사용</p>
<h2 id="사전-확인">사전 확인</h2>
<h3 id="클러스터-생성">클러스터 생성</h3>
<p>클러스터 생성 시 Cilium CNI와 Multus CNI를 배포한다.
정상적으로 배포되었다면 multus daemonset이 배포되며 노드에서도 cni 정보를 확인할 수 있다.</p>
<pre><code class="language-bash">~# ls /opt/cni/bin/multus
/opt/cni/bin/multus

~# tree /etc/cni/net.d/ | grep multus
├── 00-multus.conf
├── multus.d
│   └── multus.kubeconfig</code></pre>
<h3 id="pfphysical-funciton-할당-확인">PF(Physical Funciton) 할당 확인</h3>
<p>물리 NIC를 나누어 Pod에 가상 NIC조각을 붙이기 위해서는 현재 어떠한 물리 NIC가 매핑되어 있는 지 확인해야 한다.
여기서 물리 NIC를 PF(Physical Function), 가상 NIC를 VF(Virtual Funciton)이라 부른다. </p>
<p>RDMA 스택에서 어떠한 물리 NIC이 연결되어있는지, 커널 단에서 어떠한 명의 네트워크 인터페이스를 사용하는지를 확인해본다. </p>
<pre><code class="language-bash"># RDMA를 지원하는 NIC이름(HCA-Host Channel Adapter) 확인 
~# rdma link show
...
link mlx5_8/1 state ACTIVE physical_state LINK_UP netdev enp157s0np0

# 지원 RDMA 종류 확인
~# cat /sys/class/infiniband/mlx5_8/ports/1/gid_attrs/types/1
RoCE v2

# 커널에서 해당 NIC을 식별하는 주소 (PCI) 확인
~# readlink /sys/class/infiniband/mlx5_8/device
../../../0000:9d:00.0

# PF 확인
~# ls /sys/bus/pci/devices/0000:9d:00.0/net
enp157s0np0</code></pre>
<h3 id="gpu-workernode-sr-iov-지원-여부-및-iommu-가상화-확인">GPU WorkerNode SR-IOV 지원 여부 및 IOMMU 가상화 확인</h3>
<p>네트워크를 통해 직접 메모리에 접근하기 위해서는 SR-IOV지원 및 IOMMU(nput-Output Memory Management Unit) 가상화를 확인해야 한다.</p>
<pre><code class="language-bash">~# ls /sys/class/net/enp157s0np0/device | grep sriov
...
sriov_totalvfs

~# cat /proc/cmdline | grep iommu
BOOT_IMAGE=/boot/vmlinuz-5.15.0-91-generic root=UUID=de68bbf0-de79-441c-8e44-57c29618114e ro intel_iommu=on iommu=pt pci=pcie_bus_perf pcie_acs_override=downstream,multifunction console=tty1 console=ttyS0</code></pre>
<h2 id="nvidia-network-operator-배포">Nvidia Network Operator 배포</h2>
<p>Nvidia Network Operator란 NVIDIA GPU 서버 환경에서 RDMA 기반 고속 네트워크설정과 관리를 자동화하는 Kubernetes Operator이다. 
RDMA-capable 장치를 Pod, Node, NIC 수준에서 구성 및 모니터링, 자동화가 가능하기 때문에 Kubernetes 환경에서 많이 사용된다.</p>
<pre><code class="language-bash">helm install network-operator nvidia/network-operator \
  --namespace network-operator \
  --create-namespace \
  --set deployCR=true \
  --set nfd.enabled=true \
  --set sriovNetworkOperator.enabled=true \
  --set rdmaSharedDevicePlugin.enabled=true \
  --set secondaryNetwork.enabled=true</code></pre>
<p>Nvidia Network Operator helm Chart를 배포하면 아래와 같은 리소스들이 배포된다.</p>
<table>
<thead>
<tr>
<th>리소스</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><strong>DaemonSet: nvidia-network-operator</strong></td>
<td>클러스터 모든 GPU 노드에 배포되어 RoCE, SR-IOV, VF 설정 및 관리</td>
</tr>
<tr>
<td><strong>ConfigMap: nvidia-network-config</strong></td>
<td>NNO 설정 값 저장 (SR-IOV, RDMA VF, QoS, VLAN 등)</td>
</tr>
<tr>
<td><strong>DaemonSet: node-feature-discovery (NFD)</strong></td>
<td>노드의 SR-IOV, RDMA, GPU 등의 하드웨어 특성 레이블링</td>
</tr>
<tr>
<td><strong>DaemonSet: sriov-network-operator</strong></td>
<td>SR-IOV 네트워크 CRD를 감시하고, PF/VF 생성 및 관리, Pod에 VF 할당</td>
</tr>
<tr>
<td><strong>DaemonSet: sriov-network-config-daemon</strong></td>
<td>노드의 SR-IOV PF/VF, VLAN, QoS 등 L2/L3 네트워크 설정 수행</td>
</tr>
<tr>
<td><strong>NetworkAttachmentDefinition (NAD)</strong></td>
<td>Multus CNI에서 Pod에 추가 네트워크 인터페이스를 연결하기 위한 정의</td>
</tr>
<tr>
<td><strong>SriovNetworkNodePolicy (SR-IOV NNP)</strong></td>
<td>노드별 SR-IOV 네트워크 정책을 정의하는 CRD. <code>sriov-network-operator</code>가 이를 기반으로 PF/VF 생성 및 관리</td>
</tr>
</tbody></table>
<h2 id="sriovnetwork관련-생성">SriovNetwork관련 생성</h2>
<p>Node1, Node2 각각 SriovNetworkNodePolicy를 생성한다. 각 노드에서 어떤 NIC를 사용할지, 몇개의 VF를 사용할지에 대한 정보이다.
sriov-network-operator가 해당 리소스를 기준으로 VF생성 및 VLAN 할당을 진행한다.</p>
<pre><code class="language-yaml">apiVersion: sriovnetwork.openshift.io/v1
kind: SriovNetworkNodePolicy
metadata:
  name: rocebgr1
  namespace: network-operator
spec:
  deviceType: netdevice
  isRdma: true
  linkType: eth
  mtu: 9000
  nicSelector:
    pfNames:
      - enp157s0np0
  nodeSelector:
    kubernetes.io/hostname: node1
  numVfs: 8
  priority: 99
  resourceName: rocebgr1
---
apiVersion: sriovnetwork.openshift.io/v1
kind: SriovNetworkNodePolicy
metadata:
  name: rocebgr2
  namespace: network-operator
spec:
  deviceType: netdevice
  isRdma: true
  linkType: eth
  mtu: 9000
  nicSelector:
    pfNames:
      - enp157s0np0
  nodeSelector:
    kubernetes.io/hostname: node2
  numVfs: 8
  priority: 99
  resourceName: rocebgr2</code></pre>
<p>Node와 SriovNetwork가 잘 연결되었는지는 SriovNetworkNodeState CR을 통해 확인할 수 있다.</p>
<pre><code class="language-bash">&gt; kubectl get sriovnetworknodestate -A
NAMESPACE          NAME    SYNC STATUS   DESIRED SYNC STATE   CURRENT SYNC STATE   AGE
network-operator   node1   Succeeded     Idle                 Idle                 38h
network-operator   node2   Succeeded     Idle                 Idle                 38h</code></pre>
<p>실제로 VF가 생성되었는지 확인해본다.</p>
<pre><code class="language-bash">&gt; kubectl describe node | grep -A10 &quot;Allocatable&quot; | grep &#39;nvidia.com/roce&#39;
  nvidia.com/rocebgr1:  8
  nvidia.com/rocebgr2:  8

~# lspci | grep 9d
9d:00.0 Ethernet controller: Mellanox Technologies MT2910 Family [ConnectX-7]
9d:00.1 Ethernet controller: Mellanox Technologies ConnectX Family mlx5Gen Virtual Function
9d:00.2 Ethernet controller: Mellanox Technologies ConnectX Family mlx5Gen Virtual Function
9d:00.3 Ethernet controller: Mellanox Technologies ConnectX Family mlx5Gen Virtual Function
9d:00.4 Ethernet controller: Mellanox Technologies ConnectX Family mlx5Gen Virtual Function
9d:00.5 Ethernet controller: Mellanox Technologies ConnectX Family mlx5Gen Virtual Function
9d:00.6 Ethernet controller: Mellanox Technologies ConnectX Family mlx5Gen Virtual Function
9d:00.7 Ethernet controller: Mellanox Technologies ConnectX Family mlx5Gen Virtual Function
9d:01.0 Ethernet controller: Mellanox Technologies ConnectX Family mlx5Gen Virtual Function</code></pre>
<p>각 노드 별로 Policy를 생성했다면 SriovNetwork를 생성한다. 컨테이너에서 연결할 VF를 정의하기 위한 리소스이다.</p>
<pre><code class="language-yaml">apiVersion: sriovnetwork.openshift.io/v1
kind: SriovNetwork
metadata:
  name: rocebgr1
  namespace: network-operator
spec:
  ipam: |
    {
      &quot;type&quot;: &quot;host-local&quot;,
      &quot;subnet&quot;: &quot;10.255.0.0/30&quot;,
      &quot;rangeStart&quot;: &quot;10.255.0.1&quot;,
      &quot;rangeEnd&quot;: &quot;10.255.0.1&quot;,
      &quot;gateway&quot;: &quot;10.255.0.2&quot;,
      &quot;routes&quot;: [
        { &quot;dst&quot;: &quot;0.0.0.0/0&quot;, &quot;gw&quot;: &quot;10.255.0.2&quot; }
      ]
    }
  linkState: enable
  logLevel: info
  networkNamespace: default
  resourceName: rocebgr1
  spoofChk: &#39;off&#39;
  trust: &#39;on&#39;
  vlan: 100

apiVersion: sriovnetwork.openshift.io/v1
kind: SriovNetwork
metadata:
  name: roce2-bgr
  namespace: network-operator
spec:
  ipam: |
    {
      &quot;type&quot;: &quot;host-local&quot;,
      &quot;subnet&quot;: &quot;10.255.0.4/30&quot;,
      &quot;rangeStart&quot;: &quot;10.255.0.5&quot;,
      &quot;rangeEnd&quot;: &quot;10.255.0.5&quot;,
      &quot;gateway&quot;: &quot;10.255.0.6&quot;,
      &quot;routes&quot;: [
        { &quot;dst&quot;: &quot;0.0.0.0/0&quot;, &quot;gw&quot;: &quot;10.255.0.6&quot; }
      ]
    }
  linkState: enable
  logLevel: info
  networkNamespace: default
  resourceName: rocebgr2
  spoofChk: &#39;off&#39;
  trust: &#39;on&#39;
  vlan: 100</code></pre>
<p>SriovNetwork를 생성하면 자동으로 SriovNetwork기반 NetworkAttachmentDefinitions 리소스가 생성된다.</p>
<h2 id="deployment-생성-및-상태-확인">Deployment 생성 및 상태 확인</h2>
<p>위에서 생성한 SriovNetwork를 가지고 각 Node에 Pod가 뜨도록 리소스를 배포한다.</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: rocebgr1
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: roce-test
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      annotations:
        k8s.v1.cni.cncf.io/networks: |
          [
            { &quot;name&quot;: &quot;rocebgr1&quot; }
          ]
      labels:
        app: roce-test
    spec:
      containers:
        - command:
            - sleep
            - infinity
          image: &gt;-
            registry.hcloud.hmc.co.kr/library/nvidia/cuda:12.2.0-base-ubuntu22.04-roce
          imagePullPolicy: IfNotPresent
          name: test
          resources:
            limits:
              nvidia.com/rocebgr1: &#39;1&#39;
            requests:
              nvidia.com/rocebgr1: &#39;1&#39;
          securityContext:
            capabilities:
              add:
                - IPC_LOCK
                - SYS_RAWIO
                - NET_ADMIN
            privileged: true
          terminationMessagePath: /dev/termination-log
          terminationMessagePolicy: File
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: rocebgr2
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: roce-test
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      annotations:
        k8s.v1.cni.cncf.io/networks: |
          [
            { &quot;name&quot;: &quot;rocebgr2&quot; }
          ]
      labels:
        app: roce-test
    spec:
      containers:
        - command:
            - sleep
            - infinity
          image: &gt;-
            registry.hcloud.hmc.co.kr/library/nvidia/cuda:12.2.0-base-ubuntu22.04-roce
          imagePullPolicy: IfNotPresent
          name: test
          resources:
            limits:
              nvidia.com/rocebgr2: &#39;1&#39;
            requests:
              nvidia.com/rocebgr2: &#39;1&#39;
          securityContext:
            capabilities:
              add:
                - IPC_LOCK
                - SYS_RAWIO
                - NET_ADMIN
            privileged: true
          terminationMessagePath: /dev/termination-log
          terminationMessagePolicy: File</code></pre>
<p>생성된 Pod에 들어가보면 lo, eth0외에 net1 인터페이스가 신규로 생성된 것을 확인할 수 있다.</p>
<pre><code class="language-bash">/# ip a
1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
85: net1: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 9000 qdisc mq state UP group default qlen 1000
    link/ether 1a:16:7a:56:dc:60 brd ff:ff:ff:ff:ff:ff
    altname enp157s0v6
    inet 10.255.0.1/30 brd 10.255.0.3 scope global net1
       valid_lft forever preferred_lft forever
    inet6 fe80::1816:7aff:fe56:dc60/64 scope link 
       valid_lft forever preferred_lft forever
171: eth0@if172: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether a2:39:a1:e4:2b:5c brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.42.4.24/32 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::a039:a1ff:fee4:2b5c/64 scope link 
       valid_lft forever preferred_lft forever</code></pre>
<p>노드에서 확인해보면 VF 중 하나가 vlan100으로 할당된 것을 알 수 있다.</p>
<pre><code class="language-bash">~# ip link show enp157s0np0
9: enp157s0np0: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 9000 qdisc mq state UP mode DEFAULT group default qlen 1000
    link/ether c4:70:bd:e8:3f:b6 brd ff:ff:ff:ff:ff:ff
    vf 0     link/ether 32:16:09:43:16:22 brd ff:ff:ff:ff:ff:ff, spoof checking off, link-state auto, trust off, query_rss off
    vf 1     link/ether 36:8c:40:53:d5:84 brd ff:ff:ff:ff:ff:ff, spoof checking off, link-state auto, trust off, query_rss off
    vf 2     link/ether d6:b1:6e:c3:78:f7 brd ff:ff:ff:ff:ff:ff, spoof checking off, link-state auto, trust off, query_rss off
    vf 3     link/ether 2a:6f:cd:d6:3f:e8 brd ff:ff:ff:ff:ff:ff, spoof checking off, link-state auto, trust off, query_rss off
    vf 4     link/ether 06:6a:5b:cf:67:10 brd ff:ff:ff:ff:ff:ff, spoof checking off, link-state auto, trust off, query_rss off
    vf 5     link/ether a2:35:0e:45:65:14 brd ff:ff:ff:ff:ff:ff, spoof checking off, link-state auto, trust off, query_rss off
    vf 6     link/ether 1a:16:7a:56:dc:60 brd ff:ff:ff:ff:ff:ff, vlan 100, spoof checking off, link-state enable, trust on, query_rss off
    vf 7     link/ether d2:ff:ba:3d:b7:10 brd ff:ff:ff:ff:ff:ff, spoof checking off, link-state auto, trust off, query_rss off</code></pre>
<h2 id="pod-통신-테스트">Pod 통신 테스트</h2>
<p>동일 노드 위 pod간 net1 ping 통신은 성공하지만 
서로 다른 노드 간 Pod에서 ping통신을 하면 패킷들이 모두 실패한다.</p>
<h2 id="node-routing-설정">Node Routing 설정</h2>
<p><em>여러 라우팅 조건을 넣어 테스트 중이나 현재는 통신이 실패하는 상태이다. 추후 해결 시 업데이트 예정이다.
*</em>서로 다른 노드 간 Pod ping 통신 실패 원인**
Pod net1은 Pod 내부에만 존재하는 가상 인터페이스이며, 노드에는 대응하는 net1 인터페이스가 없다.
노드 라우팅 테이블에는 Pod 서브넷으로 향하는 경로가 존재하지 않기 때문에 다른 노드에 있는 Pod IP로 ping을 보내도, 노드는 어디로 패킷을 보내야 할지 몰라 전송이 실패한다.</p>
<p>또한 VF에 VLAN 태그가 붙어있더라도, 노드에서 L3 경로가 없으면 ARP 요청이 전달되지 않아 통신이 불가하다.</p>
<p><strong>라우팅 추가</strong>
노드에서 Pod 서브넷으로 가는 L3 경로를 명시적으로 추가해야 한다.</p>
<p>예시1) PF에 VLAN 인터페이스 생성
ip link add link <PF> name <PF>.100 type vlan id 100
ip addr add <GW_IP>/24 dev pf.100
ip link set dev pf.100 up</p>
<p>예시2) 노드 라우팅 테이블에 Pod 서브넷 경로 추가
ip route add <POD_SUBNET_CIDR> via <GW_IP> dev <PF></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Cilium - Cilium Service Mesh(2)-Gateway-API
]]></title>
            <link>https://velog.io/@_gyullbb/Cilium-Cilium-Service-Mesh2-Gateway-API</link>
            <guid>https://velog.io/@_gyullbb/Cilium-Cilium-Service-Mesh2-Gateway-API</guid>
            <pubDate>Sat, 23 Aug 2025 16:43:36 GMT</pubDate>
            <description><![CDATA[<h1 id="gateway-api">Gateway API</h1>
<p>Gateway API는 Kubernetes에서 서비스 트래픽의 진입 및 라우팅을 관리하기 위한 표준 API다. 기존 Ingress 리소스의 한계를 보완하고, 복잡한 L7 트래픽 관리, 멀티-테넌시, 확장성을 제공한다. Gateway API는 CRD(Custom Resource Definition) 형태로 제공되어 Kubernetes 네이티브 리소스처럼 사용한다.</p>
<h2 id="구성요소">구성요소</h2>
<ol>
<li><p>GatewayClass
GatewayClass는 특정 Gateway 구현체(컨트롤러)의 유형을 정의한다.
예를 들어, istio, nginx, haproxy 등 컨트롤러 이름으로 GatewayClass를 설정한다.</p>
</li>
<li><p>Gateway
Gateway는 실제 트래픽 진입 지점을 정의한다.
NodePort, LoadBalancer, HostNetwork 등 외부에서 들어오는 트래픽을 수신한다.
Gateway에는 여러 개의 Listener를 설정할 수 있어, 다양한 포트와 프로토콜을 동시에 처리할 수 있다.
Gateway는 GatewayClass를 참조하여 해당 컨트롤러에 의해 관리된다.</p>
</li>
<li><p>Listener
Listener는 Gateway에서 수신할 포트와 프로토콜을 정의한다.
Listener는 Hostname, TLS 인증서 등 L7 속성을 포함할 수 있다.
Listener는 Gateway와 연결되어 트래픽 라우팅의 세부 규칙을 설정한다.</p>
</li>
<li><p>Route (HTTPRoute, TCPRoute 등)
Route는 Gateway로 들어오는 트래픽을 실제 서비스로 전달하는 규칙을 정의한다.</p>
</li>
</ol>
<ul>
<li>HTTPRoute: HTTP/HTTPS 요청을 서비스로 라우팅한다.</li>
<li>TCPRoute: TCP 요청을 서비스로 라우팅한다.</li>
</ul>
<p>Route는 Gateway의 Listener와 연결되어, 특정 호스트명, 경로, 헤더 조건 등을 기반으로 트래픽을 분기한다.
여러 Route를 조합하여 멀티-테넌시 환경에서 서비스별 트래픽을 관리할 수 있다.</p>
<ol start="5">
<li>BackendRef
Route에서 실제 트래픽이 전달될 대상 서비스나 리소스를 지정한다.
여러 개 BackendRef를 지정하여 트래픽을 분산하거나 가중치를 줄 수 있다.</li>
</ol>
<h2 id="gateway-api-설정">Gateway API 설정</h2>
<p>Gateway API를 사용하기 위해선 NodePort를 활성화하도록 구성되어야 한다.
nodePort.enabled=true를 설정하거나, kubeProxyReplacement=true를 활성화해야 한다.</p>
<p>L7 프록시가 활성화된 상태여야 한다.
l7Proxy=true로 설정하며, 기본적으로 활성화되어 있다.</p>
<p>Gateway API v1.2.0에서 사용하는 CRD들은 사전에 설치되어 있어야 한다.</p>
<pre><code class="language-bash"># CRD 설치
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.2.0/config/crd/standard/gateway.networking.k8s.io_gatewayclasses.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.2.0/config/crd/standard/gateway.networking.k8s.io_gateways.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.2.0/config/crd/standard/gateway.networking.k8s.io_httproutes.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.2.0/config/crd/standard/gateway.networking.k8s.io_referencegrants.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.2.0/config/crd/standard/gateway.networking.k8s.io_grpcroutes.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.2.0/config/crd/experimental/gateway.networking.k8s.io_tlsroutes.yaml

# 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get crd | grep gateway.networking.k8s.io
gatewayclasses.gateway.networking.k8s.io     2025-08-23T15:20:10Z
gateways.gateway.networking.k8s.io           2025-08-23T15:20:10Z
grpcroutes.gateway.networking.k8s.io         2025-08-23T15:20:12Z
httproutes.gateway.networking.k8s.io         2025-08-23T15:20:11Z
referencegrants.gateway.networking.k8s.io    2025-08-23T15:20:11Z
tlsroutes.gateway.networking.k8s.io          2025-08-23T15:20:12Z</code></pre>
<p>Cilium Gateway API 설정을 진행한다. Cilium Ingress와 병행하여 사용할 수 없기 때문에, ingressController를 false로 설정한다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# helm upgrade cilium cilium/cilium --version 1.18.1 --namespace kube-system --reuse-values \
--set ingressController.enabled=false --set gatewayAPI.enabled=true

kubectl -n kube-system rollout restart deployment/cilium-operator
kubectl -n kube-system rollout restart ds/cilium

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get GatewayClass
NAME     CONTROLLER                     ACCEPTED   AGE
cilium   io.cilium/gateway-controller   True       60s</code></pre>
<p>Gateway와 HTTP Route를 배포하여 Envoy에 설정값을 주입한다.</p>
<pre><code class="language-bash">cat &lt;&lt; EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: my-gateway
spec:
  gatewayClassName: cilium
  listeners:
  - protocol: HTTP
    port: 80
    name: web-gw
    allowedRoutes:
      namespaces:
        from: Same
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: http-app-1
spec:
  parentRefs:
  - name: my-gateway
    namespace: default
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /details
    backendRefs:
    - name: details
      port: 9080
  - matches:
    - headers:
      - type: Exact
        name: magic
        value: foo
      queryParams:
      - type: Exact
        name: great
        value: example
      path:
        type: PathPrefix
        value: /
      method: GET
    backendRefs:
    - name: productpage
      port: 9080
EOF</code></pre>
<p>Gateway LoadBalancer 서비스가 생성되었고, EXT-IP가 할당되어 있다.
Gateway에도 동일한 EXT-IP가 할당되어 있다.</p>
<p>Gateway를 보면 상태가 PROGRAMMED인데, 이는 Gateway 설정이 Cilium Operator와 Cilium agent에 의해서 Envoy에 정상 주입되었다는 뜻이다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc,ep cilium-gateway-my-gateway
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME                                TYPE           CLUSTER-IP      EXTERNAL-IP      PORT(S)        AGE
service/cilium-gateway-my-gateway   LoadBalancer   10.96.184.183   192.168.10.211   80:31179/TCP   12s

NAME                                  ENDPOINTS              AGE
endpoints/cilium-gateway-my-gateway   192.192.192.192:9999   12s

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get gateway
NAME         CLASS    ADDRESS          PROGRAMMED   AGE
my-gateway   cilium   192.168.10.211   True         57s

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get httproutes -A
NAMESPACE   NAME         HOSTNAMES   AGE
default     http-app-1               118s

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl logs -n kube-system deployments/cilium-operator | grep gateway
time=2025-08-23T15:30:18.512590875Z level=info source=/go/src/github.com/cilium/cilium/operator/pkg/gateway-api/gateway_reconcile.go:47 msg=&quot;Reconciling Gateway&quot; module=operator.operator-controlplane.leader-lifecycle.gateway-api controller=gateway resource=default/my-gateway
time=2025-08-23T15:30:18.517371014Z level=info source=/go/src/github.com/cilium/cilium/operator/pkg/gateway-api/httproute_reconcile.go:143 msg=&quot;Successfully reconciled HTTPRoute&quot; module=operator.operator-controlplane.leader-lifecycle.gateway-api controller=httpRoute parentResource=default/http-app-1
time=2025-08-23T15:30:18.519187964Z level=info source=/go/src/github.com/cilium/cilium/operator/pkg/gateway-api/gateway_reconcile.go:202 msg=&quot;Successfully reconciled Gateway&quot; module=operator.operator-controlplane.leader-lifecycle.gateway-api controller=gateway resource=default/my-gateway</code></pre>
<h2 id="통신-확인">통신 확인</h2>
<p>HTTPRoute에 설정을 한 대로 통신을 확인해보자.
<code>curl -s --fail -v http://&quot;$GATEWAY&quot;/details/1</code>로 호출을 하면 details-v1를 호출할 것이고,
<code>curl -s -v -H &#39;magic: foo&#39; http://&quot;$GATEWAY&quot;\?great\=example</code>로 호출을 하면 productpage-v1를 호출할 것이다.</p>
<pre><code class="language-bash">root@router:~# GATEWAY=192.168.10.211

root@router:~# curl -s --fail -v http://&quot;$GATEWAY&quot;/details/1
*   Trying 192.168.10.211:80...
* Connected to 192.168.10.211 (192.168.10.211) port 80
&gt; GET /details/1 HTTP/1.1
&gt; Host: 192.168.10.211
&gt; User-Agent: curl/8.5.0
&gt; Accept: */*
&gt;
&lt; HTTP/1.1 200 OK
&lt; content-type: application/json
&lt; server: envoy
&lt; date: Sat, 23 Aug 2025 15:40:51 GMT
&lt; content-length: 178
&lt; x-envoy-upstream-service-time: 7
&lt;
* Connection #0 to host 192.168.10.211 left intact
{&quot;id&quot;:1,&quot;author&quot;:&quot;William Shakespeare&quot;,&quot;year&quot;:1595,&quot;type&quot;:&quot;paperback&quot;,&quot;pages&quot;:200,&quot;publisher&quot;:&quot;PublisherA&quot;,&quot;language&quot;:&quot;English&quot;,&quot;ISBN-10&quot;:&quot;1234567890&quot;,&quot;ISBN-13&quot;:&quot;123-1234567890&quot;}

# Header 설정이 누락되어 호출 실패
root@router:~# curl -s -v http://&quot;$GATEWAY&quot;\?great\=example
...
&lt; HTTP/1.1 404 Not Found
&lt; date: Sat, 23 Aug 2025 15:41:18 GMT
&lt; server: envoy
&lt; content-length: 0
&lt;
* Connection #0 to host 192.168.10.211 left intact

# Header 추가하여 호출 시 정상 호출
root@router:~# curl -s -v -H &#39;magic: foo&#39; http://&quot;$GATEWAY&quot;\?great\=example
*   Trying 192.168.10.211:80...
* Connected to 192.168.10.211 (192.168.10.211) port 80
&gt; GET /?great=example HTTP/1.1
&gt; Host: 192.168.10.211
&gt; User-Agent: curl/8.5.0
&gt; Accept: */*
&gt; magic: foo
&gt;
&lt; HTTP/1.1 200 OK
&lt; server: envoy
&lt; date: Sat, 23 Aug 2025 15:41:20 GMT
&lt; content-type: text/html; charset=utf-8
&lt; content-length: 2080
&lt; x-envoy-upstream-service-time: 10
...
* Connection #0 to host 192.168.10.211 left intact</code></pre>
<h3 id="tproxy">TPROXY</h3>
<p>Gateway API또한 eBPF 프로그램이 직접 TPROXY를 통해 커널 내부에서 트래픽을 전달하는 것을 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl -n kube-system get lease | grep &quot;cilium-l2announce&quot;
cilium-l2announce-default-cilium-gateway-my-gateway        k8s-ctr                                                                     15m

(⎈|HomeLab:N/A) root@k8s-ctr:~# sudo iptables -t mangle -L CILIUM_PRE_mangle --line-numbers -n -v
Chain CILIUM_PRE_mangle (1 references)
num   pkts bytes target     prot opt in     out     source               destination
4        0     0 TPROXY     6    --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x13320200 /* cilium: TPROXY to host default/cilium-gateway-my-gateway/listener proxy */ TPROXY redirect 127.0.0.1:12819 mark 0x200/0xffffffff

# Gateway 호출
root@router:~# curl -s -v -H &#39;magic: foo&#39; http://&quot;$GATEWAY&quot;\?great\=example

# TPROXY pkts 증가
(⎈|HomeLab:N/A) root@k8s-ctr:~# sudo iptables -t mangle -L CILIUM_PRE_mangle --line-numbers -n -v
Chain CILIUM_PRE_mangle (1 references)
num   pkts bytes target     prot opt in     out     source               destination
4        1    60 TPROXY     6    --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x13320200 /* cilium: TPROXY to host default/cilium-gateway-my-gateway/listener proxy */ TPROXY redirect 127.0.0.1:12819 mark 0x200/0xffffffff</code></pre>
<h2 id="tls-route">TLS Route</h2>
<p>TLSRoute는 HTTPS/TLS 트래픽을 Gateway를 통해 처리할 때, 트래픽의 종단(termination) 방식과 전달 방식을 정의하는 리소스이다. 주로 TLS 연결을 어디에서 종료할지와 Pod로 전달할 때 어떤 프로토콜을 사용할지를 결정한다.</p>
<p><strong>Terminate (종단)</strong></p>
<ul>
<li>Gateway가 TLS를 해제(terminate) 한다.</li>
<li>클라이언트와 Gateway 사이에서는 HTTPS(암호화)로 통신하지만, Gateway와 백엔드 Pod 사이에서는 HTTP로 전달한다.</li>
<li>장점: Gateway에서 TLS 관리를 중앙 집중화 → Pod에서는 TLS 처리 부담 없음</li>
</ul>
<p><strong>Passthrough (통과)</strong></p>
<ul>
<li>TLS를 Gateway에서 해제하지 않고 그대로 전달한다.</li>
<li>클라이언트와 Pod 사이의 통신이 종단까지 HTTPS로 유지된다.</li>
<li>장점: 엔드-투-엔드 암호화 유지</li>
</ul>
<h3 id="tls-route-설정">TLS Route 설정</h3>
<p>Sample App과 Gateway를 배포한다.
그 이전에 TLS Certificate와 Private Key를 생성한다.</p>
<pre><code class="language-bash">apt install mkcert -y

mkcert &#39;*.cilium.rocks&#39;

kubectl create secret tls demo-cert --key=_wildcard.cilium.rocks-key.pem --cert=_wildcard.cilium.rocks.pem

mkcert -install

mkcert -CAROOT

tail -n 50 /etc/ssl/certs/ca-certificates.crt
vi 1.pem</code></pre>
<pre><code class="language-bash">cat &lt;&lt;&#39;EOF&#39; &gt; nginx.conf
events {
}

http {
  log_format main &#39;$remote_addr - $remote_user [$time_local]  $status &#39;
  &#39;&quot;$request&quot; $body_bytes_sent &quot;$http_referer&quot; &#39;
  &#39;&quot;$http_user_agent&quot; &quot;$http_x_forwarded_for&quot;&#39;;
  access_log /var/log/nginx/access.log main;
  error_log  /var/log/nginx/error.log;

  server {
    listen 443 ssl;

    root /usr/share/nginx/html;
    index index.html;

    server_name nginx.cilium.rocks;
    ssl_certificate /etc/nginx-server-certs/tls.crt;
    ssl_certificate_key /etc/nginx-server-certs/tls.key;
  }
}
EOF

kubectl create configmap nginx-configmap --from-file=nginx.conf=./nginx.conf

cat &lt;&lt; EOF | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
  name: my-nginx
  labels:
    run: my-nginx
spec:
  ports:
    - port: 443
      protocol: TCP
  selector:
    run: my-nginx
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-nginx
spec:
  selector:
    matchLabels:
      run: my-nginx
  replicas: 1
  template:
    metadata:
      labels:
        run: my-nginx
    spec:
      containers:
        - name: my-nginx
          image: nginx
          ports:
            - containerPort: 443
          volumeMounts:
            - name: nginx-config
              mountPath: /etc/nginx
              readOnly: true
            - name: nginx-server-certs
              mountPath: /etc/nginx-server-certs
              readOnly: true
      volumes:
        - name: nginx-config
          configMap:
            name: nginx-configmap
        - name: nginx-server-certs
          secret:
            secretName: demo-cert
EOF</code></pre>
<pre><code class="language-bash">cat &lt;&lt; EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: cilium-tls-gateway
spec:
  gatewayClassName: cilium
  listeners:
    - name: https
      hostname: &quot;nginx.cilium.rocks&quot;
      port: 443
      protocol: TLS
      tls:
        mode: Passthrough
      allowedRoutes:
        namespaces:
          from: All
---
apiVersion: gateway.networking.k8s.io/v1alpha2
kind: TLSRoute
metadata:
  name: nginx
spec:
  parentRefs:
    - name: cilium-tls-gateway
  hostnames:
    - &quot;nginx.cilium.rocks&quot;
  rules:
    - backendRefs:
        - name: my-nginx
          port: 443
EOF

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get gateway cilium-tls-gateway
NAME                 CLASS    ADDRESS          PROGRAMMED   AGE
cilium-tls-gateway   cilium   192.168.10.211   True         6s

root@router:~# GATEWAY=192.168.10.211</code></pre>
<p>TLS Request를 호출하면 정상 호출되는 것을 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# curl -v --resolve &quot;nginx.cilium.rocks:443:$GATEWAY&quot; &quot;https://nginx.cilium.rocks:443&quot;
* Added nginx.cilium.rocks:443:192.168.10.211 to DNS cache
* Hostname nginx.cilium.rocks was found in DNS cache
*   Trying 192.168.10.211:443...
* Connected to nginx.cilium.rocks (192.168.10.211) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/certs/ca-certificates.crt
*  CApath: /etc/ssl/certs
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / RSASSA-PSS
* ALPN: server accepted http/1.1
* Server certificate:
*  subject: O=mkcert development certificate; OU=root@k8s-ctr
*  start date: Aug 23 16:34:26 2025 GMT
*  expire date: Nov 23 16:34:26 2027 GMT
*  subjectAltName: host &quot;nginx.cilium.rocks&quot; matched cert&#39;s &quot;*.cilium.rocks&quot;
*  issuer: O=mkcert development CA; OU=root@k8s-ctr; CN=mkcert root@k8s-ctr
*  SSL certificate verify ok.
*   Certificate level 0: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
*   Certificate level 1: Public key type RSA (3072/128 Bits/secBits), signed using sha256WithRSAEncryption
* using HTTP/1.x
&gt; GET / HTTP/1.1
&gt; Host: nginx.cilium.rocks
&gt; User-Agent: curl/8.5.0
&gt; Accept: */*
&gt;
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
&lt; HTTP/1.1 200 OK
&lt; Server: nginx/1.29.1
&lt; Date: Sat, 23 Aug 2025 16:42:01 GMT
&lt; Content-Type: text/html
&lt; Content-Length: 615
&lt; Last-Modified: Wed, 13 Aug 2025 14:33:41 GMT
&lt; Connection: keep-alive
&lt; ETag: &quot;689ca245-267&quot;
&lt; Accept-Ranges: bytes
&lt;
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Welcome to nginx!&lt;/title&gt;
&lt;style&gt;
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;Welcome to nginx!&lt;/h1&gt;
&lt;p&gt;If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.&lt;/p&gt;

&lt;p&gt;For online documentation and support please refer to
&lt;a href=&quot;http://nginx.org/&quot;&gt;nginx.org&lt;/a&gt;.&lt;br/&gt;
Commercial support is available at
&lt;a href=&quot;http://nginx.com/&quot;&gt;nginx.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thank you for using nginx.&lt;/em&gt;&lt;/p&gt;
&lt;/body&gt;
&lt;/html&gt;
* Connection #0 to host nginx.cilium.rocks left intact</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Cilium - Cilium Service Mesh(1)-Cilium-Ingress]]></title>
            <link>https://velog.io/@_gyullbb/Cilium-Cilium-Service-Mesh</link>
            <guid>https://velog.io/@_gyullbb/Cilium-Cilium-Service-Mesh</guid>
            <pubDate>Sat, 23 Aug 2025 12:44:32 GMT</pubDate>
            <description><![CDATA[<h1 id="cilium-service-mesh">Cilium Service Mesh</h1>
<p>Cilium Service Mesh는 기존의 사이드카 프록시(Envoy 등)를 강제적으로 사용하지 않고, eBPF를 활용해 커널 레벨에서 네트워크 및 애플리케이션 계층 트래픽을 제어하는 서비스 메시 구조를 제공한다.
이를 통해 애플리케이션 성능 손실을 최소화하면서도 L3~L7 보안 정책, 트래픽 관리, 모니터링 기능을 통합적으로 제공한다.</p>
<h2 id="l3service-to-service-ip-기반">L3(Service-to-Service, IP 기반)</h2>
<p>Cilium은 eBPF를 활용하여 Pod IP, Service IP 단위로 정책을 제어한다.
Kubernetes NetworkPolicy보다 확장된 <strong>CiliumNetworkPolicy(CNP)</strong>를 통해 CIDR, Label, Namespace 기반 정책을 정의한다.
L3 계층에서는 기본적으로 service-to-service 접근 제어를 수행하며, 클러스터 외부/내부 트래픽 모두 제어할 수 있다.</p>
<h2 id="l7httpgrpc-등-애플리케이션-계층">L7(HTTP/gRPC 등 애플리케이션 계층)</h2>
<p>Cilium은 eBPF 기반 L7 필터링 기능을 제공하며 요청 단위(Path, Method, Header 등)까지 제어할 수 있는 L7 정책을 정의할 수 있다.
예를 들어 /admin 경로는 차단하고 /api 경로만 허용하는 세밀한 접근 제어가 가능하며, L7 로깅 및 메트릭을 통해 서비스 간 호출 패턴을 관찰하고, 보안 이상 탐지에도 활용한다.</p>
<h2 id="tproxytransparent-proxy">TPROXY(Transparent Proxy)</h2>
<p>기존 사이드카 방식과 달리, <strong>Transparent Proxy(TPROXY)</strong>를 활용해 Pod 네트워크 스택에서 바로 L7 트래픽을 가로챈다.
이 방식은 iptables/nftables 레벨에서 L7 프록시로 트래픽을 리다이렉션하는 것이 아니라, eBPF 프로그램이 직접 TPROXY를 통해 커널 내부에서 트래픽을 전달한다.
결과적으로 사이드카 없이도 L7 레벨 정책과 관찰 기능을 적용할 수 있으며, CPU/메모리 오버헤드가 크게 줄어든다.
특히, Envoy와 같은 별도의 프록시 프로세스를 모든 Pod에 배치하지 않고 노드 단위 공유 L7 프록시를 활용할 수 있다는 점에서 성능 및 관리 효율성이 크다.</p>
<h1 id="k8s-ingress-support">K8S Ingress Support</h1>
<h2 id="cilium-ingress-기본-동작">Cilium Ingress 기본 동작</h2>
<p>ingressClassName: cilium 을 사용하여 표준 Kubernetes Ingress 리소스를 지원한다.
Path 기반 라우팅과 TLS Termination을 제공하며, 하위 호환성을 위해 kubernetes.io/ingress.class: cilium 주석(annotation)도 지원한다.
Ingress 컨트롤러는 LoadBalancer 타입의 Service를 생성하므로, 환경에서 LoadBalancer 지원이 필요하다. (필요 시 NodePort 또는 HostNetwork 모드로도 노출 가능)</p>
<h2 id="loadbalancer-모드">LoadBalancer 모드</h2>
<p>Dedicated: Ingress 리소스별로 전용 LoadBalancer를 생성한다. (충돌 방지에 유리)
Shared: 모든 Ingress 리소스가 하나의 LoadBalancer를 공유한다. (리소스 절약 가능)
모드 변경 시 새로운 LoadBalancer IP가 할당되므로, 기존 활성 연결이 끊어질 수 있다.</p>
<h2 id="필수-조건">필수 조건</h2>
<p>NodePort 활성화: nodePort.enabled=true 또는 kubeProxyReplacement=true 필요
L7 Proxy 활성화: l7Proxy=true (기본값)
Ingress 컨트롤러는 기본적으로 LoadBalancer Service를 생성하므로, NodePort/HostNetwork 대안 구성이 필요할 수 있다.</p>
<h2 id="source-ip-visibility">Source IP Visibility</h2>
<p>Envoy는 기본적으로 HTTP 연결의 Source IP를 X-Forwarded-For 헤더에 추가한다.
externalTrafficPolicy와 관계없이, Cilium Ingress는 항상 TPROXY를 사용하여 Envoy에 전달하므로 Source IP가 유지된다.
즉, 일반적인 Ingress 컨트롤러와 달리 externalTrafficPolicy: Local 설정이 없어도 클라이언트 IP를 확인할 수 있다.</p>
<h1 id="tproxytransparent-proxy-1">TPROXY(Transparent Proxy)</h1>
<p>Cilium Ingress를 정확히 이해하기 위해서는 TPROXY에 대한 개념이해가 필요하다.
TPROXY란 커널 레벨에서 원래 목적지 IP/Port를 바꾸지 않고 소켓에 트래픽을 직접 할당할 수 있는 기능을 의미한다.</p>
<p>일반 REDIRECT(DNAT)와 달리, 클라이언트가 보낸 원래 목적지 정보(IP/Port)가 유지되므로, 백엔드로 원래 목적지 정보를 그대로 전달할 수 있다.
Envoy 같은 L7 프록시가 들어오는 트래픽을 목적지 주소 그대로 전달할 수 있기 때문에 L7 레벨 정책/라우팅을 적용할 수 있다.</p>
<p>TPROXY를 통해 트래픽이 전달되는 과정에 대해 확인해보자. </p>
<h2 id="1-패킷-마킹">1) 패킷 마킹</h2>
<p>L7 정책이 걸린 패킷이 들어오면 Cilium에 의해 MARK_MAGIC_TO_PROXY 즉, <code>0x0200</code>가 마킹된다.</p>
<pre><code class="language-c">//cilium/bpf/lib/proxy.h
#ifdef ENABLE_TPROXY
    if (!from_host)
        ctx-&gt;mark |= MARK_MAGIC_TO_PROXY;
    else
#endif
        ctx-&gt;mark = MARK_MAGIC_TO_PROXY | proxy_port &lt;&lt; 16;

    cilium_dbg_capture(ctx, DBG_CAPTURE_PROXY_PRE, proxy_port);

//cilium/bpf/lib/common.h
#define MARK_MAGIC_TO_PROXY        0x0200</code></pre>
<h2 id="2-cilium-proxy-redirection">2) Cilium Proxy Redirection</h2>
<p>Iptables를 통해 마킹된 패킷을 Cilium Host Proxy로 Redirection한다.
Node에서 확인해보면 pod에서 나가는 egress 트래픽의 경우, Cilium proxy port인 35531로 Iptables에 의해 패킷이 보내짐을 알 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# iptables -t mangle -S | grep -i proxy
...
-A CILIUM_PRE_mangle -p tcp -m mark --mark 0xfb820200 -m comment --comment &quot;cilium: TPROXY to host cilium-dns-egress proxy&quot; -j TPROXY --on-port 33531 --on-ip 127.0.0.1 --tproxy-mark 0x200/0xffffffff
-A CILIUM_PRE_mangle -p udp -m mark --mark 0xfb820200 -m comment --comment &quot;cilium: TPROXY to host cilium-dns-egress proxy&quot; -j TPROXY --on-port 33531 --on-ip 127.0.0.1 --tproxy-mark 0x200/0xffffffff

(⎈|HomeLab:N/A) root@k8s-ctr:~# cat /var/run/cilium/state/proxy_ports_state.json
{&quot;cilium-dns-egress&quot;:{&quot;type&quot;:&quot;dns&quot;,&quot;ingress&quot;:false,&quot;port&quot;:33531}}

(⎈|HomeLab:N/A) root@k8s-ctr:~# ss -nltup | grep cilium
udp   UNCONN 0      0           127.0.0.1:33531      0.0.0.0:*    users:((&quot;cilium-agent&quot;,pid=5430,fd=48))</code></pre>
<p>아래 실습에서 확인을 해보겠지만, cilium-ingress 활성화 상태에서 cilium클래스의 Ingress리소스를 생성하면 cilium daemonset에 의해, cilium-ingress관련 iptables 규칙이 새롭게 생성된다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# sudo iptables -t mangle -L CILIUM_PRE_mangle --line-numbers -n -v
Chain CILIUM_PRE_mangle (1 references)
num   pkts bytes target     prot opt in     out     source               destination
1        0     0 MARK       0    --  *      !lo     0.0.0.0/0            0.0.0.0/0            socket --transparent mark match ! 0xe00/0xf00 mark match ! 0x800/0xf00 /* cilium: any-&gt;pod redirect proxied traffic to host proxy */ MARK set 0x200
2        0     0 TPROXY     6    --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0xfb820200 /* cilium: TPROXY to host cilium-dns-egress proxy */ TPROXY redirect 127.0.0.1:33531 mark 0x200/0xffffffff
3        0     0 TPROXY     17   --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0xfb820200 /* cilium: TPROXY to host cilium-dns-egress proxy */ TPROXY redirect 127.0.0.1:33531 mark 0x200/0xffffffff
4        0     0 TPROXY     6    --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x17380200 /* cilium: TPROXY to host kube-system/cilium-ingress/listener proxy */ TPROXY redirect 127.0.0.1:14359 mark 0x200/0xffffffff
5        0     0 TPROXY     17   --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x17380200 /* cilium: TPROXY to host kube-system/cilium-ingress/listener proxy */ TPROXY redirect 127.0.0.1:14359 mark 0x200/0xffffffff</code></pre>
<p>redirect port에 대해 확인을 해보면 cilium-envoy의 port이다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# ss -nltup | grep cilium | grep 14359
tcp   LISTEN 0      4096        127.0.0.1:14359      0.0.0.0:*    users:((&quot;cilium-envoy&quot;,pid=15257,fd=63))
tcp   LISTEN 0      4096        127.0.0.1:14359      0.0.0.0:*    users:((&quot;cilium-envoy&quot;,pid=15257,fd=56))
tcp   LISTEN 0      4096        127.0.0.1:14359      0.0.0.0:*    users:((&quot;cilium-envoy&quot;,pid=15257,fd=55))
tcp   LISTEN 0      4096        127.0.0.1:14359      0.0.0.0:*    users:((&quot;cilium-envoy&quot;,pid=15257,fd=52))</code></pre>
<p>Ingress로 들어오는 트래픽의 경우, Iptables Rule에 의해 cilium-envoy로 패킷이 보내진다.</p>
<h2 id="3-socket-조회">3) Socket 조회</h2>
<p>현재 연결되어 있는 소켓이 없다면 신규로 튜플을 구성하여 커널에서 조회한다.</p>
<pre><code class="language-c">//cilium/bpf/lib/proxy.h
static __always_inline int                            \
NAME(struct __ctx_buff *ctx, const CT_TUPLE_TYPE * ct_tuple,            \
     __be16 proxy_port, void *tproxy_addr)                    \
{                                        \
    struct bpf_sock_tuple *tuple = (struct bpf_sock_tuple *)ct_tuple;    \
    __u8 nexthdr = ct_tuple-&gt;nexthdr;                    \
    __u32 len = sizeof(tuple-&gt;SK_FIELD);                    \
    __u16 port;                                \
    int result;                                \
...                        \
    /* 로컬에 연결된 Socket이 없다면 새로운 TPROXY Socket을 할당한다. */ \
  /* 목적지 포트를 proxy_port 즉 cilium proxy port로 설정한다. */
    tuple-&gt;SK_FIELD.dport = proxy_port;     \
  /* 출발지 포트는 wildcard처리를 한다. */
    tuple-&gt;SK_FIELD.sport = 0;    \
  /* 목적지 주소를 TPROXY 소켓이 바인딩된 로컬 IP로 지정한다. 일반적으로 127.0.0.1 혹은 ::1 같은 루프백 주소로 들어온다. */
    memcpy(&amp;tuple-&gt;SK_FIELD.daddr, tproxy_addr, sizeof(tuple-&gt;SK_FIELD.daddr)); \
  /* 출발지 주소는 wildcard처리를 한다. */
    memset(&amp;tuple-&gt;SK_FIELD.saddr, 0, sizeof(tuple-&gt;SK_FIELD.saddr));    \
    cilium_dbg3(ctx, DBG_LOOKUP_CODE,                    \
            tuple-&gt;SK_FIELD.SADDR_DBG, tuple-&gt;SK_FIELD.DADDR_DBG,    \
            combine_ports(tuple-&gt;SK_FIELD.dport, tuple-&gt;SK_FIELD.sport));    \
    result = assign_socket(ctx, tuple, len, nexthdr, false);        \
    if (result == CTX_ACT_OK)                        \
        goto out;    \
...
}</code></pre>
<h2 id="4-socket-바인딩">4) Socket 바인딩</h2>
<p>구성한 튜플을 기반으로 커널에서 TRPROXY 리스닝 소켓을 조회한 후, 패킷을 소켓에 직접 바인딩한다.
즉, Cilium에 의해 해당 패킷이 iptables REDIRECT/DNAT없이 TPROXY 소켓에 직접 바인딩 된다. 이 단계에서 커널 TCP/IP 스택을 거치지 않고 Envoy 소켓으로 직접 바인딩되므로, NAT 없이 원래 목적지 IP/Port를 유지한 채로 L7 처리가 가능하다.</p>
<pre><code class="language-c">//cilium/bpf/lib/proxy.h
assign_socket_tcp(struct __ctx_buff *ctx,
          struct bpf_sock_tuple *tuple, __u32 len, bool established)
{
    int result = DROP_PROXY_LOOKUP_FAILED;
    struct bpf_sock *sk;
    __u32 dbg_ctx;

/* tuple과 일치하는 TCP 소켓을 커널에서 조회한다.*/
    sk = skc_lookup_tcp(ctx, tuple, len, BPF_F_CURRENT_NETNS, 0);
    if (!sk)
        goto out;
...
  /* 패킷을 소켓에 직접 바인딩한다. */
    result = sk_assign(ctx, sk, 0);
...</code></pre>
<h1 id="cilium-envoy-cilium-ingress-설정-확인">Cilium Envoy, Cilium-ingress 설정 확인</h1>
<p>cilium 설치 명령어</p>
<pre><code class="language-bash">helm install cilium cilium/cilium --version $2 --namespace kube-system \
--set k8sServiceHost=192.168.10.100 --set k8sServicePort=6443 \
--set ipam.mode=&quot;cluster-pool&quot; --set ipam.operator.clusterPoolIPv4PodCIDRList={&quot;172.20.0.0/16&quot;} --set ipv4NativeRoutingCIDR=172.20.0.0/16 \
--set routingMode=native --set autoDirectNodeRoutes=true --set endpointRoutes.enabled=true --set directRoutingSkipUnreachable=true \
--set kubeProxyReplacement=true --set bpf.masquerade=true --set installNoConntrackIptablesRules=true \
--set endpointHealthChecking.enabled=false --set healthChecking=false \
--set hubble.enabled=true --set hubble.relay.enabled=true --set hubble.ui.enabled=true \
--set hubble.ui.service.type=NodePort --set hubble.ui.service.nodePort=30003 \
--set prometheus.enabled=true --set operator.prometheus.enabled=true --set hubble.metrics.enableOpenMetrics=true \
--set hubble.metrics.enabled=&quot;{dns,drop,tcp,flow,port-distribution,icmp,httpV2:exemplars=true;labelsContext=source_ip\,source_namespace\,source_workload\,destination_ip\,destination_namespace\,destination_workload\,traffic_direction}&quot; \
--set ingressController.enabled=true --set ingressController.loadbalancerMode=shared --set loadBalancer.l7.backend=envoy \
--set localRedirectPolicy=true --set l2announcements.enabled=true \
--set operator.replicas=1 --set debug.enabled=true &gt;/dev/null 2&gt;&amp;1</code></pre>
<p>k8s ingress support 설정 확인을 해보자.</p>
<p>Cilium을 설치할 때 ingressController를 true로 선택을 하였기 때문에 각 워커 노드별로 Cilium이 Ingress 용도로 예약한 IP가 존재한다.
이 IP는 실제 Pod가 가진 IP가 아니라, Cilium 에이전트가 관리하는 가상 IP이며, Ingress로 들어온 트래픽이 Envoy(L7 Proxy)로 전달될 때 식별 목적으로 사용된다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# cilium config view | grep -E &#39;^loadbalancer|l7&#39;
enable-l7-proxy                                   true
loadbalancer-l7                                   envoy
loadbalancer-l7-algorithm                         round_robin
loadbalancer-l7-ports

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium ip list | grep ingress
172.20.0.147/32     reserved:ingress
172.20.1.202/32     reserved:ingress</code></pre>
<p>Cilium Envoy는 hostNetwork로 각 노드에 한 대씩 daemonset으로 뜨며, 각 노드의 9964 포트를 listen하고 있다.
Cilium Envoy 서비스의 Endpoint로 Cilium Envoy pod의 9964포트가 잡혀있다. 
Envoy는 Cilium Agent와 연결되며 admin.sock 유닉스 소켓을 통해 Cilium Agent ↔ Envoy 간 상태 조회/제어가 가능하다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc,ep -n kube-system cilium-envoy
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME                   TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
service/cilium-envoy   ClusterIP   None         &lt;none&gt;        9964/TCP   18h

NAME                     ENDPOINTS                                 AGE
endpoints/cilium-envoy   192.168.10.100:9964,192.168.10.101:9964   18h

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -n kube-system -l k8s-app=cilium-envoy -owide
NAME                 READY   STATUS    RESTARTS   AGE   IP               NODE      NOMINATED NODE   READINESS GATES
cilium-envoy-hw9kw   1/1     Running   0          17h   192.168.10.100   k8s-ctr   &lt;none&gt;           &lt;none&gt;
cilium-envoy-nvvw8   1/1     Running   0          17h   192.168.10.101   k8s-w1    &lt;none&gt;           &lt;none&gt;

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl describe pod -n kube-system -l k8s-app=cilium-envoy
...
Volumes:
  envoy-sockets:
    Type:          HostPath (bare host directory volume)
    Path:          /var/run/cilium/envoy/sockets
    HostPathType:  DirectoryOrCreate
  envoy-artifacts:
    Type:          HostPath (bare host directory volume)
    Path:          /var/run/cilium/envoy/artifacts
    HostPathType:  DirectoryOrCreate
  envoy-config:
    Type:      ConfigMap (a volume populated by a ConfigMap)
    Name:      cilium-envoy-config
    Optional:  false
  bpf-maps:
    Type:          HostPath (bare host directory volume)
    Path:          /sys/fs/bpf
    HostPathType:  DirectoryOrCreate
...

(⎈|HomeLab:N/A) root@k8s-ctr:~# ls -al /var/run/cilium/envoy/sockets
total 0
drwxr-xr-x 3 root root 120 Aug 22 19:30 .
drwxr-xr-x 4 root root  80 Aug 22 19:29 ..
srw-rw---- 1 root 1337   0 Aug 22 19:30 access_log.sock
srwxr-xr-x 1 root root   0 Aug 22 19:29 admin.sock
drwxr-xr-x 3 root root  60 Aug 22 19:30 envoy
srw-rw---- 1 root 1337   0 Aug 22 19:30 xds.sock

(⎈|HomeLab:N/A) root@k8s-ctr:/var/run/cilium/envoy/sockets# kubectl exec -it -n kube-system ds/cilium-envoy -- cat /var/run/cilium/envoy/bootstrap-config.json &gt; config.json

(⎈|HomeLab:N/A) root@k8s-ctr:~# cat config.json | jq
        &quot;connectTimeout&quot;: &quot;2s&quot;,
        &quot;loadAssignment&quot;: {
          &quot;clusterName&quot;: &quot;/envoy-admin&quot;,
          &quot;endpoints&quot;: [
            {
              &quot;lbEndpoints&quot;: [
                {
                  &quot;endpoint&quot;: {
                    &quot;address&quot;: {
                      &quot;pipe&quot;: {
                        &quot;path&quot;: &quot;/var/run/cilium/envoy/sockets/admin.sock&quot;
                      }
                    }
                  }
                }
              ]
            }
          ]
        },
        &quot;name&quot;: &quot;/envoy-admin&quot;,
        &quot;type&quot;: &quot;STATIC&quot;
      }
    ],
    &quot;listeners&quot;: [
      {
        &quot;address&quot;: {
          &quot;socketAddress&quot;: {
            &quot;address&quot;: &quot;0.0.0.0&quot;,
            &quot;portValue&quot;: 9964
          }
</code></pre>
<p>Cilium ingress의 경우 외부 트래픽을 받아들이기 위하여 LoadBalancer타입으로 생성된다. 
endpoint로 잡힌 192.192.192.192:9999는 외부에서 들어온 패킷이 Cilium eBPF 경로를 타고 Envoy로 리디렉션되기 위한 가상 endpoint로, 실제 이 IP로 통신되지는 않는다. </p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc,ep -n kube-system cilium-ingress
NAME                     TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)                      AGE
service/cilium-ingress   LoadBalancer   10.96.132.142   &lt;pending&gt;     80:30861/TCP,443:31103/TCP   18h

NAME                       ENDPOINTS              AGE
endpoints/cilium-ingress   192.192.192.192:9999   18h</code></pre>
<p>노드의 /sys/fs/bpf/cilium은 Cilium이 eBPF 오브젝트들을 올려두는 가상 파일시스템이다.
ControlPlane 노드의 /sys/fs/bpf/cilium 하위 트리 파일을 통해 구성 방식을 확인해보자.</p>
<pre><code class="language-bash">/sys/fs/bpf/cilium
├── devices
...
│   ├── eth0
│   │   └── links
│   │       ├── cil_from_netdev
│   │       └── cil_to_netdev

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl -n kube-system exec -it ds/cilium -- cilium endpoint list
1215       Disabled           Disabled          28440      k8s:app.kubernetes.io/name=hubble-ui                                                       172.20.0.36    ready
                                                           k8s:app.kubernetes.io/part-of=cilium
                                                           k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=kube-system
                                                           k8s:io.cilium.k8s.policy.cluster=default
                                                           k8s:io.cilium.k8s.policy.serviceaccount=hubble-ui
                                                           k8s:io.kubernetes.pod.namespace=kube-system
                                                           k8s:k8s-app=hubble-ui
...                                                           </code></pre>
<p>devices/eth0/links/cil_* 등은 노드 네트워크 인터페이스 입출구에 붙은 eBPF hook으로, Cilium이 커널 네트워크 스택의 RX/TX 경로에 끼어들어 외부에서 들어오는 패킷을 Envoy로 보내기 위해 TPROXY 동작을 삽입한다.
(예시 - 외부 클라이언트가 Ingress LB IP로 접속하면 eth0 RX → cil_from_netdev → eBPF → Envoy(TPROXY) 흐름을 탐.)</p>
<ul>
<li>cil_from_netdev : NIC에서 들어온 패킷을 Cilium datapath로 끌어들임.</li>
<li>cil_to_netdev : Cilium datapath에서 나온 패킷을 실제 NIC로 흘려보냄.</li>
</ul>
<h2 id="l2-announcement-설정-및-cilium-ingress에-ex-ip-설정">L2 Announcement 설정 및 Cilium-Ingress에 EX-IP 설정</h2>
<p>외부에서 Cilium-Ingress를 통해 호출하는 테스트를 위해 LB IPAM 및 L2 Announcement 설정을 추가한다.</p>
<pre><code class="language-bash">cat &lt;&lt; EOF | kubectl apply -f -
apiVersion: &quot;cilium.io/v2&quot; 
kind: CiliumLoadBalancerIPPool
metadata:
  name: &quot;cilium-lb-ippool&quot;
spec:
  blocks:
  - start: &quot;192.168.10.211&quot;
    stop:  &quot;192.168.10.215&quot;
EOF

cat &lt;&lt; EOF | kubectl apply -f -
apiVersion: &quot;cilium.io/v2alpha1&quot;
kind: CiliumL2AnnouncementPolicy
metadata:
  name: policy1
spec:
  interfaces:
  - eth1
  externalIPs: true
  loadBalancerIPs: true
EOF

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc,ep cilium-ingress -n kube-system
NAME                     TYPE           CLUSTER-IP      EXTERNAL-IP      PORT(S)                      AGE
service/cilium-ingress   LoadBalancer   10.96.132.142   192.168.10.211   80:30861/TCP,443:31103/TCP   18h

NAME                       ENDPOINTS              AGE
endpoints/cilium-ingress   192.192.192.192:9999   18h

#현재 L2 Announcement설정에 의한 리더 노드 = k8s-w1
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl -n kube-system get lease | grep &quot;cilium-l2announce&quot;
cilium-l2announce-kube-system-cilium-ingress   k8s-w1                                                                      55s</code></pre>
<p>외부와 내부 모두에서 cilium-ingress의 외부 IP를 통해 통신이 가능하다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# LBIP=$(kubectl get svc -n kube-system cilium-ingress -o jsonpath=&#39;{.status.loadBalancer.ingress[0].ip}&#39;)
(⎈|HomeLab:N/A) root@k8s-ctr:~# echo $LBIP
192.168.10.211
(⎈|HomeLab:N/A) root@k8s-ctr:~# arping -i eth1 $LBIP -c 2
ARPING 192.168.10.211
60 bytes from 08:00:27:b0:11:69 (192.168.10.211): index=0 time=249.708 usec
60 bytes from 08:00:27:b0:11:69 (192.168.10.211): index=1 time=315.533 usec

--- 192.168.10.211 statistics ---
2 packets transmitted, 2 packets received,   0% unanswered (0 extra)


root@router:~# LBIP=192.168.10.211
root@router:~# arping -i eth1 $LBIP -c 2
ARPING 192.168.10.211
60 bytes from 08:00:27:b0:11:69 (192.168.10.211): index=0 time=533.017 usec
60 bytes from 08:00:27:b0:11:69 (192.168.10.211): index=1 time=270.677 usec
^C
--- 192.168.10.211 statistics ---
2 packets transmitted, 2 packets received,   0% unanswered (0 extra)
rtt min/avg/max/std-dev = 0.271/0.402/0.533/0.131 ms</code></pre>
<h2 id="ingress-http-example">Ingress HTTP Example</h2>
<p>Istio 예제로 유명한 bookinfo로 실습을 진행한다.
배포 후 확인을 해보면 Istio 실습과는 다르게 Sidecar Container가 없다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod,svc,ep
NAME                                  READY   STATUS    RESTARTS   AGE
pod/details-v1-766844796b-bfgg4       1/1     Running   0          53s
pod/productpage-v1-54bb874995-579qn   1/1     Running   0          53s
pod/ratings-v1-5dc79b6bcd-csprv       1/1     Running   0          53s
pod/reviews-v1-598b896c9d-dnqln       1/1     Running   0          53s
pod/reviews-v2-556d6457d-46p47        1/1     Running   0          53s
pod/reviews-v3-564544b4d6-b5bps       1/1     Running   0          53s

NAME                  TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
service/details       ClusterIP   10.96.159.26    &lt;none&gt;        9080/TCP   53s
service/kubernetes    ClusterIP   10.96.0.1       &lt;none&gt;        443/TCP    24h
service/productpage   ClusterIP   10.96.28.207    &lt;none&gt;        9080/TCP   53s
service/ratings       ClusterIP   10.96.106.180   &lt;none&gt;        9080/TCP   53s
service/reviews       ClusterIP   10.96.212.117   &lt;none&gt;        9080/TCP   53s

NAME                    ENDPOINTS                                              AGE
endpoints/details       172.20.1.142:9080                                      53s
endpoints/kubernetes    192.168.10.100:6443                                    24h
endpoints/productpage   172.20.1.154:9080                                      53s
endpoints/ratings       172.20.1.21:9080                                       53s
endpoints/reviews       172.20.1.139:9080,172.20.1.186:9080,172.20.1.26:9080   53s</code></pre>
<p>Ingress를 배포하여 통신을 확인해본다.
Ingress IP는 cilium-ingress 서비스의 외부 IP로 지정된다. </p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# cat &lt;&lt; EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: basic-ingress
  namespace: default
spec:
  ingressClassName: cilium
  rules:
  - http:
      paths:
      - backend:
          service:
            name: details
            port:
              number: 9080
        path: /details
        pathType: Prefix
      - backend:
          service:
            name: productpage
            port:
              number: 9080
        path: /
        pathType: Prefix
EOF

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ingress
NAME            CLASS    HOSTS   ADDRESS          PORTS   AGE
basic-ingress   cilium   *       192.168.10.211   80      74s

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc -n kube-system cilium-ingress
NAME             TYPE           CLUSTER-IP      EXTERNAL-IP      PORT(S)                      AGE
cilium-ingress   LoadBalancer   10.96.132.142   192.168.10.211   80:30861/TCP,443:31103/TCP   24h

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl describe ingress
...
Address:          192.168.10.211
Ingress Class:    cilium
Rules:
  Host        Path  Backends
  ----        ----  --------
  *
              /details   details:9080 (172.20.1.142:9080)
              /          productpage:9080 (172.20.1.154:9080)</code></pre>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# LBIP=$(kubectl get svc -n kube-system cilium-ingress -o jsonpath=&#39;{.status.loadBalancer.ingress[0].ip}&#39;)
(⎈|HomeLab:N/A) root@k8s-ctr:~# echo $LBIP
192.168.10.211
(⎈|HomeLab:N/A) root@k8s-ctr:~# curl -so /dev/null -w &quot;%{http_code}\n&quot; http://$LBIP/
200

(⎈|HomeLab:N/A) root@k8s-ctr:~# curl -so /dev/null -w &quot;%{http_code}\n&quot; http://$LBIP/details/1
200

(⎈|HomeLab:N/A) root@k8s-ctr:~# curl -so /dev/null -w &quot;%{http_code}\n&quot; http://$LBIP/ratings
404

(⎈|HomeLab:N/A) root@k8s-ctr:~# hubble observe -f -t l7
Aug 23 10:52:09.731: 192.168.10.200:43330 (ingress) -&gt; default/productpage-v1-54bb874995-579qn:9080 (ID:49314) http-request FORWARDED (HTTP/1.1 GET http://192.168.10.211/)
Aug 23 10:52:09.732: 192.168.10.200:43330 (ingress) &lt;- default/productpage-v1-54bb874995-579qn:9080 (ID:49314) http-response FORWARDED (HTTP/1.1 200 2ms (GET http://192.168.10.211/))
Aug 23 10:52:37.857: 192.168.10.200:43452 (ingress) -&gt; default/details-v1-766844796b-bfgg4:9080 (ID:20097) http-request FORWARDED (HTTP/1.1 GET http://192.168.10.211/details/1)
Aug 23 10:52:37.860: 192.168.10.200:43452 (ingress) &lt;- default/details-v1-766844796b-bfgg4:9080 (ID:20097) http-response FORWARDED (HTTP/1.1 200 3ms (GET http://192.168.10.211/details/1))
Aug 23 10:53:11.823: 192.168.10.200:47462 (ingress) -&gt; default/productpage-v1-54bb874995-579qn:9080 (ID:49314) http-request FORWARDED (HTTP/1.1 GET http://192.168.10.211/ratings)
Aug 23 10:53:11.832: 192.168.10.200:47462 (ingress) &lt;- default/productpage-v1-54bb874995-579qn:9080 (ID:49314) http-response FORWARDED (HTTP/1.1 404 10ms (GET http://192.168.10.211/ratings))</code></pre>
<p>Pod가 뜨는 WorkerNode에서 veth 트래픽을 캡처하여 TPROXY로 인해 Client IP가 보존되는 것을 확인해본다.</p>
<pre><code class="language-bash">root@k8s-w1:~# PROID=172.20.1.154

root@k8s-w1:~# ip route | grep $PROID
172.20.1.154 dev lxc24276eae3fab proto kernel scope link

root@k8s-w1:~# PROVETH=lxc24276eae3fab

root@k8s-w1:~# ngrep -tW byline -d $PROVETH &#39;&#39; &#39;tcp port 9080&#39;
lxc24276eae3fab: no IPv4 address assigned: Cannot assign requested address
interface: lxc24276eae3fab
filter: ( tcp port 9080 ) and ((ip || ip6) || (vlan &amp;&amp; (ip || ip6)))
####
T 2025/08/23 19:56:14.533041 10.0.2.15:56674 -&gt; 172.20.1.154:9080 [AP] #4
GET / HTTP/1.1.
host: 192.168.10.211.
user-agent: curl/8.5.0.
accept: */*.
x-forwarded-for: 192.168.10.200. # -&gt; XFF에 client-ip가 담김.
x-forwarded-proto: http.
x-envoy-internal: true.
x-request-id: eabadda9-e958-4038-8446-aa5c5807c527.
.

##
T 2025/08/23 19:56:14.548321 172.20.1.154:9080 -&gt; 10.0.2.15:56674 [AP] #6
HTTP/1.1 200 OK.
Server: gunicorn.
Date: Sat, 23 Aug 2025 10:56:14 GMT.
Connection: keep-alive.
Content-Type: text/html; charset=utf-8.
Content-Length: 2080.
...</code></pre>
<p>추가로 Ingress로 들어오는 패킷이 TPROXY를 타고 cilium-envoy로 향하는 과정을 확인할 수 있다.</p>
<p>WorkerNode의 mangle IPTABLES를 확인하면 ingress로 들어오는 트래픽은 127.0.0.1의 10061로 향하라는 규칙이 있음을 확인할 수 있는데, 이는 cilium-envoy의 TCP 포트이다.</p>
<pre><code class="language-bash">root@k8s-w1:~# sudo iptables -t mangle -L CILIUM_PRE_mangle --line-numbers -n -v
Chain CILIUM_PRE_mangle (1 references)
num   pkts bytes target     prot opt in     out     source               destination
...
4        0     0 TPROXY     6    --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x4d270200 /* cilium: TPROXY to host kube-system/cilium-ingress/listener proxy */ TPROXY redirect 127.0.0.1:10061 mark 0x200/0xffffffff
5        0     0 TPROXY     17   --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x4d270200 /* cilium: TPROXY to host kube-system/cilium-ingress/listener proxy */ TPROXY redirect 127.0.0.1:10061 mark 0x200/0xffffffff

root@k8s-w1:~# ss -nltup | grep 10061
tcp   LISTEN 0      4096        127.0.0.1:10061      0.0.0.0:*    users:((&quot;cilium-envoy&quot;,pid=11198,fd=63))
tcp   LISTEN 0      4096        127.0.0.1:10061      0.0.0.0:*    users:((&quot;cilium-envoy&quot;,pid=11198,fd=56))
tcp   LISTEN 0      4096        127.0.0.1:10061      0.0.0.0:*    users:((&quot;cilium-envoy&quot;,pid=11198,fd=55))
tcp   LISTEN 0      4096        127.0.0.1:10061      0.0.0.0:*    users:((&quot;cilium-envoy&quot;,pid=11198,fd=52))</code></pre>
<p>외부에서 LB를 호출시키면 IPTABLES의 <code>TPROXY to host kube-system/cilium-ingress/listener proxy</code> 규칙의 pkts가 하나씩 증가함을 확인할 수 있다.</p>
<pre><code class="language-bash">root@k8s-w1:~# sudo iptables -t mangle -L CILIUM_PRE_mangle --line-numbers -n -v
Chain CILIUM_PRE_mangle (1 references)
num   pkts bytes target     prot opt in     out     source               destination
...
4        0     0 TPROXY     6    --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x4d270200 /* cilium: TPROXY to host kube-system/cilium-ingress/listener proxy */ TPROXY redirect 127.0.0.1:10061 mark 0x200/0xffffffff

root@router:~# curl -so /dev/null -w &quot;%{http_code}\n&quot; http://$LBIP/
200

root@k8s-w1:~# sudo iptables -t mangle -L CILIUM_PRE_mangle --line-numbers -n -v
Chain CILIUM_PRE_mangle (1 references)
num   pkts bytes target     prot opt in     out     source               destination
...
4        1    60 TPROXY     6    --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x4d270200 /* cilium: TPROXY to host kube-system/cilium-ingress/listener proxy */ TPROXY redirect 127.0.0.1:10061 mark 0x200/0xffffffff</code></pre>
<h3 id="dedicated-mode">Dedicated Mode</h3>
<p>이번에는 Ingress를 dedicated 모드로 생성해본다. Dedicated모드로 생성하게 되면 Ingress 리소스 별로 전용 LoadBalancer가 생성된다.</p>
<pre><code class="language-bash"># 샘플 애플리케이션 배포
cat &lt;&lt; EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webpod
spec:
  replicas: 2
  selector:
    matchLabels:
      app: webpod
  template:
    metadata:
      labels:
        app: webpod
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - sample-app
            topologyKey: &quot;kubernetes.io/hostname&quot;
      containers:
      - name: webpod
        image: traefik/whoami
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: webpod
  labels:
    app: webpod
spec:
  selector:
    app: webpod
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80
  type: ClusterIP
EOF


# k8s-ctr 노드에 curl-pod 파드 배포
cat &lt;&lt;EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: curl-pod
  labels:
    app: curl
spec:
  nodeName: k8s-ctr
  containers:
  - name: curl
    image: nicolaka/netshoot
    command: [&quot;tail&quot;]
    args: [&quot;-f&quot;, &quot;/dev/null&quot;]
  terminationGracePeriodSeconds: 0
EOF

(⎈|HomeLab:N/A) root@k8s-ctr:~# cat &lt;&lt; EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: webpod-ingress-nginx
  namespace: default
spec:
  ingressClassName: nginx
  rules:
  - host: nginx.webpod.local
    http:
      paths:
      - backend:
          service:
            name: webpod
            port:
              number: 80
        path: /
        pathType: Prefix
EOF
ingress.networking.k8s.io/webpod-ingress created

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ingress
NAME             CLASS    HOSTS   ADDRESS          PORTS   AGE
basic-ingress    cilium   *       192.168.10.211   80      19m
webpod-ingress   cilium   *       192.168.10.212   80      14s

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc,ep cilium-ingress-webpod-ingress
NAME                                    TYPE           CLUSTER-IP      EXTERNAL-IP      PORT(S)                      AGE
service/cilium-ingress-webpod-ingress   LoadBalancer   10.96.181.231   192.168.10.212   80:31255/TCP,443:31157/TCP   28s

NAME                                      ENDPOINTS              AGE
endpoints/cilium-ingress-webpod-ingress   192.192.192.192:9999   28s

#현재 L2 Announcement설정에 의한 리더 노드 = k8s-ctr
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get lease -n kube-system | grep ingress
cilium-l2announce-default-cilium-ingress-webpod-ingress   k8s-ctr                                                                     60s</code></pre>
<p>위 실습과 동일하게 Pod가 뜨는 Node에서 veth 트래픽을 캡처하여 TPROXY로 인해 Client IP가 보존되는 것을 확인해본다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -l app=webpod -owide
NAME                      READY   STATUS    RESTARTS   AGE   IP             NODE      NOMINATED NODE   READINESS GATES
webpod-697b545f57-8qgpp   1/1     Running   0          76s   172.20.0.192   k8s-ctr   &lt;none&gt;           &lt;none&gt;
webpod-697b545f57-gkzn6   1/1     Running   0          76s   172.20.1.230   k8s-w1    &lt;none&gt;           &lt;none&gt;

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip link | grep 172.20.0.192
(⎈|HomeLab:N/A) root@k8s-ctr:~# WEBIP=172.20.0.192
(⎈|HomeLab:N/A) root@k8s-ctr:~# ip route | grep $WEBIP
172.20.0.192 dev lxc02bb82001369 proto kernel scope link
(⎈|HomeLab:N/A) root@k8s-ctr:~# WPODVETH=lxc02bb82001369

root@router:~# LB2IP=192.168.10.212
root@router:~# curl -so /dev/null -w &quot;%{http_code}\n&quot; http://$LB2IP/
200</code></pre>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# ngrep -tW byline -d $WPODVETH &#39;&#39; &#39;tcp port 80&#39;
lxc02bb82001369: no IPv4 address assigned: Cannot assign requested address
interface: lxc02bb82001369
filter: ( tcp port 80 ) and ((ip || ip6) || (vlan &amp;&amp; (ip || ip6)))
...
T 2025/08/23 21:40:53.991963 172.20.0.192:80 -&gt; 10.0.2.15:36594 [AP] #6
HTTP/1.1 200 OK.
Date: Sat, 23 Aug 2025 12:40:53 GMT.
Content-Length: 341.
Content-Type: text/plain; charset=utf-8.
.
Hostname: webpod-697b545f57-8qgpp
IP: 127.0.0.1
IP: ::1
IP: 172.20.0.192
IP: fe80::5c51:1ff:fee8:c410
RemoteAddr: 10.0.2.15:36594
GET / HTTP/1.1.
Host: 192.168.10.212.
User-Agent: curl/8.5.0.
Accept: */*.
X-Envoy-Internal: true.
X-Forwarded-For: 192.168.10.200.
X-Forwarded-Proto: http.
X-Request-Id: 135499d1-ed5d-457b-9815-8b5fb49d63c9.
.

###</code></pre>
<p>여기서 하나 확인할 수 있는 점은 L2 Announcement 설정으로 인해 k8s-w1에 떠있는 파드를 호출하더라도 k8s-ctr을 거쳐서 k8s-w1로 패킷이 향하는 것인데, 이 때문에 RemoteAddr의 모습이 다르다.</p>
<p>리더 노드인 k8s-ctr위의 Pod의 경우 L2 leader Node에서 바로 패킷을 전달하기 때문에, pod가 바라보는 RemoteAddr은 k8s-ctr의 첫 번째 NIC IP이다.</p>
<p>k8s-w1위의 Pod의 경우 L2 leader Node(k8s-ctr)에서 k8s-w1로 패킷이 전달되기 때문에, RemoteAddr이 Leader Node를 거친 src IP 즉, Ingress의 EXT-IP이다.</p>
<p>하지만, TPROXY로 인해 실제 Client IP가 보존되기 때문에 X-Forwarded-For의 값은 외부 노드 Router의 eth1 IP와 동일하다.</p>
<pre><code class="language-bash"># k8s-ctr
(⎈|HomeLab:N/A) root@k8s-ctr:~# ngrep -tW byline -d $WPODVETH &#39;&#39; &#39;tcp port 80&#39;
...
Hostname: webpod-697b545f57-8qgpp
IP: 127.0.0.1
IP: ::1
IP: 172.20.0.192
IP: fe80::5c51:1ff:fee8:c410
RemoteAddr: 10.0.2.15:36594
GET / HTTP/1.1.
Host: 192.168.10.212.
User-Agent: curl/8.5.0.
Accept: */*.
X-Envoy-Internal: true.
X-Forwarded-For: 192.168.10.200.
X-Forwarded-Proto: http.
X-Request-Id: 135499d1-ed5d-457b-9815-8b5fb49d63c9.

# k8s-w1
root@k8s-w1:~# ngrep -tW byline -d $WPODVETH &#39;&#39; &#39;tcp port 80&#39;
...
Hostname: webpod-697b545f57-gkzn6
IP: 127.0.0.1
IP: ::1
IP: 172.20.1.230
IP: fe80::8c39:e6ff:fe38:4d79
RemoteAddr: 172.20.0.147:35985
GET / HTTP/1.1.
Host: 192.168.10.212.
User-Agent: curl/8.5.0.
Accept: */*.
X-Envoy-Internal: true.
X-Forwarded-For: 192.168.10.200.
X-Forwarded-Proto: http.
X-Request-Id: 7958092f-8b81-47a1-81c3-31d3e6725660.</code></pre>
<h3 id="ingress-nginx와-cilium-ingress-공존-가능">ingress-Nginx와 cilium Ingress 공존 가능</h3>
<p>ingress-nginx와 cilium Ingress는 공존이 가능하다. </p>
<pre><code class="language-bash"># Ingress-Nginx 컨트롤러 설치
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm install ingress-nginx ingress-nginx/ingress-nginx --create-namespace -n ingress-nginx

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ingressclasses.networking.k8s.io
NAME     CONTROLLER                     PARAMETERS   AGE
cilium   cilium.io/ingress-controller   &lt;none&gt;       26h
nginx    k8s.io/ingress-nginx           &lt;none&gt;       51s

# ingress 설정
cat &lt;&lt; EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: webpod-ingress-nginx
  namespace: default
spec:
  ingressClassName: nginx
  rules:
  - host: nginx.webpod.local
    http:
      paths:
      - backend:
          service:
            name: webpod
            port:
              number: 80
        path: /
        pathType: Prefix
EOF

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ingress
NAME                   CLASS    HOSTS                ADDRESS          PORTS   AGE
basic-ingress          cilium   *                    192.168.10.211   80      44m
webpod-ingress         cilium   *                    192.168.10.212   80      24m
webpod-ingress-nginx   nginx    nginx.webpod.local   192.168.10.213   80      12s</code></pre>
<h4 id="remoteaddr">RemoteAddr</h4>
<p>해당 LB를 호출하여 통신 상태를 확인해본다.
k8s-ctr, k8s-w1모두 RemoteAddr이 ingress-nginx-controller의 Pod IP임을 알 수 있다. </p>
<pre><code class="language-bash">root@router:~# LB3IP=192.168.10.213
root@router:~# curl -H &quot;Host: nginx.webpod.local&quot; $LB3IP

# k8s-ctr
(⎈|HomeLab:N/A) root@k8s-ctr:~# ngrep -tW byline -d $WPODVETH &#39;&#39; &#39;tcp port 80&#39;
Hostname: webpod-697b545f57-8qgpp
IP: 127.0.0.1
IP: ::1
IP: 172.20.0.192
IP: fe80::5c51:1ff:fee8:c410
RemoteAddr: 172.20.1.35:53392
GET / HTTP/1.1.
Host: nginx.webpod.local.
User-Agent: curl/8.5.0.
Accept: */*.
X-Forwarded-For: 192.168.10.200.
X-Forwarded-Host: nginx.webpod.local.
X-Forwarded-Port: 80.
X-Forwarded-Proto: http.
X-Forwarded-Scheme: http.
X-Real-Ip: 192.168.10.200.
X-Request-Id: 02a94cc39febb17e48af05ccff0d4f9e.
X-Scheme: http.

# k8s-w1
root@k8s-w1:~# ngrep -tW byline -d $WPODVETH &#39;&#39; &#39;tcp port 80&#39;
Hostname: webpod-697b545f57-gkzn6
IP: 127.0.0.1
IP: ::1
IP: 172.20.1.230
IP: fe80::8c39:e6ff:fe38:4d79
RemoteAddr: 172.20.1.35:35976
GET / HTTP/1.1.
Host: nginx.webpod.local.
User-Agent: curl/8.5.0.
Accept: */*.
X-Forwarded-For: 192.168.10.200.
X-Forwarded-Host: nginx.webpod.local.
X-Forwarded-Port: 80.
X-Forwarded-Proto: http.
X-Forwarded-Scheme: http.
X-Real-Ip: 192.168.10.200.
X-Request-Id: 7adfa99642ab225d4dd4c4c22b3d8408.
X-Scheme: http.

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -n ingress-nginx -o wide
NAME                                        READY   STATUS    RESTARTS   AGE   IP            NODE     NOMINATED NODE   READINESS GATES
ingress-nginx-controller-67bbdf7d8d-f9bmk   1/1     Running   0          15m   172.20.1.35   k8s-w1   &lt;none&gt;           &lt;none&gt;</code></pre>
<h4 id="tproxy">TPROXY</h4>
<p>TPROXY를 타는지를 확인을 해보면, nginx class의 Ingress의 경우 TPROXY 커널 기능을 사용하지 않는 것을 확인할 수 있다.</p>
<pre><code class="language-bash"># nginx class Ingress 호출
(⎈|HomeLab:N/A) root@k8s-ctr:~# sudo iptables -t mangle -L CILIUM_PRE_mangle --line-numbers -n -v
Chain CILIUM_PRE_mangle (1 references)
...
4        0     0 TPROXY     6    --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x17380200 /* cilium: TPROXY to host kube-system/cilium-ingress/listener proxy */ TPROXY redirect 127.0.0.1:14359 mark 0x200/0xffffffff
6        0     0 TPROXY     6    --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x94480200 /* cilium: TPROXY to host default/cilium-ingress-default-webpod-ingress/listener proxy */ TPROXY redirect 127.0.0.1:18580 mark 0x200/0xffffffff

root@router:~# curl -H &quot;Host: nginx.webpod.local&quot; $LB3IP

# pkts 변화 없음.
Chain CILIUM_PRE_mangle (1 references)
...
4        0     0 TPROXY     6    --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x17380200 /* cilium: TPROXY to host kube-system/cilium-ingress/listener proxy */ TPROXY redirect 127.0.0.1:14359 mark 0x200/0xffffffff
6        0     0 TPROXY     6    --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x94480200 /* cilium: TPROXY to host default/cilium-ingress-default-webpod-ingress/listener proxy */ TPROXY redirect 127.0.0.1:18580 mark 0x200/0xffffffff</code></pre>
<pre><code class="language-bash"># cilium class Ingress 호출
root@router:~# curl -so /dev/null -w &quot;%{http_code}\n&quot; http://$LB2IP/

# cilium-ingress listener TPROXY pkts 1 증가
(⎈|HomeLab:N/A) root@k8s-ctr:~# (⎈|HomeLab:N/A) root@k8s-ctr:~# sudo iptables -t mangle -L CILIUM_PRE_mangle --line-numbers -n -v
Chain CILIUM_PRE_mangle (1 references)
...
4        1    60 TPROXY     6    --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x94480200 /* cilium: TPROXY to host default/cilium-ingress-default-webpod-ingress/listener proxy */ TPROXY redirect 127.0.0.1:18580 mark 0x200/0xffffffff
6        0     0 TPROXY     6    --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x17380200 /* cilium: TPROXY to host kube-system/cilium-ingress/listener proxy */ TPROXY redirect 127.0.0.1:14359 mark 0x200/0xffffffff</code></pre>
<h4 id="hubble-observe">Hubble Observe</h4>
<p>webpod에 대해 hubble observe로 모니터링을 해본다.</p>
<h5 id="nginx-class-ingress">nginx class Ingress</h5>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~#  kubectl exec -n kube-system -c cilium-agent -it ds/cilium -- cilium-dbg endpoint list | grep webpod
3644       Disabled           Disabled          59436      k8s:app=webpod                                                                        172.20.1.230   ready

# nginx class Ingress 호출
root@router:~# curl -H &quot;Host: nginx.webpod.local&quot; $LB3IP

(⎈|HomeLab:N/A) root@k8s-ctr:~# hubble observe -f  --protocol tcp --from-identity  59436
Aug 23 13:43:51.260: ingress-nginx/ingress-nginx-controller-67bbdf7d8d-f9bmk:34564 (ID:47827) &lt;- default/webpod-697b545f57-8qgpp:80 (ID:59436) to-endpoint FORWARDED (TCP Flags: SYN, ACK)
Aug 23 13:43:51.261: default/webpod-697b545f57-8qgpp:80 (ID:59436) &lt;&gt; ingress-nginx/ingress-nginx-controller-67bbdf7d8d-f9bmk (ID:47827) pre-xlate-rev TRACED (TCP)
Aug 23 13:43:51.261: default/webpod-697b545f57-8qgpp:80 (ID:59436) &lt;&gt; ingress-nginx/ingress-nginx-controller-67bbdf7d8d-f9bmk (ID:47827) pre-xlate-rev TRACED (TCP)
Aug 23 13:43:51.272: ingress-nginx/ingress-nginx-controller-67bbdf7d8d-f9bmk:34564 (ID:47827) &lt;- default/webpod-697b545f57-8qgpp:80 (ID:59436) to-endpoint FORWARDED (TCP Flags: ACK, PSH)
Aug 23 13:43:51.337: ingress-nginx/ingress-nginx-controller-67bbdf7d8d-f9bmk:34564 (ID:47827) &lt;- default/webpod-697b545f57-8qgpp:80 (ID:59436) to-network FORWARDED (TCP Flags: SYN, ACK)
Aug 23 13:43:51.344: ingress-nginx/ingress-nginx-controller-67bbdf7d8d-f9bmk:34564 (ID:47827) &lt;- default/webpod-697b545f57-8qgpp:80 (ID:59436) to-network FORWARDED (TCP Flags: ACK, PSH)</code></pre>
<ul>
<li>ingress-nginx-controller Pod(k8s-w1 위)에서 백엔드(webpod)로 TCP SYN 전달</li>
<li>default/webpod Pod(k8s-ctr 위)에서 ACK, PSH로 응답</li>
<li>ingress-nginx-controller Pod로 TO-NETWORK FORWARDED</li>
<li>Nginx가 처리한 후 응답 패킷이 클라이언트로 나가도록 eBPF가 라우팅</li>
</ul>
<p><strong>특징</strong></p>
<ul>
<li>Envoy/TPROXY 없음</li>
<li>TCP 흐름 : Nginx Pod → Backend Pod → Nginx Pod → 클라이언트</li>
<li>Nginx가 L7 인지, Host 기반 라우팅 수행</li>
<li>eBPF는 Pod→Pod와 Pod→Network 경로만 담당</li>
</ul>
<h5 id="cilium-class-ingress">cilium class Ingress</h5>
<pre><code class="language-bash"># cilium class Ingress 호출
root@router:~# curl -so /dev/null -w &quot;%{http_code}\n&quot; http://$LB2IP/

(⎈|HomeLab:N/A) root@k8s-ctr:~# hubble observe -f  --from-identity  59436
# k8s-w1위의 webpod 호출됨
Aug 23 14:29:24.934: 10.0.2.15:59674 (host) &lt;- default/webpod-697b545f57-gkzn6:80 (ID:59436) to-stack FORWARDED (TCP Flags: SYN, ACK)
Aug 23 14:29:24.935: 10.0.2.15:59674 (host) &lt;- default/webpod-697b545f57-gkzn6:80 (ID:59436) to-stack FORWARDED (TCP Flags: ACK, PSH)
Aug 23 14:29:24.935: 192.168.10.200:40780 (ingress) &lt;- default/webpod-697b545f57-gkzn6:80 (ID:59436) http-response FORWARDED (HTTP/1.1 200 1ms (GET http://192.168.10.212/))
Aug 23 14:29:39.941: 10.0.2.15:59674 (host) &lt;- default/webpod-697b545f57-gkzn6:80 (ID:59436) to-stack FORWARDED (TCP Flags: ACK)
Aug 23 14:29:55.301: 10.0.2.15:59674 (host) &lt;- default/webpod-697b545f57-gkzn6:80 (ID:59436) to-stack FORWARDED (TCP Flags: ACK)
Aug 23 14:30:10.661: 10.0.2.15:59674 (host) &lt;- default/webpod-697b545f57-gkzn6:80 (ID:59436) to-stack FORWARDED (TCP Flags: ACK)
Aug 23 14:30:24.937: 10.0.2.15:59674 (host) &lt;- default/webpod-697b545f57-gkzn6:80 (ID:59436) to-stack FORWARDED (TCP Flags: ACK, FIN)

# k8s-ctr위의 webpod 호출됨
Aug 23 14:30:28.342: 192.168.10.200:42700 (ingress) &lt;- default/webpod-697b545f57-8qgpp:80 (ID:59436) http-response FORWARDED (HTTP/1.1 200 4ms (GET http://192.168.10.212/))
Aug 23 14:30:28.416: 172.20.1.202:38801 (ingress) &lt;- default/webpod-697b545f57-8qgpp:80 (ID:59436) to-network FORWARDED (TCP Flags: SYN, ACK)
Aug 23 14:30:28.418: 172.20.1.202:38801 (ingress) &lt;- default/webpod-697b545f57-8qgpp:80 (ID:59436) to-network FORWARDED (TCP Flags: ACK, PSH)
Aug 23 14:30:43.614: 172.20.1.202:38801 (ingress) &lt;- default/webpod-697b545f57-8qgpp:80 (ID:59436) to-network FORWARDED (TCP Flags: ACK)
Aug 23 14:30:58.975: 172.20.1.202:38801 (ingress) &lt;- default/webpod-697b545f57-8qgpp:80 (ID:59436) to-network FORWARDED (TCP Flags: ACK)
Aug 23 14:31:14.334: 172.20.1.202:38801 (ingress) &lt;- default/webpod-697b545f57-8qgpp:80 (ID:59436) to-network FORWARDED (TCP Flags: ACK)
Aug 23 14:31:28.420: 172.20.1.202:38801 (ingress) &lt;- default/webpod-697b545f57-8qgpp:80 (ID:59436) to-network FORWARDED (TCP Flags: ACK, FIN)</code></pre>
<p>1) 외부 요청</p>
<ul>
<li>외부 Router → Ingress LB IP 호출</li>
<li>L2 Announcement: 트래픽이 리더 노드인 k8s-w1로 전달</li>
<li>eBPF LB + TPROXY: 패킷이 Envoy 소켓(TPROXY)으로 리다이렉트</li>
<li>Envoy가 L7 요청을 파싱하고, 적절한 backend Pod 선택</li>
</ul>
<p>2) 요청 패킷 전달 (Envoy -&gt; webpod)
Envoy가 선택한 Pod로 요청 전달
2-1) Backend Pod가 k8s-w1(리더 노드)에 있는 경우</p>
<ul>
<li>Envoy → 동일 노드 webpod 로 전달 </li>
<li>요청 패킷 경로: router → k8s-w1 Node → webpod(k8s-w1)</li>
</ul>
<p>2-2) Backend Pod가 k8s-ctr(리모트 노드)에 있는 경우</p>
<ul>
<li>Envoy가 요청 패킷을 remote Pod IP로 전달</li>
<li>요청 패킷 경로: router → Ingress EXT-IP → k8s-ctr webpod</li>
</ul>
<p>3) 응답 패킷 전달 (WebPod → Client)
3-1) Backend Pod가 k8s-w1(리더 노드)에 있는 경우</p>
<ul>
<li>응답 경로: webpod → k8s-w1 host stack (Envoy, to-stack) → router </li>
<li>TPROXY가 Host Stack으로 redirect, Envoy가 HTTP 응답 처리</li>
</ul>
<p>3-2) Backend Pod가 k8s-ctr(리모트 노드)에 있는 경우</p>
<ul>
<li>응답 경로: webpod(k8s-ctr) → k8s-ctr host stack → k8s-w1 Envoy (to-network) → client</li>
<li>k8s-ctr에서 다시 k8s-w1로 응답이 돌아오는 과정에서 to-network message, Envoy가 HTTP 응답 처리</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Cilium - BGP ControlPlane]]></title>
            <link>https://velog.io/@_gyullbb/BGP-ControlPlane</link>
            <guid>https://velog.io/@_gyullbb/BGP-ControlPlane</guid>
            <pubDate>Sat, 16 Aug 2025 17:21:48 GMT</pubDate>
            <description><![CDATA[<h1 id="bgp-controlplane">BGP ControlPlane</h1>
<p>앞선 글에서 살펴본 것 처럼, 서로 다른 네트워크 대역 간 통신을 위해 라우터를 거쳐야 하는 환경에서는 노드가 많아질수록 수동 라우트 설정이 비효율적이라는 문제가 발생한다. 
이러한 한계를 해결하기 위한 대표적인 방법에는 Overlay 네트워크와 BGP를 통한 동적 라우팅이 있는데, 이번 글에서는 BGP(Border Gateway Protocol)를 활용한 주소 알리기 방식에 대해 살펴본다.</p>
<p>실습 환경은 다음과 같다.
<img src="https://velog.velcdn.com/images/_gyullbb/post/38976db0-51e3-4f6f-8a21-88c11e2c41b3/image.png" alt=""></p>
<p>Kubernetes 클러스터 노드</p>
<ul>
<li>k8s-ctr(IP: 192.168.10.100, podCIDR: 172.20.0.0/24)</li>
<li>k8s-w1(IP: 192.168.10.101, podCIDR: 172.20.1.0/24)</li>
<li>k8s-w0(IP: 192.168.20.100, podCIDR: 172.20.2.0/24)</li>
</ul>
<p>Router 노드</p>
<ul>
<li>router(IP: 192.168.10.200)</li>
</ul>
<p>autoDirectNodeRoutes = false</p>
<ul>
<li>노드 별 PodCIDR 라우팅이 없음.</li>
</ul>
<h2 id="기본-환경-통신-테스트">기본 환경 통신 테스트</h2>
<p>현재 cilium의 설정이 autoDirectNodeRoutes=false로 podCIDR에 대해 Node 라우팅이 걸려있지 않기 때문에 노드 내의 파드들 끼리만 통신이 가능하다.</p>
<pre><code class="language-bash"># 노드 별 PodCIDR 라우팅이 없음.
(⎈|HomeLab:N/A) root@k8s-ctr:~# ip -c route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100
10.0.2.2 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.0.2.3 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
172.20.0.0/24 via 172.20.0.167 dev cilium_host proto kernel src 172.20.0.167
172.20.0.167 dev cilium_host proto kernel scope link
192.168.10.0/24 dev eth1 proto kernel scope link src 192.168.10.100
192.168.20.0/24 via 192.168.10.200 dev eth1 proto static

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get endpointslices -l app=webpod
NAME           ADDRESSTYPE   PORTS   ENDPOINTS                             AGE
webpod-2zb7b   IPv4          80      172.20.0.28,172.20.1.76,172.20.2.68   21s

# 노드 내에 있는 pod와만 통신이 가능함.
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it curl-pod -- sh -c &#39;while true; do curl -s --connect-timeout 1 webpod | grep Hostname; echo &quot;---&quot; ; sleep 1; done&#39;
---
---
---
---
---
---
Hostname: webpod-697b545f57-qmxbk
---
---
Hostname: webpod-697b545f57-qmxbk
---
^Ccommand terminated with exit code 130</code></pre>
<h2 id="cilium-bgp-control-plane">Cilium BGP Control Plane</h2>
<p>Cilium은 BGP Control Plane 기능을 통해 클러스터 내 노드들이 Pod CIDR, Service IP와 같은 네트워크 정보를 외부 라우터에 동적으로 광고하도록 지원한다. 이를 위해 여러 CRD를 제공하며, 각각의 역할은 다음과 같다.</p>
<ol>
<li>CiliumBGPClusterConfig</li>
</ol>
<p>클러스터 차원에서 적용되는 BGP 인스턴스 및 피어(peer) 설정을 정의한다.
이를 통해 특정 BGP 구성을 여러 노드에 일괄적으로 적용할 수 있으며, 노드마다 동일한 피어링 정책을 손쉽게 유지할 수 있다.</p>
<ol start="2">
<li>CiliumBGPPeerConfig</li>
</ol>
<p>여러 피어에 공통적으로 적용할 수 있는 BGP 피어링 설정 집합을 정의한다.
예를 들어 hold time, keepalive 주기, eBGP multihop 등 반복적으로 쓰이는 설정을 모듈화하여 재사용할 수 있다.</p>
<ol start="3">
<li>CiliumBGPAdvertisement</li>
</ol>
<p>어떤 요소를 BGP 라우팅 테이블에 주입할지를 정의한다.
Pod CIDR, Service CIDR, 혹은 LoadBalancer IP 범위와 같은 네트워크 대역을 외부 라우터에 알리도록 설정할 수 있다.</p>
<ol start="4">
<li>CiliumBGPNodeConfigOverride</li>
</ol>
<p>특정 노드에 한정하여 적용하는 세부적인 BGP 설정을 정의한다.</p>
<h2 id="frr">FRR</h2>
<p>Cilium BGP Control Plane을 통해 쿠버네티스 노드가 Pod CIDR이나 Service IP 대역을 외부로 광고하려면, 이를 받아줄 BGP 피어가 필요하다. 일반적인 데이터센터나 가상화 환경에서는 L3 라우터가 그 역할을 담당하며, 라우터는 클러스터 노드로부터 BGP 업데이트를 받아 외부 네트워크로의 경로를 전파한다.</p>
<p><strong>FRR(FRRouting)</strong>은 리눅스에서 동작하는 라우팅 소프트웨어 오픈소스로, 
BGP, OSPF, IS-IS, RIP 등 다양한 라우팅 프로토콜을 지원하며, 커널 라우팅 테이블과 연동되어 동적으로 학습한 경로를 시스템 전반에 반영할 수 있다.</p>
<p>FRR은 다음과 같은 기능을 수행한다.</p>
<h2 id="실습">실습</h2>
<h3 id="1-frr-설정">(1) frr 설정</h3>
<p>우선 bgp 광고를 위해 router노드에 frr설정을 주입한다.</p>
<pre><code class="language-bash">root@router:~# cat /etc/frr/frr.conf
frr version 8.4.4
frr defaults traditional
hostname router
log syslog informational
no ipv6 forwarding
service integrated-vtysh-config
!
router bgp 65000
 bgp router-id 192.168.10.200
 no bgp ebgp-requires-policy
 bgp graceful-restart
 bgp bestpath as-path multipath-relax
 neighbor CILIUM peer-group
 neighbor CILIUM remote-as external
 neighbor 192.168.10.100 peer-group CILIUM
 neighbor 192.168.10.101 peer-group CILIUM
 neighbor 192.168.20.100 peer-group CILIUM
 !
 address-family ipv4 unicast
  network 10.10.1.0/24
  maximum-paths 4
 exit-address-family
exit
!</code></pre>
<h3 id="2-cilium-bgp-control-plane-설정">(2) Cilium BGP Control Plane 설정</h3>
<p>Cilium에서 노드를 bgp대상으로 인지하고, podCIDR을 외부에 광고할 수 있도록 CR을 배포한다.</p>
<pre><code class="language-bash"># Cilium에서 bgp대상을 인지하도록 Node에 라벨 설정
kubectl label nodes k8s-ctr k8s-w0 k8s-w1 enable-bgp=true

# Config Cilium BGP
# --------------------------------------------------------
# CiliumBGPAdvertisement
# - 어떤 네트워크 프리픽스를 BGP를 통해 광고할지를 정의한다.
# - 여기서는 각 노드에 할당된 PodCIDR을 외부 라우터에 알리도록 설정한다.
# --------------------------------------------------------
apiVersion: cilium.io/v2
kind: CiliumBGPAdvertisement
metadata:
  name: bgp-advertisements
  labels:
    advertise: bgp              # 이후 PeerConfig에서 matchLabels로 참조 가능
spec:
  advertisements:
    - advertisementType: &quot;PodCIDR&quot;   # 각 노드의 PodCIDR을 광고하도록 지정
---
# --------------------------------------------------------
# CiliumBGPPeerConfig
# - BGP 피어링 시 사용되는 공통 설정을 정의한다.
# --------------------------------------------------------
apiVersion: cilium.io/v2
kind: CiliumBGPPeerConfig
metadata:
  name: cilium-peer
spec:
  timers:
    holdTimeSeconds: 9          # 피어와 세션이 끊겼다고 간주하기까지의 시간
    keepAliveTimeSeconds: 3     # 피어에 keepalive 메시지를 보내는 주기
  ebgpMultihop: 2               # eBGP 피어링 시 허용할 홉 수 (기본은 1)
  gracefulRestart:
    enabled: true               # Graceful Restart 활성화
    restartTimeSeconds: 15      # 세션 복구 시 재학습을 기다리는 시간
  families:
    - afi: ipv4                
      safi: unicast             
      advertisements:
        matchLabels:            # 어떤 광고 리소스를 적용할지 라벨 기반으로 매칭
          advertise: &quot;bgp&quot;      # 위의 CiliumBGPAdvertisement와 연결됨
---
# --------------------------------------------------------
# CiliumBGPClusterConfig
# - 클러스터 단위의 BGP 인스턴스를 정의한다.
# - 특정 노드 셀렉터를 통해 어느 노드에 적용할지 지정할 수 있다.
# --------------------------------------------------------
apiVersion: cilium.io/v2
kind: CiliumBGPClusterConfig
metadata:
  name: cilium-bgp
spec:
  nodeSelector:
    matchLabels:
      &quot;enable-bgp&quot;: &quot;true&quot;      # enable-bgp=true 라벨이 붙은 노드에만 적용
  bgpInstances:
  - name: &quot;instance-65001&quot;      # BGP 인스턴스 이름 
    localASN: 65001             # 로컬 노드(쿠버네티스 노드)의 ASN 번호
    peers:
    - name: &quot;tor-switch&quot;        # 피어 이름 
      peerASN: 65000            # 라우터(피어)의 ASN 번호
      peerAddress: 192.168.10.200  # 라우터의 IP 주소
      peerConfigRef:
        name: &quot;cilium-peer&quot;     # 위에서 정의한 CiliumBGPPeerConfig 참조</code></pre>
<h3 id="3설정-이후-통신-확인">(3)설정 이후 통신 확인</h3>
<p>Router 노드에서 확인해보면, Cilium이 각 노드의 PodCIDR을 BGP를 통해 광고하였고, 라우터는 이를 수신하여 라우팅 테이블에 반영한 것을 확인할 수 있다.</p>
<ol>
<li>라우팅 테이블 확인
```bash
root@router:~# ip -c route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100</li>
<li>0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100</li>
<li>10.1.0/24 dev loop1 proto kernel scope link src 10.10.1.200</li>
<li>10.2.0/24 dev loop2 proto kernel scope link src 10.10.2.200</li>
<li>20.0.0/24 nhid 32 via 192.168.10.100 dev eth1 proto bgp metric 20</li>
<li>20.1.0/24 nhid 30 via 192.168.10.101 dev eth1 proto bgp metric 20</li>
<li>20.2.0/24 nhid 31 via 192.168.20.100 dev eth2 proto bgp metric 20</li>
<li>168.10.0/24 dev eth1 proto kernel scope link src 192.168.10.200</li>
<li>168.20.0/24 dev eth2 proto kernel scope link src 192.168.20.200<pre><code></code></pre></li>
</ol>
<p>172.20.0.0/24, 172.20.1.0/24, 172.20.2.0/24 대역이 BGP(proto bgp) 경로로 추가된 것을 확인할 수 있다.
이는 각각 컨트롤 플레인 노드(k8s-ctr), 워커 노드(k8s-w0, k8s-w1)의 PodCIDR이다.</p>
<ol start="2">
<li>BGP 세션 상태 확인<pre><code class="language-bash">root@router:~# vtysh -c &#39;show ip bgp summary&#39;
</code></pre>
</li>
</ol>
<p>Neighbor        V   AS     MsgRcvd   MsgSent   Up/Down State/PfxRcd
192.168.10.100  4  65001        76        79  00:03:38            1
192.168.10.101  4  65001        76        79  00:03:38            1
192.168.20.100  4  65001        76        79  00:03:38            1</p>
<pre><code>
총 3개의 노드(65001 ASN) 와 BGP 세션이 맺어진 것을 확인할 수 있다.
State/PfxRcd 값이 1인 것은 각 노드로부터 **1개의 프리픽스(PodCIDR)**를 수신했음을 의미한다.

3. BGP 라우팅 테이블 확인
```bash
root@router:~# vtysh -c &#39;show ip bgp&#39;
*&gt; 10.10.1.0/24     0.0.0.0                  0         32768 i
*&gt; 172.20.0.0/24    192.168.10.100                         0 65001 i
*&gt; 172.20.1.0/24    192.168.10.101                         0 65001 i
*&gt; 172.20.2.0/24    192.168.20.100                         0 65001 i</code></pre><p>172.20.0.0/24, 172.20.1.0/24, 172.20.2.0/24 프리픽스가 노드별 IP(192.168.x.x)를 NextHop으로 하여 등록되어 있다. 즉, 라우터가 Cilium 노드로부터 PodCIDR을 정상적으로 학습한 상태이다.</p>
<ol start="4">
<li>Cilium 측 광고 상태 확인<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# cilium bgp routes
Node      VRouter   Prefix          NextHop   Age      Attrs
k8s-ctr   65001     172.20.0.0/24   0.0.0.0   5m49s    [{Origin: i} {Nexthop: 0.0.0.0}]
k8s-w0    65001     172.20.2.0/24   0.0.0.0   15m48s   [{Origin: i} {Nexthop: 0.0.0.0}]
k8s-w1    65001     172.20.1.0/24   0.0.0.0   15m48s   [{Origin: i} {Nexthop: 0.0.0.0}]</code></pre>
각 노드가 자신의 PodCIDR을 BGP 경로로 광고(advertise) 하고 있음을 보여주며, 이는 라우터에서 확인한 결과와 일치한다.</li>
</ol>
<p>하지만 여전히 curl pod에서 web pod로 통신을 시도해보면 동일 노드 내의 pod와만 통신이 가능하다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it curl-pod -- sh -c &#39;while true; do curl -s --connect-timeout 1 webpod | grep Hostname; echo &quot;---&quot; ; sleep 1; done&#39;
---
---
---
---
Hostname: webpod-697b545f57-phntn
---
Hostname: webpod-697b545f57-phntn
---
Hostname: webpod-697b545f57-phntn
---
---</code></pre>
<p>이는 Cilium이 클러스터 내에서 podCIDR 대역에 대해 노드 대역을 자동으로 라우팅 테이블에 추가하지 않기 때문이다.</p>
<h4 id="cilium이-물리-네트워크-경로를-라우팅-테이블에-추가하지-않는-이유">Cilium이 물리 네트워크 경로를 라우팅 테이블에 추가하지 않는 이유</h4>
<p>Cilium은 기본적으로 모든 노드가 서로 L3 reachable 하다는 가정을 깔고 있다.
그렇기 때문에 BGP 광고는 PodCIDR을 외부(라우터)로 알리는 역할만 하고, 물리 네트워크 경로 자체를 해결해주지는 않는다. 
노드 IP 대역 자체가 서로 통신 가능한 상태여야 Cilium이 광고한 PodCIDR도 의미가 생긴다.</p>
<p>따라서 Cilium BGP 광고를 활용할 때에는 물리 라우터가 각 노드가 속한 서브넷을 상호 라우팅할 수 있도록 설정이 되어야 한다.</p>
<p>만약 물리 네트워크 레벨에서 라우팅을 보장하기 어렵다면, VXLAN, Geneve 같은 오버레이 터널링 방식을 택해야 한다.
이 경우 노드 간 직접 라우팅이 불가능해도 Pod 트래픽을 캡슐화하여 전달할 수 있다.</p>
<h4 id="cilium-bgp-통신-정리">Cilium BGP 통신 정리</h4>
<ul>
<li>Direct Routing 모드: 노드 간 PodCIDR 통신이 VXLAN/Geneve 터널 없이, L2/L3 직접 경로를 통해 전달됨.</li>
<li>autoDirectNodeRoutes=false: Cilium Agent가 커널에 PodCIDR route를 자동 추가하지 않음.</li>
</ul>
<ol>
<li>BGP설정을 Reconcile하며 CiliumNode의 PodCIDR 정보 실시간 반영 및 광고<pre><code class="language-go">//pkg/bgpv1/manager/manager.go
func (m *BGPRouterManager) reconcileBGPConfig(ctx context.Context,
 sc *instance.ServerWithConfig,
 newc *v2alpha1.CiliumBGPVirtualRouter,
 ciliumNode *v2.CiliumNode) error {
...
 for _, r := range m.Reconcilers {
 //BGP 설정을 Reconcile하며 CiliumNode의 podCIDR 정보를 실시간 저장
     if err := r.Reconcile(ctx, reconciler.ReconcileParams{
         CurrentServer: sc,
         DesiredConfig: newc,
         CiliumNode:    ciliumNode,
     }); err != nil {
         return fmt.Errorf(&quot;reconciliation of virtual router with local ASN %v failed: %w&quot;, newc.LocalASN, err)
     }
 }
...
}
</code></pre>
</li>
</ol>
<p>//pkg/bgpv1/manager/reconciler/pod_cidr.go
func (r *ExportPodCIDRReconciler) Reconcile(ctx context.Context, p ReconcileParams) error {
...
  advertisements, err := exportAdvertisementsReconciler(&amp;advertisementsReconcilerParams{
        logger:    r.Logger,
        ctx:       ctx,
        name:      &quot;pod CIDR&quot;,
        component: &quot;exportPodCIDRReconciler&quot;,
        enabled:   *p.DesiredConfig.ExportPodCIDR,</p>
<pre><code>    sc:   p.CurrentServer,
    newc: p.DesiredConfig,

    currentAdvertisements: r.getMetadata(p.CurrentServer),
    toAdvertise:           toAdvertise,
})</code></pre><p>...
  // 광고해야 할 CiliumNode의 podCIDR 정보를 실시간 저장
    r.storeMetadata(p.CurrentServer, advertisements)
    return nil
}</p>
<p>//cf) 별도로 NextHop이 지정되어있지 않다면 0.0.0.0 반환
// NextHopFromPathAttributes returns the next hop address determined by the list of provided BGP path attributes.
func NextHopFromPathAttributes(pathAttributes []bgppacket.PathAttributeInterface) string {
    for _, a := range pathAttributes {
        switch attr := a.(type) {
        case *bgppacket.PathAttributeNextHop:
            return attr.Value.String()
        case *bgppacket.PathAttributeMpReachNLRI:
            return attr.Nexthop.String()
        }
    }
    return &quot;0.0.0.0&quot;
}</p>
<pre><code>
2. cilium bgp map 상태 확인
```bash
(⎈|HomeLab:N/A) root@k8s-ctr:~# cilium bgp peers
Node      Local AS   Peer AS   Peer Address     Session State   Uptime      Family         Received   Advertised
k8s-ctr   65001      65000     192.168.10.200   established     37h44m51s   ipv4/unicast   5          3
k8s-w0    65001      65000     192.168.10.200   established     37h44m48s   ipv4/unicast   5          3
k8s-w1    65001      65000     192.168.10.200   established     37h44m48s   ipv4/unicast   5          3

(⎈|HomeLab:N/A) root@k8s-ctr:~# cilium bgp routes
(Defaulting to `available ipv4 unicast` routes, please see help for more options)

Node      VRouter   Prefix          NextHop   Age         Attrs
k8s-ctr   65001     172.16.1.1/32   0.0.0.0   37h23m15s   [{Origin: i} {Nexthop: 0.0.0.0}]
          65001     172.20.0.0/24   0.0.0.0   38h3m45s    [{Origin: i} {Nexthop: 0.0.0.0}]
k8s-w0    65001     172.16.1.1/32   0.0.0.0   37h23m14s   [{Origin: i} {Nexthop: 0.0.0.0}]
          65001     172.20.2.0/24   0.0.0.0   38h13m42s   [{Origin: i} {Nexthop: 0.0.0.0}]
k8s-w1    65001     172.16.1.1/32   0.0.0.0   37h23m13s   [{Origin: i} {Nexthop: 0.0.0.0}]
          65001     172.20.1.0/24   0.0.0.0   38h13m42s   [{Origin: i} {Nexthop: 0.0.0.0}]</code></pre><ol start="3">
<li>k8s-ctr 즉, 172.20.0.0/24에서 패킷을 보낸다고 가정을 했을 때, cilium은 bgp routes에 저장된 Prefix, Nexthop을 확인한다.
이 때, NextHop은 0.0.0.0으로 지정되어 있는데, k8s-ctr 노드의 라우팅 테이블을 확인하면 eth0으로 빠져나가게 된다. </li>
</ol>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# ip -c route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100</code></pre>
<p>해당 실습에서 실제 통신이 되게 위해서는 eth1로 빠져나가 podCIDR BGP 라우트 정보가 있는 route노드로 향해야 하기 때문에 강제적으로 podCIDR대역에 대해 route 노드로 향하는 물리 라우트를 추가해주어야 한다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# ip route add 172.20.0.0/16 via 192.168.10.200
root@k8s-w0:~# ip route add 172.20.0.0/16 via 192.168.20.200
root@k8s-w1:~# ip route add 172.20.0.0/16 via 192.168.10.200</code></pre>
<p>라우트를 추가한 이후 정상적으로 통신이 되는 것을 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it curl-pod -- sh -c &#39;while true; do curl -s --connect-timeout 1 webpod | grep Hostname; echo &quot;---&quot; ; sleep 1; done&#39;
Hostname: webpod-697b545f57-phntn
---
Hostname: webpod-697b545f57-hhjqw
---
Hostname: webpod-697b545f57-8d2xs
---
Hostname: webpod-697b545f57-8d2xs
---
Hostname: webpod-697b545f57-phntn
---
Hostname: webpod-697b545f57-hhjqw
---</code></pre>
<p>결론적으로는 아래와 같이 정리할 수 있다.</p>
<ul>
<li>autoDirectNodeRoutes=false 환경에서는, PodCIDR 광고만으로는 실제 노드 간 통신이 보장되지 않음.</li>
<li>라우팅 테이블을 통해 PodCIDR 대역 → 실제 물리 노드 경로를 명시적으로 지정해야 함.</li>
</ul>
<h1 id="service-ip-advertisement">Service IP advertisement</h1>
<p>PodCIDR을 BGP로 광고하듯, Kubernetes 서비스의 IP도 BGP를 통해 외부 라우터에 광고할 수 있다.</p>
<ul>
<li>External IP: 외부에서 접근 가능한 서비스 IP</li>
<li>Cluster IP: 클러스터 내부에서만 사용하는 서비스 IP</li>
</ul>
<p>둘 다 필요에 따라 BGP를 통해 광고 가능하며, 이를 통해 외부 네트워크에서도 서비스 접근이 가능해진다.</p>
<p>서비스 IP를 광고하기 전에 알아둬야 할 개념으로 Traffic Policy가 있는데, Traffic Policy는 서비스 트래픽을 어떻게 분산할지 결정하는 설정이다.</p>
<p><strong>External Traffic Policy</strong></p>
<ul>
<li>Cluster
외부에서 들어오는 트래픽을 클러스터 전체의 서비스 Pod로 분산한다.</li>
<li>Local
외부 트래픽은 해당 노드의 서비스 Pod로만 전달된다.</li>
</ul>
<p><strong>Internal Traffic Policy</strong></p>
<ul>
<li>Cluster
클러스터 내부 Pod → 서비스 트래픽을 전체 Pod로 분산한다.</li>
<li>Local
내부 트래픽은 같은 노드의 Pod로만 전달된다.</li>
</ul>
<p>서비스 IP를 광고할 때는 External / Internal Traffic Policy에 따라 어떤 Pod로 트래픽이 전달될지가 달라지게 된다. 각 상황에 대해 알아본다.</p>
<h2 id="external-ip">External IP</h2>
<h3 id="external-ip--external-traffic-policy-cluster">External IP + External Traffic Policy (Cluster)</h3>
<p>Service를 LoadBalancer타입으로 설정하고, External IP 설정을 한다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc
NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1       &lt;none&gt;        443/TCP   21h
webpod       ClusterIP   10.96.252.129   &lt;none&gt;        80/TCP    8h

(⎈|HomeLab:N/A) root@k8s-ctr:~# cat &lt;&lt; EOF | kubectl apply -f -
apiVersion: &quot;cilium.io/v2&quot;
kind: CiliumLoadBalancerIPPool
metadata:
  name: &quot;cilium-pool&quot;
spec:
  allowFirstLastIPs: &quot;No&quot;
  blocks:
  - cidr: &quot;172.16.1.0/24&quot;
EOF
ciliumloadbalancerippool.cilium.io/cilium-pool created

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ippool
NAME          DISABLED   CONFLICTING   IPS AVAILABLE   AGE
cilium-pool   false      False         254             7s

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl patch svc webpod -p &#39;{&quot;spec&quot;: {&quot;type&quot;: &quot;LoadBalancer&quot;}}&#39;
service/webpod patched

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc
NAME         TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
kubernetes   ClusterIP      10.96.0.1       &lt;none&gt;        443/TCP        21h
webpod       LoadBalancer   10.96.252.129   172.16.1.1    80:31726/TCP   8h

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ippool
NAME          DISABLED   CONFLICTING   IPS AVAILABLE   AGE
cilium-pool   false      False         253             16s</code></pre>
<p>LB IP를 BGP로 광고하기 위해 adviertisementType를 service, LoadBalancerIP로 지정하여 CiliumBGPAdvertisements CR을 배포한다.</p>
<pre><code class="language-bash">cat &lt;&lt; EOF | kubectl apply -f -
apiVersion: cilium.io/v2
kind: CiliumBGPAdvertisement
metadata:
  name: bgp-advertisements-lb-exip-webpod
  labels:
    advertise: bgp
spec:
  advertisements:
    - advertisementType: &quot;Service&quot;
      service:
        addresses:
          - LoadBalancerIP
      selector:             
        matchExpressions:
          - { key: app, operator: In, values: [ webpod ] }
EOF

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl describe svc webpod | grep &#39;Traffic Policy&#39;
External Traffic Policy:  Cluster
Internal Traffic Policy:  Cluster

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg bgp route-policies
VRouter   Policy Name                                             Type     Match Peers         Match Families   Match Prefixes (Min..Max Len)   RIB Action   Path Actions
65001     allow-local                                             import                                                                        accept
65001     tor-switch-ipv4-PodCIDR                                 export   192.168.10.200/32                    172.20.1.0/24 (24..24)          accept
65001     tor-switch-ipv4-Service-webpod-default-LoadBalancerIP   export   192.168.10.200/32                    172.16.1.1/32 (32..32)          accept

root@router:~# sudo vtysh -c &#39;show ip bgp 172.16.1.1/32&#39;
BGP routing table entry for 172.16.1.1/32, version 5
Paths: (3 available, best #1, table default)
  Advertised to non peer-group peers:
  192.168.10.100 192.168.10.101 192.168.20.100
  65001
    192.168.10.100 from 192.168.10.100 (192.168.10.100)
      Origin IGP, valid, external, multipath, best (Router ID)
      Last update: Fri Aug 15 06:41:04 2025
  65001
    192.168.10.101 from 192.168.10.101 (192.168.10.101)
      Origin IGP, valid, external, multipath
      Last update: Fri Aug 15 06:41:04 2025
  65001
    192.168.20.100 from 192.168.20.100 (192.168.20.100)
      Origin IGP, valid, external, multipath
      Last update: Fri Aug 15 06:41:04 2025</code></pre>
<p>LB IP를 통해 통신 테스트를 수행해본다. 통신 방식을 명확하게 확인하기 위해 webpod replicas수를 하나 줄인 후 외부 router에서 LB IP를 호출해본다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl scale deployment webpod --replicas 2
deployment.apps/webpod scaled
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -o wide
NAME                      READY   STATUS    RESTARTS      AGE   IP             NODE      NOMINATED NODE   READINESS GATES
curl-pod                  1/1     Running   1 (39h ago)   47h   172.20.0.247   k8s-ctr   &lt;none&gt;           &lt;none&gt;
webpod-697b545f57-8d2xs   1/1     Running   0             47h   172.20.2.68    k8s-w0    &lt;none&gt;           &lt;none&gt;
webpod-697b545f57-hhjqw   1/1     Running   0             47h   172.20.1.76    k8s-w1    &lt;none&gt;           &lt;none&gt;

(⎈|HomeLab:N/A) root@k8s-ctr:~# tcpdump -i eth1 -A -s 0 -nn &#39;tcp port 80&#39;
root@k8s-w0:~# tcpdump -i eth1 -A -s 0 -nn &#39;tcp port 80&#39;
root@k8s-w1:~# tcpdump -i eth1 -A -s 0 -nn &#39;tcp port 80&#39;

root@router:~# LBIP=172.16.1.1
root@router:~# curl -s $LBIP</code></pre>
<p>호출 시 Pod는 k8s-w0, k8s-w1에만 떠있는 상태에서, 일부 패킷은 k8s-w0와, k8s-w1으로 동시에 패킷을 받는 경우가 있음을 알 수 있다.
이는 External Traffic Policy가 Cluster로 설정되었기 때문이다.</p>
<h4 id="동작-방식">동작 방식</h4>
<p>1) External Traffic Policy = Cluster일 경우, nodeSelector 조건에 맞는 모든 노드는 LB의 ExternalIP를 광고한다.</p>
<pre><code class="language-go">//pkg/bgpv1/manager/reconciler/service.go
func (r *ServiceReconciler) fullReconciliation(ctx context.Context, p ReconcileParams, pathRefs pathReferencesMap) error {
...
  //해당 노드에서 광고할 수 있는 서비스 endpoint가 존재하는 서비스 즉, 해당 노드에서 External Policy Type = Local로 통신이 가능한 서비스를 구분한다.
    ls, err := r.populateLocalServices(p.CiliumNode.Name)
    if err != nil {
        return err
    }
    for _, svc := range toReconcile {
        if err := r.reconcileService(ctx, p.CurrentServer, p.DesiredConfig, svc, ls, pathRefs); err != nil {
            return fmt.Errorf(&quot;failed to reconcile service %s/%s: %w&quot;, svc.Namespace, svc.Name, err)
        }
    }
...
    return nil
}

//Service를 Reconcile하는 함수
func (r *ServiceReconciler) reconcileService(ctx context.Context, sc *instance.ServerWithConfig, newc *v2alpha1api.CiliumBGPVirtualRouter, svc *slim_corev1.Service, ls localServices, pathRefs pathReferencesMap) error {
  //주어진 서비스에서 route되어야 하는 목록을 반환한다.
    desiredRoutes, err := r.svcDesiredRoutes(newc, svc, ls)
...
    return r.reconcileServiceRoutes(ctx, sc, svc, desiredRoutes, pathRefs)
}

//Service 광고 방식에 따라 최종 광고해야하는 route 목록을 반환한다.
func (r *ServiceReconciler) svcDesiredRoutes(newc *v2alpha1api.CiliumBGPVirtualRouter, svc *slim_corev1.Service, ls localServices) ([]netip.Prefix, error) {
...
    var desiredRoutes []netip.Prefix
    for _, svcAdv := range newc.ServiceAdvertisements {
        switch svcAdv {
        case v2alpha1api.BGPLoadBalancerIPAddr:
            desiredRoutes = append(desiredRoutes, r.lbSvcDesiredRoutes(svc, ls)...)
        case v2alpha1api.BGPClusterIPAddr:
            desiredRoutes = append(desiredRoutes, r.clusterIPDesiredRoutes(svc, ls)...)
    // 해당 케이스를 타게 된다.
        case v2alpha1api.BGPExternalIPAddr:
            desiredRoutes = append(desiredRoutes, r.externalIPDesiredRoutes(svc, ls)...)
        }
    }
}</code></pre>
<p>아래 externalIPDesiredRoutes로 인해 각 Node의 Cilium-agent에서 LB의 ExternalIP를 광고한다.</p>
<pre><code class="language-go">func (r *ServiceReconciler) externalIPDesiredRoutes(svc *slim_corev1.Service, ls localServices) []netip.Prefix {
    var desiredRoutes []netip.Prefix
...
    for _, extIP := range svc.Spec.ExternalIPs {
        if extIP == &quot;&quot; {
            continue
        }
        addr, err := netip.ParseAddr(extIP)
        if err != nil {
            continue
        }
        desiredRoutes = append(desiredRoutes, netip.PrefixFrom(addr, addr.BitLen()))
    }
    return desiredRoutes
}</code></pre>
<p>2) 외부 라우터가 광고된 External IP를 보고, 패킷을 노드로 전달.
이 때, 라우터는 Pod가 어느 노드에 있는지 모르는 상태이며, External IP의 BGP 광고를 보고 트래픽을 전달하는 상황.</p>
<p>3) Cilium LB가 패킷을 받음.</p>
<p>4) Cilium agent의 Service List의 Backend 인지</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg service list
ID   Frontend               Service Type   Backend
...
16   172.16.1.1:80/TCP      LoadBalancer   1 =&gt; 172.20.1.76:80/TCP (active)
                                           2 =&gt; 172.20.2.68:80/TCP (active)

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg bpf lb list | grep 172.16.1.1
172.16.1.1:80/TCP (1)          172.20.1.76:80/TCP (16) (1)
172.16.1.1:80/TCP (2)          172.20.2.68:80/TCP (16) (2)
172.16.1.1:80/TCP (0)          0.0.0.0:0 (16) (0) [LoadBalancer]                                          </code></pre>
<p>5) bpf ipcache map을 기반으로 Pod가 떠있는 노드로 패킷 전달</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg bpf ipcache list
IP PREFIX/ADDRESS   IDENTITY
172.20.1.76/32      identity=28377 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
172.20.2.68/32      identity=28377 encryptkey=0 tunnelendpoint=192.168.20.100 flags=hastunnel
...

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip route get 172.20.1.76
172.20.1.76 via 192.168.10.200 dev eth1 src 192.168.10.100 uid 0
    cache

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip route get 172.20.2.68
172.20.2.68 via 192.168.10.200 dev eth1 src 192.168.10.100 uid 0
    cache</code></pre>
<h3 id="external-ip--external-traffic-policy-local">External IP + External Traffic Policy (Local)</h3>
<p>이번에는 서비스의 External Traffic Policy를 Local로 변경한 후 통신을 확인해본다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl patch service webpod -p &#39;{&quot;spec&quot;:{&quot;externalTrafficPolicy&quot;:&quot;Local&quot;}}&#39;
service/webpod patched</code></pre>
<p>External Traffic Policy를 local로 바꾸면 Router 노드에서의 bgp 경로도 Pod가 떠있는 노드만으로 변경됨을 알 수 있다.</p>
<pre><code class="language-bash"># External Traffic Policy = Cluster
root@router:~# ip -c route
...
172.16.1.1 nhid 101 proto bgp metric 20
    nexthop via 192.168.10.100 dev eth1 weight 1
    nexthop via 192.168.20.100 dev eth2 weight 1
    nexthop via 192.168.10.101 dev eth1 weight 1

# External Traffic Policy = Local
root@router:~# ip -c route
...
172.16.1.1 nhid 105 proto bgp metric 20
    nexthop via 192.168.20.100 dev eth2 weight 1
    nexthop via 192.168.10.101 dev eth1 weight 1</code></pre>
<p>이번에도 위와 동일하게 webpod가 k8s-w0, k8s-w1에만 떠있는 상태에서 LB IP를 호출해본다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# tcpdump -i eth1 -A -s 0 -nn &#39;tcp port 80&#39;
root@k8s-w0:~# tcpdump -i eth1 -A -s 0 -nn &#39;tcp port 80&#39;
root@k8s-w1:~# tcpdump -i eth1 -A -s 0 -nn &#39;tcp port 80&#39;

root@router:~# LBIP=172.16.1.1
root@router:~# curl -s $LBIP</code></pre>
<p>이번에는 Pod가 떠있는 노드 중 한쪽 노드로만 통신이 진행됨을 알 수 있다.</p>
<h4 id="동작-방식-1">동작 방식</h4>
<p>1) cilium에서 service가 External Policy Type = Local일 경우 Pod가 뜬 노드만 ExternalIP를 광고한다.</p>
<pre><code class="language-go">//pkg/bgpv1/manager/reconciler/service.go
func (r *ServiceReconciler) externalIPDesiredRoutes(svc *slim_corev1.Service, ls localServices) []netip.Prefix {
    var desiredRoutes []netip.Prefix
    // externalTrafficPolicy == Local 이며 endpoint 파드가 떠있지 않은 노드 같은 경우는 desiredRoutes를 빈 값으로 반환한다. 
  // 즉, externalTrafficPolicy == Local인 상황에서 endpoint파드가 떠있는 노드만 스스로를 광고한다.
    if svc.Spec.ExternalTrafficPolicy == slim_corev1.ServiceExternalTrafficPolicyLocal &amp;&amp;
        !hasLocalEndpoints(svc, ls) {
        return desiredRoutes
    }

  // externalTrafficPolicy == Local 이지만 endpoint 파드가 있는 노드는 스스로를 External IP로 광고한다.
    for _, extIP := range svc.Spec.ExternalIPs {
        if extIP == &quot;&quot; {
            continue
        }
        addr, err := netip.ParseAddr(extIP)
        if err != nil {
            continue
        }
        desiredRoutes = append(desiredRoutes, netip.PrefixFrom(addr, addr.BitLen()))
    }
    return desiredRoutes  
...
}
</code></pre>
<p>2) 외부 라우터가 광고된 External IP를 보고, 패킷을 노드로 전달힘.
이 때, Pod가 뜬 노드만 광고됨.</p>
<p>3) Pod가 뜬 노드에 패킷이 들어옴.</p>
<p>4) Cilium LB에서 Backend 확인</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg service list
ID   Frontend               Service Type   Backend
...
16   172.16.1.1:80/TCP      LoadBalancer   1 =&gt; 172.20.1.76:80/TCP (active)
                                           2 =&gt; 172.20.2.68:80/TCP (active)

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg bpf lb list | grep 172.16.1.1
172.16.1.1:80/TCP (1)          172.20.1.76:80/TCP (16) (1)
172.16.1.1:80/TCP (2)          172.20.2.68:80/TCP (16) (2)
172.16.1.1:80/TCP (0)          0.0.0.0:0 (16) (0) [LoadBalancer]                                          </code></pre>
<p>5) Cilium에서 service가 External Policy Type이 Local일 경우 해당 노드에 존재하는 Pod만 Backend로 선택
ipcache/map 기반으로 실제 Pod IP와 노드 매핑을 확인한 뒤 Local Pod로만 NAT/DNAT 적용</p>
<h4 id="ecmp-hash-policy">ECMP Hash Policy</h4>
<p>리눅스 커널은 기본적으로 L3(목적지 IP 기반) 해시를 사용한다. 보다 정교한 부하분산을 원하면 L4 해시 (IP + 포트) 기반으로 설정해야 한다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# sysctl net.ipv4.fib_multipath_hash_policy
net.ipv4.fib_multipath_hash_policy = 0
(⎈|HomeLab:N/A) root@k8s-ctr:~# sysctl -w net.ipv4.fib_multipath_hash_policy=1
net.ipv4.fib_multipath_hash_policy = 1</code></pre>
<p>이후 외부 router노드에서 LB IP로 통신을 시도하면 Pod가 떠있는 두 노드로 부하분산이 정상적으로 이뤄짐을 확인할 수 있다.</p>
<h2 id="cluster-ip">Cluster IP</h2>
<p>이번에는 ExternalIP가 아닌 Cluster IP를 광고해보자.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl edit ciliumbgpadvertisement
...
  spec:
    advertisements:
    - advertisementType: Service
      selector:
        matchExpressions:
        - key: app
          operator: In
          values:
          - webpod
      service:
        addresses:
        - ClusterIP
...

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc webpod -o wide
NAME     TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE    SELECTOR
webpod   LoadBalancer   10.96.252.129   172.16.1.1    80:31726/TCP   2d3h   app=webpod

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -n kube-system ds/cilium -- cilium service list
ID   Frontend               Service Type   Backend
...
12   10.96.252.129:80/TCP    ClusterIP     1 =&gt; 172.20.0.103:80/TCP (active)
                                           2 =&gt; 172.20.1.80:80/TCP (active)
                                           3 =&gt; 172.20.1.81:80/TCP (active)</code></pre>
<p>외부 router의 bgp설정을 확인하면 아래와 같이 Cluster IP 광고로 변경된 것을 확인할 수 있다.</p>
<pre><code class="language-bash">root@router:~# ip -c route
...
10.96.252.129 nhid 117 proto bgp metric 20
    nexthop via 192.168.10.100 dev eth1 weight 1
    nexthop via 192.168.20.100 dev eth2 weight 1
    nexthop via 192.168.10.101 dev eth1 weight 1</code></pre>
<h3 id="cluster-ip--internal-traffic-policy-cluster">Cluster IP + Internal Traffic Policy (Cluster)</h3>
<p>외부 router에서 바로 Cluster IP로 통신을 시도하면 통신이 되지 않는다.</p>
<pre><code class="language-bash">root@router:~# curl -s $LBIP
^C</code></pre>
<p>이는 원래 clusterIP의 목적이 클러스터 내부 통신을 위한 것이기 때문이다. <code>bpf.lbExternalClusterIP=true</code> 설정을 추가하여 내부 IP인 clusterIP도 외부에서 통신이 가능하도록 설정을 할 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# helm upgrade cilium cilium/cilium --version 1.18.0 --namespace kube-system --reuse-values --set  bpf.lbExternalClusterIP=true
Release &quot;cilium&quot; has been upgraded. Happy Helming!
NAME: cilium
LAST DEPLOYED: Sun Aug 17 02:53:00 2025
NAMESPACE: kube-system
STATUS: deployed
REVISION: 2
TEST SUITE: None
NOTES:
You have successfully installed Cilium with Hubble Relay and Hubble UI.

Your release version is 1.18.0.

For any further help, visit https://docs.cilium.io/en/v1.18/gettinghelp\

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl -n kube-system rollout restart ds/cilium
daemonset.apps/cilium restarted</code></pre>
<p>bpf lb list를 통해 ClusterIP를 살펴보면 <code>bpf.lbExternalClusterIP=true</code> 설정 전에는 ClusterIP가 non-routable이지만, 설정 적용 이후에는 non-routable이 사라진 것을 볼 수 있다.</p>
<pre><code class="language-bash"># 설정 적용 전
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -n kube-system ds/cilium -- cilium-dbg bpf lb list | grep 10.96.252.129
10.96.252.129:80/TCP (0)        0.0.0.0:0 (11) (0) [ClusterIP, non-routable]
10.96.252.129:80/TCP (2)        172.20.1.80:80/TCP (11) (2)
10.96.252.129:80/TCP (1)        172.20.0.103:80/TCP (11) (1)
10.96.252.129:80/TCP (3)        172.20.1.81:80/TCP (11) (3)

# 설정 적용 후
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -n kube-system ds/cilium -- cilium-dbg bpf lb list | grep 10.96.252.129
10.96.252.129:80/TCP (1)        172.20.0.103:80/TCP (12) (1)
10.96.252.129:80/TCP (2)        172.20.1.80:80/TCP (12) (2)
10.96.252.129:80/TCP (0)        0.0.0.0:0 (12) (0) [ClusterIP]
10.96.252.129:80/TCP (3)        172.20.1.81:80/TCP (12) (3)</code></pre>
<p>외부 router에서 통신을 확인해보면, clusterIP로도 외부 통신이 가능한 것을 확인할 수 있다.</p>
<pre><code class="language-bash">root@router:~# curl -s $LBIP
Hostname: webpod-697b545f57-hcqv2
IP: 127.0.0.1
IP: ::1
IP: 172.20.0.103
IP: fe80::7c3e:18ff:fe18:9940
RemoteAddr: 192.168.10.200:48558
GET / HTTP/1.1
Host: 10.96.252.129
User-Agent: curl/8.5.0
Accept: */*</code></pre>
<p>앞선 테스트 처럼 pod의 개수를 2개로 줄인 후 통신을 확인해본다. pod는 k8s-ctr과 k8s-w1에 떠있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl scale deployment/webpod --replicas=2
deployment.apps/webpod scaled

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -o wide
NAME                      READY   STATUS    RESTARTS   AGE   IP             NODE      NOMINATED NODE   READINESS GATES
curl-pod                  1/1     Running   0          49m   172.20.0.23    k8s-ctr   &lt;none&gt;           &lt;none&gt;
webpod-697b545f57-dm2hg   1/1     Running   0          49m   172.20.1.80    k8s-w1    &lt;none&gt;           &lt;none&gt;
webpod-697b545f57-hcqv2   1/1     Running   0          49m   172.20.0.103   k8s-ctr   &lt;none&gt;           &lt;none&gt;</code></pre>
<p>해당 테스트 또한 위 External Traffic Policy (Cluster)와 동일하게 일부 패킷은 k8s-ctr과, k8s-w1으로 동시에 패킷을 받는 경우가 생기게 된다.</p>
<p>k8s-ctr에서 패킷 덤프를 떠서 확인해본다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# tcpdump -i eth1 -w /tmp/dsr.pcap
tcpdump: listening on eth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
^C139 packets captured
141 packets received by filter
0 packets dropped by kernel</code></pre>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/6c813a24-5577-407b-9aef-1d231973bc91/image.png" alt=""></p>
<p>(1) Router 에서 Cilium BGP Peer인 모든 노드로 전달</p>
<p>(2) InternalTrafficPolicy:Cluster 서비스로, 여러 파드 중 (k8s-w1)의 파드를 대상으로 요청을 전달</p>
<p>(3) 해당 요청이 (k8s-ctr)을 통해 들어와 (k8s-w1)로 전달</p>
<p>(4) (k8s-w1)노드의 파드가 요청을 처리하고 응답 리턴을 위해서, NAT를 수행했던 노드(k8s-ctr)로 다시 전달</p>
<p>(5) 외부 인입을 받아서 NAT를 수행했던 연결 정보를 확인해서, Reverse NAT를 수행해서 최종 응답을 리턴</p>
<h3 id="cluster-ip--internal-traffic-policy-local">Cluster IP + Internal Traffic Policy (Local)</h3>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl patch service webpod -p &#39;{&quot;spec&quot;:{&quot;internalTrafficPolicy&quot;:&quot;Local&quot;}}&#39;</code></pre>
<p>External Traffic Policy를 local로 바꾸면 Router 노드에서의 bgp 경로도 Pod가 떠있는 노드만으로 변경이 된다.</p>
<pre><code class="language-bash">root@router:~# ip -c route
...
10.96.122.24 nhid 77 proto bgp metric 20
    nexthop via 192.168.10.101 dev eth1 weight 1
    nexthop via 192.168.10.100 dev eth1 weight 1</code></pre>
<p>webpod가 k8s-ctr, k8s-w0에 떠있는 상태에서 LB IP를 호출해본다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# tcpdump -i eth1 -A -s 0 -nn &#39;tcp port 80&#39;
root@k8s-w0:~# tcpdump -i eth1 -A -s 0 -nn &#39;tcp port 80&#39;
root@k8s-w1:~# tcpdump -i eth1 -A -s 0 -nn &#39;tcp port 80&#39;

root@router:~# LBIP=10.96.252.129
root@router:~# curl -s $LBIP</code></pre>
<h4 id="동작-방식-2">동작 방식</h4>
<p>1) cilium에서 service가 Internal Policy Type = Local일 경우 Pod가 뜬 노드만 ClusterIP를 광고한다.</p>
<pre><code class="language-go">func (r *ServiceReconciler) clusterIPDesiredRoutes(svc *slim_corev1.Service, ls localServices) []netip.Prefix {
    var desiredRoutes []netip.Prefix
    // InternalTrafficPolicy == Local 이며 endpoint 파드가 떠있지 않은 노드 같은 경우는 desiredRoutes를 빈 값으로 반환한다. 
  // 즉, InternalTrafficPolicy == Local인 상황에서 endpoint파드가 떠있는 노드만 스스로를 광고한다.
    if svc.Spec.InternalTrafficPolicy != nil &amp;&amp; *svc.Spec.InternalTrafficPolicy == slim_corev1.ServiceInternalTrafficPolicyLocal &amp;&amp;
        !hasLocalEndpoints(svc, ls) {
        return desiredRoutes
    }

  //ClusterIP를 확인한다.
    if svc.Spec.ClusterIP == &quot;&quot; || len(svc.Spec.ClusterIPs) == 0 || svc.Spec.ClusterIP == corev1.ClusterIPNone {
        return desiredRoutes
    }
    ips := sets.New[string]()
    if svc.Spec.ClusterIP != &quot;&quot; {
        ips.Insert(svc.Spec.ClusterIP)
    }

  //ClusterIP를 광고할 수 있도록 desiredRotues에 추가한다.
    for _, clusterIP := range svc.Spec.ClusterIPs {
        if clusterIP == &quot;&quot; || clusterIP == corev1.ClusterIPNone {
            continue
        }
        ips.Insert(clusterIP)
    }
...
    return desiredRoutes
}</code></pre>
<p>2) BPF map에는 Local Pod만 Backend로 등록</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg bpf lb list | grep 10.96.122.24
10.96.122.24:80/TCP (1)          172.20.0.103:80/TCP (12) (1)
10.96.122.24:80/TCP (0)          0.0.0.0:0 (12) (0) [ClusterIP, InternalLocal]

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium-dbg bpf ipcache list | grep 14076
172.20.0.103/32     identity=14076 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
172.20.1.80/32      identity=14076 encryptkey=0 tunnelendpoint=192.168.10.101 flags=hastunnel</code></pre>
<p>3) router에서 ClusterIP를 호출하면 모든 트래픽이 k8s-ctr에 있는 pod로 향한다.</p>
<ul>
<li>예상 원인
bpf ipcache list에서 Pod IP의 tunnelendpoint=0.0.0.0 이어야 로컬 Pod로 인지가 되기 때문에 k8s-ctr에 있는 pod만 backend로 인지되는 것으로 보인다.</li>
</ul>
<p>k8s-ctr에서 패킷 덤프를 떠서 확인해본다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# tcpdump -i eth1 -w /tmp/dsr2.pcap
tcpdump: listening on eth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
^C139 packets captured
141 packets received by filter
0 packets dropped by kernel</code></pre>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/1bdd8b77-5b82-4aca-a6cf-ab87463b8e74/image.png" alt=""></p>
<p>(1) Router 에서 Cilium BGP Peer인 노드로 전달 (pod가 떠있는 노드 대상)</p>
<p>(2) InternalTrafficPolicy:Local 서비스 기준으로, 유효한 backend는 k8s-ctr위의 Pod 뿐</p>
<p>(3) 해당 노드의 파드가 요청을 처리하고 최종 응답을 리턴</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Cilium - Networking - 파드 간 통신 (2) Encapsulation, Service LB-IPAM, L2 Announcement]]></title>
            <link>https://velog.io/@_gyullbb/Cilium-Networking-%ED%8C%8C%EB%93%9C-%EA%B0%84-%ED%86%B5%EC%8B%A0-2-Encapsulation-Service-LB-IPAM-L2-Announcement</link>
            <guid>https://velog.io/@_gyullbb/Cilium-Networking-%ED%8C%8C%EB%93%9C-%EA%B0%84-%ED%86%B5%EC%8B%A0-2-Encapsulation-Service-LB-IPAM-L2-Announcement</guid>
            <pubDate>Sat, 09 Aug 2025 10:54:02 GMT</pubDate>
            <description><![CDATA[<p>Native Routing 환경에서 잘못된 라우팅 설정으로 인해 통신이 되지 않는 상황을 만들고, 이를 라우팅 설정 변경을 통해 해결하는 실습을 진행한다.</p>
<h2 id="실습-환경">실습 환경</h2>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/8549e74f-b1e8-482e-aa1a-810915e74f41/image.png" alt=""></p>
<p>Kubernetes 클러스터 노드</p>
<ul>
<li>k8s-ctr(IP: 192.168.10.100, podCIDR: 172.20.0.98)</li>
<li>k8s-w1(IP: 192.168.10.101, podCIDR: 172.20.1.60)</li>
<li>k8s-w0(IP: 192.168.20.100, podCIDR: 172.20.2.159)</li>
</ul>
<p>Router 노드</p>
<ul>
<li>router(IP: 192.168.10.200)</li>
</ul>
<h3 id="초기-상태">초기 상태</h3>
<h4 id="autodirectnoderoutes">autoDirectNodeRoutes</h4>
<p>Native Routing Mode 클러스터에서 autoDirectNodeRoutes=true로 설정하면, 같은 네트워크 대역에 있는 노드들은 서로의 podCIDR 정보를 자동으로 라우팅 테이블에 추가한다.
그러나 k8s-w0 노드는 다른 네트워크 대역에 속해 있기 때문에, 이 노드의 podCIDR은 라우팅 테이블에 자동으로 추가되지 않는다.</p>
<pre><code class="language-bash">k8s-ctr
(⎈|HomeLab:N/A) root@k8s-ctr:~# ip -c route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100
10.0.2.2 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.0.2.3 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
172.20.0.0/24 via 172.20.0.98 dev cilium_host proto kernel src 172.20.0.98
172.20.0.98 dev cilium_host proto kernel scope link
172.20.1.0/24 via 192.168.10.101 dev eth1 proto kernel &lt;- autoDirectNodeRoutes로 인한 추가
192.168.10.0/24 dev eth1 proto kernel scope link src 192.168.10.100

k8s-w1
root@k8s-w1:~# ip -c route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100
10.0.2.2 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.0.2.3 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
172.20.0.0/24 via 192.168.10.100 dev eth1 proto kernel &lt;- autoDirectNodeRoutes로 인한 추가
172.20.1.0/24 via 172.20.1.60 dev cilium_host proto kernel src 172.20.1.60
172.20.1.60 dev cilium_host proto kernel scope link
192.168.10.0/24 dev eth1 proto kernel scope link src 192.168.10.101

k8s-w0
root@k8s-w0:~# ip -c route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100
10.0.2.2 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.0.2.3 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
172.20.2.0/24 via 172.20.2.159 dev cilium_host proto kernel src 172.20.2.159
172.20.2.159 dev cilium_host proto kernel scope link
192.168.20.0/24 dev eth1 proto kernel scope link src 192.168.20.100
* k8s-ctr, k8s-w1 관련 route 없음</code></pre>
<h3 id="파드-통신-확인">파드 통신 확인</h3>
<p>k8s-w1과 k8s-w0에 각각 배포된 webpod를 호출하면, 일부만 응답이 오고 일부는 응답이 없다.
특히 k8s-w0에서 실행 중인 webpod IP를 직접 지정하여 ping을 시도해도 100% 패킷 손실이 발생한다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ciliumnode -o json | jq &#39;.items[].spec.addresses&#39;
[
  {
    &quot;ip&quot;: &quot;192.168.10.100&quot;,
    &quot;type&quot;: &quot;InternalIP&quot;
  },
  {
    &quot;ip&quot;: &quot;172.20.0.98&quot;,
    &quot;type&quot;: &quot;CiliumInternalIP&quot;
  }
]
[
  {
    &quot;ip&quot;: &quot;192.168.20.100&quot;,
    &quot;type&quot;: &quot;InternalIP&quot;
  },
  {
    &quot;ip&quot;: &quot;172.20.2.159&quot;,
    &quot;type&quot;: &quot;CiliumInternalIP&quot;
  }
]
[
  {
    &quot;ip&quot;: &quot;192.168.10.101&quot;,
    &quot;type&quot;: &quot;InternalIP&quot;
  },
  {
    &quot;ip&quot;: &quot;172.20.1.60&quot;,
    &quot;type&quot;: &quot;CiliumInternalIP&quot;
  }
]

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pods -o wide
NAME                      READY   STATUS    RESTARTS      AGE     IP             NODE      NOMINATED NODE   READINESS GATES
curl-pod                  1/1     Running   1 (20m ago)   6h14m   172.20.0.201   k8s-ctr   &lt;none&gt;           &lt;none&gt;
webpod-696774f575-5nvlc   1/1     Running   1 (18m ago)   6h13m   172.20.2.50    k8s-w0    &lt;none&gt;           &lt;none&gt;
webpod-696774f575-dsblb   1/1     Running   1 (20m ago)   6h12m   172.20.0.130   k8s-ctr   &lt;none&gt;           &lt;none&gt;
webpod-696774f575-wssxg   1/1     Running   1 (19m ago)   6h12m   172.20.1.184   k8s-w1    &lt;none&gt;           &lt;none&gt;

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it curl-pod -- sh -c &#39;while true; do curl -s --connect-timeout 1 webpod | grep Hostname; echo &quot;---&quot; ; sleep 1; done&#39;
Hostname: webpod-696774f575-wssxg
---
---
Hostname: webpod-696774f575-dsblb
---
---
Hostname: webpod-696774f575-dsblb
---
---
Hostname: webpod-696774f575-dsblb
---
---
---

(⎈|HomeLab:N/A) root@k8s-ctr:~# export WEBPOD=$(kubectl get pod -l app=webpod --field-selector spec.nodeName=k8s-w0 -o jsonpath=&#39;{.items[0].status.podIP}&#39;)
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it curl-pod -- ping -c 2 -w 1 -W 1 $WEBPOD
PING 172.20.2.50 (172.20.2.50) 56(84) bytes of data.

--- 172.20.2.50 ping statistics ---
1 packets transmitted, 0 received, 100% packet loss, time 0ms</code></pre>
<p>패킷 흐름을 router 노드에서 tcpdump로 확인해보면, 요청이 eth1(내부망)으로 들어오지만, 다시 eth0(외부망)으로 나가 버린다.</p>
<pre><code class="language-bash">root@router:~# tcpdump -i any icmp -nn
tcpdump: data link type LINUX_SLL2
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
18:11:47.232880 eth1  In  IP 172.20.0.163 &gt; 172.20.2.54: ICMP echo request, id 7, seq 1, length 64
18:11:47.232892 eth0  Out IP 172.20.0.163 &gt; 172.20.2.54: ICMP echo request, id 7, seq 1, length 64</code></pre>
<p>router의 ip route를 확인한 결과, 클러스터 Pod CIDR 대역으로 가는 경로가 설정되어 있지 않다.
즉, k8s-w0의 Pod로 향해야 할 트래픽이 올바른 내부 경로를 찾지 못하고 외부로 빠져나가 통신이 실패한 것이다.</p>
<pre><code class="language-bash">root@router:~# ip route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100
10.0.2.2 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.0.2.3 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.10.1.0/24 dev loop1 proto kernel scope link src 10.10.1.200
10.10.2.0/24 dev loop2 proto kernel scope link src 10.10.2.200
192.168.10.0/24 dev eth1 proto kernel scope link src 192.168.10.200
192.168.20.0/24 dev eth2 proto kernel scope link src 192.168.20.200</code></pre>
<h3 id="파드-통신-개선">파드 통신 개선</h3>
<p>각 노드의 Pod CIDR 대역으로 향하는 트래픽이 해당 노드로 전달되도록 라우팅 규칙을 수동으로 추가한다.</p>
<pre><code class="language-bash">root@router:~# ip route add 172.20.1.0/24 via 192.168.10.101
root@router:~# ip route add 172.20.0.0/24 via 192.168.10.100
root@router:~# ip route add 172.20.2.0/24 via 192.168.20.100

root@router:~# ip -c route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100
10.0.2.2 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.0.2.3 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.10.1.0/24 dev loop1 proto kernel scope link src 10.10.1.200
10.10.2.0/24 dev loop2 proto kernel scope link src 10.10.2.200
172.20.0.0/24 via 192.168.10.100 dev eth1
172.20.1.0/24 via 192.168.10.101 dev eth1
172.20.2.0/24 via 192.168.20.100 dev eth2
192.168.10.0/24 dev eth1 proto kernel scope link src 192.168.10.200
192.168.20.0/24 dev eth2 proto kernel scope link src 192.168.20.200</code></pre>
<p>라우팅 규칙을 추가한 후 다시 k8s-w0의 pod로의 통신을 확인해보면 정상 통신되는 것을 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it curl-pod -- ping -c 2 -w 1 -W 1 $WEBPOD
PING 172.20.2.54 (172.20.2.54) 56(84) bytes of data.
64 bytes from 172.20.2.54: icmp_seq=1 ttl=61 time=0.958 ms

--- 172.20.2.54 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.958/0.958/0.958/0.000 ms
command terminated with exit code 1

root@router:~# tcpdump -i any icmp -nn
tcpdump: data link type LINUX_SLL2
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
18:20:48.457587 eth1  In  IP 172.20.0.163 &gt; 172.20.2.54: ICMP echo request, id 19, seq 1, length 64
18:20:48.457605 eth2  Out IP 172.20.0.163 &gt; 172.20.2.54: ICMP echo request, id 19, seq 1, length 64
18:20:48.458117 eth2  In  IP 172.20.2.54 &gt; 172.20.0.163: ICMP echo reply, id 19, seq 1, length 64
18:20:48.458118 eth1  Out IP 172.20.2.54 &gt; 172.20.0.163: ICMP echo reply, id 19, seq 1, length 64
^C
4 packets captured
4 packets received by filter
0 packets dropped by kernel</code></pre>
<p>해당 실습은 노드 개수가 적기 때문에 수동으로 라우트를 추가해도 문제가 되지 않는다.
그러나 노드가 100대 이상으로 늘어나고, 각 노드의 Pod CIDR이 변경되는 상황에서는 모든 노드에 대해 수동으로 라우팅을 설정하는 것은 사실상 불가능하다.</p>
<p>이러한 문제를 해결하기 위해 BGP를 통한 동적 라우팅이나 Overlay 네트워크를 활용한다.
이번 글에서는 Overlay 네트워크를 이용해 통신이 이루어지는 방식을 살펴본다.</p>
<h1 id="overlay-네트워크">Overlay 네트워크</h1>
<p>Cilium은 기본적으로 Encapsulation(캡슐화) 기반 네트워킹 방식을 제공한다. 각 노드 간에 UDP 기반 터널(VXLAN 또는 Geneve)을 자동으로 구성해 파드의 트래픽을 캡슐화하여 전달하기 때문에, 물리 노드 간 IP 통신만 가능하면 각 노드의 Pod CIDR에 대해 별도의 라우트를 수동 설정할 필요가 없다.</p>
<h2 id="vxlan">VXLAN</h2>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/1413f71f-0c3a-48ea-9383-c10645c01a58/image.png" alt="">
VXLAN은 고정된 8바이트 헤더 구조를 가진 L2 over L3 오버레이 프로토콜이다.
내부 L2 프레임을 외부 UDP 패킷으로 캡슐화하여 전송하며, 데이터센터와 클라우드 환경에서 널리 사용된다.</p>
<p>VXLAN은 <strong>VTEP(VXLAN Tunnel Endpoint)</strong>라는 네트워크 장비를 통해서 동작하는데, VTEP는 오버레이 네트워크와 물리 네트워크 사이를 연결하는 역할을 수행한다.</p>
<p>1) Ingress VTEP (송신 쪽)
패킷이 오버레이 네트워크에 들어오면, Ingress VTEP이 다음 작업을 수행한다.</p>
<ul>
<li>원본 L2 프레임 수신</li>
<li>원본 L2 프레임을 UDP 패킷의 페이로드에 넣고 헤더 추가</li>
<li>VXLAN 헤더 (8바이트): VNI(네트워크 ID)로 목적지 설정</li>
<li>Outer UDP/IP 헤더: Egress VTEP의 IP 주소로 목적지 설정</li>
<li>Outer Ethernet 헤더: 물리망에서의 다음 홉 MAC 주소로 목적지 설정</li>
<li>물리망으로 전송</li>
</ul>
<p>이렇게 캡슐화된 패킷은 일반 L3 네트워크를 통해 라우팅된다.</p>
<p>2) Egress VTEP (수신 쪽)
패킷이 터널 반대쪽에 도착하면, Egress VTEP가 다음 작업을 수행한다.</p>
<ul>
<li>Outer Ethernet, Outer IP, Outer UDP, 그리고 VXLAN 헤더를 순서대로 벗겨냄.</li>
<li>원본 L2 프레임 복원</li>
<li>목적지에 전달</li>
</ul>
<h2 id="geneve">GENEVE</h2>
<p>Geneve는 VXLAN에서 확장성과 유연성을 개선한 최신 캡슐화 프로토콜(IETF 표준)이다.
<img src="https://velog.velcdn.com/images/_gyullbb/post/7ff12bbb-0c95-4db0-b02c-056d01dede0b/image.png" alt=""></p>
<p>(참고문서 : <a href="https://tetrate.io/blog/using-geneve-tunnels-to-implement-istio-ambient-mesh-traffic-interception">https://tetrate.io/blog/using-geneve-tunnels-to-implement-istio-ambient-mesh-traffic-interception</a>)</p>
<p>VXLAN과 GENEVE의 가장 큰 차이점은 사용 포트(VXLAN(4789/UDP), GENEVE(6081/UDP))와 헤더 구조이다.</p>
<p>VXLAN의 경우 헤더가 고정되어있기 때문에 캡슐화 정보 외의 추가 메타데이터(정책, QoS, 보안 태그, 트래픽 분석 등)를 담을 수 없는데, 
GENEVE는 이 VXLAN의 단점을 TLV 기반 확장성으로 개선하였다.</p>
<h3 id="tlvtype-length-value">TLV(Type-Length-Value)</h3>
<p>TLV는 Geneve 옵션 필드를 이루는 기본 단위이며, 여러 개를 연속해서 붙일 수 있다.
이를 통해 새로운 네트워크 기능을 블록처럼 조립하듯 필요에 따라 유연하게 추가할 수 있다.</p>
<table>
<thead>
<tr>
<th>필드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Type</strong></td>
<td>옵션의 종류 식별 (예: 보안 태그, QoS, 정책 ID 등)</td>
</tr>
<tr>
<td><strong>Length</strong></td>
<td>데이터 길이(4바이트 단위)</td>
</tr>
<tr>
<td><strong>Value</strong></td>
<td>실제 메타데이터 값</td>
</tr>
</tbody></table>
<h4 id="tlv-구조의-장점">TLV 구조의 장점</h4>
<ul>
<li>유연성: 필요할 때만 옵션 추가 → 불필요한 오버헤드 감소</li>
<li>확장성: 새로운 기능이 필요해도 프로토콜 개정 없이 Type만 새로 정의해서 추가 가능</li>
<li>호환성: TLV를 이해 못하는 장비도 기본 패킷은 처리 가능 : 호환성 확장</li>
<li>메타데이터 다양성: 정책, 보안, 트래픽 분석, 장애 추적 등 다양한 목적의 데이터 삽입 가능</li>
</ul>
<h4 id="tlv-옵션-사용-예시--서비스-체이닝-service-chaining">TLV 옵션 사용 예시 : 서비스 체이닝 (Service Chaining)</h4>
<p>TLV 옵션 사용 예시로 서비스 체이닝이 있다.
서비스 체이닝이란 패킷이 네트워크를 통과하는 동안 방화벽, IDS, 로드밸런서, WAN 최적화 장비 등 여러 네트워크 기능을 정해진 순서대로 거치게 하는 기술이다.
VXLAN은 오직 VNI만 있어 어떤 순서로 서비스 거칠지 알 수 없지만, Geneve의 TLV 옵션을 쓰면 패킷 안에 경로 정보(SPID)와 현재 단계(SI)를 넣어 서비스 체이닝이 가능해진다.</p>
<p><strong>예시</strong></p>
<pre><code class="language-vbnet">Option Class: 0x0101 (Service Chaining)
Type: Path + Index
Length: 8 bytes
Option Data:
    SPID = 0x0012
    SI   = 0x04</code></pre>
<p><strong>SPID (Service Path ID)</strong></p>
<ul>
<li>어떤 서비스 경로인지를 나타내는 번호</li>
<li>예: 0x0012 → 방화벽 → IDS → WAN → LB</li>
</ul>
<p><strong>SI (Service Index)</strong></p>
<ul>
<li>현재 서비스 경로에서 어디까지 왔는지를 나타내는 카운터</li>
<li>시작할 때는 전체 길이, 서비스 거칠 때마다 1씩 감소</li>
</ul>
<p><strong>동작 원리</strong></p>
<ul>
<li>SPID 확인 → 미리 정의된 경로(서비스 체인)를 찾음</li>
<li>SI 값을 보고 지금 어느 단계인지 파악</li>
<li>서비스 기능을 수행한 뒤 SI를 1 감소</li>
<li>SI가 0이 되면 체인 종료, 최종 목적지로 전달</li>
</ul>
<h2 id="mtu-1450">MTU 1450</h2>
<p>물리적 네트워크에서 1500바이트의 표준 이더넷 MTU를 사용하는 경우, VXLAN과 Geneve는 MTU를 1450으로 설정하는 것이 일반적이다.
위의 VXLAN과 GENEVE 패킷 이미지를 보면 VXLAN은 Original L2 Frame을 제외한 고정 헤더크기가 50 Byte이며, GENEVE도 마찬가지로 최소 헤더 크기가 50Byte이다.
캡슐화 시 추가되는 외부 IP/UDP/프로토콜 헤더 크기를 고려해, 패킷이 조각(Fragmentation)나지 않도록 MTU를 1450으로 설정하는 것이다.
GENEVE의 경우 TLV 옵션에 따라 헤더 크기가 증가할 수 있기 때문에, 옵션을 고려하여 MTU 사이즈를 결정해야 한다.</p>
<p>이전 글에서 VXLAN에서의 통신 실습을 해보았기 때문에 이번에는 GENEVE로 통신 확인을 진행한다.</p>
<h2 id="geneve-통신-확인">GENEVE 통신 확인</h2>
<h3 id="geneve-설정">GENEVE 설정</h3>
<pre><code class="language-bash"># 모듈 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# grep -E &#39;CONFIG_VXLAN=y|CONFIG_VXLAN=m|CONFIG_GENEVE=y|CONFIG_GENEVE=m|CONFIG_FIB_RULES=y&#39; /boot/config-$(uname -r)
CONFIG_FIB_RULES=y
CONFIG_VXLAN=m
CONFIG_GENEVE=m

(⎈|HomeLab:N/A) root@k8s-ctr:~# lsmod | grep -E &#39;vxlan|geneve&#39;
(⎈|HomeLab:N/A) root@k8s-ctr:~# modprobe geneve
(⎈|HomeLab:N/A) root@k8s-ctr:~# lsmod | grep -E &#39;vxlan|geneve&#39;
geneve                 45056  0
ip6_udp_tunnel         16384  1 geneve
udp_tunnel             36864  1 geneve

(⎈|HomeLab:N/A) root@k8s-ctr:~# helm upgrade cilium cilium/cilium --namespace kube-system --version 1.18.0 --reuse-values --set routingMode=tunnel --set tunnelProtocol=geneve --set autoDirectNodeRoutes=false --set installNoConntrackIptablesRules=false

# cilium 설정 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# cilium features status | grep datapath_network
Yes      cilium_feature_datapath_network                                         mode=overlay-geneve                               1        1       1

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -- cilium status | grep ^Routing
Routing:                 Network: Tunnel [geneve]   Host: BPF

(⎈|HomeLab:N/A) root@k8s-ctr:~# cilium config view | grep tunnel
routing-mode                                      tunnel
tunnel-protocol                                   geneve
tunnel-source-port-range                          0-0

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip -c addr show cilium_geneve
26: cilium_geneve: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UNKNOWN group default
    link/ether 0e:73:b0:50:47:a6 brd ff:ff:ff:ff:ff:ff
    inet6 fe80::c73:b0ff:fe50:47a6/64 scope link
       valid_lft forever preferred_lft forever</code></pre>
<h3 id="geneve-설정-확인">GENEVE 설정 확인</h3>
<p>클러스터의 노드가 서로 다른 네트워크 대역에 있지만, 모든 노드에 각 Pod의 네트워크 대역 정보가 route에 등록되어 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# ip -c route | grep cilium_host
172.20.0.0/24 via 172.20.0.74 dev cilium_host proto kernel src 172.20.0.74 &lt;- pod 대역
172.20.0.74 dev cilium_host proto kernel scope link
172.20.1.0/24 via 172.20.0.74 dev cilium_host proto kernel src 172.20.0.74 mtu 1450 &lt;- pod 대역
172.20.2.0/24 via 172.20.0.74 dev cilium_host proto kernel src 172.20.0.74 mtu 1450 &lt;- pod 대역

root@k8s-w1:~# ip -c route | grep cilium_host
172.20.0.0/24 via 172.20.1.41 dev cilium_host proto kernel src 172.20.1.41 mtu 1450 &lt;- pod 대역
172.20.1.0/24 via 172.20.1.41 dev cilium_host proto kernel src 172.20.1.41 &lt;- pod 대역
172.20.1.41 dev cilium_host proto kernel scope link
172.20.2.0/24 via 172.20.1.41 dev cilium_host proto kernel src 172.20.1.41 mtu 1450 &lt;- pod 대역

root@k8s-w0:~# ip -c route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100
10.0.2.2 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.0.2.3 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.10.0.0/16 via 192.168.20.200 dev eth1 proto static
172.20.0.0/24 via 172.20.2.204 dev cilium_host proto kernel src 172.20.2.204 mtu 1450 &lt;- pod 대역
172.20.0.0/16 via 192.168.20.200 dev eth1 proto static
172.20.1.0/24 via 172.20.2.204 dev cilium_host proto kernel src 172.20.2.204 mtu 1450 &lt;- pod 대역
172.20.2.0/24 via 172.20.2.204 dev cilium_host proto kernel src 172.20.2.204 &lt;- pod 대역
172.20.2.204 dev cilium_host proto kernel scope link
192.168.10.0/24 via 192.168.20.200 dev eth1 proto static
192.168.20.0/24 dev eth1 proto kernel scope link src 192.168.20.100

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip route get 172.20.1.10
172.20.1.10 dev cilium_host src 172.20.0.74 uid 0
    cache mtu 1450

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip route get 172.20.2.10
172.20.2.10 dev cilium_host src 172.20.0.74 uid 0
    cache mtu 1450</code></pre>
<p>route 규칙을 보면 src에 172.20.0.74, 172.20.1.41, 172.20.2.204와 같은 IP가 지정되어 있는데, 이는 각 노드의 cilium-agent에서 router역할을 하는 IP이다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it $CILIUMPOD0 -n kube-system -c cilium-agent -- cilium status --all-addresses | grep router
  172.20.0.74 (router)
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it $CILIUMPOD1 -n kube-system -c cilium-agent -- cilium status --all-addresses | grep router
  172.20.1.41 (router)
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it $CILIUMPOD2 -n kube-system -c cilium-agent -- cilium status --all-addresses | grep router
  172.20.2.204 (router)</code></pre>
<p>Cilium은 각 노드에 eBPF 기반 데이터패스와 가상 인터페이스(cilium_host)를 만드는데, 이 cilium_host 인터페이스가 해당 노드에서 다른 노드/외부로 나가는 트래픽의 기본 라우터 역할을 한다. 
Cilium Router IP는 이 cilium_host 인터페이스에 할당된 IP이다. 
cilium status로 본 router ip와 cilium_host의 ip가 동일한 것을 볼 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# ip addr show cilium_host
5: cilium_host@cilium_net: &lt;BROADCAST,MULTICAST,NOARP,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether aa:71:74:33:f7:d6 brd ff:ff:ff:ff:ff:ff
    inet 172.20.0.74/32 scope global cilium_host
       valid_lft forever preferred_lft forever
    inet6 fe80::a871:74ff:fe33:f7d6/64 scope link
       valid_lft forever preferred_lft forever

root@k8s-w1:~# ip addr show cilium_host
5: cilium_host@cilium_net: &lt;BROADCAST,MULTICAST,NOARP,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 16:39:18:15:d2:cd brd ff:ff:ff:ff:ff:ff
    inet 172.20.1.41/32 scope global cilium_host
       valid_lft forever preferred_lft forever
    inet6 fe80::1439:18ff:fe15:d2cd/64 scope link
       valid_lft forever preferred_lft forever

root@k8s-w0:~# ip addr show cilium_host
5: cilium_host@cilium_net: &lt;BROADCAST,MULTICAST,NOARP,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether fa:b0:9f:c2:1b:84 brd ff:ff:ff:ff:ff:ff
    inet 172.20.2.204/32 scope global cilium_host
       valid_lft forever preferred_lft forever
    inet6 fe80::f8b0:9fff:fec2:1b84/64 scope link
       valid_lft forever preferred_lft forever</code></pre>
<p>Cilium Router IP는 각 노드에 하나씩 존재하며, 같은 노드의 모든 Pod는 이 Router IP를 게이트웨이로 사용한다.</p>
<p>bpf ipcache를 보면 Cilium이 다른 노드에 뜬 pod의 경우 hastunnel이라는 flag를 부여하는 것을 볼 수 있는데, 이 flag가 있을 경우 캡슐화 대상으로 인지하여 캡슐화를 진행하게 된다.</p>
<pre><code class="language-bash"># 다른 노드 내 pod
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -n kube-system $CILIUMPOD0 -- cilium-dbg bpf ipcache list | grep hastunnel
172.20.1.41/32      identity=6 encryptkey=0 tunnelendpoint=192.168.10.101 flags=hastunnel
172.20.1.73/32      identity=11761 encryptkey=0 tunnelendpoint=192.168.10.101 flags=hastunnel
172.20.2.54/32      identity=11761 encryptkey=0 tunnelendpoint=192.168.20.100 flags=hastunnel
172.20.2.204/32     identity=6 encryptkey=0 tunnelendpoint=192.168.20.100 flags=hastunnel
172.20.2.0/24       identity=2 encryptkey=0 tunnelendpoint=192.168.20.100 flags=hastunnel
172.20.1.0/24       identity=2 encryptkey=0 tunnelendpoint=192.168.10.101 flags=hastunnel

# 동일 노드 내 pod 
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -n kube-system $CILIUMPOD0 -- cilium-dbg bpf ipcache list | grep -v hastunnel
IP PREFIX/ADDRESS   IDENTITY
172.20.0.63/32      identity=11761 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
172.20.0.152/32     identity=22419 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
172.20.0.163/32     identity=29101 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
172.20.0.210/32     identity=4046 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
172.20.0.224/32     identity=57021 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
192.168.10.101/32   identity=6 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
192.168.20.100/32   identity=6 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
0.0.0.0/0           identity=2 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
10.0.2.15/32        identity=1 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
172.20.0.81/32      identity=35319 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
172.20.0.242/32     identity=57021 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
172.20.0.74/32      identity=1 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
172.20.0.77/32      identity=31483 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
172.20.0.25/32      identity=14322 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
172.20.0.186/32     identity=16348 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;
192.168.10.100/32   identity=1 encryptkey=0 tunnelendpoint=0.0.0.0 flags=&lt;none&gt;</code></pre>
<h3 id="geneve-통신-확인-1">GENEVE 통신 확인</h3>
<p>서로 다른 노드 간 Pod통신을 통해 GENEVE 통신을 확인해본다.
GENEVE는 기본 포트로 6081/UDP를 사용한다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it curl-pod -- ping -c 2 -w 1 -W 1 $WEBPOD
PING 172.20.2.54 (172.20.2.54) 56(84) bytes of data.
64 bytes from 172.20.2.54: icmp_seq=1 ttl=63 time=0.896 ms
--- 172.20.2.54 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.896/0.896/0.896/0.000 ms
command terminated with exit code 1

root@router:~# tcpdump -i any udp port 6081 -nn
tcpdump: data link type LINUX_SLL2
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
23:59:32.251108 eth1  In  IP 192.168.10.100.31322 &gt; 192.168.20.100.6081: Geneve, Flags [none], vni 0x71ad: IP 172.20.0.163 &gt; 172.20.2.54: ICMP echo request, id 37, seq 1, length 64
23:59:32.251125 eth2  Out IP 192.168.10.100.31322 &gt; 192.168.20.100.6081: Geneve, Flags [none], vni 0x71ad: IP 172.20.0.163 &gt; 172.20.2.54: ICMP echo request, id 37, seq 1, length 64
23:59:32.251597 eth2  In  IP 192.168.20.100.29257 &gt; 192.168.10.100.6081: Geneve, Flags [none], vni 0x2df1: IP 172.20.2.54 &gt; 172.20.0.163: ICMP echo reply, id 37, seq 1, length 64
23:59:32.251607 eth1  Out IP 192.168.20.100.29257 &gt; 192.168.10.100.6081: Geneve, Flags [none], vni 0x2df1: IP 172.20.2.54 &gt; 172.20.0.163: ICMP echo reply, id 37, seq 1, length 64</code></pre>
<p>pod 대역이 캡슐화되어 노드 간 통신을 통해 패킷이 전송되는 것을 확인할 수 있다.</p>
<p>이전 글에서 확인한 VXLAN과 마찬가지로 hubble로 모니터링을 할 때, 패킷이 캡슐화 된다는 의미의 to-overlay 메시지를 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# hubble observe -f --protocol tcp --pod curl-pod
(⎈|HomeLab:N/A) root@k8s-ctr:~# hubble observe -f --protocol tcp --pod curl-pod
Aug  8 15:05:43.118: default/curl-pod:36548 (ID:29101) -&gt; default/webpod-697b545f57-9c7vb:80 (ID:11761) to-endpoint FORWARDED (TCP Flags: SYN)
Aug  8 15:05:43.118: default/curl-pod:36548 (ID:29101) &lt;- default/webpod-697b545f57-9c7vb:80 (ID:11761) to-overlay FORWARDED (TCP Flags: SYN, ACK)
Aug  8 15:05:43.119: default/curl-pod:36548 (ID:29101) -&gt; default/webpod-697b545f57-9c7vb:80 (ID:11761) to-endpoint FORWARDED (TCP Flags: ACK)
Aug  8 15:05:43.119: default/curl-pod:36548 (ID:29101) -&gt; default/webpod-697b545f57-9c7vb:80 (ID:11761) to-endpoint FORWARDED (TCP Flags: ACK, PSH)
Aug  8 15:05:43.120: default/curl-pod:36548 (ID:29101) &lt;&gt; default/webpod-697b545f57-9c7vb (ID:11761) pre-xlate-rev TRACED (TCP)
Aug  8 15:05:43.120: default/curl-pod:36548 (ID:29101) &lt;&gt; default/webpod-697b545f57-9c7vb (ID:11761) pre-xlate-rev TRACED (TCP)
Aug  8 15:05:43.121: default/curl-pod:36548 (ID:29101) &lt;&gt; default/webpod-697b545f57-9c7vb (ID:11761) pre-xlate-rev TRACED (TCP)
Aug  8 15:05:43.121: default/curl-pod (ID:29101) &lt;&gt; 10.96.107.144:80 (world) pre-xlate-fwd TRACED (TCP)
...</code></pre>
<h1 id="service-lb-ipam">Service LB-IPAM</h1>
<p>LB IPAM이란 Cilium이 IP 주소를 LoadBalancer 유형의 서비스에 할당할 수 있게 해주는 기능이다.
LB IPAM은 Cilium BGP Control Plane 및 L2 Announcements / L2 Aware LB(베타)와 같은 기능과 함께 작동하는데, Cilium BGP Control Plane을 사용하여 LB IPAM이 할당한 IP 주소를 BGP를 통해 광고하고 L2 Announcements / L2 Aware LB(베타)를 통해 로컬로 광고하게 된다.</p>
<p>Cilium에서 LoadBalancer IP Pool을 생성한 후 해당 Pool 내에서 LB에 IP를 할당하는 설정을 해본다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# cat &lt;&lt; EOF | kubectl apply -f -
apiVersion: &quot;cilium.io/v2&quot;  # v1.17 : cilium.io/v2alpha1
kind: CiliumLoadBalancerIPPool
metadata:
  name: &quot;cilium-lb-ippool&quot;
spec:
  blocks:
  - start: &quot;192.168.10.211&quot;
    stop:  &quot;192.168.10.215&quot;
EOF
ciliumloadbalancerippool.cilium.io/cilium-lb-ippool created

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl api-resources | grep -i CiliumLoadBalancerIPPool
ciliumloadbalancerippools           ippools,ippool,lbippool,lbippools   cilium.io/v2                      false        CiliumLoadBalancerIPPool

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ippools
NAME               DISABLED   CONFLICTING   IPS AVAILABLE   AGE
cilium-lb-ippool   false      False         5               74s</code></pre>
<p>기존에 배포한 webpod의 Service를 Loadbalancer Service로 변경하면 LB IPAM에 의해 자동으로 ExternalIP가 할당된다.
<code>kubectl get ippools</code>명령어를 통해 사용가능한 ip의 개수를 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc webpod
NAME     TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
webpod   ClusterIP   10.96.107.144   &lt;none&gt;        80/TCP    26h

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl patch svc webpod -p &#39;{&quot;spec&quot;:{&quot;type&quot;:&quot;LoadBalancer&quot;}}&#39;
service/webpod patched

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc webpod
NAME     TYPE           CLUSTER-IP      EXTERNAL-IP      PORT(S)        AGE
webpod   LoadBalancer   10.96.107.144   192.168.10.211   80:31968/TCP   26h

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ippools
NAME               DISABLED   CONFLICTING   IPS AVAILABLE   AGE
cilium-lb-ippool   false      False         4               3m11s</code></pre>
<h2 id="kubernetes-노드-내-통신">Kubernetes 노드 내 통신</h2>
<p>Kubernetes 노드에서 생성한 LoadBalancer의 ExternalIP를 통해 Pod와 정상적으로 통신되는 것을 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# LBIP=$(kubectl get svc webpod -o jsonpath=&#39;{.status.loadBalancer.ingress[0].ip}&#39;)

(⎈|HomeLab:N/A) root@k8s-ctr:~# curl -s $LBIP
Hostname: webpod-697b545f57-9c7vb
IP: 127.0.0.1
IP: ::1
IP: 172.20.2.54
IP: fe80::a8cc:5fff:fee9:7bf7
RemoteAddr: 172.20.0.74:52054
GET / HTTP/1.1
Host: 192.168.10.211
User-Agent: curl/8.5.0
Accept: */*

(⎈|HomeLab:N/A) root@k8s-ctr:~# for i in {1..100};  do kubectl exec -it curl-pod -- curl -s $LBIP | grep Hostname; done | sort | uniq -c | sort -nr

     36 Hostname: webpod-697b545f57-9r7tp
     33 Hostname: webpod-697b545f57-9c7vb
     31 Hostname: webpod-697b545f57-wls7t</code></pre>
<h2 id="외부-통신">외부 통신</h2>
<p>실습 환경 상에서 클러스터 외부에 있는 Router 노드에서 LoadBalancer의 ExternalIP로 통신을 시도해보면 정상 통신이 되지 않는다.</p>
<pre><code class="language-bash">root@router:~# LBIP=192.168.10.211

root@router:~# curl --connect-timeout 1 $LBIP
curl: (28) Failed to connect to 192.168.10.211 port 80 after 1001 ms: Timeout was reached

root@router:~# arping -i eth1 $LBIP -c 1
ARPING 192.168.10.211
Timeout

--- 192.168.10.211 statistics ---
1 packets transmitted, 0 packets received, 100% unanswered (0 extra)</code></pre>
<p>LoadBalancer 타입 서비스의 ExternalIP는 클러스터 내부 노드들이 해당 IP를 직접 소유하거나 응답할 수 있는 IP가 아니다.
외부에서 ExternalIP에 ARP ping을 보냈지만, ARP 응답이 없는데, 이는 ExternalIP가 클러스터 노드 네트워크 내에 실제로 할당된 IP가 아니기 때문에 클러스터 내부 노드에서 ARP 응답을 하지 않아 외부 라우터가 MAC 주소를 알 수 없기 때문이다.</p>
<p>ExternalIP를 인지하기 위해서는 외부 네트워크에 클러스터 내 특정 노드가 LoadBalancer IP를 소유하고 있음을 알릴 수 있는 수단이 필요한데, 대표적으로는 L2 Announcement가 있다.</p>
<h1 id="cilium-l2-announcement">Cilium L2 Announcement</h1>
<p>Cilium L2 Announcement는 클러스터 내 특정 노드가 LoadBalancer IP를 자신이 소유한 IP인 것처럼 외부 네트워크에 알리는 기능이다.
특정 노드가 LoadBalancer IP에 대한 ARP 요청을 받으면 직접 ARP 응답을 보내도록 처리하기 때문에, 외부 라우터는 LoadBalancer IP와 연결된 MAC 주소를 학습할 수 있게 된다.
결과적으로, 외부 네트워크에서 LoadBalancer IP로 보내는 패킷이 올바른 노드로 전달되어 통신이 가능해집진다.</p>
<p>Cilium에서 L2 Announcement 설정을 활성화 한 후 상태 변화를 확인해보자.</p>
<pre><code class="language-bash"># router
root@router:~# arping -i eth1 $LBIP -c 100000
ARPING 192.168.10.211
Timeout
Timeout
Timeout
Timeout
Timeout

(⎈|HomeLab:N/A) root@k8s-ctr:~# helm upgrade cilium cilium/cilium --namespace kube-system --version 1.18.0 --reuse-values \
   --set l2announcements.enabled=true --set l2NeighDiscovery.enabled=true &amp;&amp; watch -d kubectl get pod -A

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl rollout restart -n kube-system ds/cilium
daemonset.apps/cilium restarted

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg config --all | grep EnableL2Announcements
EnableL2Announcements             : true

(⎈|HomeLab:N/A) root@k8s-ctr:~# cilium config view | grep enable-l2
enable-l2-announcements                           true
enable-l2-neigh-discovery                         true

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip neigh show | grep -i reachable
192.168.10.101 dev eth1 lladdr 08:00:27:af:e6:df managed extern_learn REACHABLE
10.0.2.2 dev eth0 lladdr 52:55:0a:00:02:02 managed extern_learn REACHABLE
192.168.10.200 dev eth1 lladdr 08:00:27:0f:bd:dd managed extern_learn REACHABLE</code></pre>
<h2 id="l2-arp-모드-정책-설정">L2 ARP 모드 정책 설정</h2>
<p>CiliumL2AnnouncementPolicy를 통해 ARP를 광고할 대상 Service와 Node를 지정한다.
이 때, LoadBalancer IP Pool은 반드시 같은 네트워크 대역에 속한 노드에서만 유효하다는 제약사항이 있다.
이 때문에 실습 환경에서 다른 네트워크 대역에 존재하는 노드인 k8s-w0를 제외하고 정책을 설정해야 한다.
k8s-w0를 리더 노드로 지정하게 되면 리더 노드 선정 과정에서 동작 실패가 발생하게 된다.</p>
<pre><code class="language-bash">cat &lt;&lt; EOF | kubectl apply -f -
apiVersion: &quot;cilium.io/v2alpha1&quot;  # not v2
kind: CiliumL2AnnouncementPolicy
metadata:
  name: policy1
spec:
  serviceSelector:
    matchLabels:
      app: webpod
  nodeSelector:
    matchExpressions:
      - key: kubernetes.io/hostname
        operator: NotIn
        values:
          - k8s-w0
  interfaces:
  - ^eth[1-9]+
  externalIPs: true
  loadBalancerIPs: true
EOF</code></pre>
<h2 id="리더-노드-선정-확인-및-상태-확인">리더 노드 선정 확인 및 상태 확인</h2>
<p>정책 적용 직후, Cilium은 Lease 리소스를 생성하여 ARP 광고를 수행할 리더 노드를 선출한다.
해당 실습에서는 k8s-w1 노드가 리더로 선정되었다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl -n kube-system get lease | grep &quot;cilium-l2announce&quot;
cilium-l2announce-default-webpod       k8s-w1                                                                      4s

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl -n kube-system get lease/cilium-l2announce-default-webpod -o yaml | yq
{
  &quot;apiVersion&quot;: &quot;coordination.k8s.io/v1&quot;,
  &quot;kind&quot;: &quot;Lease&quot;,
  &quot;metadata&quot;: {
    &quot;creationTimestamp&quot;: &quot;2025-08-09T12:35:25Z&quot;,
    &quot;name&quot;: &quot;cilium-l2announce-default-webpod&quot;,
    &quot;namespace&quot;: &quot;kube-system&quot;,
    &quot;resourceVersion&quot;: &quot;76902&quot;,
    &quot;uid&quot;: &quot;98cf5f36-f611-47b4-aad6-de3f83312fc9&quot;
  },
  &quot;spec&quot;: {
    &quot;acquireTime&quot;: &quot;2025-08-09T12:35:25.085954Z&quot;,
    &quot;holderIdentity&quot;: &quot;k8s-w1&quot;,
    &quot;leaseDurationSeconds&quot;: 15,
    &quot;leaseTransitions&quot;: 0,
    &quot;renewTime&quot;: &quot;2025-08-09T12:35:45.144393Z&quot;
  }
}

(⎈|HomeLab:N/A) root@k8s-ctr:~# export CILIUMPOD1=$(kubectl get -l k8s-app=cilium pods -n kube-system --field-selector spec.nodeName=k8s-w1  -o jsonpath=&#39;{.items[0].metadata.name}&#39;)</code></pre>
<p>리더 노드의 Cilium 에이전트에서 현재 ARP 광고가 수행되는 인터페이스와 IP를 조회한다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -n kube-system $CILIUMPOD1 -- cilium-dbg shell -- db/show l2-announce
IP               NetworkInterface
192.168.10.211   eth1</code></pre>
<p>리더 노드에서 External LoadBalancer IP을 대상으로 ARP 요청을 보내면 ARP 패킷이 인터페이스를 통해 정상 전송된다.
또한 HTTP 요청도 정상 전송된다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# arping -i eth1 $LBIP -c 1000
ARPING 192.168.10.211
60 bytes from 08:00:27:af:e6:df (192.168.10.211): index=0 time=362.306 usec
60 bytes from 08:00:27:af:e6:df (192.168.10.211): index=1 time=330.916 usec
60 bytes from 08:00:27:af:e6:df (192.168.10.211): index=2 time=260.588 usec
60 bytes from 08:00:27:af:e6:df (192.168.10.211): index=3 time=255.961 usec
^C
--- 192.168.10.211 statistics ---
4 packets transmitted, 4 packets received,   0% unanswered (0 extra)
rtt min/avg/max/std-dev = 0.256/0.302/0.362/0.046 ms

(⎈|HomeLab:N/A) root@k8s-ctr:~# curl --connect-timeout 1 $LBIP
Hostname: webpod-697b545f57-wls7t
IP: 127.0.0.1
IP: ::1
IP: 172.20.1.73
IP: fe80::a076:2dff:fe73:24ac
RemoteAddr: 172.20.0.74:46438
GET / HTTP/1.1
Host: 192.168.10.211
User-Agent: curl/8.5.0
Accept: */*</code></pre>
<p>ARP 테이블을 조회해보면, LoadBalancer IP에 대응하는 MAC 주소가 현재 리더 노드인 k8s-w1의 eth1 MAC 주소와 일치한다.</p>
<p>Router 노드에서 ARP 테이블을 조회해보면 LoadBalancer IP(192.168.10.211)가 리더노드 MAC 주소와 매핑되어 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# arp -a | grep &#39;08:00:27:af:e6:df&#39;
k8s-w1 (192.168.10.101) at 08:00:27:af:e6:df [ether] on eth1

root@router:~# arp -a
? (192.168.10.101) at 08:00:27:af:e6:df [ether] on eth1
? (192.168.20.100) at 08:00:27:3a:be:fa [ether] on eth2
? (192.168.10.100) at 08:00:27:04:d1:9c [ether] on eth1
? (192.168.10.211) at 08:00:27:af:e6:df [ether] on eth1
? (10.0.2.3) at 52:55:0a:00:02:03 [ether] on eth0
_gateway (10.0.2.2) at 52:55:0a:00:02:02 [ether] on eth0</code></pre>
<p>LoadBalancer IP에 다수 요청을 보내 서비스가 모든 Pod에 분산되는지를 확인해보면, 모든 Pod에 정상 분산되고 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# for i in {1..100};  do kubectl exec -it curl-pod -- curl -s $LBIP | grep Hostname; done | sort | uniq -c | sort -nr
     43 Hostname: webpod-697b545f57-wls7t
     29 Hostname: webpod-697b545f57-9r7tp
     28 Hostname: webpod-697b545f57-9c7vb</code></pre>
<h2 id="failover">Failover</h2>
<p>리더 노드의 Cilium Agent는 주기적으로 lease를 갱신하며 리더임을 선언하는데, 리더 노드에 네트워크 장애 등 특정 이유로 정상 동작을 하지 못하는 상황이면 Lease 갱신이 실패 된다.
Kubernetes API 서버는 <code>leaseDurationSeconds</code> 이후 Lease를 만료로 인식하게된다.</p>
<p>Lease가 만료되면, 다른 노드들의 Cilium Agent들이 API 서버에 Lease 획득 요청을 보낸다. 
Kubernetes API 서버는 Lease를 신규 획득하려는 노드들 중 첫번째 요청을 승인하고, 승인받은 노드는 새로운 리더가 된다.</p>
<p>리더가 된 노드의 Cilium Agent는 즉시, 자신이 소유한 LoadBalancer IP에 대해 ARP 응답을 송출한다.</p>
<p>이를 실습으로 확인해본다.</p>
<pre><code class="language-bash"># Failover 전 arp 상태 : 리더 노드 = k8s-w1
root@router:~# arp -a
? (192.168.10.101) at 08:00:27:af:e6:df [ether] on eth1
? (192.168.20.100) at 08:00:27:3a:be:fa [ether] on eth2
? (192.168.10.100) at 08:00:27:04:d1:9c [ether] on eth1
? (192.168.10.211) at 08:00:27:af:e6:df [ether] on eth1
? (10.0.2.3) at 52:55:0a:00:02:03 [ether] on eth0
_gateway (10.0.2.2) at 52:55:0a:00:02:02 [ether] on eth0

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl -n kube-system get lease | grep &quot;cilium-l2announce&quot;
cilium-l2announce-default-webpod       k8s-w1                                                                      11m

# k8s-w1 reboot를 통한 장애 발생
k8s-w1 reboot

# 새로운 리더 노드 선출 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl -n kube-system get lease | grep &quot;cilium-l2announce&quot;
cilium-l2announce-default-webpod                                                                                   11m
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl -n kube-system get lease | grep &quot;cilium-l2announce&quot;
cilium-l2announce-default-webpod       k8s-ctr

# 새로운 리더 선출 이후 arp 송신 확인
root@router:~# arping -i eth1 $LBIP -c 100000
ARPING 192.168.10.211
60 bytes from 08:00:27:04:d1:9c (192.168.10.211): index=0 time=297.774 usec
60 bytes from 08:00:27:04:d1:9c (192.168.10.211): index=1 time=262.590 usec
60 bytes from 08:00:27:04:d1:9c (192.168.10.211): index=2 time=233.700 usec
60 bytes from 08:00:27:04:d1:9c (192.168.10.211): index=3 time=276.347 usec

root@router:~# arp -a
? (192.168.10.101) at 08:00:27:af:e6:df [ether] on eth1
? (192.168.20.100) at 08:00:27:3a:be:fa [ether] on eth2
? (192.168.10.100) at 08:00:27:04:d1:9c [ether] on eth1
? (192.168.10.211) at 08:00:27:04:d1:9c [ether] on eth1
? (10.0.2.3) at 52:55:0a:00:02:03 [ether] on eth0
_gateway (10.0.2.2) at 52:55:0a:00:02:02 [ether] on eth0

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip addr show dev eth1
3: eth1: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 08:00:27:04:d1:9c brd ff:ff:ff:ff:ff:ff
    altname enp0s9
    inet 192.168.10.100/24 brd 192.168.10.255 scope global eth1
       valid_lft forever preferred_lft forever
    inet6 fe80::a00:27ff:fe04:d19c/64 scope link
       valid_lft forever preferred_lft forever</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Cilium - Networking - 파드 간 통신 (1) IPAM, Routing, Masquerading]]></title>
            <link>https://velog.io/@_gyullbb/Cilium-Networking-1-IPAM-Routing-Masquerading</link>
            <guid>https://velog.io/@_gyullbb/Cilium-Networking-1-IPAM-Routing-Masquerading</guid>
            <pubDate>Sat, 02 Aug 2025 16:07:23 GMT</pubDate>
            <description><![CDATA[<h1 id="ipam-ip-address-management">IPAM (IP Address Management)</h1>
<p>IPAM이란 IP Address Management의 약자로, 네트워크의 IP 주소 할당을 자동화하고, 사용 상태를 추적하며, 충돌이나 낭비 없이 효과적으로 IP를 관리하는 시스템이다.</p>
<p>Cilium은 기본적으로 eBPF를 기반으로 한 고성능 네트워킹을 제공하는데, 이때 여러 IPAM 모드를 통해 다양한 IP 할당 정책 및 전략을 지원한다.</p>
<p>Cilium의 IPAM에는 다음과 같은 종류가 있다. <a href="https://docs.cilium.io/en/stable/network/concepts/ipam/">공식문서 참조</a></p>
<ul>
<li>Cluster Scope (Default)</li>
<li>Kubernetes Host Scope</li>
<li>Multi-Pool (Beta)</li>
<li>CRD-Backed</li>
<li>기타 외부 IPAM (Azure IPAM, AWS ENI, Google Kubernetes Engine ...)</li>
</ul>
<p>공식문서에 적혀있듯이 IPAM은 한번 설정한 이후 변경을 하지 않는 것을 권장한다.
라이브 환경에서 IPAM 모드를 변경하면 기존 워크로드의 지속적인 연결 중단이 발생할 수 있으며 IPAM 모드를 변경하는 가장 안전한 방법은 새로운 IPAM 구성으로 새로운 Kubernetes 클러스터를 설치하는 것이다.</p>
<p>각 IPAM에 대해 상세히 알아보자.</p>
<h2 id="kubernetes-host-scope">Kubernetes Host Scope</h2>
<pre><code class="language-yaml">ipam:
  mode: kubernetes</code></pre>
<p>Kubernetes Host Scope IPAM 모드는 노드 단위 IP 풀을 사용하는 방식이다.
각 노드는 고유한 PodCIDR을 가지고 있으며, Cilium은 해당 범위 내에서 Pod에 IP를 할당한다.</p>
<p>Kubernetes의 <code>kube-controller-manager</code>가 <code>--allocate-node-cidrs</code> 옵션을 통해 노드별 PodCIDR을 할당하면,
Cilium 에이전트는 v1.Node 리소스의 spec.podCIDR 또는 spec.podCIDRs 필드를 참조하여 IP를 배정한다.</p>
<h3 id="설정-값-확인">설정 값 확인</h3>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl cluster-info dump | grep -m 2 -E &quot;cluster-cidr|service-cluster-ip-range&quot;
                            &quot;--service-cluster-ip-range=10.96.0.0/16&quot;,
                            &quot;--cluster-cidr=10.244.0.0/16&quot;,
(⎈|HomeLab:N/A) root@k8s-ctr:~# cilium config view | grep kubernetes
ipam                                              kubernetes
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get nodes -o jsonpath=&#39;{range .items[*]}{.metadata.name}{&quot;\t&quot;}{.spec.podCIDR}{&quot;\n&quot;}{end}&#39;
k8s-ctr    10.244.0.0/24
k8s-w1    10.244.1.0/24
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl describe pod -n kube-system kube-controller-manager-k8s-ctr | grep kube-controller-manager -A3
...
    Command:
      kube-controller-manager
      --allocate-node-cidrs=true
      --authentication-kubeconfig=/etc/kubernetes/controller-manager.conf
      --authorization-kubeconfig=/etc/kubernetes/controller-manager.conf</code></pre>
<p>Kubernetes Host Scope IPAM설정에서 Sample Application을 배포하여 상세 사항을 확인해보자.
Hubble로 모니터링을 하기 전 알아두면 좋을 bpf trace 메시지를 코드와 함께 정리해본다. Cilium 관련 첫번째 글에서  분석한 Cilium 서비스 통신 코드 분석의 연장선이다. </p>
<h3 id="bpf-tracemessage">BPF tracemessage</h3>
<h4 id="1-pre-xlate-fwd">1) pre-xlate-fwd</h4>
<p>ClusterIP를 받고, NAT가 수행되기 전 메시지에 해당한다.</p>
<pre><code class="language-c">//cilium/bpf/bpf_sock.c
static __always_inline int __sock4_xlate_fwd(struct bpf_sock_addr *ctx,
                         struct bpf_sock_addr *ctx_full,
                         const bool udp_only)
{
  ...
  //서비스 매칭을 시도한다.
    svc = lb4_lookup_service(&amp;key, true);
    if (!svc) {
        /* Restore the original key&#39;s protocol as lb4_lookup_service
         * has overwritten it.
         */
        lb4_key_set_protocol(&amp;key, protocol);
        svc = sock4_wildcard_lookup_full(&amp;key, in_hostns);
    }
  ...
  //pre-xlate-fwd 메시지를 보낸다.
    send_trace_sock_notify4(ctx_full, XLATE_PRE_DIRECTION_FWD, dst_ip,
                bpf_ntohs(dst_port));
...
}</code></pre>
<h4 id="2-post-xlate-fwd">2) post-xlate-fwd</h4>
<p>NAT 직전에 pre-xlate-fwd 메시지가 보내지고 NAT(서비스 IP -&gt; backend pod IP)가 실제 수행된다.</p>
<pre><code class="language-c">//cilium/bpf/bpf_sock.c
static __always_inline int __sock4_xlate_fwd(struct bpf_sock_addr *ctx,
                         struct bpf_sock_addr *ctx_full,
                         const bool udp_only)
{
  ...
  //pre-xlate-fwd 메시지를 보낸다.
    send_trace_sock_notify4(ctx_full, XLATE_PRE_DIRECTION_FWD, dst_ip,
                bpf_ntohs(dst_port));
    ...
    //backend 정보 수집
        backend_id = backend_slot-&gt;backend_id;
        backend = __lb4_lookup_backend(backend_id);
  ...
  //post-xlate-fwd 메시지를 보낸다.
    send_trace_sock_notify4(ctx_full, XLATE_POST_DIRECTION_FWD, backend-&gt;address,
                bpf_ntohs(backend-&gt;port));
...
  //NAT 수행
    ctx-&gt;user_ip4 = backend-&gt;address;
    ctx_set_port(ctx, backend-&gt;port);
}</code></pre>
<h4 id="3-pre-xlate-rev">3) pre-xlate-rev</h4>
<p>RevNAT 변환 전, 현재 패킷의 목적지 IP/포트 상태와 함께 pre-xlate-rev 메시지를 보낸다.</p>
<pre><code class="language-c">//cilium/bpf/bpf_sock.c
static __always_inline int __sock4_xlate_rev(struct bpf_sock_addr *ctx,
                         struct bpf_sock_addr *ctx_full)
{
  ...
  //pre-xlate-rev 메시지를 보낸다.
    send_trace_sock_notify4(ctx_full, XLATE_PRE_DIRECTION_REV, dst_ip,
                bpf_ntohs(dst_port));
...
  //RevNAT 매핑 테이블을 조회한다.
    val = map_lookup_elem(&amp;cilium_lb4_reverse_sk, &amp;key);
...
}</code></pre>
<h4 id="4-post-xlate-rev">4) post-xlate-rev</h4>
<p>RevNAT 변환 후에 post-xlate-rev 메시지를 보내 패킷의 목적지 IP/포트가 원래 서비스 IP/포트로 바뀐 시점을 알린다.</p>
<pre><code class="language-c">//cilium/bpf/bpf_sock.c
static __always_inline int __sock4_xlate_rev(struct bpf_sock_addr *ctx,
                         struct bpf_sock_addr *ctx_full)
{
  ...
  //RevNAT 매핑 테이블을 조회한다.
    val = map_lookup_elem(&amp;cilium_lb4_reverse_sk, &amp;key);
...
    //RevNAT 변환을 수행한다.
        ctx-&gt;user_ip4 = val-&gt;address;
        ctx_set_port(ctx, val-&gt;port);
    //post-xlate-rev 메시지를 보낸다.
        send_trace_sock_notify4(ctx_full, XLATE_POST_DIRECTION_REV, val-&gt;address,
                    bpf_ntohs(val-&gt;port));
        return 0;
    }</code></pre>
<h4 id="5-to-endpoint">5) to-endpoint</h4>
<p>정책 통과 후 실제 목적지 Pod로 트래픽이 전달될 때 발생하는 이벤트이다.</p>
<pre><code class="language-c">//cilium/bpf/bpf_lxc.c
static __always_inline int
ipv4_policy(struct __ctx_buff *ctx, struct iphdr *ip4, __u32 src_label,
        struct ipv4_ct_tuple *tuple_out, __s8 *ext_err, __u16 *proxy_port,
        bool from_tunnel)
{
    //1) 정책이 통과되고
        if (verdict != CTX_ACT_OK || ret != CT_ESTABLISHED)
  ...
  //2) Conntrack이 신규 생성되었으며
    if (ret == CT_NEW) 
  ...
  //3) proxy로 redirect되지 않고, 바로 Pod로 전달하는 경로이면
    if (*proxy_port &gt; 0)
        goto redirect_to_proxy;

    //TRACE_TO_LXC 즉 to-endpoint 메시지를 보낸다.
    send_trace_notify4(ctx, TRACE_TO_LXC, src_label, SECLABEL_IPV4, orig_sip,
               LXC_ID, ifindex, trace.reason, trace.monitor);
...

}</code></pre>
<p>참고로 TRACE 변수에 대한 값은 아래에서 확인 가능하다.</p>
<pre><code class="language-go">//cilium/pkg/monitoring/api/types.go
var TraceObservationPoints = map[uint8]string{
    TraceToLxc:       &quot;to-endpoint&quot;,
    TraceToNetwork:   &quot;to-network&quot;,
...
}</code></pre>
<h4 id="6-to-network">6) to-network</h4>
<p>터널 모드가 아닐 경우에 한해서 패킷이 노드를 벗어나기 직전 TRACE_TO_NETWORK 메시지를 발생시킨다.</p>
<pre><code class="language-c">//cilium/bpf/bpf_lxc.c
static __always_inline int handle_ipv4_from_lxc(struct __ctx_buff *ctx, __u32 *dst_sec_identity,
                        __s8 *ext_err)
{
  ...
  //터널 모드 조건 끝
  #endif /* TUNNEL_MODE */

  //터널 모드가 아닐 경우 + 호스트 라우팅이 되어야 하는 경우
    if (is_defined(ENABLE_HOST_ROUTING)) {
        int oif = 0;

    //라우팅 테이블을 조회해서 목적지 IP에 맞는 인터페이스 번호를 채워넣는다.
        ret = fib_redirect_v4(ctx, ETH_HLEN, ip4, false, false, ext_err, &amp;oif);
        if (fib_ok(ret))
      //TRACE_TO_NETWORK 즉 to-network 메시지를 보낸다.
            send_trace_notify(ctx, TRACE_TO_NETWORK, SECLABEL_IPV4,
                      *dst_sec_identity, TRACE_EP_ID_UNKNOWN, oif,
                      trace.reason, trace.monitor, bpf_htons(ETH_P_IP));
        return ret;
    }
  //커널 네트워크 스택으로 패킷을 넘긴다.
    goto pass_to_stack;
}</code></pre>
<h3 id="sample-application">Sample Application</h3>
<p>Sample Application을 배포하여 통신 모니터링을 진행해본다.
<img src="https://velog.velcdn.com/images/_gyullbb/post/48d72f82-4c23-4021-9cfb-576324b4f1cc/image.png" alt=""></p>
<h4 id="같은-노드에서의-통신">같은 노드에서의 통신</h4>
<p>kubernetes IPAM 모드에서는 노드마다 Pod CIDR을 갖고 있으며, 같은 노드 내 Pod 간 통신 시 Cilium은 해당 노드의 인터페이스와 endpoint map만으로 로컬 라우팅을 처리한다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -o wide
NAME                      READY   STATUS    RESTARTS   AGE     IP             NODE      NOMINATED NODE   READINESS GATES
curl-pod                  1/1     Running   0          5d23h   10.244.0.164   k8s-ctr   &lt;none&gt;           &lt;none&gt;
webpod-697b545f57-hkjzn   1/1     Running   0          5d23h   10.244.0.111   k8s-ctr   &lt;none&gt;           &lt;none&gt;
webpod-697b545f57-qz7vp   1/1     Running   0          5d23h   10.244.1.47    k8s-w1    &lt;none&gt;           &lt;none&gt;

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it curl-pod -- curl 10.244.0.111 | grep Hostname
Hostname: webpod-697b545f57-hkjzn</code></pre>
<p>curl-pod에서 같은 노드 내에 위치한 webpod를 호출시키는 상황을 hubble로 관측하면 다음과 같다.</p>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/c4c35617-1899-46ad-9282-518768246c11/image.png" alt=""></p>
<p><strong>통신 시나리오:</strong>
curl-pod → ClusterIP(web-svc) → webpod (같은 노드 상의 Pod)</p>
<p>

<p><strong>IPAM 모드: kubernetes</strong>
→ 각 노드에 PodCIDR가 할당되며, 이 범위 내에서 Pod IP가 고정적으로 할당됨.</p>
<p><strong>Routing : Native Routing</strong><p></p>
<p>

<p><strong>모니터링 흐름:</strong></p>
<p>1) curl-pod에서 webpod의 ClusterIP 호출</p>
<p>2) pre-xlate-fwd</p>
<ul>
<li>bpf_sock 계층에서 패킷 수신 직후 발생.</li>
<li>NAT 전 상태로 ClsuterIP를 그대로 반환.</li>
</ul>
<p>3) post-xlate-fwd</p>
<ul>
<li>bpf_sock 계층에서 service IP → backend pod IP로 NAT된 이후 발생</li>
</ul>
<p>4) to-endpoint</p>
<ul>
<li>Cilium endpoint map을 통해 패킷이 webpod로 전달.</li>
<li>webpod에서 보낸 응답도 curl-pod로 도달.</li>
</ul>
<p>5) pre-xlate-rev</p>
<ul>
<li>webpod가 응답을 보낼 때 발생.</li>
<li>이후 bpf_sock 계층에서 Reverse NAT 여부를 탐색</li>
</ul>
<p>❌ cf) post-xlate-rev 없음</p>
<blockquote>
<p>왜 없을까? 에 대해 chatGPT에게 물어본 결과 아래와 같은 답변을 얻었다. <p>
kubernetes IPAM 모드에서 Pod 간 통신은, 노드가 다르더라도 Cilium이 IP 경로를 직선적으로 계산할 수 있다면, 응답 시 Reverse NAT 처리를 생략할 수 있습니다.
이때 Cilium은 cilium_lb4_reverse_sk에 항목을 생성하지 않으며, 이에 따라 post-xlate-rev 트레이스 이벤트가 발생하지 않습니다. 이는 같은 노드 간 통신뿐 아니라, 다른 노드 간 통신에서도 동일하게 발생할 수 있는 최적화 동작입니다.</p>
</blockquote>
<h4 id="다른-노드-간-통신">다른 노드 간 통신</h4>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/fd23e78f-b9f8-4750-9d46-cc098cd35833/image.png" alt=""></p>
<p><strong>통신 시나리오:</strong>
curl-pod → ClusterIP(web-svc) → webpod (다른 노드 상의 Pod)</p>
<p>

<p><strong>Routing : Native Routing</strong><p></p>
<p><strong>모니터링 흐름:</strong></p>
<p>1) curl-pod에서 webpod의 ClusterIP 호출</p>
<p>2) pre-xlate-fwd</p>
<ul>
<li>bpf_sock 계층에서 패킷 수신 직후 발생.</li>
<li>NAT 전 상태로 ClsuterIP를 그대로 반환.</li>
</ul>
<p>3) post-xlate-fwd</p>
<ul>
<li>bpf_sock 계층에서 service IP → backend pod IP로 NAT된 이후 발생</li>
</ul>
<p>4) to-network</p>
<ul>
<li>curl-pod가 webpod에 보내는 패킷이 다른 노드로 향하기 전 물리 네트워크 인터페이스(eth0 등)로 전달되는 시점에 발생 (Native Routing)</li>
</ul>
<p>5) to-endpoint</p>
<ul>
<li>webpod에서 보낸 응답 패킷이 Cilium endpoint map을 통해 curl-pod에 도달.</li>
</ul>
<p>6) pre-xlate-rev</p>
<ul>
<li>webpod가 응답을 보낼 때 발생.</li>
<li>이후 bpf_sock 계층에서 Reverse NAT 여부를 탐색</li>
</ul>
<h2 id="cluster-scope-default">Cluster Scope (Default)</h2>
<pre><code class="language-yaml">ipam:
  podCIDRs: 10.1.1.0/24 (예시)</code></pre>
<p>Cluster Scope IPAM 모드는 각 노드에 노드별 PodCIDR을 할당하고 각 노드에 호스트 범위 할당기를 사용하여 IP를 할당하는 방식이다.</p>
<p>Kubernetes Host Scope IPAM 모드와의 차이점은 Kubernetes가 Kubernetes v1.Node 리소스를 통해 노드별 PodCIDR을 할당하는 대신, Cilium 운영자가 v2.CiliumNode 리소스(CRD)를 통해 노드별 PodCIDR을 관리한다는 점이다.</p>
<p>각 노드는 고유한 PodCIDR을 가지고 있으며, Cilium은 해당 범위 내에서 Pod에 IP를 할당한다.</p>
<p>Kubernetes의 <code>kube-controller-manager</code>가 <code>--allocate-node-cidrs</code> 옵션을 통해 노드별 PodCIDR을 할당하면,
Cilium 에이전트는 v1.Node 리소스의 spec.podCIDR 또는 spec.podCIDRs 필드를 참조하여 IP를 배정한다.</p>
<p>앞서 설치한 Kubernetes Host Scope IPAM모드를 Cluster Scope 모드로 마이그레이션 해보자.</p>
<pre><code class="language-bash">
# Cluster Scopre 로 설정 변경
helm upgrade cilium cilium/cilium --namespace kube-system --reuse-values \
--set ipam.mode=&quot;cluster-pool&quot; --set ipam.operator.clusterPoolIPv4PodCIDRList={&quot;172.20.0.0/16&quot;} --set ipv4NativeRoutingCIDR=172.20.0.0/16

kubectl -n kube-system rollout restart deploy/cilium-operator # 오퍼레이터 재시작 필요
kubectl -n kube-system rollout restart ds/cilium

(⎈|HomeLab:N/A) root@k8s-ctr:~# cilium config view | grep ^ipam
ipam                                              cluster-pool

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ciliumnode -o json | grep podCIDRs -A2
                    &quot;podCIDRs&quot;: [
                        &quot;172.20.1.0/24&quot;
                    ],
--
                    &quot;podCIDRs&quot;: [
                        &quot;172.20.0.0/24&quot;
                    ],

# pod IP 재할당을 위해서는 pod들 재기동 필요
# 
kubectl delete ciliumnode k8s-w1
kubectl -n kube-system rollout restart ds/cilium

#
kubectl delete ciliumnode k8s-ctr
kubectl -n kube-system rollout restart ds/cilium

#
kubectl -n kube-system rollout restart deploy/hubble-relay deploy/hubble-ui
kubectl -n cilium-monitoring rollout restart deploy/prometheus deploy/grafana
kubectl rollout restart deploy/webpod
kubectl delete pod curl-pod

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ciliumendpoints.cilium.io -A
NAMESPACE           NAME                            SECURITY IDENTITY   ENDPOINT STATE   IPV4           IPV6
cilium-monitoring   grafana-5554b5b99d-ngp5c        63656               ready            172.20.0.34
cilium-monitoring   prometheus-56564cbb6f-pgtzp     60653               ready            172.20.0.1
default             curl-pod                        28108               ready            172.20.1.147
default             webpod-5f99d864bd-6mbxt         5610                ready            172.20.0.125
default             webpod-5f99d864bd-7kwbv         5610                ready            172.20.1.88
kube-system         coredns-674b8bbfcf-n4wn5        63747               ready            172.20.1.239
kube-system         coredns-674b8bbfcf-wnqpt        63747               ready            172.20.0.195
kube-system         hubble-relay-7767cb5659-r6kjd   25120               ready            172.20.0.7
kube-system         hubble-ui-869b77984-z9crh       36288               ready            172.20.0.149</code></pre>
<p>위 실습에서 진행했다시피 IPAM모드 변경을 위해서는 cilium 관련 리소스 재기동이 필요하기 때문에, 운영중 IPAM 모드 마이그레이션은 최대한 지양해야 한다.</p>
<h1 id="routing">Routing</h1>
<p>Routing에는 크게 두가지 방식이 있다.
하나는 앞에서 계속 다뤄왔던 Native-Routing 방식이고, 다른 하나는 Encapsulation 방식이다.</p>
<h2 id="native-routing-vs-encapsulation-비교">Native Routing vs Encapsulation 비교</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>Native Routing (직접 라우팅)</th>
<th>Encapsulation (캡슐화, 예: VXLAN)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>트래픽 처리 방식</strong></td>
<td>Pod IP 간 라우팅 테이블에 직접 경로 등록하여 노드 간 직접 라우팅</td>
<td>트래픽을 VXLAN 같은 터널 프로토콜로 캡슐화하여 터널을 통해 전달</td>
</tr>
<tr>
<td><strong>네트워크 오버헤드</strong></td>
<td>적음 (캡슐화 없음)</td>
<td>캡슐화 오버헤드 존재 (UDP 헤더 + VXLAN 헤더 추가)</td>
</tr>
<tr>
<td><strong>MTU 이슈</strong></td>
<td>기본 MTU 사용 (대체로 1500)</td>
<td>캡슐화로 MTU 감소, Path MTU 문제 발생 가능</td>
</tr>
<tr>
<td><strong>라우팅 설정 복잡도</strong></td>
<td>노드 라우팅 테이블에 Pod CIDR 경로 추가 필요</td>
<td>라우팅 테이블 복잡도 낮음, 터널 엔드포인트 간 패킷 전달</td>
</tr>
<tr>
<td><strong>네트워크 정책 적용 지점</strong></td>
<td>노드와 Pod 모두에서 정책 적용 가능</td>
<td>캡슐화로 인해 터널 종단점에서 정책 적용 필요 (터널 내부는 패킷 변경)</td>
</tr>
<tr>
<td><strong>네트워크 환경 요구사항</strong></td>
<td>클러스터 내 모든 노드가 Pod CIDR를 라우팅 가능해야 함</td>
<td>중간 네트워크(라우터, 스위치)에서 터널 프로토콜 지원 필요 없음</td>
</tr>
<tr>
<td><strong>멀티테넌시 / 복잡한 네트워크</strong></td>
<td>복잡한 네트워크 환경에서 라우팅 관리 어려움</td>
<td>복잡한 네트워크 환경에서 터널로 격리 및 네트워크 분리 가능</td>
</tr>
<tr>
<td><strong>디버깅 난이도</strong></td>
<td>비교적 쉬움</td>
<td>캡슐화로 인해 트래픽 분석 및 디버깅 어려움</td>
</tr>
<tr>
<td><strong>성능 영향</strong></td>
<td>일반적으로 더 낮은 지연 및 CPU 오버헤드</td>
<td>캡슐화/디캡슐화에 따른 CPU 오버헤드 및 약간의 지연 발생</td>
</tr>
<tr>
<td><strong>노드 간 트래픽 흐름</strong></td>
<td>노드 IP 기반 직접 전달</td>
<td>터널 엔드포인트 간 캡슐화된 패킷 전달</td>
</tr>
</tbody></table>
<p>Native-Routing 통신에 관해서는 앞선 글과 위 실습에서 다루었기 때문에 이번에는 vxlan Encapsulation 실습을 진행해본다.</p>
<h2 id="vxlan-encapsulation">vxlan encapsulation</h2>
<h3 id="설치">설치</h3>
<pre><code class="language-bash">helm install cilium cilium/cilium --version 1.17.6 --namespace kube-system \
  --set k8sServiceHost=192.168.10.100 \
  --set k8sServicePort=6443 \
  --set ipam.mode=&quot;kubernetes&quot; \
  --set k8s.requireIPv4PodCIDR=true \
  --set ipv4NativeRoutingCIDR=10.244.0.0/16 \
  --set routingMode=tunnel \
  --set encapsulation=vxlan \
  --set autoDirectNodeRoutes=false \
  --set endpointRoutes.enabled=true \
  --set kubeProxyReplacement=true \
  --set bpf.masquerade=true \
  --set installNoConntrackIptablesRules=false \
  --set endpointHealthChecking.enabled=false \
  --set healthChecking=false \
  --set hubble.enabled=true \
  --set hubble.relay.enabled=true \
  --set hubble.ui.enabled=true \
  --set hubble.ui.service.type=NodePort \
  --set hubble.ui.service.nodePort=30003 \
  --set prometheus.enabled=true \
  --set operator.prometheus.enabled=true \
  --set hubble.metrics.enableOpenMetrics=true \
  --set hubble.metrics.enabled=&quot;{dns,drop,tcp,flow,port-distribution,icmp,httpV2:exemplars=true;labelsContext=source_ip\,source_namespace\,source_workload\,destination_ip\,destination_namespace\,destination_workload\,traffic_direction}&quot; \
  --set operator.replicas=1 \
  --set debug.enabled=true</code></pre>
<h3 id="상태-확인">상태 확인</h3>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# ip -c route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100
10.0.2.2 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.0.2.3 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.10.0.0/16 via 192.168.10.200 dev eth1 proto static
10.244.0.4 dev cilium_host proto kernel scope link
10.244.0.37 dev lxceae0fb68caf5 proto kernel scope link
10.244.0.145 dev lxcd1114ba6891e proto kernel scope link
10.244.0.169 dev lxc8810a263de82 proto kernel scope link
10.244.0.203 dev lxc1fa09f003b38 proto kernel scope link
10.244.0.209 dev lxc158567b48672 proto kernel scope link
10.244.1.0/24 via 10.244.0.4 dev cilium_host proto kernel src 10.244.0.4 mtu 1450
192.168.10.0/24 dev eth1 proto kernel scope link src 192.168.10.100

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip addr show 
4: cilium_net@cilium_host: &lt;BROADCAST,MULTICAST,NOARP,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 6a:87:2e:a2:87:d7 brd ff:ff:ff:ff:ff:ff
    inet6 fe80::6887:2eff:fea2:87d7/64 scope link
       valid_lft forever preferred_lft forever
5: cilium_host@cilium_net: &lt;BROADCAST,MULTICAST,NOARP,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether e2:0f:04:be:e7:f1 brd ff:ff:ff:ff:ff:ff
    inet 10.244.0.4/32 scope global cilium_host
       valid_lft forever preferred_lft forever
    inet6 fe80::e00f:4ff:febe:e7f1/64 scope link
       valid_lft forever preferred_lft forever
24: cilium_vxlan: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UNKNOWN group default
    link/ether 36:da:0d:c5:8e:26 brd ff:ff:ff:ff:ff:ff
    inet6 fe80::34da:dff:fec5:8e26/64 scope link
       valid_lft forever preferred_lft forever</code></pre>
<h3 id="통신-확인1---tcpdump">통신 확인(1) - tcpdump</h3>
<p>VXLAN은 기본적으로 UDP 포트 8472를 통해 encapsulated 트래픽을 전송한다. 실제 HTTP 트래픽이 VXLAN encapsulation 안에 포함되어 있기 때문에, eth1에서는 HTTP (TCP/80) 패킷을 직접 볼 수 없고 UDP 포트 8472를 통해 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it curl-pod -- curl webpod | grep Hostname

(⎈|HomeLab:N/A) root@k8s-ctr:~# tcpdump -i eth1 udp port 8472 -nn | grep 10.244.1.108
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
IP 10.244.0.25.42626 &gt; 10.244.1.108.80: Flags [S], seq 384054985, win 64860, options [mss 1410,sackOK,TS val 3044357904 ecr 0,nop,wscale 7], length 0
IP 10.244.1.108.80 &gt; 10.244.0.25.42626: Flags [S.], seq 1361241289, ack 384054986, win 64308, options [mss 1410,sackOK,TS val 1148259990 ecr 3044357904,nop,wscale 7], length 0
IP 10.244.0.25.42626 &gt; 10.244.1.108.80: Flags [.], ack 1, win 507, options [nop,nop,TS val 3044357905 ecr 1148259990], length 0
IP 10.244.0.25.42626 &gt; 10.244.1.108.80: Flags [P.], seq 1:71, ack 1, win 507, options [nop,nop,TS val 3044357905 ecr 1148259990], length 70: HTTP: GET / HTTP/1.1
IP 10.244.1.108.80 &gt; 10.244.0.25.42626: Flags [.], ack 71, win 502, options [nop,nop,TS val 1148259991 ecr 3044357905], length 0
IP 10.244.1.108.80 &gt; 10.244.0.25.42626: Flags [P.], seq 1:322, ack 71, win 502, options [nop,nop,TS val 1148259993 ecr 3044357905], length 321: HTTP: HTTP/1.1 200 OK
IP 10.244.0.25.42626 &gt; 10.244.1.108.80: Flags [.], ack 322, win 505, options [nop,nop,TS val 3044357908 ecr 1148259993], length 0
IP 10.244.0.25.42626 &gt; 10.244.1.108.80: Flags [F.], seq 71, ack 322, win 505, options [nop,nop,TS val 3044357908 ecr 1148259993], length 0
IP 10.244.1.108.80 &gt; 10.244.0.25.42626: Flags [F.], seq 322, ack 72, win 502, options [nop,nop,TS val 1148259994 ecr 3044357908], length 0
IP 10.244.0.25.42626 &gt; 10.244.1.108.80: Flags [.], ack 323, win 505, options [nop,nop,TS val 3044357909 ecr 1148259994], length 0
^C73 packets captured
73 packets received by filter
0 packets dropped by kernel

(⎈|HomeLab:N/A) root@k8s-ctr:~# tcpdump -i eth1 tcp port 80 -nn | grep 10.244.1.108
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
^C0 packets captured
0 packets received by filter
0 packets dropped by kernel</code></pre>
<p>termshark 도구를 활용하여 udp port 8472 tcpdump 결과를 확인해보면 패킷이 캡슐화 되어 패킷 헤더의 Src, Dst IP가 노드의 eth1 IP임을 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# tcpdump -i eth1 udp port 8472 -w /tmp/icmp.pcap

(⎈|HomeLab:N/A) root@k8s-ctr:~# termshark -r /tmp/icmp.pcap</code></pre>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/1f64a25d-743e-4bdc-ad43-951ecd6103d0/image.png" alt=""></p>
<h3 id="통신-확인2---hubble">통신 확인(2) - hubble</h3>
<p>vxlan 모드에서 다른 노드 위 Pod간 통신을 모니터링해본다.
<img src="https://velog.velcdn.com/images/_gyullbb/post/7a68437e-50af-4d88-8763-81168392c2a5/image.png" alt=""></p>
<p><strong>통신 시나리오:</strong>
curl-pod → ClusterIP(web-svc) → webpod (다른 노드 상의 Pod)</p>
<p>

<p><strong>Routing : vxlan Routing</strong><p></p>
<p><strong>모니터링 흐름:</strong></p>
<p>1) curl-pod에서 webpod의 ClusterIP 호출</p>
<p>2) pre-xlate-fwd</p>
<ul>
<li>bpf_sock 계층에서 패킷 수신 직후 발생.</li>
<li>아직 서비스 IP 그대로, NAT 전 상태.</li>
</ul>
<p>3) post-xlate-fwd</p>
<ul>
<li>bpf_sock 계층에서 service IP → backend pod IP로 NAT된 이후 발생</li>
</ul>
<p>4) to-overlay</p>
<ul>
<li>curl-pod가 다른 노드에 있는 webpod에 패킷을 보낼 때 패킷이 다른 노드로 향하기 전 캡슐화 정보를 세팅하기 전 발생 (vxlan Routing)</li>
<li>이후 캡슐화 후 패킷을 터널 인터페이스로 리다이렉션 요청 반환</li>
</ul>
<p>5) to-endpoint</p>
<ul>
<li>webpod에서 보낸 응답 패킷이 Cilium endpoint map을 통해 curl-pod에 도달.</li>
</ul>
<p>6) pre-xlate-rev</p>
<ul>
<li>webpod가 응답을 보낼 때 발생.</li>
<li>이후 bpf_sock 계층에서 Reverse NAT 여부를 탐색</li>
</ul>
<p>위에서 분석한 것과 같이 to-overaly BPF tracemessage 코드를 분석해보자.</p>
<h4 id="to-overlay">to-overlay</h4>
<pre><code class="language-c">//cilium/bpf/bpf_lxc.c
// 터널 모드의 경우
#if defined(TUNNEL_MODE) 
...
    //실제 터널링 정보가 있을 경우
        if (info &amp;&amp; info-&gt;flag_has_tunnel_ep) {
      //캡슐화 및 리다이렉션 수행
            ret = encap_and_redirect_lxc(ctx, info, SECLABEL_IPV4,
                             *dst_sec_identity, &amp;trace,
                             bpf_htons(ETH_P_IP));
#endif /* TUNNEL_MODE */</code></pre>
<pre><code class="language-c">//cilium/bpf/lib/encap.h
encap_and_redirect_lxc(struct __ctx_buff *ctx, struct remote_endpoint_info *info,
               __u32 seclabel, __u32 dstid, const struct trace_ctx *trace, __be16 proto)
{
    return encap_and_redirect_with_nodeid(ctx, info, seclabel, dstid, trace, proto);
}

//코드를 따라가다 보면 encap_with_nodeid4를 수행하고, 해당 함수에서 to-overlay 메시지를 호출 후 캡슐화 정보를 세팅함.

__encap_with_nodeid4(struct __ctx_buff *ctx, __u32 src_ip, __be16 src_port,
             __be32 tunnel_endpoint,
             __u32 seclabel, __u32 dstid, __u32 vni,
             enum trace_reason ct_reason, __u32 monitor, int *ifindex,
             __be16 proto)
{
...
    send_trace_notify(ctx, TRACE_TO_OVERLAY, seclabel, dstid, TRACE_EP_ID_UNKNOWN,
              *ifindex, ct_reason, monitor, proto);

    return ctx_set_encap_info4(ctx, src_ip, src_port, tunnel_endpoint, seclabel, vni,
                   NULL, 0);
}</code></pre>
<h1 id="masquerading">Masquerading</h1>
<p>Masquerade (마스커레이드)란, 특정 네트워크 패킷의 출발지 IP 주소를 노드의 IP로 변경하는 SNAT(Source NAT) 동작을 의미한다.</p>
<p>Cilium은 클러스터를 떠나는 모든 트래픽의 소스 IP 주소를 자동으로 masquerade 하는데, 이는 Cilium의 경우 Pod에서 나가는 트래픽의 출발지 IP가 Pod IP이기 때문에, 그대로 외부로 보내면 외부에서 응답할 수 없기 때문이다.
따라서 출발지 IP를 해당 Pod가 위치한 노드의 IP로 바꿔서(SNAT) 보내고, 나중에 응답이 오면 다시 원래의 Pod IP를 반환하는 방식으로 동작한다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -c cilium-agent  -- cilium status | grep Masquerading
Masquerading:            BPF   [eth0, eth1]   10.244.0.0/16 [IPv4: Enabled, IPv6: Disabled]

(⎈|HomeLab:N/A) root@k8s-ctr:~# cilium config view  | grep ipv4-native-routing-cidr
ipv4-native-routing-cidr                          10.244.0.0/16</code></pre>
<p>ipv4-native-routing-cidr(10.244.0.0/16) 범위는 native routing (터널링 없이 직접 라우팅) 되는 네트워크이고, 이 범위 밖으로 나가는 트래픽은 SNAT(Masquerading) 된다.
단, 대상 IP가 클러스터 내부 Node IP라면 예외적으로 Masquerading 되지 않는다.</p>
<h3 id="실습">실습</h3>
<p>다음은 Pod에서 클러스터 내부 노드, 클러스터 외부 서버 호출을 비교한 실습이다.
클러스터 내부 노드 호출을 할 때에는 Pod IP가 잡히지만, 클러스터 외부 서버 호출을 할 때에는 Node IP로 SNAT되는 것을 확인할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/e7593c1d-4b17-4e79-82da-e1ca6ef6f6bb/image.png" alt=""></p>
<h2 id="ipmasqagent">ipMasqAgent</h2>
<p>ipMasqAgent란 쿠버네티스 클러스터에서 IP 마스커레이딩(즉, SNAT)을 제어하는 에이전트의 설정 항목이다.</p>
<p>ipMasqAgent로 다음 설정들을 할 수 있다.</p>
<table>
<thead>
<tr>
<th>설정 항목</th>
<th>설명</th>
<th>예시 값</th>
</tr>
</thead>
<tbody><tr>
<td><code>enabled</code></td>
<td>ip-masq-agent 기능 활성화 여부</td>
<td><code>true</code>, <code>false</code></td>
</tr>
<tr>
<td><code>config.nonMasqueradeCIDRs</code></td>
<td>SNAT이 적용되지 않을 CIDR 목록</td>
<td><code>{10.10.1.0/24,10.10.2.0/24}</code></td>
</tr>
<tr>
<td><code>config.masqLinkLocal</code></td>
<td>링크 로컬 주소 (169.254.0.0/16)에 대해 SNAT 적용 여부</td>
<td><code>true</code>, <code>false</code></td>
</tr>
<tr>
<td><code>config.masqLinkLocalIPv6</code></td>
<td>IPv6 링크 로컬 주소 (fe80::/10)에 대해 SNAT 적용 여부</td>
<td><code>true</code>, <code>false</code></td>
</tr>
<tr>
<td><code>config.masqAgentConfigPath</code></td>
<td>사용자 정의 config 파일 경로</td>
<td><code>/etc/cilium/masq-agent.json</code></td>
</tr>
<tr>
<td><code>config.masqOutBoundCIDRs</code></td>
<td>SNAT이 항상 적용될 외부 CIDR 목록</td>
<td><code>{0.0.0.0/0}</code></td>
</tr>
<tr>
<td><code>config.masqOutBoundPortRanges</code></td>
<td>SNAT이 항상 적용될 포트 범위 목록</td>
<td><code>{80,443,1000-2000}</code></td>
</tr>
<tr>
<td><code>config.refreshInterval</code></td>
<td>iptables 규칙 갱신 주기</td>
<td><code>1m</code>, <code>30s</code></td>
</tr>
<tr>
<td><code>installIptablesRules</code></td>
<td>iptables 규칙을 ip-masq-agent가 직접 설치할지 여부</td>
<td><code>true</code>, <code>false</code></td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[Cilium - (Observability) Hubble]]></title>
            <link>https://velog.io/@_gyullbb/Cilium-Observability-Hubble</link>
            <guid>https://velog.io/@_gyullbb/Cilium-Observability-Hubble</guid>
            <pubDate>Sat, 26 Jul 2025 18:18:07 GMT</pubDate>
            <description><![CDATA[<h1 id="hubble">Hubble</h1>
<p>Hubble이란 Cilium의 eBPF 흐름을 기반으로, 네트워크 보안 정책, 서비스 흐름, L3~L7 수준의 트래픽을 관찰·분석할 수 있도록 도와주는 관찰/모니터링 플랫폼이다.</p>
<h2 id="hubble-구성-요소">Hubble 구성 요소</h2>
<p>Cilium에서 제공하는 공식문서에는 Hubble을 이용한 Observability에 관해 상세하게 작성되어 있다.</p>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>설명</th>
<th>관찰 범위</th>
<th>연결 방식</th>
<th>배포/사용 위치</th>
<th>주요 특징</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Hubble API</strong></td>
<td>Cilium 에이전트가 실행 중인 <strong>로컬 노드</strong>에서 관찰된 네트워크 트래픽 정보를 제공하는 gRPC API</td>
<td>단일 Cilium 노드 (로컬)</td>
<td>Unix 도메인 소켓 (<code>/var/run/cilium/hubble.sock</code>)</td>
<td>각 Cilium 에이전트 Pod 내부</td>
<td>L3~L7 네트워크 이벤트 제공. 외부에서 직접 접근 불가</td>
</tr>
<tr>
<td><strong>Hubble Relay</strong></td>
<td>여러 Cilium 노드의 Hubble API를 집계하여 <strong>클러스터 전체</strong> 또는 ClusterMesh 환경의 <strong>여러 클러스터</strong>의 트래픽 정보를 통합 제공</td>
<td>전체 클러스터 <br>또는 ClusterMesh</td>
<td>내부: Hubble API와 통신 <br>외부: gRPC (CLI, UI에서 연결)</td>
<td>별도 Pod(Deployment 등)로 실행</td>
<td>중앙 집중형 데이터 수집기. 보안 및 인증 구성 가능. CLI 및 UI의 주요 백엔드 역할 수행</td>
</tr>
<tr>
<td><strong>Hubble UI</strong></td>
<td>클러스터의 서비스 간 통신 흐름을 자동으로 탐지하고 <strong>시각화</strong>하여 보여주는 웹 UI</td>
<td>전체 클러스터 <br>또는 ClusterMesh</td>
<td>gRPC 또는 HTTP로 Hubble Relay와 통신</td>
<td>Pod로 배포되며 웹 브라우저에서 접근</td>
<td>서비스 종속성 맵, 필터링 UI, L3/L4/L7 데이터 시각화. Grafana와 유사한 UX 제공</td>
</tr>
<tr>
<td><strong>Hubble CLI</strong></td>
<td><code>hubble</code> 명령어를 통해 Hubble API 또는 Hubble Relay에 접근하여 트래픽 흐름을 조회하는 CLI 도구</td>
<td>로컬 노드 or 전체 클러스터</td>
<td>① Unix 도메인 소켓 (API 직접 연결) <br>② Hubble Relay 주소 (gRPC)</td>
<td>Cilium Pod 내부 또는 외부 클라이언트</td>
<td>실시간 흐름 조회, 필터링, JSON 출력 등 다양한 커맨드 지원</td>
</tr>
</tbody></table>
<h2 id="hubble-설치">Hubble 설치</h2>
<p>Hubble을 설치한 후 기본적인 구성을 확인해본다.
helm을 이용하여 Hubble을 설치하면 hubble-relay, hubble-ui Deployment 및 Secret 등 리소스가 클러스터에 배포된다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# helm upgrade cilium cilium/cilium --namespace kube-system --reuse-values \
--set hubble.enabled=true \
--set hubble.relay.enabled=true \
--set hubble.ui.enabled=true \
--set hubble.ui.service.type=NodePort \
--set hubble.ui.service.nodePort=31234 \
--set hubble.export.static.enabled=true \
--set hubble.export.static.filePath=/var/run/cilium/hubble/events.log \
--set prometheus.enabled=true \
--set operator.prometheus.enabled=true \
--set hubble.metrics.enableOpenMetrics=true \
--set hubble.metrics.enabled=&quot;{dns,drop,tcp,flow,port-distribution,icmp,httpV2:exemplars=true;labelsContext=source_ip\,source_namespace\,source_workload\,destination_ip\,destination_namespace\,destination_workload\,traffic_direction}&quot;

(⎈|HomeLab:N/A) root@k8s-ctr:~# cilium status
    /¯¯\
 /¯¯\__/¯¯\    Cilium:             OK
 \__/¯¯\__/    Operator:           OK
 /¯¯\__/¯¯\    Envoy DaemonSet:    OK
 \__/¯¯\__/    Hubble Relay:       OK
    \__/       ClusterMesh:        disabled

...
Deployment             hubble-relay             Desired: 1, Ready: 1/1, Available: 1/1
Deployment             hubble-ui                Desired: 1, Ready: 1/1, Available: 1/1
Containers:            ...
                       hubble-relay             Running: 1
                       hubble-ui                Running: 1
...

(⎈|HomeLab:N/A) root@k8s-ctr:~# cilium config view | grep -i hubble
enable-hubble                                     true
enable-hubble-open-metrics                        true
hubble-disable-tls                                false
hubble-export-allowlist
hubble-export-denylist
hubble-export-fieldmask
hubble-export-file-max-backups                    5
hubble-export-file-max-size-mb                    10
hubble-export-file-path                           /var/run/cilium/hubble/events.log
hubble-listen-address                             :4244
hubble-metrics                                    dns drop tcp flow port-distribution icmp httpV2:exemplars=true;labelsContext=source_ip,source_namespace,source_workload,destination_ip,destination_namespace,destination_workload,traffic_direction
hubble-metrics-server                             :9965
hubble-metrics-server-enable-tls                  false
hubble-socket-path                                /var/run/cilium/hubble.sock
hubble-tls-cert-file                              /var/lib/cilium/tls/hubble/server.crt
hubble-tls-client-ca-files                        /var/lib/cilium/tls/hubble/client-ca.crt
hubble-tls-key-file                               /var/lib/cilium/tls/hubble/server.key

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get secret -n kube-system | grep -iE &#39;cilium-ca|hubble&#39;
cilium-ca                      Opaque                          2      4d14h
hubble-relay-client-certs      kubernetes.io/tls               3      4d14h
hubble-server-certs            kubernetes.io/tls               3      4d14h</code></pre>
<h2 id="hubble-구조">Hubble 구조</h2>
<p>Hubble의 구조는 정리하면 다음과 같다.
<img src="https://velog.velcdn.com/images/_gyullbb/post/1501bdd7-968c-46ef-8e16-65ad1d47878e/image.png" alt=""></p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc,ep -n kube-system | grep -i hubble-relay
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
service/hubble-relay     ClusterIP   10.96.173.198   &lt;none&gt;        80/TCP                   56s
endpoints/hubble-relay     172.20.1.6:4245                                               56s

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc,ep -n kube-system | grep -i hubble-peer
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
service/hubble-peer      ClusterIP   10.96.21.177    &lt;none&gt;        443/TCP                  60s
endpoints/hubble-peer      192.168.10.100:4244,192.168.10.101:4244,192.168.10.102:4244   60s</code></pre>
<p>Hubble Relay는 kube-system/hubble-peer Endpoints 리소스를 참조하여, 각 노드의 cilium-agent가 노출하는 Hubble API(포트 4244)에 gRPC로 연결한다. 이를 통해 각 노드의 네트워크 흐름 데이터를 수집해 중앙에서 통합 제공한다.</p>
<p>Hubble이 배포된 전 후를 비교하면 Hubble API인 4244 포트가 신규로 열린 것을 확인할 수 있다.</p>
<p><a href="https://docs.cilium.io/en/stable/observability/hubble/setup/">공식문서 참조</a></p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# ss -tnlp | grep 4244
LISTEN 0      4096                *:4244             *:*    users:((&quot;cilium-agent&quot;,pid=6955,fd=52))

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl describe pod -n kube-system -l k8s-app=hubble-relay
Name:             hubble-relay-5dcd46f5c-6pmq9
...
Containers:
  hubble-relay:
...
    Port:          4245/TCP
    Host Port:     0/TCP

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc,ep -n kube-system hubble-relay
NAME                   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
service/hubble-relay   ClusterIP   10.96.180.56   &lt;none&gt;        80/TCP    4d14h

NAME                     ENDPOINTS          AGE
endpoints/hubble-relay   172.20.2.58:4245   4d14h</code></pre>
<h3 id="hubble-구조---code-확인">Hubble 구조 - Code 확인</h3>
<p>Hubble Relay에서 어떻게 Hubble API 즉 peer를 인지하고, GRPC 통신을 하는지 코드를 통해 알아보자.</p>
<pre><code class="language-go">//cilium/pkg/hubble/relay/pool/manager.go
func (m *PeerManager) Start() {
    m.wg.Add(3)
    go func() {
        defer m.wg.Done()
    // Hubble Relay가 Hubble Agent와 gRPC연결을 통해 Peer목록을 실시간으로 반영
        m.watchNotifications()
    }()
    go func() {
        defer m.wg.Done()
    //Peer와의 gRPC통신 연결 수행
        m.manageConnections()
    }()
    go func() {
        defer m.wg.Done()
    // 연결 상태 확인
        m.reportConnectionStatus()
    }()
}</code></pre>
<h4 id="1-watchnotifications">1. watchNotifications</h4>
<p>Hubble Relay에서 코드에 명시된 Default ServerPort를 기준으로 gRPC연결을 할 Peer를 생성한다.</p>
<pre><code class="language-go">//cilium/pkg/hubble/relay/pool/manager.go
func (m *PeerManager) watchNotifications() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    go func() {
        &lt;-m.stop
        cancel()
    }()
connect:
    for {
        cl, err := m.opts.peerClientBuilder.Client(m.opts.peerServiceAddress)
...
        client, err := cl.Notify(ctx, &amp;peerpb.NotifyRequest{})
...
            cn, err := client.Recv()
...
      //신규 peer 생성
            p := peerTypes.FromChangeNotification(cn)
            switch cn.GetType() {
            case peerpb.ChangeNotificationType_PEER_ADDED:
                m.upsert(p)
            case peerpb.ChangeNotificationType_PEER_DELETED:
                m.remove(p)
            case peerpb.ChangeNotificationType_PEER_UPDATED:
                m.upsert(p)
            }
        }
    }
}

//cilium/pkg/hubble/peer/types/peer.go
// FromChangeNotification creates a new Peer from a ChangeNotification.
func FromChangeNotification(cn *peerpb.ChangeNotification) *Peer {
    if cn == nil {
        return (*Peer)(nil)
    }
    var err error
    var addr net.Addr
    switch a := cn.GetAddress(); {
...
    default:
        var host, port string
        if host, port, err = net.SplitHostPort(a); err == nil {
...
  //별도로 IP와 Port를 지정한 것이 아니면 Peer의 Port를 default Server Port로 지정한다.
        } else if ip := net.ParseIP(a); ip != nil {
            err = nil
            addr = &amp;net.TCPAddr{
                IP:   ip,
                Port: defaults.ServerPort,
            }
        }
    }
...
    return &amp;Peer{
        Name:          cn.GetName(),
        Address:       addr,
        TLSEnabled:    tlsEnabled,
        TLSServerName: tlsServerName,
    }
}

//cilium/pkg/hubble/defaults
//default Server Port는 4244로 코드에 명시되어 있다.
const (
    // ServerPort is the default port for hubble server when a provided
    // listen address does not include one.
    ServerPort = 4244
  ...
)</code></pre>
<h4 id="2-manageconnections">2. manageConnections</h4>
<p>Peer정보가 업데이트 될 때 및 주기적으로 Peer상태 확인 및 연결을 시도한다.</p>
<pre><code class="language-go">//cilium/pkg/hubble/relay/pool/manager.go
func (m *PeerManager) manageConnections() {
    for {
        select {
        case &lt;-m.stop:
            return
    // Peer 정보 업데이트 시 연결 시도
        case name := &lt;-m.updated:
            m.mu.RLock()
            p := m.peers[name]
            m.mu.RUnlock()
            m.wg.Add(1)
            go func(p *peer) {
                defer m.wg.Done()
                // a connection request has been made, make sure to attempt a connection
                m.connect(p, true)
            }(p)
    // 주기적으로 Peer 연결 시도
        case &lt;-time.After(m.opts.connCheckInterval):
            m.mu.RLock()
            for _, p := range m.peers {
                m.wg.Add(1)
                go func(p *peer) {
                    defer m.wg.Done()
                    m.connect(p, false)
                }(p)
            }
            m.mu.RUnlock()
        }
    }
}
...

func (m *PeerManager) connect(p *peer, ignoreBackoff bool) {
...
  //실제 gRPC 연결을 생성한다.
    scopedLog.Info(&quot;Connecting&quot;)
    conn, err := m.opts.clientConnBuilder.ClientConn(p.Address.String(), p.TLSServerName)
    if err != nil {
        duration := m.opts.backoff.Duration(p.connAttempts)
        p.nextConnAttempt = now.Add(duration)
        p.connAttempts++
        scopedLog.Warn(
            &quot;Failed to create gRPC client&quot;,
            logfields.Error, err,
            logfields.NextTryIn, duration,
        )
        return
    }
    p.nextConnAttempt = time.Time{}
    p.connAttempts = 0
    p.conn = conn
    scopedLog.Info(&quot;Connected&quot;)
}</code></pre>
<h3 id="hubble-ui-접속">Hubble UI 접속</h3>
<p>이를 Hubble UI 접속을 통해 알아보자</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc,ep -n kube-system hubble-ui
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME                TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
service/hubble-ui   NodePort   10.96.137.247   &lt;none&gt;        80:31234/TCP   3m43s

NAME                  ENDPOINTS           AGE
endpoints/hubble-ui   172.20.2.101:8081   3m43s</code></pre>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/c1a389b5-cebe-4e49-8346-c1c983a6da38/image.png" alt=""></p>
<h3 id="hubble-cli-사용">Hubble CLI 사용</h3>
<p>Hubble CLI를 통해 실시간 통신 모니터링을 확인해본다.</p>
<h4 id="hubble-cli-설치">Hubble CLI 설치</h4>
<pre><code class="language-bash">HUBBLE_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/hubble/master/stable.txt)
HUBBLE_ARCH=amd64
if [ &quot;$(uname -m)&quot; = &quot;aarch64&quot; ]; then HUBBLE_ARCH=arm64; fi
curl -L --fail --remote-name-all https://github.com/cilium/hubble/releases/download/$HUBBLE_VERSION/hubble-linux-${HUBBLE_ARCH}.tar.gz{,.sha256sum}
sudo tar xzvfC hubble-linux-${HUBBLE_ARCH}.tar.gz /usr/local/bin
which hubble
hubble status</code></pre>
<h4 id="모니터링">모니터링</h4>
<p>Local Machine에서 Hubble CLI로  Hubble Relay와 통신을 하기 위해 백그라운드에서 port-forwarding을 수행한 후 모니터링을 확인해본다.</p>
<pre><code class="language-bash">cilium hubble port-forward&amp;
Hubble Relay is available at 127.0.0.1:4245

# Now you can validate that you can access the Hubble API via the installed CLI
hubble status
Healthcheck (via localhost:4245): Ok
Current/Max Flows: 12,285/12,285 (100.00%)
Flows/s: 41.20

# hubble (api) server 기본 접속 주소 확인
hubble config view 
...
port-forward-port: &quot;4245&quot;
server: localhost:4245</code></pre>
<p>다음은 hubble observe 옵션의 일부이다. 해당 옵션을 사용하여 원하는 설정으로 모니터링이 가능하다.</p>
<table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><code>--from-pod</code></td>
<td>Source Pod 지정 (<code>&lt;namespace&gt;/&lt;pod-name&gt;</code> 형식)</td>
<td><code>--from-pod kube-system/cilium-abc</code></td>
</tr>
<tr>
<td><code>--to-pod</code></td>
<td>Destination Pod 지정</td>
<td><code>--to-pod default/myapp</code></td>
</tr>
<tr>
<td><code>--from-ip</code></td>
<td>Source IP 주소 지정</td>
<td><code>--from-ip 10.0.0.12</code></td>
</tr>
<tr>
<td><code>--to-ip</code></td>
<td>Destination IP 주소 지정</td>
<td><code>--to-ip 10.0.1.25</code></td>
</tr>
<tr>
<td><code>--from-fqdn</code></td>
<td>Source Fully Qualified Domain Name (FQDN) 지정</td>
<td><code>--from-fqdn api.example.com</code></td>
</tr>
<tr>
<td><code>--to-fqdn</code></td>
<td>Destination FQDN 지정</td>
<td><code>--to-fqdn google.com</code></td>
</tr>
<tr>
<td><code>--selector</code></td>
<td>Label selector (Source/Destination 모두에 적용됨)</td>
<td><code>--selector k8s:app=frontend</code></td>
</tr>
</tbody></table>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# hubble observe -f
Jul 25 15:13:25.416: 192.168.10.100:49996 (kube-apiserver) &lt;- kube-system/hubble-ui-76d4965bb6-vbjtc:8081 (ID:64472) to-network FORWARDED (TCP Flags: ACK, PSH)
Jul 25 15:13:26.683: 127.0.0.1:32926 (world) &lt;&gt; kube-system/coredns-674b8bbfcf-pdpmn (ID:3810) pre-xlate-rev TRACED (TCP)
Jul 25 15:13:26.683: 127.0.0.1:32926 (world) &lt;&gt; kube-system/coredns-674b8bbfcf-pdpmn (ID:3810) pre-xlate-rev TRACED (TCP)
Jul 25 15:13:27.434: 127.0.0.1:34640 (world) &lt;&gt; 192.168.10.102 (host) pre-xlate-rev TRACED (TCP)
Jul 25 15:13:27.484: 127.0.0.1:55200 (world) &lt;&gt; kube-system/hubble-relay-5dcd46f5c-78fnx (ID:29925) pre-xlate-rev TRACED (TCP)
Jul 25 15:13:27.484: 127.0.0.1:55200 (world) &lt;&gt; kube-system/hubble-relay-5dcd46f5c-78fnx (ID:29925) pre-xlate-rev TRACED (TCP)

(⎈|HomeLab:N/A) root@k8s-ctr:~# hubble observe --from-pod kube-system/coredns-674b8bbfcf-pdpmn -f
Jul 25 15:14:19.621: kube-system/coredns-674b8bbfcf-pdpmn:53128 (ID:3810) -&gt; 192.168.10.100:6443 (host) to-stack FORWARDED (TCP Flags: ACK)
Jul 25 15:14:23.375: 10.0.2.15:49350 (host) &lt;- kube-system/coredns-674b8bbfcf-pdpmn:8080 (ID:3810) to-stack FORWARDED (TCP Flags: SYN, ACK)
Jul 25 15:14:23.375: 10.0.2.15:49350 (host) &lt;- kube-system/coredns-674b8bbfcf-pdpmn:8080 (ID:3810) to-stack FORWARDED (TCP Flags: ACK, PSH)
Jul 25 15:14:23.375: 10.0.2.15:49350 (host) &lt;- kube-system/coredns-674b8bbfcf-pdpmn:8080 (ID:3810) to-stack FORWARDED (TCP Flags: ACK, FIN)

(⎈|HomeLab:N/A) root@k8s-ctr:~# hubble observe --to-ip 192.168.10.100 -f
Jul 25 15:15:11.670: 192.168.10.102:37974 (host) -&gt; 192.168.10.100:6443 (kube-apiserver) to-network FORWARDED (TCP Flags: ACK)
Jul 25 15:15:13.414: 192.168.10.100:49953 (kube-apiserver) &lt;- kube-system/hubble-ui-76d4965bb6-vbjtc:8081 (ID:64472) to-network FORWARDED (TCP Flags: ACK, PSH)
Jul 25 15:15:13.944: kube-system/hubble-ui-76d4965bb6-vbjtc:47176 (ID:64472) -&gt; 192.168.10.100:6443 (kube-apiserver) to-network FORWARDED (TCP Flags: ACK, PSH)
Jul 25 15:15:14.882: 192.168.10.102:56384 (host) -&gt; 192.168.10.100:6443 (kube-apiserver) to-network FORWARDED (TCP Flags: ACK)
Jul 25 15:15:18.256: 192.168.10.101:49758 (host) -&gt; 192.168.10.100:6443 (kube-apiserver) to-network FORWARDED (TCP Flags: ACK, PSH)
Jul 25 15:15:19.404: 192.168.10.100:49953 (kube-apiserver) &lt;- kube-system/hubble-ui-76d4965bb6-vbjtc:8081 (ID:64472) to-network FORWARDED (TCP Flags: ACK, PSH)
Jul 25 15:15:19.405: kube-system/hubble-relay-5dcd46f5c-78fnx:51508 (ID:29925) -&gt; 192.168.10.100:4244 (kube-apiserver) to-network FORWARDED (TCP Flags: ACK, PSH)</code></pre>
<h2 id="starwars-demo">Starwars Demo</h2>
<p>Cilium에서 제공하는 Demo를 통해 접근 제어를 위한 다양한 보안 정책을 테스트 해본다.</p>
<h3 id="demo-구성">Demo 구성</h3>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod --show-labels
NAME                        READY   STATUS    RESTARTS   AGE   LABELS
deathstar-8c4c77fb7-7rqzr   1/1     Running   0          22m   app.kubernetes.io/name=deathstar,class=deathstar,org=empire,pod-template-hash=8c4c77fb7
deathstar-8c4c77fb7-8wdts   1/1     Running   0          22m   app.kubernetes.io/name=deathstar,class=deathstar,org=empire,pod-template-hash=8c4c77fb7
tiefighter                  1/1     Running   0          22m   app.kubernetes.io/name=tiefighter,class=tiefighter,org=empire
xwing                       1/1     Running   0          22m   app.kubernetes.io/name=xwing,class=xwing,org=alliance

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get deploy,svc,ep deathstar
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME                        READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/deathstar   2/2     2            2           22m

NAME                TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)   AGE
service/deathstar   ClusterIP   10.96.60.53   &lt;none&gt;        80/TCP    22m

NAME                  ENDPOINTS                       AGE
endpoints/deathstar   172.20.1.85:80,172.20.2.33:80   22m</code></pre>
<h3 id="시나리오-1-조건-x">시나리오 1) 조건 X</h3>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/26de9e85-f152-4a13-8f48-2140202fad58/image.png" alt=""></p>
<h4 id="1-1-tiefighter---deathstar--request-landing">1-1) tiefighter -&gt; deathstar : request-landing</h4>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec tiefighter -- curl -s -XPOST deathstar.default.svc.cluster.local/v1/request-landing
Ship landed

(⎈|HomeLab:N/A) root@k8s-ctr:~# hubble observe -f --protocol tcp --from-identity $TIEFIGHTERID
Jul 25 15:56:06.052: default/tiefighter:51000 (ID:62396) -&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-endpoint FORWARDED (TCP Flags: SYN)
Jul 25 15:56:06.052: default/tiefighter:51000 (ID:62396) -&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-endpoint FORWARDED (TCP Flags: ACK)
Jul 25 15:56:06.053: default/tiefighter:51000 (ID:62396) &lt;&gt; default/deathstar-8c4c77fb7-8wdts (ID:50122) pre-xlate-rev TRACED (TCP)
Jul 25 15:56:06.053: default/tiefighter:51000 (ID:62396) &lt;&gt; default/deathstar-8c4c77fb7-8wdts (ID:50122) pre-xlate-rev TRACED (TCP)
Jul 25 15:56:06.053: default/tiefighter:51000 (ID:62396) &lt;&gt; default/deathstar-8c4c77fb7-8wdts (ID:50122) pre-xlate-rev TRACED (TCP)
Jul 25 15:56:06.053: default/tiefighter:51000 (ID:62396) -&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-endpoint FORWARDED (TCP Flags: ACK, PSH)
Jul 25 15:56:06.053: default/tiefighter:51000 (ID:62396) &lt;&gt; default/deathstar-8c4c77fb7-8wdts (ID:50122) pre-xlate-rev TRACED (TCP)
Jul 25 15:56:06.053: default/tiefighter:51000 (ID:62396) &lt;&gt; default/deathstar-8c4c77fb7-8wdts (ID:50122) pre-xlate-rev TRACED (TCP)
Jul 25 15:56:06.054: default/tiefighter:51000 (ID:62396) -&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-endpoint FORWARDED (TCP Flags: ACK, FIN)
Jul 25 15:56:06.098: default/tiefighter (ID:62396) &lt;&gt; 10.96.60.53:80 (world) pre-xlate-fwd TRACED (TCP)
Jul 25 15:56:06.098: default/tiefighter (ID:62396) &lt;&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) post-xlate-fwd TRANSLATED (TCP)
Jul 25 15:56:06.098: default/tiefighter:51000 (ID:62396) -&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-network FORWARDED (TCP Flags: SYN)
Jul 25 15:56:06.099: default/tiefighter:51000 (ID:62396) -&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-network FORWARDED (TCP Flags: ACK)
Jul 25 15:56:06.099: default/tiefighter:51000 (ID:62396) -&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-network FORWARDED (TCP Flags: ACK, PSH)
Jul 25 15:56:06.100: default/tiefighter:51000 (ID:62396) -&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-network FORWARDED (TCP Flags: ACK, FIN)</code></pre>
<h4 id="1-2-xwing---deathstar--request-landing">1-2) xwing -&gt; deathstar : request-landing</h4>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec xwing -- curl -s -XPOST deathstar.default.svc.cluster.local/v1/request-landing
Ship landed

(⎈|HomeLab:N/A) root@k8s-ctr:~# hubble observe -f --protocol tcp --from-identity $XWINGID
Jul 25 15:53:42.883: default/xwing (ID:6305) &lt;&gt; 10.96.60.53:80 (world) pre-xlate-fwd TRACED (TCP)
Jul 25 15:53:42.883: default/xwing (ID:6305) &lt;&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) post-xlate-fwd TRANSLATED (TCP)
Jul 25 15:53:42.883: default/xwing:37840 (ID:6305) -&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-endpoint FORWARDED (TCP Flags: SYN)
Jul 25 15:53:42.883: default/xwing:37840 (ID:6305) -&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-endpoint FORWARDED (TCP Flags: ACK)
Jul 25 15:53:42.883: default/xwing:37840 (ID:6305) &lt;&gt; default/deathstar-8c4c77fb7-8wdts (ID:50122) pre-xlate-rev TRACED (TCP)
Jul 25 15:53:42.883: default/xwing:37840 (ID:6305) &lt;&gt; default/deathstar-8c4c77fb7-8wdts (ID:50122) pre-xlate-rev TRACED (TCP)
Jul 25 15:53:42.883: default/xwing:37840 (ID:6305) -&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-endpoint FORWARDED (TCP Flags: ACK, PSH)
Jul 25 15:53:42.883: default/xwing:37840 (ID:6305) &lt;&gt; default/deathstar-8c4c77fb7-8wdts (ID:50122) pre-xlate-rev TRACED (TCP)
Jul 25 15:53:42.883: default/xwing:37840 (ID:6305) &lt;&gt; default/deathstar-8c4c77fb7-8wdts (ID:50122) pre-xlate-rev TRACED (TCP)
Jul 25 15:53:42.883: default/xwing:37840 (ID:6305) &lt;&gt; default/deathstar-8c4c77fb7-8wdts (ID:50122) pre-xlate-rev TRACED (TCP)
Jul 25 15:53:42.884: default/xwing:37840 (ID:6305) -&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-endpoint FORWARDED (TCP Flags: ACK, FIN)</code></pre>
<h3 id="시나리오-2-empire에-속한-그룹에-한해-deathstar-호출-가능">시나리오 2) empire에 속한 그룹에 한해 deathstar 호출 가능</h3>
<p><strong>L3/L4 정책 적용</strong>
Cilium은 IP 주소가 아닌 포드의 레이블로 보안 정책을 정의한다.
예를 들어, org=empire 레이블이 있는 그룹만 deathstar 서비스에 접근할 수 있도록 접근 제한 정책을 생성 할 수 있다.
이 정책은 L3/L4 수준의 네트워크 보안 정책으로, IP와 TCP 수준에서 동작한다.</p>
<p>Cilium은 요청 트래픽만 명시적으로 허용하더라도, 그에 대한 응답 트래픽은 자동으로 허용된다.
이는 Cilium이 Linux 커널의 conntrack(connection tracking) 기능을 기반으로 하여 TCP/UDP 연결 상태를 추적하고, eBPF 프로그램 내에서 해당 상태를 검사하여 연결이 이미 허용된 것인지 확인하기 때문이다.
즉, 클라이언트가 서버에 요청을 보내는 방향의 트래픽만 정책으로 허용하면, 그 요청에 대한 응답은 conntrack에 의해 자동으로 허용된다.</p>
<p>cilium 코드를 통해 전반적인 과정을 이해해보자.</p>
<h4 id="conntrack-기반-연결-상태-추적-code">conntrack 기반 연결 상태 추적 Code</h4>
<p>1) CiliumNetworkPolicy기반 L4Policy 구조체 생성</p>
<pre><code class="language-go">// cilium/pkg/policy/repository.go
func (p *Repository) resolvePolicyLocked(securityIdentity *identity.Identity) (*selectorPolicy, error) {
    ...
        // Policy 적용 여부 및 정책 목록을 반환한다.
        matchingRules := p.computePolicyEnforcementAndRules(securityIdentity)
    ...
    // ingerss 및 egress 항목을 분석하여 사용자가 정의한 정책을 L4Policy로 기록한다.(L4Policy 구조체 생성)
    if ingressEnabled {
        newL4IngressPolicy, err := matchingRules.resolveL4IngressPolicy(&amp;policyCtx)
        if err != nil {
            return nil, err
        }
        calculatedPolicy.L4Policy.Ingress.PortRules = newL4IngressPolicy
    }

    if egressEnabled {
        newL4EgressPolicy, err := matchingRules.resolveL4EgressPolicy(&amp;policyCtx)
        if err != nil {
            return nil, err
        }
        calculatedPolicy.L4Policy.Egress.PortRules = newL4EgressPolicy
    }</code></pre>
<p>2) 생성된 정책을 실제 PolicyMap에 적용</p>
<pre><code class="language-go">// cilium/pkg/endpoint/bpf.go
func (e *Endpoint) runPreCompilationSteps(regenContext *regenerationContext) (preCompilationError error) {
...
// 저장된 policy 정책을 평가하여 endpointPolicy를 생성한다.
err := e.regeneratePolicy(stats, datapathRegenCtxt)
...
        // endpointPolicy를 PolicyMap(eBPF map)에 적용
        err = e.applyPolicyMapChangesLocked(regenContext, e.desiredPolicy != e.realizedPolicy)
...
}

// cilium/pkg/endpoint/policy.go
// 저장된 policy 정책을 평가하여 endpointPolicy를 생성한다.
func (e *Endpoint) regeneratePolicy(stats *regenerationStatistics, datapathRegenCtxt *datapathRegenerationContext) error {
...
    //1. selectorPolicy = 정책 리포지토리에서 추출된 정책
    selectorPolicy, result.policyRevision, err = e.policyRepo.GetSelectorPolicy(securityIdentity, skipPolicyRevision, stats, e.GetID())
...
    //2. selectorPolicy를 endpointPolicy로 변환하여 BPF로 전달 가능한 형태로 정제
    result.endpointPolicy = selectorPolicy.DistillPolicy(e.getLogger(), e, desiredRedirects)</code></pre>
<p>3) eBPF 프로그램에서 패킷 수신 시 PolicyMap기반 conntrack 조회 및 정책 검사</p>
<pre><code class="language-c">//cilium/bpf/bpf_lxc.c
static __always_inline int handle_ipv4_from_lxc(struct __ctx_buff *ctx, __u32 *dst_sec_identity,
                        __s8 *ext_err)
                        ...
    switch (ct_status) {
    case CT_NEW:
    case CT_ESTABLISHED:

        //PolicyMap(cilium_policy_v2)를 기반으로 connection 상태를 확인한다.
        verdict = policy_can_egress4(ctx, &amp;cilium_policy_v2, tuple, l4_off, SECLABEL_IPV4,
                         *dst_sec_identity, &amp;policy_match_type, &amp;audited,
                         ext_err, &amp;proxy_port);
    switch (ct_status) {
    //새로운 connection인 경우 conntrack 엔트리를 새롭게 생성한다.
    case CT_NEW:
ct_recreate4:
...
        break;

    //기존에 conntrack엔트리가 존재할 경우 통신을 허용한다. (필요 시 재 생성)
    case CT_ESTABLISHED:
...
        break;
</code></pre>
<h4 id="ciliumnetworkpolicy-생성">CiliumNetworkPolicy 생성</h4>
<pre><code class="language-bash"># sw_l3_l4_policy.yaml
apiVersion: &quot;cilium.io/v2&quot;
kind: CiliumNetworkPolicy
metadata:
  name: &quot;rule1&quot;
spec:
  description: &quot;L3-L4 policy to restrict deathstar access to empire ships only&quot;
  endpointSelector:
    matchLabels:
      org: empire
      class: deathstar
  ingress:
  - fromEndpoints:
    - matchLabels:
        org: empire
    toPorts:
    - ports:
      - port: &quot;80&quot;
        protocol: TCP

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl apply -f https://raw.githubusercontent.com/cilium/cilium/1.17.6/examples/minikube/sw_l3_l4_policy.yaml
ciliumnetworkpolicy.cilium.io/rule1 created

# ingress에 policy 적용 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# c1 endpoint list
ENDPOINT   POLICY (ingress)   POLICY (egress)   IDENTITY   LABELS (source:key[=value])                                                  IPv6   IPv4          STATUS
           ENFORCEMENT        ENFORCEMENT
...                                                                                  ready
1224       Enabled            Disabled          50122      k8s:app.kubernetes.io/name=deathstar                                                172.20.1.85   ready
                                                           k8s:class=deathstar
                                                           k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=default
                                                           k8s:io.cilium.k8s.policy.cluster=default
                                                           k8s:io.cilium.k8s.policy.serviceaccount=default
                                                           k8s:io.kubernetes.pod.namespace=default
                                                           k8s:org=empire</code></pre>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/b08d1115-dac4-475b-858c-99e43471052e/image.png" alt=""></p>
<h4 id="2-1-tiefighter---deathstar--request-landing">2-1) tiefighter -&gt; deathstar : request-landing</h4>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec tiefighter -- curl -s -XPOST deathstar.default.svc.cluster.local/v1/request-landing
Ship landed

(⎈|HomeLab:N/A) root@k8s-ctr:~hubble observe -f --protocol tcp --from-identity $DEATHSTARIDID
Jul 25 16:07:58.104: default/tiefighter:50142 (ID:62396) &lt;- default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-network FORWARDED (TCP Flags: SYN, ACK)
Jul 25 16:07:58.106: default/tiefighter:50142 (ID:62396) &lt;- default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-network FORWARDED (TCP Flags: ACK, PSH)
Jul 25 16:07:58.107: default/tiefighter:50142 (ID:62396) &lt;- default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-network FORWARDED (TCP Flags: ACK, FIN)
Jul 25 16:07:58.151: default/tiefighter:50142 (ID:62396) &lt;- default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-endpoint FORWARDED (TCP Flags: SYN, ACK)
Jul 25 16:07:58.151: default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) &lt;&gt; default/tiefighter (ID:62396) pre-xlate-rev TRACED (TCP)
Jul 25 16:07:58.151: default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) &lt;&gt; default/tiefighter (ID:62396) pre-xlate-rev TRACED (TCP)
Jul 25 16:07:58.153: default/tiefighter:50142 (ID:62396) &lt;- default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-endpoint FORWARDED (TCP Flags: ACK, PSH)
Jul 25 16:07:58.154: default/tiefighter:50142 (ID:62396) &lt;- default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) to-endpoint FORWARDED (TCP Flags: ACK, FIN)</code></pre>
<h4 id="2-2-xwing---deathstar--request-landing">2-2) xwing -&gt; deathstar : request-landing</h4>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec xwing -- curl -s -XPOST deathstar.default.svc.cluster.local/v1/request-landing

(⎈|HomeLab:N/A) root@k8s-ctr:~# hubble observe -f --type drop
Jul 25 16:05:58.942: default/xwing:38870 (ID:6305) &lt;&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) Policy denied DROPPED (TCP Flags: SYN)
Jul 25 16:05:59.962: default/xwing:38870 (ID:6305) &lt;&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) Policy denied DROPPED (TCP Flags: SYN)
Jul 25 16:06:00.987: default/xwing:38870 (ID:6305) &lt;&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) Policy denied DROPPED (TCP Flags: SYN)
Jul 25 16:06:02.011: default/xwing:38870 (ID:6305) &lt;&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) Policy denied DROPPED (TCP Flags: SYN)
Jul 25 16:06:03.034: default/xwing:38870 (ID:6305) &lt;&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) Policy denied DROPPED (TCP Flags: SYN)
Jul 25 16:06:04.059: default/xwing:38870 (ID:6305) &lt;&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) Policy denied DROPPED (TCP Flags: SYN)</code></pre>
<h3 id="시나리오-3-empire에-속한-그룹에-한해-명확한-요청으로request-landing-deathstar-호출-가능">시나리오 3) empire에 속한 그룹에 한해 명확한 요청으로(request-landing) deathstar 호출 가능</h3>
<p><strong>L7 정책 적용</strong>
L7 동작 처리는 cilium-envoy 데몬셋이 담당한다.
<a href="https://docs.cilium.io/en/stable/network/ebpf/lifeofapacket/">공식 문서 참조1</a>
<a href="https://docs.cilium.io/en/stable/security/network/proxy/envoy/">공식 문서 참조2</a>
<img src="https://velog.velcdn.com/images/_gyullbb/post/50d001aa-94ae-41b1-b6a9-389ada2e2ba5/image.png" alt=""></p>
<p>L7 정책은 L3/L4와는 다르게 단순한 eBPF map 기반 정책으로는 처리할 수 없기 때문에, Cilium에서는 Envoy Proxy를 연동하여 L7 처리를 수행하도록 설계되어있다.</p>
<p>사용자가 CiliumNetworkPolicy에 HTTP method나 path 등 L7 룰을 정의하면, Cilium은 해당 정책을 분석해 해당 트래픽을 Envoy Proxy로 보낸다.</p>
<p>proxy_port는 eBPF 코드에서 트래픽을 리디렉션할 포트를 의미하며, L7 정책이 있을 때에만 할당되는 port이다.
proxy_port가 0보다 크면 아래 코드와 같이 해당 트래픽을 proxy redirection 체크를 한 후, envoy proxy로 redirect시킨다.</p>
<pre><code class="language-c">//cilium/bpf/bpf_lxc.c
        ct_state_new.proxy_redirect = *proxy_port &gt; 0;

        /* ext_err may contain a value from __policy_can_access, and
         * ct_create6 overwrites it only if it returns an error itself.
         * As the error from __policy_can_access is dropped in that
         * case, it&#39;s OK to return ext_err from ct_create6 along with
         * its error code.
         */
        ret = ct_create6(get_ct_map6(tuple), &amp;cilium_ct_any6_global, tuple, ctx, CT_INGRESS,
                 &amp;ct_state_new, ext_err);
        if (IS_ERR(ret))
            return ret;
    }

    if (*proxy_port &gt; 0)
        goto redirect_to_proxy;

...
redirect_to_proxy:
    send_trace_notify4(ctx, TRACE_TO_PROXY, src_label, SECLABEL_IPV4, orig_sip,
               bpf_ntohs(*proxy_port), ifindex, trace.reason,
               trace.monitor);
    if (tuple_out)
        *tuple_out = *tuple;
    return POLICY_ACT_PROXY_REDIRECT;
}</code></pre>
<p>이후 Envoy는 전달받은 트래픽을 정책에 따라 검사하고, 허용되면 다시 eBPF를 통해 원래 목적지(Pod)로 전달한다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ds -n kube-system cilium-envoy
NAME           DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR            AGE
cilium-envoy   3         3         3       3            3           kubernetes.io/os=linux   23h

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl describe ds -n kube-system cilium-envoy | grep -i mount -A4
    Mounts:
      /sys/fs/bpf from bpf-maps (rw)
      /var/run/cilium/envoy/ from envoy-config (ro)
      /var/run/cilium/envoy/artifacts from envoy-artifacts (ro)
      /var/run/cilium/envoy/sockets from envoy-sockets (rw)

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -c cilium-agent -- ss -xnp | grep -i -envoy
u_str ESTAB 0      0                                                /var/run/cilium/envoy/sockets/admin.sock 28795            * 29729
u_str ESTAB 0      0                                                /var/run/cilium/envoy/sockets/admin.sock 28789            * 29726
u_str ESTAB 0      0                                                  /var/run/cilium/envoy/sockets/xds.sock 35039            * 35038 users:((&quot;cilium-agent&quot;,pid=1,fd=72))

</code></pre>
<h4 id="ciliumnetworkpolicy-업데이트">CiliumNetworkPolicy 업데이트</h4>
<pre><code class="language-bash"># sw_l3_l4_l7_policy.yaml
apiVersion: &quot;cilium.io/v2&quot;
kind: CiliumNetworkPolicy
metadata:
  name: &quot;rule1&quot;
spec:
  description: &quot;L7 policy to restrict access to specific HTTP call&quot;
  endpointSelector:
    matchLabels:
      org: empire
      class: deathstar
  ingress:
  - fromEndpoints:
    - matchLabels:
        org: empire
    toPorts:
    - ports:
      - port: &quot;80&quot;
        protocol: TCP
      rules:
        http:
        - method: &quot;POST&quot;
          path: &quot;/v1/request-landing&quot;

kubectl apply -f https://raw.githubusercontent.com/cilium/cilium/1.17.6/examples/minikube/sw_l3_l4_l7_policy.yaml

(⎈|HomeLab:N/A) root@k8s-ctr:~# c0 policy get
[
  {
    &quot;endpointSelector&quot;: {
      &quot;matchLabels&quot;: {
        &quot;any:class&quot;: &quot;deathstar&quot;,
        &quot;any:org&quot;: &quot;empire&quot;,
        &quot;k8s:io.kubernetes.pod.namespace&quot;: &quot;default&quot;
      }
    },
    &quot;ingress&quot;: [
      {
        &quot;fromEndpoints&quot;: [
          {
            &quot;matchLabels&quot;: {
              &quot;any:org&quot;: &quot;empire&quot;,
              &quot;k8s:io.kubernetes.pod.namespace&quot;: &quot;default&quot;
            }
          }
        ],
        &quot;toPorts&quot;: [
          {
            &quot;ports&quot;: [
              {
                &quot;port&quot;: &quot;80&quot;,
                &quot;protocol&quot;: &quot;TCP&quot;
              }
            ],
            &quot;rules&quot;: {
              &quot;http&quot;: [
                {
                  &quot;path&quot;: &quot;/v1/request-landing&quot;,
                  &quot;method&quot;: &quot;POST&quot;
                }
              ]
            }
          }
        ]
      }
    ],
    &quot;labels&quot;: [
      {
        &quot;key&quot;: &quot;io.cilium.k8s.policy.derived-from&quot;,
        &quot;value&quot;: &quot;CiliumNetworkPolicy&quot;,
        &quot;source&quot;: &quot;k8s&quot;
      },
      {
        &quot;key&quot;: &quot;io.cilium.k8s.policy.name&quot;,
        &quot;value&quot;: &quot;rule1&quot;,
        &quot;source&quot;: &quot;k8s&quot;
      },
      {
        &quot;key&quot;: &quot;io.cilium.k8s.policy.namespace&quot;,
        &quot;value&quot;: &quot;default&quot;,
        &quot;source&quot;: &quot;k8s&quot;
      },
      {
        &quot;key&quot;: &quot;io.cilium.k8s.policy.uid&quot;,
        &quot;value&quot;: &quot;c07db93d-ea58-448b-aee1-3a4701800f13&quot;,
        &quot;source&quot;: &quot;k8s&quot;
      }
    ],
    &quot;enableDefaultDeny&quot;: {
      &quot;ingress&quot;: true,
      &quot;egress&quot;: false
    },
    &quot;description&quot;: &quot;L7 policy to restrict access to specific HTTP call&quot;
  }
]
Revision: 3</code></pre>
<h4 id="3-1-tiefighter---deathstar--exhaust-port">3-1) tiefighter -&gt; deathstar : exhaust-port</h4>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/8341dde4-f825-4379-bf8d-66aa9bbf8c78/image.png" alt=""></p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec tiefighter -- curl -s -XPUT deathstar.default.svc.cluster.local/v1/exhaust-port
Access denied

(⎈|HomeLab:N/A) root@k8s-ctr:~# hubble observe -f --pod deathstar --verdict DROPPED
Jul 26 14:37:11.689: default/tiefighter:49534 (ID:62396) -&gt; default/deathstar-8c4c77fb7-8wdts:80 (ID:50122) http-request DROPPED (HTTP/1.1 PUT http://deathstar.default.svc.cluster.local/v1/exhaust-port)

(⎈|HomeLab:N/A) root@k8s-ctr:~# c1 monitor -v --type l7
CPU 01: [pre-xlate-rev] cgroup_id: 7275 sock_cookie: 9612, dst [172.20.1.39]:56024 tcp
&lt;- Request http from 1899 ([k8s:app.kubernetes.io/name=tiefighter k8s:class=tiefighter k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=default k8s:io.cilium.k8s.policy.cluster=default k8s:io.cilium.k8s.policy.serviceaccount=default k8s:io.kubernetes.pod.namespace=default k8s:org=empire]) to 1224 ([k8s:app.kubernetes.io/name=deathstar k8s:class=deathstar k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=default k8s:io.cilium.k8s.policy.cluster=default k8s:io.cilium.k8s.policy.serviceaccount=default k8s:io.kubernetes.pod.namespace=default k8s:org=empire]), identity 62396-&gt;50122, verdict Denied PUT http://deathstar.default.svc.cluster.local/v1/exhaust-port =&gt; 0
&lt;- Response http to 1899 ([k8s:app.kubernetes.io/name=tiefighter k8s:class=tiefighter k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=default k8s:io.cilium.k8s.policy.cluster=default k8s:io.cilium.k8s.policy.serviceaccount=default k8s:io.kubernetes.pod.namespace=default k8s:org=empire]) from 1224 ([k8s:app.kubernetes.io/name=deathstar k8s:class=deathstar k8s:io.cilium.k8s.namespace.labels.kubernetes.io/metadata.name=default k8s:io.cilium.k8s.policy.cluster=default k8s:io.cilium.k8s.policy.serviceaccount=default k8s:io.kubernetes.pod.namespace=default k8s:org=empire]), identity 50122-&gt;62396, verdict Forwarded PUT http://deathstar.default.svc.cluster.local/v1/exhaust-port =&gt; 403</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Cilium - Cilium 기본 설치 및 통신 확인 (2)]]></title>
            <link>https://velog.io/@_gyullbb/Cilium-Cilium-%EA%B8%B0%EB%B3%B8-%EC%84%A4%EC%B9%98-%EB%B0%8F-%ED%86%B5%EC%8B%A0-%ED%99%95%EC%9D%B8-2</link>
            <guid>https://velog.io/@_gyullbb/Cilium-Cilium-%EA%B8%B0%EB%B3%B8-%EC%84%A4%EC%B9%98-%EB%B0%8F-%ED%86%B5%EC%8B%A0-%ED%99%95%EC%9D%B8-2</guid>
            <pubDate>Sat, 19 Jul 2025 16:54:55 GMT</pubDate>
            <description><![CDATA[<h1 id="migration-to-cilium">Migration to Cilium</h1>
<p>현재 많은 Kubernetes 클러스터에서 Flannel 또는 Calico와 같은 전통적인 CNI(Container Network Interface) 플러그인이 사용되고 있다.</p>
<p>하지만 최근 eBPF 기반의 고성능 네트워킹 기능이 각광받으면서, CNI 플러그인을 Cilium으로 전환하는 방향이 검토되고 있다. 다음은 Flannel과 Calico에서 Cilium으로 마이그레이션하는 간단한 데모이다.</p>
<h2 id="flannel-to-cilium">Flannel to Cilium</h2>
<h3 id="기존-flannel-환경-클러스터-점검">기존 Flannel 환경 클러스터 점검</h3>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# helm list -A
NAME       NAMESPACE       REVISION    UPDATED                                    STATUS      CHART              APP VERSION
flannel    kube-flannel    1           2025-07-19 19:42:16.187290147 +0900 KST    deployed    flannel-v0.27.1    v0.27.1

(⎈|HomeLab:N/A) root@k8s-ctr:~# tree /opt/cni/bin/ | grep flannel
├── flannel

(⎈|HomeLab:N/A) root@k8s-ctr:~# tree /etc/cni/net.d/
/etc/cni/net.d/
└── 10-flannel.conflist

(⎈|HomeLab:N/A) root@k8s-ctr:~# cat /etc/cni/net.d/10-flannel.conflist | jq
{
  &quot;name&quot;: &quot;cbr0&quot;,
  &quot;cniVersion&quot;: &quot;0.3.1&quot;,
  &quot;plugins&quot;: [
    {
      &quot;type&quot;: &quot;flannel&quot;,
      &quot;delegate&quot;: {
        &quot;hairpinMode&quot;: true,
        &quot;isDefaultGateway&quot;: true
      }
    },
    {
      &quot;type&quot;: &quot;portmap&quot;,
      &quot;capabilities&quot;: {
        &quot;portMappings&quot;: true
      }
    }
  ]
}

(⎈|HomeLab:N/A) root@k8s-ctr:~# kc describe cm -n kube-flannel kube-flannel-cfg
...
Data
====
net-conf.json:
----
{
  &quot;Network&quot;: &quot;10.244.0.0/16&quot;,
  &quot;Backend&quot;: {
    &quot;Type&quot;: &quot;vxlan&quot;
  }
}

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip -c route | grep 10.244.
10.244.0.0/24 dev cni0 proto kernel scope link src 10.244.0.1
10.244.1.0/24 via 10.244.1.0 dev flannel.1 onlink
10.244.3.0/24 via 10.244.3.0 dev flannel.1 onlink

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get nodes
NAME      STATUS   ROLES           AGE     VERSION
k8s-ctr   Ready    control-plane   3h41m   v1.33.2
k8s-w1    Ready    &lt;none&gt;          3h40m   v1.33.2
k8s-w2    Ready    &lt;none&gt;          3h39m   v1.33.2

(⎈|HomeLab:N/A) root@k8s-ctr:~# brctl show
bridge name    bridge id        STP enabled    interfaces
cni0        8000.5e70c06d50fe    no        veth10a6853e
                            veth75d3a172
                            vethbf10bd3c

(⎈|HomeLab:N/A) root@k8s-ctr:~# iptables -t nat -S | wc -l
77

(⎈|HomeLab:N/A) root@k8s-ctr:~# iptables -t filter -S | wc -l
30

(⎈|HomeLab:N/A) root@k8s-ctr:~# iptables -t nat -S &gt; nat_flannel
(⎈|HomeLab:N/A) root@k8s-ctr:~# iptables -t filter -S &gt; filter_flannel

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pods -o wide -A
NAMESPACE      NAME                              READY   STATUS    RESTARTS   AGE     IP               NODE      NOMINATED NODE   READINESS GATES
default        curl-pod                          1/1     Running   0          91m     10.244.0.4       k8s-ctr   &lt;none&gt;           &lt;none&gt;
default        webpod-697b545f57-45wbp           1/1     Running   0          91m     10.244.1.2       k8s-w1    &lt;none&gt;           &lt;none&gt;
default        webpod-697b545f57-bvz97           1/1     Running   0          91m     10.244.3.2       k8s-w2    &lt;none&gt;           &lt;none&gt;
kube-flannel   kube-flannel-ds-6jj2z             1/1     Running   0          93m     192.168.10.102   k8s-w2    &lt;none&gt;           &lt;none&gt;
kube-flannel   kube-flannel-ds-8lnj8             1/1     Running   0          93m     192.168.10.101   k8s-w1    &lt;none&gt;           &lt;none&gt;
kube-flannel   kube-flannel-ds-l69mp             1/1     Running   0          93m     192.168.10.100   k8s-ctr   &lt;none&gt;           &lt;none&gt;
kube-system    coredns-674b8bbfcf-sk67g          1/1     Running   0          3h41m   10.244.0.2       k8s-ctr   &lt;none&gt;           &lt;none&gt;
kube-system    coredns-674b8bbfcf-xlw52          1/1     Running   0          3h41m   10.244.0.3       k8s-ctr   &lt;none&gt;           &lt;none&gt;
kube-system    etcd-k8s-ctr                      1/1     Running   0          3h41m   192.168.10.100   k8s-ctr   &lt;none&gt;           &lt;none&gt;
kube-system    kube-apiserver-k8s-ctr            1/1     Running   0          3h41m   192.168.10.100   k8s-ctr   &lt;none&gt;           &lt;none&gt;
kube-system    kube-controller-manager-k8s-ctr   1/1     Running   0          3h41m   192.168.10.100   k8s-ctr   &lt;none&gt;           &lt;none&gt;
kube-system    kube-proxy-dn95s                  1/1     Running   0          3h41m   192.168.10.100   k8s-ctr   &lt;none&gt;           &lt;none&gt;
kube-system    kube-proxy-kr4xv                  1/1     Running   0          3h40m   192.168.10.101   k8s-w1    &lt;none&gt;           &lt;none&gt;
kube-system    kube-proxy-ldx7j                  1/1     Running   0          3h39m   192.168.10.102   k8s-w2    &lt;none&gt;           &lt;none&gt;
kube-system    kube-scheduler-k8s-ctr            1/1     Running   0          3h41m   192.168.10.100   k8s-ctr   &lt;none&gt;           &lt;none&gt;</code></pre>
<h3 id="기존-flannel-cni-제거">기존 Flannel CNI 제거</h3>
<pre><code class="language-bash"># helm uninstall -n kube-flannel flannel
# helm list -A

# kubectl get all -n kube-flannel
# kubectl delete ns kube-flannel

# kubectl get pod -A -owide

vnic 제거
# ip link del flannel.1
# ip link del cni0

제거 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# ip -c link
1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether 08:00:27:71:19:d8 brd ff:ff:ff:ff:ff:ff
    altname enp0s8
3: eth1: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether 08:00:27:d8:a8:88 brd ff:ff:ff:ff:ff:ff
    altname enp0s9
6: veth75d3a172@if2: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 5e:94:82:fd:f5:19 brd ff:ff:ff:ff:ff:ff link-netns cni-51adc222-7922-b776-8c89-11b2530104a7
7: vethbf10bd3c@if2: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether 0e:f0:10:d4:33:65 brd ff:ff:ff:ff:ff:ff link-netns cni-ad9cc013-1136-0f8d-3bdc-8335414f68b8
8: veth10a6853e@if2: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue state UP mode DEFAULT group default qlen 1000
    link/ether aa:22:ce:ca:55:5d brd ff:ff:ff:ff:ff:ff link-netns cni-70f28269-5810-35aa-459d-42d545727521

(⎈|HomeLab:N/A) root@k8s-ctr:~# brctl show

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip -c route
default via 10.0.2.2 dev eth0 proto dhcp src 10.0.2.15 metric 100
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15 metric 100
10.0.2.2 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
10.0.2.3 dev eth0 proto dhcp scope link src 10.0.2.15 metric 100
192.168.10.0/24 dev eth1 proto kernel scope link src 192.168.10.100</code></pre>
<h3 id="기존-kube-proxy-제거-및-노드별-파드-ipampod-cidr-확인">기존 kube-proxy 제거 및 노드별 파드 IPAM(pod CIDR) 확인</h3>
<p>kube-controller-manager는 --allocate-node-cidrs=true 옵션이 설정되어 있을 경우, --cluster-cidr 플래그로 지정된 CIDR을 노드에 자동 할당한다.</p>
<pre><code class="language-bash"># kubectl -n kube-system delete ds kube-proxy
# kubectl -n kube-system delete cm kube-proxy

# iptables-save | grep -v KUBE | grep -v FLANNEL | iptables-restore
# iptables-save

제거 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get nodes -o jsonpath=&#39;{range .items[*]}{.metadata.name}{&quot;\t&quot;}{.spec.podCIDR}{&quot;\n&quot;}{end}&#39;
k8s-ctr    10.244.0.0/24
k8s-w1    10.244.1.0/24
k8s-w2    10.244.3.0/24

(⎈|HomeLab:N/A) root@k8s-ctr:~# kc describe pod -n kube-system kube-controller-manager-k8s-ctr | grep -e cidr -e &#39;service-cluster-ip-range&#39;
      --allocate-node-cidrs=true
      --cluster-cidr=10.244.0.0/16
      --service-cluster-ip-range=10.96.0.0/16

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -o wide
NAME                      READY   STATUS    RESTARTS   AGE    IP           NODE      NOMINATED NODE   READINESS GATES
curl-pod                  1/1     Running   0          105m   10.244.0.4   k8s-ctr   &lt;none&gt;           &lt;none&gt;
webpod-697b545f57-45wbp   1/1     Running   0          105m   10.244.1.2   k8s-w1    &lt;none&gt;           &lt;none&gt;
webpod-697b545f57-bvz97   1/1     Running   0          105m   10.244.3.2   k8s-w2    &lt;none&gt;           &lt;none&gt;</code></pre>
<h3 id="cilium-설치">Cilium 설치</h3>
<pre><code class="language-bash"># helm repo add cilium https://helm.cilium.io/

# 
helm install cilium cilium/cilium --version 1.17.5 --namespace kube-system \
--set k8sServiceHost=192.168.10.100 --set k8sServicePort=6443 \
--set kubeProxyReplacement=true \
--set routingMode=native \
--set autoDirectNodeRoutes=true \
--set ipam.mode=&quot;cluster-pool&quot; \
--set ipam.operator.clusterPoolIPv4PodCIDRList={&quot;172.20.0.0/16&quot;} \
--set ipv4NativeRoutingCIDR=172.20.0.0/16 \
--set endpointRoutes.enabled=true \
--set installNoConntrackIptablesRules=true \
--set bpf.masquerade=true \
--set ipv6.enabled=false</code></pre>
<table>
<thead>
<tr>
<th><strong>옵션</strong></th>
<th><strong>설명</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>kubeProxyReplacement=true</code></td>
<td>kube-proxy를 완전히 대체하여 Cilium이 직접 kube-proxy 기능을 수행하도록 설정.</td>
</tr>
<tr>
<td><code>routingMode=native</code></td>
<td>Cilium의 라우팅 모드를 native 모드로 설정 (Linux 커널 네이티브 라우팅 사용).</td>
</tr>
<tr>
<td><code>autoDirectNodeRoutes=true</code></td>
<td>노드 간 트래픽을 위해 자동으로 직접 라우팅 경로 설정.</td>
</tr>
<tr>
<td><code>ipam.mode=&quot;cluster-pool&quot;</code></td>
<td>IP 주소 할당 모드를 클러스터 풀 모드로 설정 (Cilium이 직접 IP 할당 관리).</td>
</tr>
<tr>
<td><code>ipam.operator.clusterPoolIPv4PodCIDRList={&quot;172.20.0.0/16&quot;}</code></td>
<td>클러스터에서 사용할 Pod용 IPv4 CIDR 풀을 설정.</td>
</tr>
<tr>
<td><code>ipv4NativeRoutingCIDR=172.20.0.0/16</code></td>
<td>노드의 네이티브 라우팅에 사용할 IPv4 CIDR 범위 설정.</td>
</tr>
<tr>
<td><code>endpointRoutes.enabled=true</code></td>
<td>각 Pod에 대한 라우팅 경로를 별도로 생성하여 네트워크 경로를 최적화.</td>
</tr>
<tr>
<td><code>installNoConntrackIptablesRules=true</code></td>
<td>Cilium이 conntrack 기반 iptables 규칙을 설치하지 않도록 설정 (eBPF 방식 우선).</td>
</tr>
<tr>
<td><code>bpf.masquerade=true</code></td>
<td>eBPF를 사용해 IP 마스커레이딩 수행 (NAT 대체).</td>
</tr>
</tbody></table>
<h3 id="설치-확인">설치 확인</h3>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ciliumnodes -o json | grep podCIDRs -A2
                    &quot;podCIDRs&quot;: [
                        &quot;172.20.0.0/24&quot;
                    ],
--
                    &quot;podCIDRs&quot;: [
                        &quot;172.20.1.0/24&quot;
                    ],
--
                    &quot;podCIDRs&quot;: [
                        &quot;172.20.2.0/24&quot;
                    ],

파드 재배포 후 IP 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -owide
NAME                      READY   STATUS    RESTARTS   AGE   IP             NODE      NOMINATED NODE   READINESS GATES
curl-pod                  1/1     Running   0          33s   172.20.0.150   k8s-ctr   &lt;none&gt;           &lt;none&gt;
webpod-85ccc4b7dd-qphtq   1/1     Running   0          74s   172.20.2.150   k8s-w2    &lt;none&gt;           &lt;none&gt;
webpod-85ccc4b7dd-tqc9r   1/1     Running   0          71s   172.20.1.165   k8s-w1    &lt;none&gt;           &lt;none&gt;

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ciliumendpoints
NAME                      SECURITY IDENTITY   ENDPOINT STATE   IPV4           IPV6
curl-pod                  16024               ready            172.20.0.150
webpod-85ccc4b7dd-qphtq   47031               ready            172.20.2.150
webpod-85ccc4b7dd-tqc9r   47031               ready            172.20.1.165

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -c cilium-agent -- cilium-dbg endpoint list</code></pre>
<h2 id="calico-to-cilium">Calico to Cilium</h2>
<h3 id="calico-설치">Calico 설치</h3>
<pre><code class="language-bash">#
helm repo add projectcalico https://docs.tigera.io/calico/charts
helm repo update
helm install calico projectcalico/tigera-operator \
  --namespace tigera-operator \
  --create-namespace

cat &lt;&lt;EOF &gt; installation.yaml
apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
  name: default
spec:
  calicoNetwork:
    ipPools:
    - blockSize: 26
      cidr: 10.244.0.0/16
      encapsulation: VXLAN
      natOutgoing: Enabled
      nodeSelector: all()
EOF

kubectl apply installation.yaml</code></pre>
<h3 id="기존-calico-환경-클러스터-점검">기존 Calico 환경 클러스터 점검</h3>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# helm list -A
NAME      NAMESPACE          REVISION    UPDATED                                    STATUS      CHART                      APP VERSION
calico    tigera-operator    1           2025-07-19 22:45:44.341876006 +0900 KST    deployed    tigera-operator-v3.30.2    v3.30.2

(⎈|HomeLab:N/A) root@k8s-ctr:~# tree /opt/cni/bin/ | grep calico
├── calico
├── calico-ipam

(⎈|HomeLab:N/A) root@k8s-ctr:~# tree /etc/cni/net.d/
/etc/cni/net.d/
├── 10-calico.conflist
└── calico-kubeconfig

(⎈|HomeLab:N/A) root@k8s-ctr:~# cat /etc/cni/net.d/10-calico.conflist | jq
{
  &quot;name&quot;: &quot;k8s-pod-network&quot;,
  &quot;cniVersion&quot;: &quot;0.3.1&quot;,
  &quot;plugins&quot;: [
    {
      &quot;container_settings&quot;: {
        &quot;allow_ip_forwarding&quot;: false
      },
      &quot;datastore_type&quot;: &quot;kubernetes&quot;,
      &quot;endpoint_status_dir&quot;: &quot;/var/run/calico/endpoint-status&quot;,
      &quot;ipam&quot;: {
        &quot;assign_ipv4&quot;: &quot;true&quot;,
        &quot;assign_ipv6&quot;: &quot;false&quot;,
        &quot;type&quot;: &quot;calico-ipam&quot;
      },
      &quot;kubernetes&quot;: {
        &quot;k8s_api_root&quot;: &quot;https://10.96.0.1:443&quot;,
        &quot;kubeconfig&quot;: &quot;/etc/cni/net.d/calico-kubeconfig&quot;
      },
      &quot;log_file_max_age&quot;: 30,
      &quot;log_file_max_count&quot;: 10,
      &quot;log_file_max_size&quot;: 100,
      &quot;log_file_path&quot;: &quot;/var/log/calico/cni/cni.log&quot;,
      &quot;log_level&quot;: &quot;Info&quot;,
      &quot;mtu&quot;: 0,
      &quot;nodename_file_optional&quot;: false,
      &quot;policy&quot;: {
        &quot;type&quot;: &quot;k8s&quot;
      },
      &quot;policy_setup_timeout_seconds&quot;: 0,
      &quot;type&quot;: &quot;calico&quot;
    },
    {
      &quot;capabilities&quot;: {
        &quot;portMappings&quot;: true
      },
      &quot;snat&quot;: true,
      &quot;type&quot;: &quot;portmap&quot;
    }
  ]
}

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip -c route | grep 10.244.
10.244.46.0/26 via 10.244.46.8 dev vxlan.calico onlink
10.244.46.8 dev vxlan.calico scope link
blackhole 10.244.78.64/26 proto 80
10.244.78.65 dev cali4aab08373ba scope link
10.244.78.66 dev cali33014ef5b3d scope link
10.244.228.64/26 via 10.244.228.66 dev vxlan.calico onlink
10.244.228.66 dev vxlan.calico scope link

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -o wide -A | grep 10.244 | grep ctr
calico-system      csi-node-driver-mvzvw                      2/2     Running   0          12m   10.244.78.65     k8s-ctr   &lt;none&gt;           &lt;none&gt;
calico-system      whisker-7f5b7c657b-975qr                   2/2     Running   0          12m   10.244.78.66     k8s-ctr   &lt;none&gt;           &lt;none&gt;

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip link show vxlan.calico
9: vxlan.calico: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/ether 66:65:25:b2:65:0a brd ff:ff:ff:ff:ff:ff

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip link show | grep cali
4: cali4aab08373ba@if2: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
5: cali33014ef5b3d@if2: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1480 qdisc noqueue state UP mode DEFAULT group default qlen 1000
9: vxlan.calico: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1450 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000

샘플 Pod, Svc 배포
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pods -o wide
NAME                      READY   STATUS    RESTARTS   AGE   IP              NODE      NOMINATED NODE   READINESS GATES
curl-pod                  1/1     Running   0          10s   10.244.78.68    k8s-ctr   &lt;none&gt;           &lt;none&gt;
webpod-697b545f57-fbmmm   1/1     Running   0          10s   10.244.228.67   k8s-w1    &lt;none&gt;           &lt;none&gt;
webpod-697b545f57-fg6bg   1/1     Running   0          10s   10.244.46.9     k8s-w2    &lt;none&gt;           &lt;none&gt;</code></pre>
<h3 id="기존-calico-cni-제거">기존 Calico CNI 제거</h3>
<pre><code class="language-bash"># helm uninstall -n tigera-operator calico
# helm list -A

# kubectl get all -n calico-system
# kubectl delete ns tigera-operator

# ip link del vxlan.calico</code></pre>
<h3 id="기존-kube-proxy-제거-및-노드별-파드-ipampod-cidr-확인-1">기존 kube-proxy 제거 및 노드별 파드 IPAM(pod CIDR) 확인</h3>
<pre><code class="language-bash"># kubectl -n kube-system delete ds kube-proxy
# kubectl -n kube-system delete cm kube-proxy

# iptables-save | grep -v KUBE | grep -vi cali | iptables-restore
# iptables-save

# reboot

제거 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get nodes -o jsonpath=&#39;{range .items[*]}{.metadata.name}{&quot;\t&quot;}{.spec.podCIDR}{&quot;\n&quot;}{end}&#39;
k8s-ctr    10.244.0.0/24
k8s-w1    10.244.1.0/24
k8s-w2    10.244.2.0/24]

(⎈|HomeLab:N/A) root@k8s-ctr:~# kc describe pod -n kube-system kube-controller-manager-k8s-ctr | grep -e cidr -e &#39;service-cluster-ip-range&#39;
      --allocate-node-cidrs=true
      --cluster-cidr=10.244.0.0/16
      --service-cluster-ip-range=10.96.0.0/16

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -o wide
NAME                      READY   STATUS    RESTARTS   AGE   IP              NODE      NOMINATED NODE   READINESS GATES
curl-pod                  1/1     Running   0          21m   10.244.78.68    k8s-ctr   &lt;none&gt;           &lt;none&gt;
webpod-697b545f57-fbmmm   1/1     Running   0          21m   10.244.228.67   k8s-w1    &lt;none&gt;           &lt;none&gt;
webpod-697b545f57-fg6bg   1/1     Running   0          21m   10.244.46.9     k8s-w2    &lt;none&gt;           &lt;none&gt;</code></pre>
<h3 id="cilium-설치-1">Cilium 설치</h3>
<p>위 Flannel =&gt; Cilium에서의 과정과 동일하여 생략한다.</p>
<h3 id="설치-확인-1">설치 확인</h3>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ciliumnodes -o json | grep podCIDRs -A2
                    &quot;podCIDRs&quot;: [
                        &quot;172.20.0.0/24&quot;
                    ],
--
                    &quot;podCIDRs&quot;: [
                        &quot;172.20.2.0/24&quot;
                    ],
--
                    &quot;podCIDRs&quot;: [
                        &quot;172.20.1.0/24&quot;
                    ],

파드 재배포 후 IP 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -owide
NAME                      READY   STATUS    RESTARTS   AGE   IP             NODE      NOMINATED NODE   READINESS GATES
curl-pod                  1/1     Running   0          20s   172.20.0.6     k8s-ctr   &lt;none&gt;           &lt;none&gt;
webpod-659cd747f8-v9xkm   1/1     Running   0          64s   172.20.1.172   k8s-w2    &lt;none&gt;           &lt;none&gt;
webpod-659cd747f8-zqbk8   1/1     Running   0          67s   172.20.2.151   k8s-w1    &lt;none&gt;           &lt;none&gt;

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ciliumendpoints
NAME                      SECURITY IDENTITY   ENDPOINT STATE   IPV4           IPV6
curl-pod                  61805               ready            172.20.0.6
webpod-659cd747f8-v9xkm   11362               ready            172.20.1.172
webpod-659cd747f8-zqbk8   11362               ready            172.20.2.151

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -c cilium-agent -- cilium-dbg endpoint list</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS EKS : VPC CNI]]></title>
            <link>https://velog.io/@_gyullbb/AWS-EKS-VPC-CNI</link>
            <guid>https://velog.io/@_gyullbb/AWS-EKS-VPC-CNI</guid>
            <pubDate>Sat, 02 Nov 2024 19:16:38 GMT</pubDate>
            <description><![CDATA[<h1 id="aws-vpc-cni">AWS VPC CNI</h1>
<p>AWS VPC CNI는 Amazon EKS에서 Pod가 VPC의 IP 주소를 직접 사용할 수 있게 해주는 네트워크 플러그인이다. 
이를 통해 Pod와 외부 네트워크간의 통신이 VPC 내에서 직접적으로 이루어지며, 추가적인 네트워크 변환 없이 통신이 가능하도록 한다.</p>
<p>VPC CNI의 중요한 특징 중 하나는 Pod가 노드의 네트워크 대역(VPC 서브넷)과 동일한 IP 대역을 사용한다는 것이다. 
각 Pod는 별도의 NAT나 프록시 없이 VPC 내의 다른 리소스와 직접 통신이 가능하며, pod와 노드의 네트워크 대역이 같다보니 오버레이(VXLAN, IP-IP 등)으로 통신하는 일반적인 K8s CNI와는 달리 동일 대역으로 직접 통신을 한다.</p>
<p>AWS VPC CNI에는 두 가지 주요 구성 요소인 <strong>CNI 바이너리(CNI Binary)</strong>와 <strong>IP 주소 관리(IPAM, IP Address Management)</strong>가 있다.</p>
<h2 id="cni-바이너리-cni-binary">CNI 바이너리 (CNI Binary)</h2>
<p>CNI 바이너리는 Pod의 네트워크 인터페이스를 생성하고 삭제하는 역할을 수행한다.</p>
<p>Pod가 시작될 때 ENI(Elastic Network Interface)와 연결된 IP를 Pod에 할당한다.
Pod가 종료되면 네트워크 자원을 정리한다.
이를 통해 Pod와 외부 네트워크 간의 원활한 통신이 가능하게 하며, 모든 Pod가 VPC 네트워크 대역의 IP를 사용하도록 한다.</p>
<h2 id="ip-주소-관리-l-ipam">IP 주소 관리 (L-IPAM)</h2>
<p>IPAM은 VPC 서브넷 내에서 Pod에 할당할 IP 주소를 효율적으로 관리한다.</p>
<p>노드에 할당된 ENI에서 여러 IP를 미리 풀(pool) 형태로 확보해 두고, Pod가 생성될 때 즉시 할당한다.
사용하지 않는 IP는 재사용하거나 반환하여 네트워크 자원을 절약한다.
이 과정은 동적으로 관리되며, VPC 서브넷과 노드의 자원 상태에 따라 유연하게 조정된다.
이 두 요소를 통해 AWS VPC CNI는 Pod 간의 통신과 VPC 내 네트워크 자원 관리를 효율적으로 지원한다.</p>
<ul>
<li>ip를 미리 가지고 있다가 warm pool에서 ip바로 pod에 할당할수있어서 pod도 빨리뜨고, api호출수도 줄어든다.</li>
</ul>
<h2 id="pod-통신">Pod 통신</h2>
<h3 id="기본-네트워크-구성-확인">기본 네트워크 구성 확인</h3>
<p>생성된 노드(t3.medium)의 기본 네트워크 구성을 확인해보자.
<img src="https://velog.velcdn.com/images/_gyullbb/post/d342040e-2555-4516-bf23-ec232225038a/image.png" alt=""></p>
<p>현재 ENI는 2개로, 각 ENI는 자신의 IP 이외에 추가적으로 5개의 보조 프라이빗 IP를 가질수 있다.
coredns 파드는 veth 인터페이스로, 호스트에는 eniY@ifN 인터페이스와 파드에 eth0 과 연결되어 있다</p>
<p>파드를 배포한 후 각 워커 노드의 라우팅 정보와 ip 정보를 확인해보자.</p>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/9e10e150-f8a8-47ee-8626-e90228cb96f9/image.png" alt="">
<img src="https://velog.velcdn.com/images/_gyullbb/post/ca4790ba-0032-4b4d-a281-af08014efa30/image.png" alt=""></p>
<p>파드가 생성 후, 워커 노드에 eniY@ifN 이 추가되고 라우팅 테이블에도 정보가 추가 된 것을 확인할 수 있다.</p>
<h3 id="노드간-파드-통신">노드간 파드 통신</h3>
<p>위에서 설명했듯이,  AWS VPC CNI 경우 Pod가 노드의 네트워크 대역과 동일한 IP 대역을 사용하기 때문에, 별도의 오버레이(Overlay) 통신 기술 없이, VPC Native 하게 파드간 직접 통신이 가능하다
<img src="https://velog.velcdn.com/images/_gyullbb/post/81b28e99-95df-482c-8adb-9d0da15131a5/image.png" alt="참조 : KANS Kubernetes Network Study 3기"></p>
<p>Pod1에서 Pod2 통신을 시도함과 동시에 Pod가 떠있는 노드에서 eth0 패킷 캡처를 해본다.
<img src="https://velog.velcdn.com/images/_gyullbb/post/e1f6a09b-8a1c-4d82-a6e8-bff1861a7c0f/image.png" alt=""></p>
<p>패킷 캡처된 내역을 보면, 서로 다른 노드 위에 있음에도 파드 간 통신이 NAT없이 direct 통신이 이뤄짐을 알 수 있다.</p>
<pre><code class="language-bash">[ec2-user@ip-192-168-1-193 ~]$ ip route show table main
default via 192.168.1.1 dev eth0
169.254.169.254 dev eth0
192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.193
192.168.1.72 dev eni6376c4bd6b9 scope link</code></pre>
<p>노드에서 라우팅 정보를 확인하면, default via 192.168.1.1 dev eth0라는 라우팅 정보가 있는데, 이는 서버에서 로컬 네트워크에 속하지 않는 패킷을 전송할 때 기본 게이트웨이(192.168.1.1)를 통해 eth0 네트워크 인터페이스로 보낸다는 뜻이다.</p>
<p>이 라우팅 경로가 default로 설정되어 있기 때문에, 이 서버에서 외부 네트워크나 인터넷으로 나가는 모든 트래픽은 eth0을 통해 빠져나가게 된다.</p>
<h3 id="파드에서-외부-통신">파드에서 외부 통신</h3>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/325b2b5e-f275-41a6-8fec-9a479488a85a/image.png" alt="https://github.com/aws/amazon-vpc-cni-k8s/blob/master/docs/cni-proposal.md"></p>
<p>파드에서 외부 통신을 함과 동시에 파드가 떠있는 워커노드에서 eth0 패킷을 캡처해본다.
<img src="https://velog.velcdn.com/images/_gyullbb/post/5afac339-ffdd-42b4-90cc-df597f2def92/image.png" alt=""></p>
<p>파드가 외부 통신 할 때에는 &#39;AWS-SNAT-CHAIN-0&#39; 룰(rule)에 의해서 SNAT 되어서 외부와 통신이 된다.
해당 룰에 대해 확인해본다.</p>
<pre><code class="language-bash">[ec2-user@ip-192-168-2-90 ~]$ sudo iptables -t nat -S | grep &#39;A AWS-SNAT-CHAIN&#39;
-A AWS-SNAT-CHAIN-0 -d 192.168.0.0/16 -m comment --comment &quot;AWS SNAT CHAIN&quot; -j RETURN
-A AWS-SNAT-CHAIN-0 ! -o vlan+ -m comment --comment &quot;AWS, SNAT&quot; -m addrtype ! --dst-type LOCAL -j SNAT --to-source 192.168.2.90 --random-fully</code></pre>
<p><code>-A AWS-SNAT-CHAIN-0 -d 192.168.0.0/16 -m comment --comment &quot;AWS SNAT CHAIN&quot; -j RETURN</code></p>
<p>이 규칙은 destination ip가 192.168.0.0/16 대역에 속하는 패킷을 SNAT 적용에서 제외시킨다. 즉, 로컬 서브넷 안에서의 트래픽은 SNAT을 적용하지 않도록 하는 설정이다.</p>
<p><code>-A AWS-SNAT-CHAIN-0 ! -o vlan+ -m comment --comment &quot;AWS, SNAT&quot; -m addrtype ! --dst-type LOCAL -j SNAT --to-source 192.168.2.90 --random-fully</code></p>
<p>이 규칙은 VLAN을 제외한 다른 인터페이스를 통한 트래픽에 SNAT을 적용하는 규칙이다. AWS 네트워크 외부로 나가는 트래픽에 대해 출발지 IP 주소를 노드 IP인 192.168.2.90으로 설정하여 관리 및 라우팅을 일관되게 유지할 수 있다.</p>
<h3 id="노드에-파드-생성-갯수-제한">노드에 파드 생성 갯수 제한</h3>
<p>AWS는 워커 노드의 인스턴스 타입 별로 파드 생성 갯수를 제한한다.
<strong>인스턴스 타입</strong> 별 ENI 최대 갯수와 할당 가능한 최대 IP 갯수에 따라서 파드 배치 갯수가 결정되는데, aws-node 와 kube-proxy 파드의 경우 호스트의 IP를 사용함으로 최대 갯수에서 제외한다.</p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# aws ec2 describe-instance-types --filters Name=instance-type,Values=t3.* \
&gt;  --query &quot;InstanceTypes[].{Type: InstanceType, MaxENI: NetworkInfo.MaximumNetworkInterfaces, IPv4addr: NetworkInfo.Ipv4AddressesPerInterface}&quot; \
&gt;  --output table
--------------------------------------
|        DescribeInstanceTypes       |
+----------+----------+--------------+
| IPv4addr | MaxENI   |    Type      |
+----------+----------+--------------+
|  12      |  3       |  t3.large    |
|  6       |  3       |  t3.medium   |
|  15      |  4       |  t3.xlarge   |
|  15      |  4       |  t3.2xlarge  |
|  2       |  2       |  t3.micro    |
|  2       |  2       |  t3.nano     |
|  4       |  3       |  t3.small    |
+----------+----------+--------------+

(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl describe node | grep Allocatable: -A6
Allocatable:
  cpu:                1930m
  ephemeral-storage:  27905944324
  hugepages-1Gi:      0
  hugepages-2Mi:      0
  memory:             3388312Ki
  pods:               17</code></pre>
<p>제한된 개수 이상의 파드를 생성했을 때, 어떻게 되는지 알아본다.</p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl scale deployment nginx-deployment --replicas=50
deployment.apps/nginx-deployment scaled</code></pre>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/82e12607-d99a-4e0e-b692-d7502c020557/image.png" alt="">
<img src="https://velog.velcdn.com/images/_gyullbb/post/4b065c56-7c66-45bc-91d2-15cbdb0e5025/image.png" alt="">
제한 개수를 넘어가면 pod가 Pending상태로 정상 기동되지 않는다.</p>
<p>워커노드에 pod ip할당도 제한된 개수까지 할당된 것을 볼 수 있다.
<img src="https://velog.velcdn.com/images/_gyullbb/post/a9ad9741-e35d-4184-9566-92dbb8ce9987/image.png" alt=""></p>
<h2 id="service--aws-loadbalancer-controller">Service &amp; AWS LoadBalancer Controller</h2>
<p>AWS 환경에서 쿠버네티스 서비스의 트래픽을 외부로 노출하기 위해 AWS Load Balancer Controller와 Network Load Balancer(NLB)를 활용할 수 있다. </p>
<h3 id="aws-load-balancer-controller">AWS Load Balancer Controller</h3>
<p>AWS Load Balancer Controller는 쿠버네티스 클러스터 내 서비스의 트래픽을 AWS의 네트워크 로드 밸런서(NLB)로 자동 연결해 주는 컨트롤러이다. 이 컨트롤러는 LoadBalancer 타입의 서비스를 생성할 때 자동으로 NLB를 프로비저닝하고 이를 통해 서비스 트래픽을 처리합니다.</p>
<h3 id="nlb">NLB</h3>
<p>NLB 모드에는 인스턴스 유형과 IP 유형이 있다.</p>
<ol>
<li>인스턴스 유형</li>
</ol>
<ul>
<li>인스턴스 유형 로드 밸런서는 Amazon EC2 인스턴스를 대상으로 사용한다.</li>
<li>주로 기존의 Classic Load Balancer(CLB)와 연결되며, 대상 인스턴스는 EC2의 ID를 기준으로 로드 밸런서에 등록된다.</li>
<li>인스턴스의 상태에 따라 자동으로 로드 밸런서의 트래픽이 분배되며, 인스턴스가 중지 또는 종료되면 로드 밸런서에서 자동으로 제거된다.</li>
<li>탄력적 IP 주소(EIP)를 사용하지 않는 경우 IP 주소가 동적으로 할당되므로, 인스턴스의 IP 주소가 변경될 수 있다.</li>
</ul>
<ol start="2">
<li>IP 유형</li>
</ol>
<ul>
<li>IP 유형 로드 밸런서는 특정 IP 주소를 대상으로 사용하며, 인스턴스뿐만 아니라 온프레미스 서버 등 다양한 IP 주소를 대상으로 로드 밸런싱할 수 있다.</li>
<li>주로 Application Load Balancer(ALB)와 Network Load Balancer(NLB)에서 사용되며, 로드 밸런서가 VPC 외부의 IP 주소로도 트래픽을 분배할 수 있다.</li>
<li>각 대상 IP는 탄력적 IP 또는 고정 IP일 수 있어 IP 주소가 변경되지 않고 고정된 대상 서버에 트래픽을 전달하는 경우 유용하다.</li>
<li>IP 기반 로드 밸런서를 사용하면 컨테이너화된 환경이나 서버리스 아키텍처에서도 효과적으로 로드 밸런싱을 수행할 수 있다.</li>
</ul>
<h3 id="aws-loadbalancer-controller-servicepod-배포">AWS LoadBalancer Controller, Service/Pod 배포</h3>
<p>AWS LoadBalancer Controller를 배포하여 분산 접속을 확인해보자.</p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl get crd
NAME                                         CREATED AT
cninodes.vpcresources.k8s.aws                2024-11-02T13:55:05Z
eniconfigs.crd.k8s.amazonaws.com             2024-11-02T13:58:33Z
ingressclassparams.elbv2.k8s.aws             2024-11-02T18:31:35Z
policyendpoints.networking.k8s.aws           2024-11-02T13:55:06Z
securitygrouppolicies.vpcresources.k8s.aws   2024-11-02T13:55:05Z
targetgroupbindings.elbv2.k8s.aws            2024-11-02T18:31:35Z


(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl get deployment -n kube-system aws-load-balancer-controller
NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
aws-load-balancer-controller   2/2     2            2           5m10s</code></pre>
<pre><code class="language-bash">Every 2.0s: kubectl get pod,svc,ep                                                                             Sun Nov  3 03:37:11 2024

NAME                                READY   STATUS    RESTARTS   AGE
pod/deploy-echo-857b6cfb88-mgdcm    1/1     Running   0          5m12s
pod/deploy-echo-857b6cfb88-z9qv7    1/1     Running   0          5m12s
pod/netshoot-pod-74b7555dc7-7hpkt   1/1     Running   0          175m
pod/netshoot-pod-74b7555dc7-mjs77   1/1     Running   0          175m
pod/netshoot-pod-74b7555dc7-s76j2   1/1     Running   0          175m

NAME                      TYPE           CLUSTER-IP     EXTERNAL-IP
     PORT(S)        AGE
service/kubernetes        ClusterIP      10.100.0.1     &lt;none&gt;
     443/TCP        4h42m
service/svc-nlb-ip-type   LoadBalancer   10.100.12.82   k8s-default-svcnlbip-4588d22da5-bd009cf4bd83d981.elb.ap-northeast-2.amazonaws.c
om   80:31013/TCP   5m12s

NAME                        ENDPOINTS                               AGE
endpoints/kubernetes        192.168.2.143:443,192.168.3.204:443     4h42m
endpoints/svc-nlb-ip-type   192.168.1.170:8080,192.168.3.113:8080   5m12s

(bgrtest@myeks:default) [root@myeks-bastion ~]# for i in {1..100}; do curl -s $NLB | grep Hostname ; done | sort | uniq -c | sort -nr
     50 Hostname: deploy-echo-857b6cfb88-z9qv7
     50 Hostname: deploy-echo-857b6cfb88-mgdcm</code></pre>
<p>pod 개수를 변화시켜도 정상적으로 분배를 수행한다. 
이 때, NLB 대상 타겟이 모두 정상 반영되었는지를 확인해야한다.</p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl scale deployment deploy-echo --replicas=1

deployment.apps/deploy-echo scaled

(bgrtest@myeks:default) [root@myeks-bastion ~]# for i in {1..100}; do curl -s $NLB | grep Hostname ; done | sort | uniq -c | sort -nr
    100 Hostname: deploy-echo-857b6cfb88-mgdcm

(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl scale deployment deploy-echo --replicas=3
deployment.apps/deploy-echo scaled


# 아직 NLB 대상 타겟이 아직 initial 상태 : 타겟이 정상 반영되기 전
(bgrtest@myeks:default) [root@myeks-bastion ~]# for i in {1..100}; do curl -s $NLB | grep Hostname ; done | sort | uniq -c | sort -nr
    100 Hostname: deploy-echo-857b6cfb88-mgdcm

(bgrtest@myeks:default) [root@myeks-bastion ~]# for i in {1..100}; do curl -s $NLB | grep Hostname ; done | sort | uniq -c | sort -nr
     41 Hostname: deploy-echo-857b6cfb88-cqt65
     31 Hostname: deploy-echo-857b6cfb88-mgdcm
     28 Hostname: deploy-echo-857b6cfb88-znshb     </code></pre>
<h2 id="ingress">Ingress</h2>
<p>AWS Load Balancer Controller는 Kubernetes 환경에서 AWS Application Load Balancer(ALB)와 통합되어 Ingress 리소스를 통해 트래픽을 관리한다. AWS VPC CNI를 사용해 IP 모드로 동작하는 경우, ALB는 Kubernetes 서비스의 개별 Pod IP를 대상으로 직접 트래픽을 라우팅한다.</p>
<h3 id="alb-ip-모드">ALB IP 모드</h3>
<p>ALB의 기본 연결 방식은 NodePort이지만, IP 모드에서는 Pod의 IP가 ALB 타겟 그룹에 직접 등록된다. NodePort를 거치지 않고 트래픽을 바로 Pod로 라우팅하여 지연 시간을 줄이고 성능을 개선할 수 있다.</p>
<p>AWS VPC CNI는 Pod에게 VPC IP를 직접 할당하여 VPC 네트워크에 직접 연결되도록 한다. IP 모드의 ALB와 연동 시 각 Pod가 고유한 IP를 가져 네트워크 성능과 유연성이 개선된다.</p>
<h3 id="실습">실습</h3>
<p>ALB 대상 그룹에 등록된 대상을 확인한다. ALB 대상에 파드 IP가 바로 등록되어 있음을 확인할 수 있다.
<img src="https://velog.velcdn.com/images/_gyullbb/post/ca261af2-0de4-4f57-b868-9d29bb231187/image.png" alt=""></p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl get pod -n game-2048 -o wide
NAME                              READY   STATUS    RESTARTS   AGE     IP              NODE                                               NOMINATED NODE   READINESS GATES
deployment-2048-85f8c7d69-k6t8b   1/1     Running   0          3m45s   192.168.3.113   ip-192-168-3-157.ap-northeast-2.compute.internal   &lt;none&gt;           &lt;none&gt;
deployment-2048-85f8c7d69-nrpsx   1/1     Running   0          3m45s   192.168.1.251   ip-192-168-1-193.ap-northeast-2.compute.internal   &lt;none&gt;           &lt;none&gt;</code></pre>
<p>인그레스를 통한 접속을 확인해본다.</p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# # Ingress 확인

(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl describe ingress -n game-2048 ingress-2048
Name:             ingress-2048
Labels:           &lt;none&gt;
Namespace:        game-2048
Address:          k8s-game2048-ingress2-70d50ce3fd-900250973.ap-northeast-2.elb.amazonaws.com
Ingress Class:    alb
Default backend:  &lt;default&gt;
Rules:
  Host        Path  Backends
  ----        ----  --------
  *
              /   service-2048:80 (192.168.1.251:80,192.168.3.113:80)
Annotations:  alb.ingress.kubernetes.io/scheme: internet-facing
              alb.ingress.kubernetes.io/target-type: ip
Events:
  Type    Reason                  Age    From     Message
  ----    ------                  ----   ----     -------
  Normal  SuccessfullyReconciled  4m46s  ingress  Successfully reconciled

(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl get ingress -n game-2048 ingress-2048 -o jsonpath=&quot;{.status.loadBalancer.ingress[*].hostname}{&#39;\n&#39;}&quot;
k8s-game2048-ingress2-70d50ce3fd-900250973.ap-northeast-2.elb.amazonaws.com

(bgrtest@myeks:default) [root@myeks-bastion ~]# # 게임 접속 : ALB 주소로 웹 접속
(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl get ingress -n game-2048 ingress-2048 -o jsonpath={.status.loadBalancer.ingress[0].hostname} | awk &#39;{ print &quot;Game URL = http://&quot;$1 }&#39;
Game URL = http://k8s-game2048-ingress2-70d50ce3fd-900250973.ap-northeast-2.elb.amazonaws.com</code></pre>
<p>ALB 주소로 웹 접속하면 재밌는 게임도 즐길 수 있다.
<img src="https://velog.velcdn.com/images/_gyullbb/post/07d9bac2-d157-4b49-b6f7-52a013badfca/image.png" alt=""></p>
<h2 id="topology-aware-routing">Topology aware routing</h2>
<p>Topology Aware Routing(토폴로지 인식 라우팅)은 네트워크의 구조를 고려하여 데이터 패킷을 전송하는 방식이다. 다음과 같은 장점을 제공한다</p>
<ul>
<li>지연 최소화: 지리적으로 가까운 노드 간에 트래픽을 라우팅하여 지연을 줄인다.</li>
<li>부하 분산: 노드 간의 트래픽 분산을 최적화하여 특정 노드에 트래픽이 몰리는 것을 방지한다.</li>
<li>장애 복구: 경로 실패 시 대체 경로를 신속하게 찾아 트래픽을 재조정할 수 있다.</li>
<li>비용 효율성: 최소한의 경로를 사용하여 데이터 전송 비용을 절감한다.</li>
</ul>
<p>Kubernetes에서는 topologyKeys를 설정하여 특정 노드의 레이블에 따라 요청을 분산시키는 방식으로 Topology Aware Routing을 구현한다. 이를 통해 대규모 분산 시스템에서 성능 최적화와 자원 관리를 효과적으로 수행할 수 있다.</p>
<p>현재 노드 AZ 분산을 확인해본다.</p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl get node --label-columns=topology.kubernetes.io/zone

NAME                                               STATUS   ROLES    AGE     VERSION               ZONE
ip-192-168-1-193.ap-northeast-2.compute.internal   Ready    &lt;none&gt;   5h14m   v1.30.4-eks-a737599   ap-northeast-2a
ip-192-168-2-90.ap-northeast-2.compute.internal    Ready    &lt;none&gt;   5h14m   v1.30.4-eks-a737599   ap-northeast-2b
ip-192-168-3-157.ap-northeast-2.compute.internal   Ready    &lt;none&gt;   5h14m   v1.30.4-eks-a737599   ap-northeast-2c</code></pre>
<h3 id="topology-aware-routing-적용-전">Topology aware routing 적용 전</h3>
<p>테스트 파드(netshoot-pod)에서 ClusterIP 접속 시 부하분산을 확인하면 AZ(zone) 상관없이 랜덤 확률 부하분산 동작하는 것을 볼 수 있다.</p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl get pod -l app=deploy-websrv -owide
NAME                           READY   STATUS    RESTARTS   AGE    IP              NODE                                               NOMINATED NODE   READINESS GATES
deploy-echo-859cc9b57d-2sk67   1/1     Running   0          115s   192.168.2.215   ip-192-168-2-90.ap-northeast-2.compute.internal    &lt;none&gt;           &lt;none&gt;
deploy-echo-859cc9b57d-6dczc   1/1     Running   0          115s   192.168.3.174   ip-192-168-3-157.ap-northeast-2.compute.internal   &lt;none&gt;           &lt;none&gt;
deploy-echo-859cc9b57d-dn4cz   1/1     Running   0          115s   192.168.1.170   ip-192-168-1-193.ap-northeast-2.compute.internal   &lt;none&gt;           &lt;none&gt;

(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl exec -it netshoot-pod -- zsh -c &quot;for i in {1..100}; do curl -s svc-clusterip | grep Hostname; done | sort | uniq -c | sort -nr&quot;
      37 Hostname: deploy-echo-859cc9b57d-dn4cz
     33 Hostname: deploy-echo-859cc9b57d-6dczc
     30 Hostname: deploy-echo-859cc9b57d-2sk67</code></pre>
<h3 id="topology-aware-routing-적용-이후">Topology aware routing 적용 이후</h3>
<p>Topology Mode 설정 후 테스트 파드(netshoot-pod)에서 ClusterIP 접속 시 부하분산을 확인하면 같은 AZ(zone)의 목적지 파드로만 접속하는 것을 볼 수 있다.</p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl annotate service svc-clusterip &quot;service.kubernetes.io/topology-mode=auto&quot;
service/svc-clusterip annotated
(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl exec -it netshoot-pod -- zsh -c &quot;for i in {1..100}; do curl -s svc-clusterip | grep Hostname; done | sort | uniq -c | sort -nr&quot;
    100 Hostname: deploy-echo-859cc9b57d-6dczc</code></pre>
<p>endpointslices를 확인하면 기존에 없던 hints 가 추가되어 있다. 
Topology Hints는 Pod와 Node의 지리적 위치나 네트워크 토폴로지 정보를 제공하여 라우팅 결정을 돕는다. 각 Pod의 위치 정보가 힌트로 제공되면, 클러스터는 이 힌트를 기반으로 가까운 경로로 트래픽을 라우팅한다.</p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl get endpointslices -l kubernetes.io/service-name=svc-clusterip -o yaml
apiVersion: v1
items:
- addressType: IPv4
  apiVersion: discovery.k8s.io/v1
  endpoints:
  - addresses:
    - 192.168.3.174
    conditions:
      ready: true
      serving: true
      terminating: false
    hints:
      forZones:
      - name: ap-northeast-2c
    nodeName: ip-192-168-3-157.ap-northeast-2.compute.internal
...</code></pre>
<h4 id="iptables">iptables</h4>
<p>iptables로 확인했을 때, SEP(Endpoint) 파드가 각 노드 당 노드와 동일한 AZ에 배포된 파드1개만 출력되는 것을 확인할 수 있다.</p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# ssh ec2-user@$N1 sudo iptables -v --numeric --table nat --list KUBE-SERVICES
Chain KUBE-SERVICES (2 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 KUBE-SVC-UAGC4PYEYZJJEW6D  tcp  --  *      *       0.0.0.0/0            10.100.245.133       /* kube-system/aws-load-balancer-webhook-service:webhook-server cluster IP */ tcp dpt:443
    0     0 KUBE-SVC-KBDEBIL6IU6WL7RF  tcp  --  *      *       0.0.0.0/0            10.100.229.199       /* default/svc-clusterip:svc-webport cluster IP */ tcp dpt:80
    0     0 KUBE-SVC-NPX46M4PTMTKRN6Y  tcp  --  *      *       0.0.0.0/0            10.100.0.1           /* default/kubernetes:https cluster IP */ tcp dpt:443
    0     0 KUBE-SVC-I7SKRZYQ7PWYV5X7  tcp  --  *      *       0.0.0.0/0            10.100.238.197       /* kube-system/eks-extension-metrics-api:metrics-api cluster IP */ tcp dpt:443
    0     0 KUBE-SVC-TCOU7JCQXEZGVUNU  udp  --  *      *       0.0.0.0/0            10.100.0.10          /* kube-system/kube-dns:dns cluster IP */ udp dpt:53
    0     0 KUBE-SVC-ERIFXISQEP7F7OF4  tcp  --  *      *       0.0.0.0/0            10.100.0.10          /* kube-system/kube-dns:dns-tcp cluster IP */ tcp dpt:53
    0     0 KUBE-SVC-JD5MR3NA4I4DYORP  tcp  --  *      *       0.0.0.0/0            10.100.0.10          /* kube-system/kube-dns:metrics cluster IP */ tcp dpt:9153
   39  2340 KUBE-NODEPORTS  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* kubernetes service nodeports; NOTE: this must be the last rule in this chain */ ADDRTYPE match dst-type LOCAL

(bgrtest@myeks:default) [root@myeks-bastion ~]#
(bgrtest@myeks:default) [root@myeks-bastion ~]# ssh ec2-user@$N1 sudo iptables -v --numeric --table nat --list KUBE-SVC-KBDEBIL6IU6WL7RF
Chain KUBE-SVC-KBDEBIL6IU6WL7RF (1 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 KUBE-SEP-2ZUOF3KQXKLOSGMW  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* default/svc-clusterip:svc-webport -&gt; 192.168.1.170:8080 */
(bgrtest@myeks:default) [root@myeks-bastion ~]# ssh ec2-user@$N2 sudo iptables -v --numeric --table nat --list KUBE-SVC-KBDEBIL6IU6WL7RF
Chain KUBE-SVC-KBDEBIL6IU6WL7RF (1 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 KUBE-SEP-SX6FOWFFGSKOV6K3  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* default/svc-clusterip:svc-webport -&gt; 192.168.2.215:8080 */
(bgrtest@myeks:default) [root@myeks-bastion ~]# ssh ec2-user@$N3 sudo iptables -v --numeric --table nat --list KUBE-SVC-KBDEBIL6IU6WL7RF
Chain KUBE-SVC-KBDEBIL6IU6WL7RF (1 references)
 pkts bytes target     prot opt in     out     source               destination
  200 12000 KUBE-SEP-L65MDBTORLAOI3KR  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* default/svc-clusterip:svc-webport -&gt; 192.168.3.174:8080 */</code></pre>
<h4 id="동일-az에-목적지-파드가-없다면">동일 AZ에 목적지 파드가 없다면?</h4>
<p>파드 개수를 1개로 줄여서 동일 AZ노드에 목적지 파드가 없는 상황을 만들어보자.</p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl scale deployment deploy-echo --replicas 1

(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl get pod -o wide
NAME                            READY   STATUS    RESTARTS   AGE     IP              NODE                                               NOMINATED NODE   READINESS GATES
deploy-echo-859cc9b57d-dn4cz    1/1     Running   0          12m     192.168.1.170   ip-192-168-1-193.ap-northeast-2.compute.internal   &lt;none&gt;           &lt;none&gt;
netshoot-pod                    1/1     Running   0          11m     192.168.3.37    ip-192-168-3-157.ap-northeast-2.compute.internal   &lt;none&gt;           &lt;none&gt;
netshoot-pod-74b7555dc7-7hpkt   1/1     Running   0          3h48m   192.168.2.196   ip-192-168-2-90.ap-northeast-2.compute.internal    &lt;none&gt;           &lt;none&gt;
netshoot-pod-74b7555dc7-mjs77   1/1     Running   0          3h48m   192.168.3.12    ip-192-168-3-157.ap-northeast-2.compute.internal   &lt;none&gt;           &lt;none&gt;
netshoot-pod-74b7555dc7-s76j2   1/1     Running   0          3h48m   192.168.1.72    ip-192-168-1-193.ap-northeast-2.compute.internal   &lt;none&gt;</code></pre>
<p>이전과 달리 hint 정보가 사라져있다.</p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# kubectl get endpointslices -l kubernetes.io/service-name=svc-clusterip -o yaml | grep -i hint
</code></pre>
<p>iptables를 확인해보면 AZ와 관련없이 3개 노드 모두 SVC에 1개의 SEP 정책 존재함을 확인할 수 있다.</p>
<pre><code class="language-bash">(bgrtest@myeks:default) [root@myeks-bastion ~]# ssh ec2-user@$N1 sudo iptables -v --numeric --table nat --list KUBE-SVC-KBDEBIL6IU6WL7RF
Chain KUBE-SVC-KBDEBIL6IU6WL7RF (1 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 KUBE-SEP-2ZUOF3KQXKLOSGMW  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* default/svc-clusterip:svc-webport -&gt; 192.168.1.170:8080 */
(bgrtest@myeks:default) [root@myeks-bastion ~]# ssh ec2-user@$N2 sudo iptables -v --numeric --table nat --list KUBE-SVC-KBDEBIL6IU6WL7RF
Chain KUBE-SVC-KBDEBIL6IU6WL7RF (1 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 KUBE-SEP-2ZUOF3KQXKLOSGMW  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* default/svc-clusterip:svc-webport -&gt; 192.168.1.170:8080 */
(bgrtest@myeks:default) [root@myeks-bastion ~]# ssh ec2-user@$N3 sudo iptables -v --numeric --table nat --list KUBE-SVC-KBDEBIL6IU6WL7RF
Chain KUBE-SVC-KBDEBIL6IU6WL7RF (1 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 KUBE-SEP-2ZUOF3KQXKLOSGMW  all  --  *      *       0.0.0.0/0            0.0.0.0/0            /* default/svc-clusterip:svc-webport -&gt; 192.168.1.170:8080 */</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Cilium - Cilium 기본 설치 및 통신 확인 (1)]]></title>
            <link>https://velog.io/@_gyullbb/Cilium-Cilium-%EA%B8%B0%EB%B3%B8-%EC%84%A4%EC%B9%98-%EB%B0%8F-%ED%86%B5%EC%8B%A0-%ED%99%95%EC%9D%B8-1</link>
            <guid>https://velog.io/@_gyullbb/Cilium-Cilium-%EA%B8%B0%EB%B3%B8-%EC%84%A4%EC%B9%98-%EB%B0%8F-%ED%86%B5%EC%8B%A0-%ED%99%95%EC%9D%B8-1</guid>
            <pubDate>Sat, 26 Oct 2024 19:19:40 GMT</pubDate>
            <description><![CDATA[<h1 id="bpfebpf">BPF/eBPF</h1>
<p>iptables는 오랜 기간 리눅스 네트워크 필터링 및 방화벽의 핵심 역할을 해왔지만, 몇 가지 명확한 한계가 있었다.</p>
<ul>
<li>성능 저하: 방대한 규칙이 쌓일수록 검사해야하는 패킷 수가 늘어나면서 성능이 저하된다.</li>
<li>복잡한 유지보수: 규칙이 많아질수록 관리가 어려워지고 오류 발생 가능성이 커진다.</li>
<li>확장성의 한계: 커널과 user space 간 빈번한 전환으로 인한 오버헤드가 발생하며, 고속 네트워크 환경에서 iptables는 병목이 될 수 있다.</li>
</ul>
<p>이러한 한계를 해결하기 위해 <strong>BPF(Berkeley Packet Filter)</strong>와 그 확장판인 <strong>eBPF(extended BPF)</strong>가 등장하였다. eBPF는 커널에서 사용자 정의 프로그램을 실행할 수 있도록 해주며, 이로 인해 기존 iptables가 가지지 못한 향상된 성능 / 유연한 프로그래밍 / 확장성 등 다양한 장점을 제공한다.</p>
<h2 id="ebpf-kernel-hooks">eBPF kernel hooks</h2>
<p>eBPF는 리눅스 커널의 다양한 지점에 프로그램을 hook, 즉 연결을 해서 특정 이벤트가 발생할 때 커스텀 로직을 실행할 수 있게 해준다.
각 계층 별로 걸 수 있는 eBPF Hook 예시를 확인해본다.</p>
<h3 id="1-driver-계층">1. Driver 계층</h3>
<hr>
<p>네트워크 장치 드라이버 또는 하드웨어와의 인터페이스에서 BPF 프로그램을 직접 실행한다.</p>
<h4 id="1-1-xdp">1-1. XDP</h4>
<p>위치: NIC(Network Interface Card) 드라이버 수준에서 직접 실행
용도: 초고속 패킷 처리
예시: 고성능 로드 밸런서(NIC)에서 패킷을 바로 분산 처리 가능</p>
<h3 id="2-네트워크-계층">2. 네트워크 계층</h3>
<hr>
<h4 id="2-1-tctraffic-control">2-1. TC(Traffic Control)</h4>
<ul>
<li>위치: 네트워크 트래픽의 Ingress(수신) 및 Egress(송신) 지점</li>
<li>용도: 패킷 필터링, QoS(품질 보장), 로드 밸런싱, 정책 기반 라우팅</li>
<li>예시: Cilium에서 네트워크 보안 정책 구현</li>
</ul>
<h4 id="2-2-xdpexpress-data-path">2-2. XDP(eXpress Data Path)</h4>
<ul>
<li>위치: 네트워크 인터페이스에서 패킷 수신 직후, 커널의 네트워크 스택에 도달하기 전에 실행</li>
<li>용도: 고성능 네트워크 필터링 및 정책 구현</li>
<li>예시: DDoS 방어(악성 패킷이 스택에 전달되기 전에 drop), L4 로드 밸런싱(목적지 서버에 대한 초기 라우팅 수행)</li>
</ul>
<h3 id="3-소켓-계층">3. 소켓 계층</h3>
<hr>
<p>소켓에서 발생하는 이벤트에 Hook을 걸어 애플리케이션 계층 통신 제어가 가능하다.</p>
<h4 id="3-1-socket-filtering">3-1. Socket Filtering</h4>
<ul>
<li>위치: Socket의 Send/Receive 지점</li>
<li>용도: 특정 애플리케이션 소켓에 대한 패킷을 필터링하거나 수정</li>
<li>예시: 특정 포트나 프로토콜의 소켓 트래픽을 모니터링하거나 차단</li>
</ul>
<h4 id="3-2-socket-options">3-2. Socket Options</h4>
<ul>
<li>위치: 소켓 옵션을 설정하는 지점에 BPF 프로그램 연결</li>
<li>용도: 애플리케이션별 커스텀 필터링 로직 구현</li>
<li>예시: 특정 애플리케이션 트래픽에 대한 방화벽 규칙 적용</li>
</ul>
<h3 id="4-system-call-계층">4. System call 계층</h3>
<hr>
<p>시스템 호출 인터페이스에 Hook을 걸어 프로세스와 커널 간 상호작용을 제어한다.</p>
<h4 id="4-1-kprobe--kretprobe">4-1. kprobe / kretprobe</h4>
<ul>
<li>위치: 커널 함수 진입(kprobe)과 종료(kretprobe) 지점</li>
<li>용도: 커널 함수가 호출될 때 파라미터를 추적하거나 로깅</li>
<li>예시: sys_execve 함수에 kprobe를 걸어 프로세스 생성 시 로깅</li>
</ul>
<h4 id="4-2-tracepoints">4-2. tracepoints</h4>
<ul>
<li>위치: 커널의 특정 이벤트가 발생하는 지점</li>
<li>용도: I/O 작업, 네트워크 트래픽, 프로세스 생성 등의 이벤트 추적</li>
<li>예시: 시스템 호출 모니터링, 특정 파일 접근 추적</li>
</ul>
<h3 id="5-filesystem-및-process-계층">5. Filesystem 및 Process 계층</h3>
<hr>
<p>파일 접근과 프로세스 이벤트에 Hook을 걸어 보안 정책을 적용하거나 모니터링한다.</p>
<h4 id="5-1-lsm-linux-security-modules-hooks">5-1. LSM (Linux Security Modules) Hooks</h4>
<ul>
<li>위치: 커널의 파일 접근, 프로세스 권한 변경, 네트워크 접속 등 보안 관련 지점</li>
<li>용도: 보안 정책 구현 (e.g., SELinux, AppArmor)</li>
<li>예시: 특정 프로세스의 파일 접근 차단, 네트워크 사용 제한</li>
</ul>
<h4 id="5-2-cgroup-bpf">5-2. cgroup BPF</h4>
<ul>
<li>위치: cgroup에 연결된 리소스 제한과 네트워크 트래픽 제어 지점</li>
<li>용도: 특정 컨테이너나 프로세스 그룹에 대해 네트워크 및 CPU 사용을 제한</li>
<li>예시: Kubernetes 컨테이너 네트워크 정책 설정</li>
</ul>
<h3 id="6-userspace-계층">6. UserSpace 계층</h3>
<hr>
<p>eBPF는 커널 내에서 동작하는 프로그램이지만, userspace와 커널 간의 통신을 통해 애플리케이션 레벨의 트래픽을 추적하고 제어할 수 있다.</p>
<h4 id="6-1-uprobe--uretprobe">6-1. uprobe / uretprobe</h4>
<ul>
<li>위치: userspace의 특정 함수에 Hook을 걸어 모니터링.</li>
<li>예시: 데이터베이스 클라이언트 함수에 uprobe를 걸어 쿼리 지연 시간 측정 가능</li>
</ul>
<h4 id="6-2envoy--ebpf">6-2.Envoy + eBPF</h4>
<p>Envoy와 eBPF를 연동해 애플리케이션의 L7 트래픽을 제어.</p>
<ul>
<li>예시: HTTP/gRPC 호출 추적, 보안 정책 적용.</li>
</ul>
<h1 id="cilium">Cilium</h1>
<p>Cilium은 eBPF를 기반으로 Pod 네트워크 환경과 보안을 제공하는 CNI 플러그인이다.
Cilium eBPF 는 추가적인 App 이나 설정 변경 없이 리눅스 커널을 자유롭게 프로그래밍하여 동작 가능 하다.</p>
<p>Kubernetes에서는 Cilium으로 100% kube-proxy replacement가 가능하다. 이로 인해 복잡한 kube-proxy, iptables, conntrack 관리와, 커널 버그로 인해 발생하는 다양한 환경 변수의 조합을 덜 고민해도 될 것으로 보인다.</p>
<blockquote>
<p><strong>최근 운영 하면서 겪은 이슈</strong> 
서비스 간에 Large Payload 전송 시 Connection reset by peer 에러와 함께 TCP connection이 Resete되며 끊기는 이슈 발생
원인 : Large Payload 전송 시 Out-of-order가 발생하는 경우가 있는데, 문제가 없는 패킷임에도 conntrack버그로 인해 패킷에 INVALID 마킹이 됨. INVALID 마킹이 된 패킷이 DROP되지 않고, 정상적으로 DNAT되지 않은 상태로 노드로 전송되면서 노드는 출처가 불분명해진 패킷에 대해 Connection Reset 요청을 보냄.</p>
<p><strong>해결 방법 1</strong>: Kernel 6버전 이상에서는 해결되는 이슈이나, 운영상 민감한 부분이 많은 클러스터이기에 Kernel 업그레이드가 자유롭지 않음.</p>
<p><strong>해결 방법 2</strong>: kube-proxy에 tcp_be_liberal=1 적용 가능, INVALID 마킹을 유연하게 하는 W/A 옵션으로 K8s 1.29부터 적용됨. 이 또한  K8s 업그레이드를 바로 하기에는 운영상 민감한 부분이 많음.</p>
<p><strong>해결 방법 3</strong>: 노드에 net.netfilter.nf_conntrack_tcp_be_liberal=1 설정을 적용. INVALID 마킹을 유연하게 하는 옵션으로, 커널 자체의 문제를 개선한 것이 아니고 out-of-order 패킷을 허용하는 옵션이기 때문에 <strong>보안적으로 취약</strong>하다는 단점이 있음. 다만, 바로 <strong>별도의 업그레이드 없이 적용할 수 있는 가장 간단한 방법</strong>이기 때문에 해당 방법을 선택함.</p>
<p>커널 버전, 쿠버네티스 버전 업그레이드는 안정성 등의 이슈로 운영 중에 바로바로 업그레이드를 할 수 없어 문제를 가장 확실하게 해결할 수 있는 방법을 바로 적용하지 못하는 경우가 있는데, eBPF를 사용할 경우 <strong>기존 커널 위에 커스텀 커널 및 로직 개발</strong>이 가능하기 때문에, 이슈 해결을 조금 더 유연하게 할 수 있지 않을까 하는 생각이 든다.</p>
</blockquote>
<h2 id="cilium-구성요소">Cilium 구성요소</h2>
<ul>
<li>Cilium <strong>Agent</strong> : 데몬셋으로 실행, K8S API 설정으로 부터 &#39;네트워크 설정, 네트워크 정책, 서비스 부하분산, 모니터링&#39; 등을 수행하며, eBPF 프로그램을 관리한다.</li>
<li>Cilium <strong>Client</strong> (CLI) : Cilium 커멘드툴, eBPF maps 에 직접 접속하여 상태를 확인할 수 있다.</li>
<li>Cilium <strong>Operator</strong> : K8S 클러스터에 대한 한 번씩 처리해야 하는 작업을 관리.</li>
<li><strong>Hubble</strong> : 네트워크와 보안 모니터링 플랫폼 역할을 하여, &#39;Server, Relay, Client, Graphical UI&#39; 로 구성되어 있다.</li>
<li><strong>Data Store</strong> : Cilium Agent 간의 상태를 저장하고 전파하는 데이터 저장소, 2가지 종류 중 선택(K8S CRDs, Key-Value Store)<blockquote>
<p>cf) eBPF map</p>
<ul>
<li>eBPF map이란 eBPF 프로그램과 커널 또는 사용자 공간 간에 데이터를 저장하고 교환하기 위한 자료구조이다. 즉 eBPF용 공유 메모리 공간 (key-value 기반의 데이터 저장소) 을 의미한다.</li>
<li>모든 eBPF Map 은 상한 용량이 있으며, limit 관련 여러 옵션들이 있다.
<a href="https://www.notion.so/22e50aec5edf810a9fc8f6df14ae7d2a?pvs=21">eBPF Map</a></li>
<li>kube-proxy 는 리눅스 코어에 따라 CT table 최대 수가 결정되며, Cilium 은 BPF Maps 이라는 자체 연결 추적 테이블을 가지고 메모리에 따라 최대 수가 결정된다.</li>
</ul>
</blockquote>
</li>
</ul>
<h2 id="cilium-설치">Cilium 설치</h2>
<h3 id="cilium-시스템-요구-사항-확인">Cilium 시스템 요구 사항 확인</h3>
<h4 id="1-cpu-아키텍처">1. CPU 아키텍처</h4>
<p>AMD64 또는 AArch64 CPU 아키텍처를 사용하는 호스트</p>
<pre><code class="language-bash"># arch
aarch64</code></pre>
<h4 id="2-커널-버전">2. 커널 버전</h4>
<p>Linux 커널 5.4 이상 또는 동등 버전(예: RHEL 8.6의 경우 4.18)</p>
<pre><code class="language-bash"># uname -r
6.8.0-53-generic</code></pre>
<p>고급 기능 동작을 위한 최소 커널 버전 - Docs</p>
<table>
<thead>
<tr>
<th>Cilium Feature</th>
<th>Minimum Kernel Version</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://docs.cilium.io/en/stable/security/network/encryption-wireguard/#encryption-wg">WireGuard Transparent Encryption</a></td>
<td>&gt;= 5.6</td>
</tr>
<tr>
<td><a href="https://docs.cilium.io/en/stable/network/kubernetes/kubeproxy-free/#session-affinity">Full support for Session Affinity</a></td>
<td>&gt;= 5.7</td>
</tr>
<tr>
<td>BPF-based proxy redirection</td>
<td>&gt;= 5.7</td>
</tr>
<tr>
<td>Socket-level LB bypass in pod netns</td>
<td>&gt;= 5.7</td>
</tr>
<tr>
<td>L3 devices</td>
<td>&gt;= 5.8</td>
</tr>
<tr>
<td><strong>BPF-based host routing</strong></td>
<td><strong>&gt;= 5.10</strong></td>
</tr>
<tr>
<td><a href="https://docs.cilium.io/en/stable/network/multicast/#enable-multicast">Multicast Support in Cilium (Beta) (AMD64)</a></td>
<td>&gt;= 5.10</td>
</tr>
<tr>
<td>IPv6 BIG TCP support</td>
<td>&gt;= 5.19</td>
</tr>
<tr>
<td><a href="https://docs.cilium.io/en/stable/network/multicast/#enable-multicast">Multicast Support in Cilium (Beta) (AArch64)</a></td>
<td>&gt;= 6.0</td>
</tr>
<tr>
<td><strong>IPv4 BIG TCP support</strong></td>
<td><strong>&gt;= 6.3</strong></td>
</tr>
</tbody></table>
<h4 id="3-커널-구성-옵션-활성화">3. 커널 구성 옵션 활성화</h4>
<pre><code class="language-bash"># [커널 구성 옵션] 기본 요구 사항 
grep -E &#39;CONFIG_BPF|CONFIG_BPF_SYSCALL|CONFIG_NET_CLS_BPF|CONFIG_BPF_JIT|CONFIG_NET_CLS_ACT|CONFIG_NET_SCH_INGRESS|CONFIG_CRYPTO_SHA1|CONFIG_CRYPTO_USER_API_HASH|CONFIG_CGROUPS|CONFIG_CGROUP_BPF|CONFIG_PERF_EVENTS|CONFIG_SCHEDSTATS&#39; /boot/config-$(uname -r)
CONFIG_BPF=y
CONFIG_BPF_SYSCALL=y
CONFIG_BPF_JIT=y
CONFIG_NET_CLS_BPF=m
CONFIG_NET_CLS_ACT=y
CONFIG_NET_SCH_INGRESS=m
CONFIG_CRYPTO_SHA1=y
CONFIG_CRYPTO_USER_API_HASH=m
CONFIG_CGROUPS=y
CONFIG_CGROUP_BPF=y
CONFIG_PERF_EVENTS=y
CONFIG_SCHEDSTATS=y


# [커널 구성 옵션] Requirements for Tunneling and Routing
grep -E &#39;CONFIG_VXLAN=y|CONFIG_VXLAN=m|CONFIG_GENEVE=y|CONFIG_GENEVE=m|CONFIG_FIB_RULES=y&#39; /boot/config-$(uname -r)
CONFIG_FIB_RULES=y # 커널에 내장됨
CONFIG_VXLAN=m # 모듈로 컴파일됨 → 커널에 로드해서 사용
CONFIG_GENEVE=m # 모듈로 컴파일됨 → 커널에 로드해서 사용

## (참고) 커널 로드
lsmod | grep -E &#39;vxlan|geneve&#39;
modprobe geneve
lsmod | grep -E &#39;vxlan|geneve&#39;


# [커널 구성 옵션] Requirements for L7 and FQDN Policies
grep -E &#39;CONFIG_NETFILTER_XT_TARGET_TPROXY|CONFIG_NETFILTER_XT_TARGET_MARK|CONFIG_NETFILTER_XT_TARGET_CT|CONFIG_NETFILTER_XT_MATCH_MARK|CONFIG_NETFILTER_XT_MATCH_SOCKET&#39; /boot/config-$(uname -r)
CONFIG_NETFILTER_XT_TARGET_CT=m
CONFIG_NETFILTER_XT_TARGET_MARK=m
CONFIG_NETFILTER_XT_TARGET_TPROXY=m
CONFIG_NETFILTER_XT_MATCH_MARK=m
CONFIG_NETFILTER_XT_MATCH_SOCKET=m

...

# [커널 구성 옵션] Requirements for Netkit Device Mode
grep -E &#39;CONFIG_NETKIT=y|CONFIG_NETKIT=m&#39; /boot/config-$(uname -r)</code></pre>
<h3 id="cilium-설치-1">Cilium 설치</h3>
<pre><code class="language-bash">helm install cilium cilium/cilium --version 1.16.3 --namespace kube-system \
--set k8sServiceHost=192.168.10.10 --set k8sServicePort=6443 --set debug.enabled=true \
--set rollOutCiliumPods=true --set routingMode=native --set autoDirectNodeRoutes=true \
--set bpf.masquerade=true --set bpf.hostRouting=true --set endpointRoutes.enabled=true \
--set ipam.mode=kubernetes --set k8s.requireIPv4PodCIDR=true --set kubeProxyReplacement=true \
--set ipv4NativeRoutingCIDR=192.168.0.0/16 --set installNoConntrackIptablesRules=true \
--set hubble.ui.enabled=true --set hubble.relay.enabled=true --set prometheus.enabled=true --set operator.prometheus.enabled=true --set hubble.metrics.enableOpenMetrics=true \
--set hubble.metrics.enabled=&quot;{dns:query;ignoreAAAA,drop,tcp,flow,port-distribution,icmp,httpV2:exemplars=true;labelsContext=source_ip\,source_namespace\,source_workload\,destination_ip\,destination_namespace\,destination_workload\,traffic_direction}&quot; \
--set operator.replicas=1</code></pre>
<table>
<thead>
<tr>
<th>옵션</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>rollOutCiliumPods</code></td>
<td>Cilium Pods의 롤아웃을 활성화하여 새로운 버전으로의 배포를 관리한다.</td>
</tr>
<tr>
<td><code>routingMode</code></td>
<td>패킷의 라우팅 방식을 설정하며, 기본적으로 네이티브 라우팅 모드를 사용한다.</td>
</tr>
<tr>
<td><code>autoDirectNodeRoutes</code></td>
<td>자동으로 노드 간 라우팅을 설정하여 패킷 전송을 최적화한다.</td>
</tr>
<tr>
<td><code>bpf.masquerade</code></td>
<td>BPF를 사용하여 IP 마스커레이드를 활성화한다.</td>
</tr>
<tr>
<td><code>bpf.hostRouting</code></td>
<td>호스트 라우팅 기능을 활성화하여 패킷을 직접 전달한다.</td>
</tr>
<tr>
<td><code>endpointRoutes.enabled</code></td>
<td>엔드포인트 라우트를 활성화하여 Pod 간의 트래픽 흐름을 최적화한다.</td>
</tr>
<tr>
<td><code>ipam.mode</code></td>
<td>IP 주소 할당 방식을 Kubernetes로 설정한다.</td>
</tr>
<tr>
<td><code>k8s.requireIPv4PodCIDR</code></td>
<td>IPv4 Pod CIDR 요구 사항을 활성화하여 클러스터 내 IPv4 주소 사용을 보장한다.</td>
</tr>
<tr>
<td><code>kubeProxyReplacement</code></td>
<td>Cilium이 kube-proxy의 역할을 대체하도록 설정한다.</td>
</tr>
<tr>
<td><code>ipv4NativeRoutingCIDR</code></td>
<td>IPv4 네이티브 라우팅에 사용할 CIDR 블록을 설정한다.</td>
</tr>
<tr>
<td><code>installNoConntrackIptablesRules</code></td>
<td>conntrack iptables 규칙을 설치하지 않도록 설정하여 Cilium의 효율성을 높인다.</td>
</tr>
<tr>
<td><code>hubble.ui.enabled</code></td>
<td>Hubble UI를 활성화하여 네트워크 모니터링 및 시각화를 지원한다.</td>
</tr>
<tr>
<td><code>hubble.relay.enabled</code></td>
<td>Hubble Relay를 활성화하여 메트릭 수집 및 이벤트 전달 기능을 지원한다.</td>
</tr>
<tr>
<td><code>prometheus.enabled</code></td>
<td>Prometheus 모니터링을 활성화하여 메트릭 수집을 지원한다.</td>
</tr>
<tr>
<td><code>operator.prometheus.enabled</code></td>
<td>Cilium Operator의 Prometheus 모니터링 기능을 활성화한다.</td>
</tr>
<tr>
<td><code>hubble.metrics.enableOpenMetrics</code></td>
<td>Hubble 메트릭의 OpenMetrics 형식을 활성화하여 호환성을 높인다.</td>
</tr>
<tr>
<td><code>hubble.metrics.enabled</code></td>
<td>Hubble에서 수집할 메트릭과 이벤트를 정의한다.</td>
</tr>
<tr>
<td><code>operator.replicas</code></td>
<td>Cilium Operator의 복제본 수를 설정하여 고가용성을 유지한다.</td>
</tr>
</tbody></table>
<h3 id="cilium-설정-및-확인">Cilium 설정 및 확인</h3>
<h4 id="cilium-cli-설치">Cilium CLI 설치</h4>
<pre><code class="language-bash">#
CILIUM_CLI_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/cilium-cli/main/stable.txt)
CLI_ARCH=amd64
if [ &quot;$(uname -m)&quot; = &quot;aarch64&quot; ]; then CLI_ARCH=arm64; fi
curl -L --fail --remote-name-all https://github.com/cilium/cilium-cli/releases/download/${CILIUM_CLI_VERSION}/cilium-linux-${CLI_ARCH}.tar.gz &gt;/dev/null 2&gt;&amp;1
tar xzvfC cilium-linux-${CLI_ARCH}.tar.gz /usr/local/bin
rm cilium-linux-${CLI_ARCH}.tar.gz

cilium config set debug true

#
kubectl exec -n kube-system -c cilium-agent -it ds/cilium -- cilium-dbg status --verbose
...
KubeProxyReplacement:   True   [eth0    10.0.2.15 fd17:625c:f037:2:a00:27ff:fe71:19d8 fe80::a00:27ff:fe71:19d8, eth1   192.168.10.102 fe80::a00:27ff:fe6d:c80b (Direct Routing)]
...
Routing:                Network: Native   Host: BPF
Attach Mode:            TCX
Device Mode:            veth
Masquerading:           BPF   [eth0, eth1]   172.20.0.0/16 [IPv4: Enabled, IPv6: Disabled]
...
KubeProxyReplacement Details:
  Status:                 True
  Socket LB:              Enabled
  Socket LB Tracing:      Enabled
  Socket LB Coverage:     Full
  Devices:                eth0    10.0.2.15 fd17:625c:f037:2:a00:27ff:fe71:19d8 fe80::a00:27ff:fe71:19d8, eth1   192.168.10.102 fe80::a00:27ff:fe6d:c80b (Direct Routing)
  Mode:                   SNAT
  Backend Selection:      Random
  Session Affinity:       Enabled
  Graceful Termination:   Enabled
  NAT46/64 Support:       Disabled
  XDP Acceleration:       Disabled
  Services:
  - ClusterIP:      Enabled
  - NodePort:       Enabled (Range: 30000-32767)
  - LoadBalancer:   Enabled
  - externalIPs:    Enabled
  - HostPort:       Enabled
  Annotations:
  - service.cilium.io/node
  - service.cilium.io/src-ranges-policy
  - service.cilium.io/type</code></pre>
<h4 id="네트워크-기본-정보">네트워크 기본 정보</h4>
<p>cilium-operator, cilium, cilium-envoy가 설치되어 있다.</p>
<pre><code class="language-bash">(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# kubectl get node,pod,svc -A -o wide
NAME          STATUS   ROLES           AGE    VERSION   INTERNAL-IP      EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION   CONTAINER-RUNTIME
node/k8s-s    Ready    control-plane   141m   v1.30.6   192.168.10.10    &lt;none&gt;        Ubuntu 22.04.5 LTS   6.8.0-1015-aws   containerd://1.7.22
node/k8s-w1   Ready    &lt;none&gt;          140m   v1.30.6   192.168.10.101   &lt;none&gt;        Ubuntu 22.04.5 LTS   6.8.0-1015-aws   containerd://1.7.22
node/k8s-w2   Ready    &lt;none&gt;          140m   v1.30.6   192.168.10.102   &lt;none&gt;        Ubuntu 22.04.5 LTS   6.8.0-1015-aws   containerd://1.7.22

NAMESPACE     NAME                                   READY   STATUS    RESTARTS   AGE    IP               NODE     NOMINATED NODE   READINESS GATES
kube-system   pod/cilium-7qtr8                       1/1     Running   0          100s   192.168.10.102   k8s-w2   &lt;none&gt;           &lt;none&gt;
kube-system   pod/cilium-envoy-g29t8                 1/1     Running   0          100s   192.168.10.102   k8s-w2   &lt;none&gt;           &lt;none&gt;
kube-system   pod/cilium-envoy-gksj4                 1/1     Running   0          100s   192.168.10.10    k8s-s    &lt;none&gt;           &lt;none&gt;
kube-system   pod/cilium-envoy-m546t                 1/1     Running   0          100s   192.168.10.101   k8s-w1   &lt;none&gt;           &lt;none&gt;
kube-system   pod/cilium-f6hl2                       1/1     Running   0          100s   192.168.10.10    k8s-s    &lt;none&gt;           &lt;none&gt;
kube-system   pod/cilium-operator-76bb588dbc-gx6bn   1/1     Running   0          100s   192.168.10.101   k8s-w1   &lt;none&gt;           &lt;none&gt;
kube-system   pod/cilium-tgkbh                       1/1     Running   0          100s   192.168.10.101   k8s-w1   &lt;none&gt;           &lt;none&gt;
kube-system   pod/coredns-55cb58b774-7tj27           1/1     Running   0          140m   172.16.1.13      k8s-w1   &lt;none&gt;           &lt;none&gt;
kube-system   pod/coredns-55cb58b774-pldd7           1/1     Running   0          140m   172.16.1.217     k8s-w1   &lt;none&gt;           &lt;none&gt;
kube-system   pod/etcd-k8s-s                         1/1     Running   0          141m   192.168.10.10    k8s-s    &lt;none&gt;           &lt;none&gt;
kube-system   pod/hubble-relay-88f7f89d4-zpgdl       1/1     Running   0          100s   172.16.1.150     k8s-w1   &lt;none&gt;           &lt;none&gt;
kube-system   pod/hubble-ui-59bb4cb67b-dk4hp         2/2     Running   0          100s   172.16.1.214     k8s-w1   &lt;none&gt;           &lt;none&gt;
kube-system   pod/kube-apiserver-k8s-s               1/1     Running   0          141m   192.168.10.10    k8s-s    &lt;none&gt;           &lt;none&gt;
kube-system   pod/kube-controller-manager-k8s-s      1/1     Running   0          141m   192.168.10.10    k8s-s    &lt;none&gt;           &lt;none&gt;
kube-system   pod/kube-scheduler-k8s-s               1/1     Running   0          141m   192.168.10.10    k8s-s    &lt;none&gt;           &lt;none&gt;

NAMESPACE     NAME                     TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)                  AGE    SELECTOR
default       service/kubernetes       ClusterIP   10.10.0.1      &lt;none&gt;        443/TCP                  141m   &lt;none&gt;
kube-system   service/cilium-envoy     ClusterIP   None           &lt;none&gt;        9964/TCP                 101s   k8s-app=cilium-envoy
kube-system   service/hubble-metrics   ClusterIP   None           &lt;none&gt;        9965/TCP                 101s   k8s-app=cilium
kube-system   service/hubble-peer      ClusterIP   10.10.77.48    &lt;none&gt;        443/TCP                  101s   k8s-app=cilium
kube-system   service/hubble-relay     ClusterIP   10.10.24.62    &lt;none&gt;        80/TCP                   101s   k8s-app=hubble-relay
kube-system   service/hubble-ui        ClusterIP   10.10.202.89   &lt;none&gt;        80/TCP                   101s   k8s-app=hubble-ui
kube-system   service/kube-dns         ClusterIP   10.10.0.10     &lt;none&gt;        53/UDP,53/TCP,9153/TCP   141m   k8s-app=kube-dns</code></pre>
<p>cilium_net과 cilium_host 인터페이스로 Pod간 통신 , Pod-외부 통신을 가능하게 한다.</p>
<ul>
<li>cilium_net : Cilium이 관리하는 Pod 간의 통신을 처리한다.</li>
<li>clium_host : Cilium과 호스트 간의 트래픽을 처리하는 인터페이스로, 외부와의 통신을 가능하게 한다.</li>
<li>lxc_healthcheck : 헬스체크 전용 인터페이스<pre><code class="language-bash">(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# ip -c addr
1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
  link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
  inet 127.0.0.1/8 scope host lo
     valid_lft forever preferred_lft forever
  inet6 ::1/128 scope host
     valid_lft forever preferred_lft forever
2: eth0: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 9001 qdisc mq state UP group default qlen 1000
  link/ether 02:95:92:79:ac:d1 brd ff:ff:ff:ff:ff:ff
  inet 192.168.10.10/24 metric 100 brd 192.168.10.255 scope global dynamic eth0
     valid_lft 2194sec preferred_lft 2194sec
  inet6 fe80::95:92ff:fe79:acd1/64 scope link
     valid_lft forever preferred_lft forever
3: cilium_net@cilium_host: &lt;BROADCAST,MULTICAST,NOARP,UP,LOWER_UP&gt; mtu 9001 qdisc noqueue state UP group default qlen 1000
  link/ether 02:f7:b5:46:ac:49 brd ff:ff:ff:ff:ff:ff
  inet6 fe80::f7:b5ff:fe46:ac49/64 scope link
     valid_lft forever preferred_lft forever
4: cilium_host@cilium_net: &lt;BROADCAST,MULTICAST,NOARP,UP,LOWER_UP&gt; mtu 9001 qdisc noqueue state UP group default qlen 1000
  link/ether 52:5b:5b:86:6a:6d brd ff:ff:ff:ff:ff:ff
  inet 172.16.0.70/32 scope global cilium_host
     valid_lft forever preferred_lft forever
  inet6 fe80::505b:5bff:fe86:6a6d/64 scope link
     valid_lft forever preferred_lft forever
6: lxc_health@if5: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 9001 qdisc noqueue state UP group default qlen 1000
  link/ether 7a:98:19:81:96:4e brd ff:ff:ff:ff:ff:ff link-netnsid 0
  inet6 fe80::7898:19ff:fe81:964e/64 scope link
     valid_lft forever preferred_lft forever</code></pre>
</li>
</ul>
<p>이 중 lxc_health 인터페이스는 cilium 자체가 각 노드에서 클러스터 내 연결 상태를 검사하기 위해 자동으로 생성하는 내부 Pod로, veth으로 cilium과 veth pair 이다.
cilium 인터페이스에 Pod IP가 할당되어 있으며, cilium-health-responder로 동작한다.
cilium endpoint로 확인을 해보았을 때, reserved:health 를 확인할 수 있는데, 이는 cilium이 사용하는 예약된 엔드포인트 ID중 하나로, 다른 것과 충돌하지 않게 하기 위해 예약된 것이다.</p>
<p>Kubernetes에 등록된 Pod가 아닌, Cilium 내부에서 관리되기 때문에 kubectl get pods 명령어로 확인이 되지 않는다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# ip -c addr show lxc_health
26: lxc_health@if25: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 26:53:c1:ce:2d:54 brd ff:ff:ff:ff:ff:ff link-netnsid 4
    inet6 fe80::2453:c1ff:fece:2d54/64 scope link
       valid_lft forever preferred_lft forever

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -c cilium-agent -- cilium-dbg status --verbose
...
Cluster health:   3/3 reachable   (2025-07-19T15:57:07Z)
Name              IP              Node   Endpoints
  k8s-w2 (localhost):
    Host connectivity to 192.168.10.102: &lt;- Node IP
      ICMP to stack:   OK, RTT=136.792µs
      HTTP to agent:   OK, RTT=481.291µs
    Endpoint connectivity to 172.20.1.204: &lt;- Health IP
      ICMP to stack:   OK, RTT=303.375µs
      HTTP to agent:   OK, RTT=516.209µs
  k8s-ctr:
    Host connectivity to 192.168.10.100: 
      ICMP to stack:   OK, RTT=700.542µs
      HTTP to agent:   OK, RTT=997.667µs
    Endpoint connectivity to 172.20.0.198: 
      ICMP to stack:   OK, RTT=569.584µs
      HTTP to agent:   OK, RTT=1.537167ms
  k8s-w1:
    Host connectivity to 192.168.10.101:
      ICMP to stack:   OK, RTT=296.417µs
      HTTP to agent:   OK, RTT=893.958µs
    Endpoint connectivity to 172.20.2.52:
      ICMP to stack:   OK, RTT=653.917µs
      HTTP to agent:   OK, RTT=781.833µs

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get node k8s-w2 -o wide
NAME     STATUS   ROLES    AGE    VERSION   INTERNAL-IP      EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION     CONTAINER-RUNTIME
k8s-w2   Ready    &lt;none&gt;   3h3m   v1.33.2   192.168.10.102   &lt;none&gt;        Ubuntu 24.04.2 LTS   6.8.0-53-generic   containerd://1.7.27

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -it -n kube-system ds/cilium -c cilium-agent -- cilium-dbg endpoint list | grep health
2251       Disabled           Disabled          4          reserved:health                                                                 172.20.1.204   ready</code></pre>
<h4 id="routing-설정-확인">routing 설정 확인</h4>
<h5 id="1-nativerouting--autodirectnoderoutestrue">1. NativeRouting + autoDirectNodeRoutes=true</h5>
<p>Cilium 설치 시 지정한 옵션인 routingMode=native와 autoDirectNodeRoutes=true 설정에 의해 노드 간 Pod 트래픽이 overlay(VXLAN, Geneve 등)가 아닌 Direct Routing 된다.</p>
<ul>
<li>Cilium이 자동으로 노드 간 Pod CIDR 라우팅 경로를 추가.</li>
<li>노드 A에서 노드 B의 Pod CIDR 대역으로 패킷을 보낼 때, eth1(지정된 NIC)를 통해 직접 전달됨.</li>
<li>Flannel과 Calico는 VXLAN 혹은 IPIP를 사용하여 encapsulation하지만, Cilium의 native routing은 더 빠르고 단순한 경로를 사용하여 통신이 가능함.</li>
</ul>
<pre><code class="language-bash"># pod의 CIDR 라우트가 노드의 eth1로 직접 전달됨.
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ciliumnodes -o json
...
                &quot;name&quot;: &quot;k8s-w1&quot;,
                &quot;ipam&quot;: {
                    &quot;podCIDRs&quot;: [
                        &quot;172.20.2.0/24&quot;
                    ],

root@k8s-w1:~# ip -c route | grep 172.20 | grep eth1
172.20.0.0/24 via 192.168.10.100 dev eth1 proto kernel
172.20.1.0/24 via 192.168.10.102 dev eth1 proto kernel</code></pre>
<h5 id="2-endpointroutesenabledtrue--non-hostnetwork-pods">2. endpointRoutes.enabled=true + Non-hostNetwork Pods</h5>
<p>endpointRoutes.enabled=true를 설정하면, Cilium은 Pod별로 독립된 라우팅 경로와 인터페이스(lxcXXXX) 를 설정한다.</p>
<p>lxcXXX 인터페이스는 각 Pod를 호스트 네트워크와 연결하는 가상 인터페이스 (veth pair)로, 각 Pod는 고유한 인터페이스로 통신하고, 이 경로를 통해 네트워크 정책 적용, 모니터링이 가능하다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get ciliumendpoints -A
NAMESPACE     NAME                       SECURITY IDENTITY   ENDPOINT STATE   IPV4           IPV6
default       curl-pod                   61805               ready            172.20.0.6
default       webpod-659cd747f8-v9xkm    11362               ready            172.20.1.172
default       webpod-659cd747f8-zqbk8    11362               ready            172.20.2.144
kube-system   coredns-674b8bbfcf-hsz8b   57947               ready            172.20.0.64
kube-system   coredns-674b8bbfcf-ntlf2   57947               ready            172.20.0.71

(⎈|HomeLab:N/A) root@k8s-ctr:~# ip -c route | grep lxc
172.20.0.6 dev lxca2ec5268c2d5 proto kernel scope link
172.20.0.64 dev lxc2055df1042a6 proto kernel scope link
172.20.0.71 dev lxc729021c580c0 proto kernel scope link
172.20.0.198 dev lxc_health proto kernel scope link</code></pre>
<h5 id="3-hostnetwork-pods">3. hostNetwork Pods</h5>
<p>hostNetwork: true인 Pod는 노드의 네트워크 네임스페이스를 그대로 쓰기 때문에, 별도 인터페이스(lxcX)가 생성되지 않는다.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -o wide -A | grep k8s-ctr | grep -v 172.20
kube-system   cilium-ctqxp                       1/1     Running   0              128m    192.168.10.100   k8s-ctr   &lt;none&gt;           &lt;none&gt;
kube-system   cilium-envoy-jm92v                 1/1     Running   0              141m    192.168.10.100   k8s-ctr   &lt;none&gt;           &lt;none&gt;
kube-system   etcd-k8s-ctr                       1/1     Running   0              3h49m   192.168.10.100   k8s-ctr   &lt;none&gt;           &lt;none&gt;
kube-system   kube-apiserver-k8s-ctr             1/1     Running   0              3h49m   192.168.10.100   k8s-ctr   &lt;none&gt;           &lt;none&gt;
kube-system   kube-controller-manager-k8s-ctr    1/1     Running   0              3h49m   192.168.10.100   k8s-ctr   &lt;none&gt;           &lt;none&gt;
kube-system   kube-scheduler-k8s-ctr             1/1     Running   0              3h49m   192.168.10.100   k8s-ctr   &lt;none&gt;           &lt;none&gt;

# 노드의 network namespace 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# readlink /proc/1/ns/net
net:[4026531840]

# cilium pod(hostNetwork pod)의 network namespace 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec -n kube-system ds/cilium -c cilium-agent -- readlink /proc/1/ns/net
net:[4026531840]

# curl pod(Non hostNetwork pod)의 network namespace 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec curl-pod -- readlink /proc/1/ns/net
net:[4026532218]</code></pre>
<h4 id="iptables-규칙-확인">iptables 규칙 확인</h4>
<p>iptables규칙을 확인해보면 Cilium과 관련된 NAT체인 규칙 외에 규칙이 거의 없다. cilium 설치 시 <code>installNoConntrackIptablesRules</code> 옵션을 통해 conntrack iptables룰을 사용하지 않고 eBPF를 통해 패킷이 처리도록 설정을 하였기 때문에 conntrack -L로 보았을 때 기록되는 연결 상태가 거의 없는 것을 확인할 수 있다.
<code>notrack</code>옵션으로 iptables 규칙을 조회해보면, 트래픽에서 conntrack을 우회하기 위한 설정(pod 네트워크 범위 트래픽 우회, L7 프록시 트래픽 우회)이 되어있음을 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# iptables -t nat -S
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-N CILIUM_OUTPUT_nat
-N CILIUM_POST_nat
-N CILIUM_PRE_nat
-N KUBE-KUBELET-CANARY
-A PREROUTING -m comment --comment &quot;cilium-feeder: CILIUM_PRE_nat&quot; -j CILIUM_PRE_nat
-A OUTPUT -m comment --comment &quot;cilium-feeder: CILIUM_OUTPUT_nat&quot; -j CILIUM_OUTPUT_nat
-A POSTROUTING -m comment --comment &quot;cilium-feeder: CILIUM_POST_nat&quot; -j CILIUM_POST_nat

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# conntrack -L | grep -v 2379 | wc -l
conntrack v1.4.6 (conntrack-tools): 125 flow entries have been shown.
54

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# iptables -t raw -S | grep notrack
-A CILIUM_OUTPUT_raw -d 192.168.0.0/16 -m comment --comment &quot;cilium: NOTRACK for pod traffic&quot; -j CT --notrack
-A CILIUM_OUTPUT_raw -s 192.168.0.0/16 -m comment --comment &quot;cilium: NOTRACK for pod traffic&quot; -j CT --notrack
-A CILIUM_OUTPUT_raw -o lxc+ -m comment --comment &quot;cilium: NOTRACK for proxy return traffic&quot; -j CT --notrack
-A CILIUM_OUTPUT_raw -o cilium_host -m comment --comment &quot;cilium: NOTRACK for proxy return traffic&quot; -j CT --notrack
-A CILIUM_OUTPUT_raw -o lxc+ -m comment --comment &quot;cilium: NOTRACK for L7 proxy upstream traffic&quot; -j CT --notrack
-A CILIUM_OUTPUT_raw -o cilium_host -m comment --comment &quot;cilium: NOTRACK for L7 proxy upstream traffic&quot; -j CT --notrack
-A CILIUM_PRE_raw -d 192.168.0.0/16 -m comment --comment &quot;cilium: NOTRACK for pod traffic&quot; -j CT --notrack
-A CILIUM_PRE_raw -s 192.168.0.0/16 -m comment --comment &quot;cilium: NOTRACK for pod traffic&quot; -j CT --notrack
-A CILIUM_PRE_raw -m comment --comment &quot;cilium: NOTRACK for proxy traffic&quot; -j CT --notrack</code></pre>
<h4 id="cilium-crd">cilium CRD</h4>
<p>cilium CRD를 살펴보면, ciliumnodes, ciliumendpoints라는 CRD가 있다.</p>
<ul>
<li>ciliumnodes : cilium이 설치된 클러스터의 노드에 대한 정보를 저장하는 리소스이다. </li>
<li>ciliumendpoints : 클러스터 내의 각 Pod에 대한 네트워크 정보를 저장하는 리소스이다. Cilium은 ciliumendpoints를 통해 Pod에 대한 정책 적용 여부를 모니터링하고, 필요한 정책을 동적으로 추가한다.<pre><code class="language-bash">(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# kubectl get crd
NAME                                         CREATED AT
ciliumcidrgroups.cilium.io                   2024-10-25T15:00:57Z
ciliumclusterwidenetworkpolicies.cilium.io   2024-10-25T15:00:58Z
ciliumendpoints.cilium.io                    2024-10-25T15:00:57Z
ciliumexternalworkloads.cilium.io            2024-10-25T15:00:57Z
ciliumidentities.cilium.io                   2024-10-25T15:00:57Z
ciliuml2announcementpolicies.cilium.io       2024-10-25T15:00:57Z
ciliumloadbalancerippools.cilium.io          2024-10-25T15:00:57Z
ciliumnetworkpolicies.cilium.io              2024-10-25T15:00:58Z
ciliumnodeconfigs.cilium.io                  2024-10-25T15:00:57Z
ciliumnodes.cilium.io                        2024-10-25T15:00:57Z
ciliumpodippools.cilium.io                   2024-10-25T15:00:57Z
</code></pre>
</li>
</ul>
<p>(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# kubectl get ciliumnodes
NAME     CILIUMINTERNALIP   INTERNALIP       AGE
k8s-s    172.16.0.70        192.168.10.10    15m
k8s-w1   172.16.1.153       192.168.10.101   15m
k8s-w2   172.16.2.237       192.168.10.102   15m</p>
<p>(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# kubectl get ciliumendpoints -A
NAMESPACE     NAME                           SECURITY IDENTITY   ENDPOINT STATE   IPV4           IPV6
kube-system   coredns-55cb58b774-7tj27       24672               ready            172.16.1.13
kube-system   coredns-55cb58b774-pldd7       24672               ready            172.16.1.217
kube-system   hubble-relay-88f7f89d4-zpgdl   37739               ready            172.16.1.150
kube-system   hubble-ui-59bb4cb67b-dk4hp     42419               ready            172.16.1.214</p>
<p>(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# kubectl get pod -o wide -A | grep -v 192.168
NAMESPACE     NAME                               READY   STATUS    RESTARTS   AGE    IP               NODE     NOMINATED NODE   READINESS GATES
kube-system   coredns-55cb58b774-7tj27           1/1     Running   0          157m   172.16.1.13      k8s-w1   <none>           <none>
kube-system   coredns-55cb58b774-pldd7           1/1     Running   0          157m   172.16.1.217     k8s-w1   <none>           <none>
kube-system   hubble-relay-88f7f89d4-zpgdl       1/1     Running   0          18m    172.16.1.150     k8s-w1   <none>           <none>
kube-system   hubble-ui-59bb4cb67b-dk4hp         2/2     Running   0          18m    172.16.1.214     k8s-w1   <none>           <none></p>
<pre><code>
아래 명령어로 다양한 cilium config가 적용되어 있는 것을 확인할 수 있다.
```bash
(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# kubectl get cm -n kube-system cilium-config -o json | jq
{
  &quot;apiVersion&quot;: &quot;v1&quot;,
  &quot;data&quot;: {
    &quot;agent-not-ready-taint-key&quot;: &quot;node.cilium.io/agent-not-ready&quot;,
    &quot;arping-refresh-period&quot;: &quot;30s&quot;,
    &quot;auto-direct-node-routes&quot;: &quot;true&quot;,
    &quot;bpf-events-drop-enabled&quot;: &quot;true&quot;,
    &quot;bpf-events-policy-verdict-enabled&quot;: &quot;true&quot;,
    ...</code></pre><h2 id="cilium-노드-간-파드-통신-확인">Cilium 노드 간 파드 통신 확인</h2>
<p>파드를 생성해서 노드간 파드 통신을 확인해본다.</p>
<pre><code class="language-bash"># cilium 파드 이름
export CILIUMPOD0=$(kubectl get -l k8s-app=cilium pods -n kube-system --field-selector spec.nodeName=k8s-ctr -o jsonpath=&#39;{.items[0].metadata.name}&#39;)
export CILIUMPOD1=$(kubectl get -l k8s-app=cilium pods -n kube-system --field-selector spec.nodeName=k8s-w1  -o jsonpath=&#39;{.items[0].metadata.name}&#39;)
export CILIUMPOD2=$(kubectl get -l k8s-app=cilium pods -n kube-system --field-selector spec.nodeName=k8s-w2  -o jsonpath=&#39;{.items[0].metadata.name}&#39;)
echo $CILIUMPOD0 $CILIUMPOD1 $CILIUMPOD2

# 단축키(alias) 지정
alias c0=&quot;kubectl exec -it $CILIUMPOD0 -n kube-system -c cilium-agent -- cilium&quot;
alias c1=&quot;kubectl exec -it $CILIUMPOD1 -n kube-system -c cilium-agent -- cilium&quot;
alias c2=&quot;kubectl exec -it $CILIUMPOD2 -n kube-system -c cilium-agent -- cilium&quot;

alias c0bpf=&quot;kubectl exec -it $CILIUMPOD0 -n kube-system -c cilium-agent -- bpftool&quot;
alias c1bpf=&quot;kubectl exec -it $CILIUMPOD1 -n kube-system -c cilium-agent -- bpftool&quot;
alias c2bpf=&quot;kubectl exec -it $CILIUMPOD2 -n kube-system -c cilium-agent -- bpftool&quot;

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# kubectl get pod -o wide
NAME      READY   STATUS    RESTARTS   AGE    IP             NODE     NOMINATED NODE   READINESS GATES
netpod    1/1     Running   0          115s   172.16.0.110   k8s-s    &lt;none&gt;           &lt;none&gt;
webpod1   1/1     Running   0          115s   172.16.1.244   k8s-w1   &lt;none&gt;           &lt;none&gt;
webpod2   1/1     Running   0          115s   172.16.2.198   k8s-w2   &lt;none&gt;           &lt;none&gt;

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# c0 status --verbose | grep Allocated -A5
Allocated addresses:
  172.16.0.110 (default/netpod)
  172.16.0.250 (health)
  172.16.0.70 (router)
IPv4 BIG TCP:           Disabled
IPv6 BIG TCP:           Disabled

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# c1 status --verbose | grep Allocated -A5
Allocated addresses:
  172.16.1.106 (health)
  172.16.1.13 (kube-system/coredns-55cb58b774-7tj27)
  172.16.1.150 (kube-system/hubble-relay-88f7f89d4-zpgdl)
  172.16.1.153 (router)
  172.16.1.214 (kube-system/hubble-ui-59bb4cb67b-dk4hp)

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# c2 status --verbose | grep Allocated -A5
Allocated addresses:
  172.16.2.198 (default/webpod2)
  172.16.2.237 (router)
  172.16.2.246 (health)
IPv4 BIG TCP:           Disabled
IPv6 BIG TCP:           Disabled</code></pre>
<h3 id="cilium-정보-확인">cilium 정보 확인</h3>
<pre><code class="language-bash"># endpoint 정보 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get pod -o wide
NAME                      READY   STATUS    RESTARTS       AGE    IP             NODE      NOMINATED NODE   READINESS GATES
curl-pod                  1/1     Running   0              163m   172.20.0.6     k8s-ctr   &lt;none&gt;           &lt;none&gt;
webpod-659cd747f8-v9xkm   1/1     Running   0              164m   172.20.1.172   k8s-w2    &lt;none&gt;           &lt;none&gt;
webpod-659cd747f8-zqbk8   1/1     Running   1 (139m ago)   164m   172.20.2.144   k8s-w1    &lt;none&gt;           &lt;none&gt;

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl get svc,ep webpod
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME             TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
service/webpod   ClusterIP   10.96.251.68   &lt;none&gt;        80/TCP    3h9m

NAME               ENDPOINTS                         AGE
endpoints/webpod   172.20.1.172:80,172.20.2.144:80   3h9m</code></pre>
<p>Pod가 생성되면 생기는 인터페이스인 lxc...인터페이스에는 eBPF프로그램이 ingress/egress에 붙어있다.
즉, Pod에 들어오고 나가는 패킷을 BPF hook으로 가로채서 처리하는 것을 의미한다.</p>
<pre><code class="language-bash"># BPF maps : 목적지 Pod와 통신 시 어디로 보내야할지 확인할 수 있음.
# tunnelendpoint = Pod가 떠있는 Node
(⎈|HomeLab:N/A) root@k8s-ctr:~# c0 map get cilium_ipcache | grep 172.20.1.172
172.20.1.172/32     identity=11362 encryptkey=0 tunnelendpoint=192.168.10.102 flags=&lt;none&gt;   sync

# curl-pod LXC 변수 지정
# bpf net show
(⎈|HomeLab:N/A) root@k8s-ctr:~# LXC=lxca2ec5268c2d5
(⎈|HomeLab:N/A) root@k8s-ctr:~# c0bpf net show | grep $LXC
lxca2ec5268c2d5(24) tcx/ingress cil_from_container prog_id 1609 link_id 33
lxca2ec5268c2d5(24) tcx/egress cil_to_container prog_id 1600 link_id 34</code></pre>
<p>BPF 프로그램 정보를 알아보자.
아래 예시에서 prog_id 1609는 Pod에서 나가는 패킷 처리용 eBPF를 의미하며
prog_id 1600은 Pod로 들어오는 패킷 처리용 eBPF를 의미한다.</p>
<p>각 프로그램에는 프로그램이 사용하는 eBPF Map정보가 있다.</p>
<pre><code class="language-bash"># bpf prog show
(⎈|HomeLab:N/A) root@k8s-ctr:~# c0bpf prog show id 1609
1609: sched_cls  name cil_from_container  tag 41989045bb171bee  gpl
    loaded_at 2025-07-19T14:37:08+0000  uid 0
    xlated 752B  jited 784B  memlock 4096B  map_ids 272,271,90
    btf_id 563

# bpf prog show    
(⎈|HomeLab:N/A) root@k8s-ctr:~# c0bpf prog show id 1600
1600: sched_cls  name cil_to_container  tag 0b3125767ba1861c  gpl
    loaded_at 2025-07-19T14:37:08+0000  uid 0
    xlated 1448B  jited 1144B  memlock 4096B  map_ids 272,90,271
    btf_id 554</code></pre>
<p>eBPF map을 상세히 확인해보자.</p>
<ul>
<li><p>90: cilium_metrics
→ Pod의 메트릭을 위한 per-CPU hash map (패킷 수, 바이트 등)</p>
</li>
<li><p>271: cilium_calls_02
→ 프로그램 간 호출을 연결해주는 prog_array (BPF 프로그램 체인 구성)</p>
</li>
<li><p>272: .rodata.config
→ 읽기 전용 구성 값, BPF 프로그램이 설정값을 참고할 때 사용</p>
<pre><code class="language-bash"># bpf map list
(⎈|HomeLab:N/A) root@k8s-ctr:~# c0bpf map list | grep -e 272: -e 271: -e ^90: -A2
90: percpu_hash  name cilium_metrics  flags 0x1
  key 8B  value 16B  max_entries 1024  memlock 19024B
...
271: prog_array  name cilium_calls_02  flags 0x0
  key 4B  value 4B  max_entries 50  memlock 720B
  owner_prog_type sched_cls  owner jited
272: array  name .rodata.config  flags 0x480
  key 4B  value 64B  max_entries 1  memlock 8192B
  btf_id 552  frozen</code></pre>
</li>
</ul>
<h3 id="통신-과정-분석">통신 과정 분석</h3>
<p>netpod에서 webpod1로 통신하는 과정을 확인해본다.</p>
<pre><code class="language-bash">(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# p0 ping -c 1 $WEBPOD1IP
PING 172.16.2.42 (172.16.2.42) 56(84) bytes of data.
64 bytes from 172.16.2.42: icmp_seq=1 ttl=62 time=0.514 ms

--- 172.16.2.42 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.514/0.514/0.514/0.000 ms

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# hubble observe --pod netpod
Oct 26 15:05:12.844: default/netpod (ID:46040) -&gt; default/webpod1 (ID:39469) to-network FORWARDED (ICMPv4 EchoRequest)
Oct 26 15:05:12.844: default/netpod (ID:46040)  default/webpod1 (ID:39469) to-endpoint FORWARDED (ICMPv4 EchoReply)</code></pre>
<p><img src="https://velog.velcdn.com/images/_gyullbb/post/2d286eff-ace2-4693-82e5-d656624b3ec7/image.png" alt=""></p>
<p>1) netpod에서 ping 명령어를 실행하여 webpod1의 IP 주소인 172.16.1.244로 ICMP 패킷을 전송한다.
2) 패킷이 k8s-s의 cilium_net 인터페이스로 도달하면, cilium의 eBPF 프로그램이 패킷을 가로챈다. </p>
<ul>
<li>패킷의 목적지 IP(172.16.1.244)를 확인하고, cilium ipcache에서 해당 IP에 대한 엔드포인트 ID(55847), 목적지 Node(k8s-w1)을 조회한다.</li>
</ul>
<p>3) 목적지 Node가 동일한 서브넷임을 인지하고 Direct Routing 모드를 적용한다.</p>
<ul>
<li>패킷은 터널링 없이 k8s-w1 노드로 전송된다.</li>
</ul>
<p>4) k8s-w1 노드의 cilium_host 인터페이스에 패킷이 도착하면 k8s-w1노드의 cilium의 eBPF 프로그램이 패킷을 가로챈다.</p>
<ul>
<li>cilium_ipcache를 조회하여 목적지가 webpod1임을 확인한다.</li>
</ul>
<p>5) lxc 인터페이스의 veth 쌍과 cilium eBPF에서 생성한 목적지 MAC주소를 포함한 ARP 응답을 기반으로 패킷을 webpod1에 전달한다.</p>
<p>각 과정이 cilium에서는 어떤 코드로 구성되어 있는지 확인해보자.</p>
<h3 id="통신-과정-코드">통신 과정 코드</h3>
<p><strong>* cilium eBPF에서 패킷 유효성 검사 후 IPv4 프로토콜 패킷 처리 *</strong></p>
<pre><code class="language-c">//cilium/bpf/bpf_lxc.c
static __always_inline int handle_ipv4_from_lxc(struct __ctx_buff *ctx, __u32 *dst_sec_identity,
                        __s8 *ext_err)
{
    struct ct_state *ct_state, ct_state_new = {};
    struct ipv4_ct_tuple *tuple;
...
// 패킷이 검증이 실패하면 패킷 DROP
    if (!revalidate_data(ctx, &amp;data, &amp;data_end, &amp;ip4))
        return DROP_INVALID;
...

# ifdef ENABLE_HIGH_SCALE_IPCACHE
    if (identity_is_world_ipv4(identity)) {
        struct endpoint_info *ep;
        void *data, *data_end;
        struct iphdr *ip4;

        if (!revalidate_data(ctx, &amp;data, &amp;data_end, &amp;ip4)) {
            ret = DROP_INVALID;
            goto out;
        }

// 패킷의 목적지 IP를 기반으로 ipcache에서 엔드포인트를 조회함. 
// 엔드포인트가 존재할 경우 security id를 패킷의 identity에 할당함.
        ep = __lookup_ip4_endpoint(ip4-&gt;saddr);
        if (ep)
            identity = ep-&gt;sec_id;
    }
# endif /* ENABLE_HIGH_SCALE_IPCACHE */
//패킷의 메타데이터를 저장함
        ctx_store_meta(ctx, CB_SRC_LABEL, identity);
        ret = tail_call_internal(ctx, CILIUM_CALL_IPV4_CT_INGRESS, &amp;ext_err);
        break;
...
}</code></pre>
<p><strong>* IPCache eBPF맵 조회 *</strong></p>
<pre><code class="language-c">//cilium/bpf/lib/eps.h
static __always_inline __maybe_unused struct endpoint_info *
__lookup_ip4_endpoint(__u32 ip)
{
    struct endpoint_key key = {};

    key.ip4 = ip;
    key.family = ENDPOINT_KEY_IPV4;

    return map_lookup_elem(&amp;ENDPOINTS_MAP, &amp;key);
}</code></pre>
<p>통신을 시도하면 Hubble UI에서 관련된 패킷 흐름을 확인할 수 있다.
  <img src="https://velog.velcdn.com/images/_gyullbb/post/2f62424b-6e64-4a8c-aa9c-63ec4ea04030/image.png" alt=""></p>
<h2 id="cilium-서비스-통신-확인">Cilium 서비스 통신 확인</h2>
<p>ClusterIP 서비스를 생성해본다. iptables를 확인해보아도 더이상 KUBE-SVC룰이 생성되지 않는 것을 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# kubectl get svc,ep svc
NAME          TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
service/svc   ClusterIP   10.10.254.180   &lt;none&gt;        80/TCP    2m1s

NAME            ENDPOINTS                       AGE
endpoints/svc   172.16.1.79:80,172.16.2.42:80   2m1s

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# iptables-save | grep KUBE-SVC
(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# iptables-save | grep CILIUM
:CILIUM_POST_mangle - [0:0]
:CILIUM_PRE_mangle - [0:0]
-A PREROUTING -m comment --comment &quot;cilium-feeder: CILIUM_PRE_mangle&quot; -j CILIUM_PRE_mangle
-A POSTROUTING -m comment --comment &quot;cilium-feeder: CILIUM_POST_mangle&quot; -j CILIUM_POST_mangle
-A CILIUM_PRE_mangle ! -o lo -m socket --transparent -m comment --comment &quot;cilium: any-&gt;pod redirect proxied traffic to host proxy&quot; -j MARK --set-xmark 0x200/0xffffffff
-A CILIUM_PRE_mangle -p tcp -m comment --comment &quot;cilium: TPROXY to host cilium-dns-egress proxy&quot; -j TPROXY --on-port 41489 --on-ip 127.0.0.1 --tproxy-mark 0x200/0xffffffff
-A CILIUM_PRE_mangle -p udp -m comment --comment &quot;cilium: TPROXY to host cilium-dns-egress proxy&quot; -j TPROXY --on-port 41489 --on-ip 127.0.0.1 --tproxy-mark 0x200/0xffffffff
:CILIUM_OUTPUT_raw - [0:0]
...</code></pre>
<p>netpod에서 서비스를 호출함과 동시에 netpod 내에서 tcpdump를 통해 파드 내부 통신을 캡처해본다.</p>
<pre><code class="language-bash">(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# while true; do kubectl exec netpod -- curl -s $SVCIP | grep Hostname;echo &quot;-----&quot;;sleep 1;done
Hostname: webpod2
-----
Hostname: webpod2
-----
Hostname: webpod2
-----
Hostname: webpod2
-----</code></pre>
<p>파드 내부에서 캡처를 했음에도 SVC IP가 보이는 것이 아니라 DNAT된 web-pod IP가 보이는 것을 알 수 있다.</p>
<pre><code class="language-bash">(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# kubectl exec netpod -- tcpdump -enni any -q
tcpdump: data link type LINUX_SLL2
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
17:20:38.927364 eth0  Out ifindex 11 ba:11:4d:8c:87:38 172.16.0.214.60718 &gt; 172.16.1.79.80: tcp 0
17:20:38.927861 eth0  In  ifindex 11 1a:09:90:79:58:0e 172.16.1.79.80 &gt; 172.16.0.214.60718: tcp 0
17:20:38.927916 eth0  Out ifindex 11 ba:11:4d:8c:87:38 172.16.0.214.60718 &gt; 172.16.1.79.80: tcp 0
17:20:38.927974 eth0  Out ifindex 11 ba:11:4d:8c:87:38 172.16.0.214.60718 &gt; 172.16.1.79.80: tcp 76
17:20:38.928318 eth0  In  ifindex 11 1a:09:90:79:58:0e 172.16.1.79.80 &gt; 172.16.0.214.60718: tcp 0
17:20:38.929140 eth0  In  ifindex 11 1a:09:90:79:58:0e 172.16.1.79.80 &gt; 172.16.0.214.60718: tcp 312

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# kubectl get pod -o wide
NAME      READY   STATUS    RESTARTS   AGE     IP             NODE     NOMINATED NODE   READINESS GATES
netpod    1/1     Running   0          3h49m   172.16.0.214   k8s-s    &lt;none&gt;           &lt;none&gt;
webpod1   1/1     Running   0          3h49m   172.16.2.42    k8s-w1   &lt;none&gt;           &lt;none&gt;
webpod2   1/1     Running   0          3h49m   172.16.1.79    k8s-w2   &lt;none&gt;           &lt;none&gt;</code></pre>
<p>cilium에서 확인할 수 있는 서비스 정보로, ClusterIP, Backend endpoint 정보를 확인할 수 있다.</p>
<pre><code class="language-bash">(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# c0 service list
ID   Frontend              Service Type   Backend
9    10.10.254.180:80      ClusterIP      1 =&gt; 172.16.1.79:80 (active)
                                          2 =&gt; 172.16.2.42:80 (active)

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# c0 bpf lb list
SERVICE ADDRESS           BACKEND ADDRESS (REVNAT_ID) (SLOT)
10.10.254.180:80 (1)      172.16.1.79:80 (9) (1)
10.10.254.180:80 (0)      0.0.0.0:0 (9) (0) [ClusterIP, non-routable]
10.10.254.180:80 (2)      172.16.2.42:80 (9) (2)

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# c0 map get cilium_lb4_services_v2
Key                       Value                State   Error
10.10.254.180:80 (0)      0 2 (9) [0x0 0x0]    sync
10.10.254.180:80 (1)      9 0 (9) [0x0 0x0]    sync
10.10.254.180:80 (2)      10 0 (9) [0x0 0x0]   sync

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# c0 map get cilium_lb4_backends_v3
Key   Value                 State   Error
10    ANY://172.16.2.42     sync
9     ANY://172.16.1.79     sync

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# c0 map get cilium_lb4_reverse_nat
Key   Value                 State   Error
9     10.10.254.180:80      sync

(⎈|kubernetes-admin@kubernetes:N/A) root@k8s-s:~# c0 map get cilium_lb4_reverse_sk
Key                           Value                         State   Error
[172.16.2.42]:20480, 130299   [10.10.254.180]:20480, 2304
[172.16.1.79]:20480, 125195   [10.10.254.180]:20480, 2304
[172.16.1.79]:20480, 128771   [10.10.254.180]:20480, 2304
[172.16.1.79]:20480, 128778   [10.10.254.180]:20480, 2304
[172.16.1.79]:20480, 125202   [10.10.254.180]:20480, 2304
[172.16.1.79]:20480, 130277   [10.10.254.180]:20480, 2304</code></pre>
<h3 id="socket-based-loadbalancing">Socket-Based LoadBalancing</h3>
<p>Socket-based LoadBalancing(SLB)란, Cilium이 kube-proxy를 대체하면서 iptables 없이 커널 수준의 소켓 레벨 트래픽 처리를 통해 서비스를 라우팅하는 기술이다.
kube-proxy는 iptables 또는 IPVS를 사용해 서비스 IP → Pod IP 라우팅을 수행하는 반면, SLB는 socket이 생성되고 연결되는 순간 eBPF를 통해 트래픽을 처리한다.</p>
<p>이를 확인하기 위해 실제 syscall 호출을 strace로 분석해보자.</p>
<pre><code class="language-bash">(⎈|HomeLab:N/A) root@k8s-ctr:~# c0 status --verbose | grep -i kubeproxyreplacement -A30
...
--
  KubeProxyReplacement Details:
  Status:                 True
  Socket LB:              Enabled
  Socket LB Tracing:      Enabled
  Socket LB Coverage:     Full
  Devices:                eth0    10.0.2.15 fd17:625c:f037:2:a00:27ff:fe71:19d8 fe80::a00:27ff:fe71:19d8, eth1   192.168.10.100 fe80::a00:27ff:fefd:46df (Direct Routing)
  Mode:                   SNAT
  Backend Selection:      Random
  Session Affinity:       Enabled
  Graceful Termination:   Enabled
  NAT46/64 Support:       Disabled
  XDP Acceleration:       Disabled
  Services:
  - ClusterIP:      Enabled
  - NodePort:       Enabled (Range: 30000-32767)
  - LoadBalancer:   Enabled
  - externalIPs:    Enabled
  - HostPort:       Enabled
...

# syscall 호출 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec curl-pod -- strace -c curl -s webpod
Hostname: webpod-659cd747f8-zqbk8
...

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 ...
  2.33    0.000287          95         3         1 connect
  0.63    0.000077          19         4           socket
  ...
  0.11    0.000013           2         5           getsockname
...
  0.02    0.000002           2         1           getsockopt
...
</code></pre>
<p>connect, getsocketname, getsockopt 시스템콜이 호출됨을 확인할 수 있다.</p>
<table>
<thead>
<tr>
<th>syscall</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>connect()</code></td>
<td>소켓을 통해 서비스 ClusterIP에 연결 요청을 시도함</td>
</tr>
<tr>
<td><code>getsockname()</code></td>
<td>연결된 소켓의 로컬 주소를 확인함 *유저 공간에서는 BPF에서 redirect하는 목적지 IP는 확인할 수 없음. (실제 redirect는 유저스페이스가 아닌 커널 소켓 수준에서 실행되기 때문)</td>
</tr>
<tr>
<td><code>getsockopt()</code></td>
<td>소켓 오류 상태나 옵션을 확인함</td>
</tr>
</tbody></table>
<pre><code class="language-bash"># syscall 호출 상세 확인
(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec curl-pod -- strace -e trace=connect curl -s webpod
connect(4, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr(&quot;10.96.0.10&quot;)}, 16) = 0
connect(5, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr(&quot;10.96.251.68&quot;)}, 16) = 0
connect(4, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr(&quot;10.96.251.68&quot;)}, 16) = -1 EINPROGRESS (Operation in progress)

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec curl-pod -- strace -e trace=getsockname curl -s webpod
getsockname(4, {sa_family=AF_INET, sin_port=htons(33247), sin_addr=inet_addr(&quot;172.20.0.6&quot;)}, [128 =&gt; 16]) = 0
getsockname(5, {sa_family=AF_INET, sin_port=htons(57096), sin_addr=inet_addr(&quot;172.20.0.6&quot;)}, [16]) = 0
getsockname(4, {sa_family=AF_INET, sin_port=htons(60714), sin_addr=inet_addr(&quot;172.20.0.6&quot;)}, [128 =&gt; 16]) = 0
getsockname(4, {sa_family=AF_INET, sin_port=htons(60714), sin_addr=inet_addr(&quot;172.20.0.6&quot;)}, [128 =&gt; 16]) = 0
getsockname(4, {sa_family=AF_INET, sin_port=htons(60714), sin_addr=inet_addr(&quot;172.20.0.6&quot;)}, [128 =&gt; 16]) = 0

(⎈|HomeLab:N/A) root@k8s-ctr:~# kubectl exec curl-pod -- strace -e trace=getsockopt curl -s webpod
getsockopt(4, SOL_SOCKET, SO_ERROR, [0], [4]) = 0</code></pre>
<h4 id="connect">connect()</h4>
<ul>
<li>위 sin_addr=inet_addr(&quot;10.96.251.68&quot;)은 ClusterIP로 지정된 webpod 서비스의 IP이다.</li>
<li>Cilium은 connect syscall 시점에 이 IP를 감지하고 해당 서비스에 연결된 실제 Pod IP 중 하나를 선택하여 연결을 리디렉션하며, 이 과정은 커널 eBPF 레이어에서 이루어진다.</li>
</ul>
<h4 id="getsockopt">getsockopt()</h4>
<ul>
<li>소켓에 오류가 없는지 확인하는 일반적인 호출로, 커넥션이 성공되었음을 확인할 수 있다.</li>
</ul>
<h3 id="통신-과정-분석-1">통신 과정 분석</h3>
<p>pod에서 connect() 시스템콜을 호출하여 소켓을 연결할 때, 목적지 주소가 서비스 주소면, 소켓의 목적지 주소를 서비스의 백엔드 주소로 변경한다.(*이후 과정은 pod - pod 통신과 동일)
해당 과정은 bpf프로그램을 cgroup에 연결해서 처리하게되는데, 이를 위해 cgroup을 pod에서 사용할  수 있도록 마운트하는 작업이 필요하다.
cilium파드의 init containers를 보면 cgroup 마운트를 수행하는 것을 볼 수 있다.</p>
<pre><code class="language-yaml">  - command:
    - sh
    - -ec
    - |
      cp /usr/bin/cilium-mount /hostbin/cilium-mount;
      nsenter --cgroup=/hostproc/1/ns/cgroup --mount=/hostproc/1/ns/mnt &quot;${BIN_PATH}/cilium-mount&quot; $CGROUP_ROOT;</code></pre>
<h3 id="통신-코드-분석">통신 코드 분석</h3>
<p>connect()시스템콜을 호출하여 소켓을 연결할 때 <code>lb4_lookup_service</code> 를 호출해 dst_ip, dst_port에 해당하는 서비스가 존재하는지 확인한다.</p>
<pre><code class="language-c">//cilium/bpf/bpf_sock.c
__section(&quot;cgroup/connect4&quot;)
int cil_sock4_connect(struct bpf_sock_addr *ctx)
{
    int err;
...

    err = __sock4_xlate_fwd(ctx, ctx, false);
...
}

static __always_inline int __sock4_xlate_fwd(struct bpf_sock_addr *ctx,
                         struct bpf_sock_addr *ctx_full,
                         const bool udp_only)
{
      struct lb4_key key = {
        .address    = dst_ip,
        .dport        = dst_port,
#if defined(ENABLE_SERVICE_PROTOCOL_DIFFERENTIATION)
        .proto        = protocol,
    }
  ...
  //서비스 존재 유무 확인
  svc = lb4_lookup_service(&amp;key, true);
}</code></pre>
<p>서비스가 존재하면 해당 서비스에 연결된 백엔드 정보를 backend_slot에 저장한 후, 소켓의 목적지 주소와 포트를 백엔드 주소와 포트로 변경한다.</p>
<pre><code class="language-c">//cilium/bpf/bpf_sock.c
static __always_inline int __sock4_xlate_fwd(struct bpf_sock_addr *ctx,
                         struct bpf_sock_addr *ctx_full,
                         const bool udp_only)
{
  ...
//서비스가 존재하지 않으면 에러 반환
if (!svc)
        return -ENXIO;
//서비스의 백엔드가 존재하지 않으면 에러 반환
    if (svc-&gt;count == 0 &amp;&amp; !lb4_svc_is_l7loadbalancer(svc))
        return -EHOSTUNREACH;
...
    if (backend_id == 0) {
        backend_from_affinity = false;
    //서비스에 연결된 여러 백엔드 중 하나를 backend_slot에 저장한다.
        key.backend_slot = (sock_select_slot(ctx_full) % svc-&gt;count) + 1;
        backend_slot = __lb4_lookup_backend_slot(&amp;key);
...
    //백엔드 ID를 이용하여 백엔드 정보를 가져온다.
        backend_id = backend_slot-&gt;backend_id;
        backend = __lb4_lookup_backend(backend_id);
    }
  ...
  //ReverseNAT테이블에 정보를 등록하여서 응답이 올바른 출발지로 돌아가는 것을 보장한다.
    if (sock4_update_revnat(ctx_full, backend, &amp;orig_key,
                svc-&gt;rev_nat_index) &lt; 0) {
        update_metrics(0, METRIC_EGRESS, REASON_LB_REVNAT_UPDATE);
        return -ENOMEM;
    }
  //소켓의 목적지 주소를 백엔드 IP로 설정하고, 목적지 포트도 백엔드 포트로 설정한다.
    ctx-&gt;user_ip4 = backend-&gt;address;
    ctx_set_port(ctx, backend-&gt;port);

    return 0;
}</code></pre>
]]></description>
        </item>
    </channel>
</rss>