<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>bear's blog</title>
    <link>https://okbear3.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sun, 26 Jul 2026 00:02:25 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>okbear3</managingEditor>
    <image>
      <title>bear's blog</title>
      <url>https://tistory1.daumcdn.net/tistory/4887867/attach/e6c21ebb51834990a088a40985013976</url>
      <link>https://okbear3.tistory.com</link>
    </image>
    <item>
      <title>8. 스토리지 관리 - Volume, PVC, Storage Class</title>
      <link>https://okbear3.tistory.com/103</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글에서 Pod를 생성하여 관리하는 방법에 대해 정리했는데, 컨테이너가 죽었을 때 데이터를 어떻게 보존할까 하는 궁금이 생겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너 파일 시스템은 재시작 시 스토리지가 초기화되기 때문에, Pod 내에서 기록한 데이터는 전부 사라지게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 영속 스토리지를 위한 Volume, PV, PVC, Storage Class, 그리고 백업을 위한 VolumeSnapshot에 대해 정리합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Volume&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;kubernetes에서 Volume은 Pod 안에서 컨테이너들이 접근할 수 있는 디렉터리를 가리키는 추상적인 개념입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글에서 본 ConfigMap과 Secret도 사실은 Volume의 한 종류이기 때문에, configMap, secret, emptyDir 등의 데이터가 volume 하위의 내용으로 들어가게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Volume 종류&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;수명&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;용도&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;emptyDir&lt;/td&gt;
&lt;td&gt;Pod 수명&lt;/td&gt;
&lt;td&gt;컨테이너 간 임시 공유, 캐시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;configMap / secret&lt;/td&gt;
&lt;td&gt;ConfigMap&amp;middot;Secret과 동일&lt;/td&gt;
&lt;td&gt;설정 파일 주입&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hostPath&lt;/td&gt;
&lt;td&gt;노드 수명&lt;/td&gt;
&lt;td&gt;노드 파일 시스템 접근 (위험)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;persistentVolumeClaim&lt;/td&gt;
&lt;td&gt;PVC 수명 (독립적)&lt;/td&gt;
&lt;td&gt;영속 데이터 (DB, 업로드 파일 등)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서 다루는 PV / PVC는 이중에서 영속 데이터 영역에 포함됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;emptyDir &amp;mdash; 컨테이너 간 임시 공유&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 Pod 내 여러 컨테이너가 데이터를 공유할 때 사용할 수 있고, Pod가 삭제되면 데이터도 사라집니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;volumes:
- name: shared
  emptyDir: {}           # Pod 삭제 시 데이터도 삭제

# 메모리 기반 (tmpfs)으로도 가능 &amp;mdash; 빠르지만 Pod 메모리에서 차감
- name: cache
  emptyDir:
    medium: Memory
    sizeLimit: 500Mi
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영속성이 필요 없는 경우, 예를 들어 사이드카가 메인 컨테이너의 로그를 읽어 전송하는 경우에 적합합니다. 메인 컨테이너가 /var/log/app에 로그를 쓰고, 사이드카가 같은 경로를 읽어 외부로 보내는 식이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;medium: Memory로 설정하면 디스크가 아닌 tmpfs(메모리)에 마운트되어 I/O가 빠릅니다. 다만 Pod 메모리에서 차감되니 sizeLimit을 같이 잡아두는 게 안전합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 사이드카가 메인 컨테이너의 로그를 읽어 전송하는 경우처럼 영속성이 필요 없는 경우에 사용할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 컨테이너가 /var/log/app에 로그를 작성하면 사이드카가 같은 경로를 읽어 외부로 보내는 형식으로 사용합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Note : medium: Memory와 같이 설정하면 디스크가 아닌 tmpfs(메모리)에 마운트되어 I/O가 빠르지만, Pod의 메모리에 포함되어 sizeLimit를 함께 고려하는 것이 안전합니다.&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 주의: emptyDir은 Pod 재시작 시에는 유지되지만, Pod 삭제 시에는 사라집니다. Deployment의 롤링 업데이트는 새 Pod를 만들고 기존 Pod를 지우는 방식이므로, emptyDir 데이터는 보존되지 않습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;hostPath &amp;mdash; 노드 파일 시스템에 직접 접근&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;hostPath는 노드의 실제 파일 시스템 경로를 Pod에 마운트합니다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;volumes:
- name: docker-sock
  hostPath:
    path: /var/run/docker.sock
    type: Socket
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;hostPath는 노드 자체에 접근해야하는 시스템 컴포넌트에서 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, Fluent Bit DaemonSet과 같이 로그 수집 DeamonSet이 /var/log를 읽거나, 모니터링 에이전트가 /proc을 읽는 경우에 사용할 수 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;주의:&lt;/b&gt; 일반 애플리케이션에서는 hostPath를 &lt;b&gt;쓰지 않는 게 원칙&lt;/b&gt;입니다.&lt;br /&gt;노드 파일 시스템을 그대로 노출해서 컨테이너 격리가 깨지고, Pod가 어느 노드에 가느냐에 따라 데이터가 달라져서 사실상 영속 스토리지로도 쓰지 못하기 때문에 데이터가 필요하면 PV/PVC를 쓰는 것이 권장됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PV / PVC - 영속 스토리지 계층&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영속 데이터는 PV, PVC의 추상화 계층을 거쳐 저장할 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;개념&lt;/td&gt;
&lt;td&gt;범위&lt;/td&gt;
&lt;td&gt;누가 만들까?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;PersistentVolume (PV)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;클러스터 수준&lt;/td&gt;
&lt;td&gt;관리자 또는 동적 프로비저너&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;PersistentVolumeClaim (PVC)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Namespace 수준&lt;/td&gt;
&lt;td&gt;개발자(앱)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PV를 스토리지 제공자(관리자), PVC를 소비자(개발자) 형태로 분리하여 사용하는 형태로 스토리지의 관리 영역을 분리할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 관리자가 PV를 만들어주면, 개발자는 &quot;10G 용량의 스토리지가 필요해&quot;라고 PVC를 작성해서 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자 입장에서는 어떤 디스크인지, 어떤 파일 시스템 형식인지는 몰라도 사용이 가능합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;290&quot; data-origin-height=&quot;734&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sjZq7/dJMcafAlhX3/U6SYwWrdmZ0oYHDopy1Ld1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sjZq7/dJMcafAlhX3/U6SYwWrdmZ0oYHDopy1Ld1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sjZq7/dJMcafAlhX3/U6SYwWrdmZ0oYHDopy1Ld1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsjZq7%2FdJMcafAlhX3%2FU6SYwWrdmZ0oYHDopy1Ld1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;218&quot; height=&quot;552&quot; data-origin-width=&quot;290&quot; data-origin-height=&quot;734&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PVC가 PV를 &quot;주장(claim)&quot; 한다는 표현을 사용하는데, PVC는 요청서, PV는 실제 디스크로 이해할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자가 필요한 스토리지 용량의 요청서를 제출하면, 클러스터가 조건에 맞는 PV를 찾아 바인딩(연결)해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PV - 실제 스토리지의 표현&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PV는 클러스터에 등록된 실제 스토리지 용량을 표현합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 Pod가 NFS 서버 주소나 EBS 볼륨 ID를 직접 들고 있지 않아도 되도록, &lt;b&gt;스토리지 정보를 클러스터 객체로 추상화한 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;정적 프로비저닝 &amp;mdash; PV를 수동으로 만들기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관리자가 PV를 미리 만들어두면, 개발자 요청한 PVC가 조건에 맞는 PV를 찾아 바인딩됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 &quot;정적 프로비저닝(static provisioning)&quot;이라고 합니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs-pv-001
spec:
  capacity:
    storage: 50Gi
  accessModes:
  - ReadWriteMany
  persistentVolumeReclaimPolicy: Retain
  storageClassName: nfs                # 같은 storageClassName을 가진 PVC와 매칭
  nfs:
    server: nfs.example.com
    path: /exports/data
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: shared-data
spec:
  storageClassName: nfs
  accessModes:
  - ReadWriteMany
  resources:
    requests:
      storage: 50Gi
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PVC가 PV와 바인딩되려면 아래의 내용이 일치해야 합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;storageClassName 일치&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PVC 요청 크기 &amp;le; PV 용량&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PVC accessModes &amp;sube; PV accessModes&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PVC selector(있는 경우) &amp;sube; PV labels&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정적 프로비저닝은 사내 NFS 서버나 기존 인프라의 디스크를 끌어다 쓸 때 유용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 매번 관리자가 PV를 만들어야 해서 번거롭고, 그래서 실무에서는 다음에 다룰 &lt;b&gt;동적 프로비저닝&lt;/b&gt;을 더 많이 씁니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PV의 생명주기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PV는 자기만의 상태(phase)를 가집니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 87px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 15px;&quot;&gt;
&lt;td style=&quot;height: 15px;&quot;&gt;&lt;b&gt;phase&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 15px;&quot;&gt;&lt;b&gt;의미&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;Available&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;만들어졌지만 아직 바인딩 안 됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;Bound&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;어떤 PVC와 연결된 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;Released&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;PVC가 삭제됐지만 PV는 아직 정리되지 않음 (Retain 정책일 때)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;Failed&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;자동 회수에 실패한 상태&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Released가 좀 헷갈리는 설정인데,&amp;nbsp;&lt;b&gt;Retain 정책에서 PVC를 삭제하면 PV는 데이터를 그대로 유지한 채 Released 상태가 됩니다&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 PV는 다른 PVC가 자동으로 가져갈 수 없고, 관리자가 데이터를 확인하고 수동으로 정리해야 다시 Available이 됩니다.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# PV 상태 확인
$ kubectl get pv
NAME         CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS    CLAIM
nfs-pv-001   50Gi       RWX            Retain           Bound     default/shared-data&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;StorageClass &amp;mdash; 스토리지의 &quot;유형&quot;을 정의&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StorageClass는 &lt;b&gt;&quot;어떤 종류의 스토리지인가&quot;를 정의하는 객체&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빠른 SSD, 일반 디스크, 아카이브용 저렴한 스토리지 등 클러스터에 여러 등급을 만들어둘 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PVC는 storageClassName으로 원하는 등급을 고르고, StorageClass가 그에 맞는 PV를 자동 생성합니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: fast-ssd
provisioner: ebs.csi.aws.com           # CSI 드라이버
parameters:                            # provisioner마다 다른 옵션
  type: gp3
  iops: &quot;3000&quot;
  throughput: &quot;125&quot;
  encrypted: &quot;true&quot;
reclaimPolicy: Delete                  # PVC 삭제 시 PV/디스크 처리
allowVolumeExpansion: true             # 나중에 크기 확장 가능
volumeBindingMode: WaitForFirstConsumer
mountOptions:
  - debug
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 필드를 하나씩 살펴보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;provisioner &amp;mdash; 어떤 드라이버에서 디스크 생성할까?&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;provisioner 필드는 어떤 CSI 드라이버가 실제 디스크 생성을 담당할지 지정합니다. 클라우드/온프레미스마다 다릅니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Provisioner&lt;/td&gt;
&lt;td&gt;백엔드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ebs.csi.aws.com&lt;/td&gt;
&lt;td&gt;AWS EBS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;efs.csi.aws.com&lt;/td&gt;
&lt;td&gt;AWS EFS (RWX 지원)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pd.csi.storage.gke.io&lt;/td&gt;
&lt;td&gt;GCP Persistent Disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;disk.csi.azure.com&lt;/td&gt;
&lt;td&gt;Azure Disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;file.csi.azure.com&lt;/td&gt;
&lt;td&gt;Azure Files (RWX 지원)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rook-ceph.rbd.csi.ceph.com&lt;/td&gt;
&lt;td&gt;Ceph RBD (온프레미스)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nfs.csi.k8s.io&lt;/td&gt;
&lt;td&gt;NFS (외부 NFS 서버 연결)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매니지드 쿠버네티스(EKS, GKE, AKS)는 기본 provisioner가 미리 설치되어 있고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 구축한 클러스터는 별도로 CSI 드라이버를 설치해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;parameters&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;parameters는 provisioner가 해석하는 옵션입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS EBS라면 type (gp3/io2/sc1 등), iops, throughput, encrypted 같은 값이 들어가고, GCP는 type이 pd-ssd/pd-standard 와 같이 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 플랫폼 별로 어떤 값을 사용할 수 있는지는 각 CSI 드라이버 문서를 확인하여 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;volumeBindingMode&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;volumeBindingMode 는&amp;nbsp;&lt;b&gt;PV를 언제 만들지&lt;/b&gt;를 결정하는 필드입니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;모드&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;동작&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Immediate&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;PVC가 만들어지자마자 즉시 PV(=클라우드 디스크) 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;WaitForFirstConsumer&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Pod가 PVC를 마운트하려는 순간까지 기다렸다가 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두개의 모드를 지원하는 이유는&amp;nbsp;&lt;b&gt;클라우드 환경의 AZ(가용 영역) 때문&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, AWS EBS 같은 블록 스토리지는 특정 AZ에 묶여 있어서, AZ-a에 만든 디스크는 AZ-b의 노드에 마운트할 수 없습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Immediate 모드로 디스크가 AZ-a에 먼저 만들어졌는데, 스케줄러는 노드 자원을 보고 Pod를 AZ-b에 배치하려고 하면 충돌이 발생합니다.&lt;/li&gt;
&lt;li&gt;WaitForFirstConsumer는 스케줄러가 Pod를 어느 AZ에 배치할지 먼저 정하고, 그 AZ에 디스크를 만듭니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 환경에서는 &lt;b&gt;WaitForFirstConsumer&lt;/b&gt;를 쓰는 게 안전합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;Note : PVC를 만들고 한참 동안 Pod에 연결 안 하는 상황이라면 PVC가 계속 Pending 상태로 보이는데, 이건 비정상이 아니라 의도된 동작입니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;reclaimPolicy &amp;mdash; PVC 삭제 시 처리 방법&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StorageClass 수준에서 기본값을 정합니다. PVC를 통해 동적으로 만들어진 PV는 이 정책을 상속받습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;정책&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;동작&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Delete&lt;/b&gt; (기본값)&lt;/td&gt;
&lt;td&gt;PVC 삭제 시 PV와 실제 디스크 모두 삭제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Retain&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;PVC 삭제 시 PV는 Released로 남고 데이터 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;주의:&lt;/b&gt; 동적으로 생성된 PV에 Delete 정책이 적용된 채로 PVC를 삭제하면 &lt;b&gt;실제 클라우드 디스크도 사라집니다&lt;/b&gt;. &lt;br /&gt;프로덕션 DB용 StorageClass는 반드시 &quot;reclaimPolicy: Retain&quot;으로 별도로 만들어두는 게 안전합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 만들어진 PV의 reclaimPolicy를 사후에 바꾸는 것도 가능합니다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;# 단일 PV의 정책 변경
kubectl patch pv &amp;lt;pv-name&amp;gt; -p '{&quot;spec&quot;:{&quot;persistentVolumeReclaimPolicy&quot;:&quot;Retain&quot;}}'
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;allowVolumeExpansion &amp;mdash; 나중에 크기 확장 가능한지&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;allowVolumeExpansion: true&quot;이면 PVC의 spec.resources.requests.storage를 늘려서 디스크를 확장할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단방향이라 축소는 불가능합니다. 처음 StorageClass를 만들 때 활성화하여 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;StorageClass 티어링 패턴&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 환경에서는 보통 StorageClass를 여러 개 만들어 용도별로 나눕니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# 빠른 SSD - 트랜잭션 DB, 실시간 워크로드
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: fast-ssd
provisioner: ebs.csi.aws.com
parameters: { type: gp3, iops: &quot;3000&quot; }
reclaimPolicy: Retain                  # 중요 데이터는 Retain
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
---
# 일반 표준 - 일반 앱 데이터
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: standard
  annotations:
    storageclass.kubernetes.io/is-default-class: &quot;true&quot;   # 기본 StorageClass
provisioner: ebs.csi.aws.com
parameters: { type: gp3 }
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
---
# 저렴한 콜드 스토리지 - 로그, 백업
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: cold
provisioner: ebs.csi.aws.com
parameters: { type: sc1 }
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;is-default-class 어노테이션을 단 StorageClass는 PVC에서 storageClassName을 생략했을 때 쓰이는 기본값이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, &lt;b&gt;기본 StorageClass는 클러스터당 하나만&lt;/b&gt; 있어야 합니다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;# 기본 StorageClass 확인 (이름 옆에 (default) 표시)
$ kubectl get storageclass
NAME                  PROVISIONER         RECLAIMPOLICY   VOLUMEBINDINGMODE
fast-ssd              ebs.csi.aws.com     Retain          WaitForFirstConsumer
standard (default)    ebs.csi.aws.com     Delete          WaitForFirstConsumer
cold                  ebs.csi.aws.com     Delete          WaitForFirstConsumer&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PVC를 Pod에서 사용하는 방법&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 만든 StorageClass + PVC를 Pod에서 마운트하는 방법입니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: db-data
spec:
  storageClassName: fast-ssd
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi
---
apiVersion: v1
kind: Pod
metadata:
  name: db
spec:
  containers:
  - name: postgres
    image: postgres:16
    volumeMounts:
    - name: data
      mountPath: /var/lib/postgresql/data
  volumes:
  - name: data
    persistentVolumeClaim:
      claimName: db-data
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PVC만 apply하면 EBS 볼륨이 자동으로 생성되고, PV로 등록되고, PVC와 바인딩되고, Pod에 마운트됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;접근 모드 (Access Modes)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PVC를 만들 때 accessModes에 어떤 방식으로 마운트할지를 지정합니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;약어&lt;/td&gt;
&lt;td&gt;설명&lt;/td&gt;
&lt;td&gt;사용 대상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;RWO&lt;/b&gt; (ReadWriteOnce)&lt;/td&gt;
&lt;td&gt;하나의 노드에서만 읽기/쓰기&lt;/td&gt;
&lt;td&gt;AWS EBS, GCP PD, Azure Disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;ROX&lt;/b&gt; (ReadOnlyMany)&lt;/td&gt;
&lt;td&gt;여러 노드에서 읽기 전용&lt;/td&gt;
&lt;td&gt;NFS, CephFS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;RWX&lt;/b&gt; (ReadWriteMany)&lt;/td&gt;
&lt;td&gt;여러 노드에서 동시 읽기/쓰기&lt;/td&gt;
&lt;td&gt;NFS, EFS, CephFS, Azure Files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;RWOP&lt;/b&gt; (ReadWriteOncePod)&lt;/td&gt;
&lt;td&gt;하나의 Pod에서만 읽기/쓰기 (v1.29 GA)&lt;/td&gt;
&lt;td&gt;CSI 드라이버 일부&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RWO vs RWX &amp;mdash; 언제 무엇을 쓰나&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS EBS, GCP Persistent Disk처럼 블록 스토리지는 한 노드에서만 마운트 가능하기 때문에, 가장 흔하게 사용되는 옵션은 RWO입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스, 단일 인스턴스 앱, StatefulSet의 각 Pod 등이 여기에 해당합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RWX는 &lt;b&gt;여러 Pod가 같은 파일 시스템을 동시에 봐야 할 때&lt;/b&gt; 씁니다. 대표적인 시나리오는 아래와 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;워드프레스 같은 CMS의 업로드 디렉터리 공유 (여러 웹 Pod가 같은 미디어 파일에 접근)&lt;/li&gt;
&lt;li&gt;ML 학습 데이터셋을 여러 워커가 함께 읽는 경우&lt;/li&gt;
&lt;li&gt;로그를 여러 Pod에서 같은 디렉터리에 쓰는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 경우엔 블록 스토리지(EBS 등)로는 RWX를 지원할 수 없기 때문에, NFS, EFS, CephFS 같은 파일 스토리지가 필요합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;주의:&lt;/b&gt; 접근 모드는 대상 스토리지에서 지원해야 합니다. AWS EBS는 RWO만, NFS/EFS는 RWX를 지원합니다. &lt;br /&gt;지원하지 않는 모드를 요청하면 바인딩에 실패합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RWOP &amp;mdash; RWO보다 더 엄격한 격리&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RWO가 &quot;한 노드&quot;라면 RWOP는 &quot;한 Pod&quot;입니다. 같은 노드 위에 있어도 다른 Pod는 이 PVC를 마운트할 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;보안에 민감한 워크로드&lt;/b&gt;(중요 인증서, 키 자료 등)에서 같은 노드의 다른 Pod와 격리하고 싶을 때 씁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;subPath &amp;mdash; 한 볼륨의 일부만 마운트&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;볼륨 전체가 아니라 특정 경로만 마운트하고 싶을 때 subPath를 씁니다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;volumeMounts:
- name: data
  mountPath: /var/lib/mysql
  subPath: mysql        # PVC의 mysql/ 디렉터리만 마운트
- name: data
  mountPath: /var/log/app
  subPath: logs         # PVC의 logs/ 디렉터리만 마운트
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 PVC를 여러 용도로 나눠 쓸 때 유용한데, 보안 측면에서 주의할 점이 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;주의:&lt;/b&gt; subPath에 변수($(VAR))나 사용자 입력을 그대로 넣으면 &lt;b&gt;경로 탐색(path traversal) 취약점&lt;/b&gt;이 될 수 있습니다. Kubernetes는 절대 경로와 ..을 거부하지만, 동적으로 만들어지는 값이라면 검증 후 써야 합니다. 안전한 대안으로 subPathExpr이 있는데, 환경 변수 기반으로만 동작하고 더 엄격하게 검증됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;주의:&lt;/b&gt; subPath로 마운트하는 경우 ConfigMap/Secret의 &lt;b&gt;자동 갱신이 동작하지 않습니다&lt;/b&gt;. 자동 갱신이 필요하면 별도 디렉터리를 마운트하는 방식으로 우회해야 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;VolumeSnapshot &amp;mdash; 백업과 복원 (v1.20 GA)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VolumeSnapshot은 KubernetesAPI 로 통합관리할 수 있는 백업 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v1.12 alpha &amp;rarr; v1.17 beta &amp;rarr; &lt;b&gt;v1.20 GA&lt;/b&gt;를 거쳐 안정화됐고, CSI 드라이버가 지원하는 스토리지 유형(EBS, GCP PD, CephFS 등)에서 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;스냅샷 만들기&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# 관리자가 만들어두는 VolumeSnapshotClass
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshotClass
metadata:
  name: ebs-snapshot
driver: ebs.csi.aws.com
deletionPolicy: Delete
---
# 개발자가 작성하는 VolumeSnapshot
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
  name: db-data-backup-20260605
spec:
  volumeSnapshotClassName: ebs-snapshot
  source:
    persistentVolumeClaimName: db-data    # 백업할 PVC
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;apply하면 CSI 드라이버가 클라우드 스냅샷 API를 호출해서 실제 디스크 스냅샷을 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;스냅샷에서 복원&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복원은 PVC를 만들 때 dataSource에 스냅샷을 지정하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: db-data-restored
spec:
  storageClassName: fast-ssd
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 20Gi
  dataSource:
    name: db-data-backup-20260605
    kind: VolumeSnapshot
    apiGroup: snapshot.storage.k8s.io
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 PVC가 스냅샷 시점의 데이터로 채워진 상태로 만들어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장애 복구나 환경 복제(스테이징을 프로덕션 데이터로 채우는 등)에 유용합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;주의:&lt;/b&gt; VolumeSnapshot은 &lt;b&gt;CSI 드라이버가 지원해야&lt;/b&gt; 동작합니다. in-tree 드라이버는 지원하지 않습니다. 그리고 스냅샷 자체에도 비용이 들고(특히 클라우드), 자동 삭제 정책(deletionPolicy)을 잘못 설정하면 의도치 않게 스냅샷이 사라질 수 있으니 주의합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Trouble Shooting&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PVC Pending&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PVC가 Pending에서 안 넘어가는 원인은 아래의 세 가지 중 하나입니다.&lt;/p&gt;
&lt;pre class=&quot;haskell&quot;&gt;&lt;code&gt;# PVC 이벤트 확인
kubectl describe pvc db-data
&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;조건에 맞는 PV가 없다&lt;/b&gt; (크기&amp;middot;접근 모드&amp;middot;StorageClass 불일치) &amp;mdash; 정적 프로비저닝&lt;/li&gt;
&lt;li&gt;&lt;b&gt;StorageClass의 CSI 드라이버가 설치되지 않았다&lt;/b&gt; &amp;mdash; 동적 프로비저닝에서 흔함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;volumeBindingMode: WaitForFirstConsumer인데 아직 Pod가 스케줄되지 않았다&lt;/b&gt; &amp;mdash; 정상 동작&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째는 앞서 다룬 것처럼 AZ 매칭을 위해 의도된 것이기 때문에, Pod가 스케줄 되면 그때 PV가 생성됩니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;주의:&lt;/b&gt; PVC는 &lt;b&gt;축소할 수 없습니다&lt;/b&gt;. 확장은 allowVolumeExpansion: true인 StorageClass에서만 가능합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# PV/PVC 상태 확인
kubectl get pv
kubectl get pvc -n prod

# StorageClass 목록
kubectl get storageclass

# PVC 크기 확장
kubectl edit pvc db-data    # spec.resources.requests.storage 수정
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;StatefulSet과의 연계&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StatefulSet을 정리할 때 volumeClaimTemplates 옵션을 사용했었는데, PVC를 자동으로 만들어주는 장치입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StatefulSet이 Pod를 3개 띄우면 PVC도 3개(data-redis-0, data-redis-1, data-redis-2)가 자동 생성되고, 각 Pod가 자기 PVC에 바인딩됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 StatefulSet을 삭제해도 PVC는 남는다는 점도 데이터 보호를 위한 의도적인 설계이기 때문에, 완전히 정리하려면 PVC를 명시적으로 삭제해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>K8S</category>
      <category>Kubernetes</category>
      <category>PV</category>
      <category>PVC</category>
      <category>stateful</category>
      <category>storage</category>
      <category>StorageClass</category>
      <category>Volume</category>
      <category>학습 기록</category>
      <author>okbear3</author>
      <guid isPermaLink="true">https://okbear3.tistory.com/103</guid>
      <comments>https://okbear3.tistory.com/103#entry103comment</comments>
      <pubDate>Sat, 27 Jun 2026 21:31:58 +0900</pubDate>
    </item>
    <item>
      <title>7. Resource 관리 - requests, limits, QoS</title>
      <link>https://okbear3.tistory.com/102</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;앞 글에서 ConfigMap과 Secret으로 설정값 주입을 다뤘습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 Pod에 어떤 값을 주입할지는 알겠는데, &lt;b&gt;이 Pod가 노드 자원을 얼마나 쓸 수 있는지&lt;/b&gt;는 아직 명시하지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리소스를 지정하지 않으면, 하나의 Pod가 노드 자원을 독점해서 다른 Pod를 밀어낼 수 있습니다. 반대로 너무 작게 잡으면 OOMKilled나 CPU 스로틀링이 발생하고요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 Pod의 자원 관리에 필요한 것들 - requests, limits, QoS 클래스, Namespace 수준의 정책(LimitRange, ResourceQuota), 그리고 최근 GA된 In-Place Pod Resize까지 정리합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;requests와 limits&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;워크로드편에서 Pod를 만들 때 resources.requests와 limits를 잠깐 설정했었는데, 이게 왜 중요한지는 간략하게만 짚고 넘어갔습니다. 여기서 좀 더 자세히 다룹니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;resources:
  requests:
    cpu: &quot;500m&quot;          # 0.5 코어 보장
    memory: &quot;256Mi&quot;      # 256 MiB 보장
  limits:
    cpu: &quot;1&quot;             # 최대 1 코어
    memory: &quot;512Mi&quot;      # 최대 512 MiB
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;requests vs limits&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구분 requests limits&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 64px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;requests&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;limits&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;역할&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;스케줄러가 노드 선택에 사용&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;런타임이 사용량 제한에 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 10px;&quot;&gt;
&lt;td style=&quot;height: 10px;&quot;&gt;CPU 초과 시&lt;/td&gt;
&lt;td style=&quot;height: 10px;&quot;&gt;&amp;mdash;&lt;/td&gt;
&lt;td style=&quot;height: 10px;&quot;&gt;스로틀링 (느려지지만 죽지 않음)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;메모리 초과 시&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&amp;mdash;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;OOMKilled (컨테이너 종료)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CPU와 메모리의 초과 동작이 다르다는 게 중요합니다. CPU는 스로틀링만 걸리지만 메모리는 컨테이너가 죽습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 메모리 limits는 보수적으로, CPU limits는 여유 있게 잡는 게 일반적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;CPU 단위와 메모리 단위&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 500m이 뭔가 헷갈렸습니다. CPU 단위는 &lt;b&gt;밀리코어(millicore)&lt;/b&gt; 로, 1000m이 1코어입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;500m은 0.5코어, 100m은 0.1코어 식입니다. 소수점(0.5)으로 적어도 되지만, 500m이 더 명확하고 실수가 적습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모리는 이진 접두사를 씁니다. Mi(메비바이트)는 1024&amp;sup2; 바이트, Gi는 1024&amp;sup3; 바이트입니다. M(메가바이트, 1000&amp;sup2;)이나 G도 쓸 수 있지만, 컨테이너 메모리는 보통 Mi/Gi를 씁니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 주의: 500M과 500Mi는 다릅니다(전자는 500,000,000 바이트, 후자는 524,288,000 바이트). 정확한 의도가 아니면 항상 Mi/Gi를 쓰는 게 안전합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Init Container의 리소스 계산&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Init Container를 쓸 때 자원 계산이 일반 컨테이너와 다릅니다. &lt;b&gt;일반 컨테이너는 모든 컨테이너의 합(sum)&lt;/b&gt; 으로 계산되는데, &lt;b&gt;Init Container는 가장 큰 값(max)&lt;/b&gt; 만 잡힙니다. 어차피 순차로 실행되니 동시에 점유하지는 않으니까요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 Init A가 200m, Init B가 500m을 요청하고 메인 컨테이너 두 개가 각각 300m을 요청하면, Pod 전체의 effective request는 max(500m, 300m + 300m) = 600m입니다. Init 단계에서 일시적으로 큰 자원이 필요한 경우 이 점을 기억해두면 됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;QoS 클래스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kubernetes는 requests/limits 설정에 따라 Pod에 QoS(Quality of Service) 클래스를 부여합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노드 리소스가 부족할 때 QoS가 낮은 Pod부터 퇴거시킵니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 71px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;QoS 클래스&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;조건&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;퇴거 우선순위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;Guaranteed&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;모든 컨테이너의 requests = limits&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;가장 나중 (보호)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;Burstable&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;requests &amp;lt; limits이거나 일부만 설정&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;중간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;BestEffort&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;requests, limits 모두 미설정&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;가장 먼저 (취약)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로덕션 워크로드는 최소한 Burstable, 중요한 건 Guaranteed로 설정하는 게 좋습니다. BestEffort는 노드 압박 시 가장 먼저 쫓겨납니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Pod의 QoS 클래스 확인
kubectl get pod &amp;lt;pod&amp;gt; -o jsonpath='{.status.qosClass}'
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 주의: requests 없이 limits만 설정하면 Kubernetes가 requests를 limits와 같은 값으로 자동 설정합니다. 의도치 않게 Guaranteed가 되어 리소스를 많이 &quot;예약&quot;하게 될 수 있습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PriorityClass&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QoS와 헷갈리기 쉬운 게 PriorityClass입니다. &lt;b&gt;QoS는 노드 리소스 압박 시 퇴거 우선순위&lt;/b&gt;를 결정하고, &lt;b&gt;PriorityClass는 스케줄링 시 우선순위와 선점(preemption)&lt;/b&gt; 을 결정합니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: high-priority
value: 1000000
globalDefault: false
description: &quot;중요한 워크로드용&quot;
---
apiVersion: v1
kind: Pod
metadata:
  name: critical-app
spec:
  priorityClassName: high-priority
  containers:
  - name: app
    image: myapp:v1
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;높은 우선순위 Pod가 스케줄될 노드가 없으면, 낮은 우선순위 Pod를 강제 퇴거시키고 자리를 차지할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시스템 컴포넌트는 미리 정의된 system-cluster-critical, system-node-critical PriorityClass를 씁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;LimitRange와 ResourceQuota&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개별 Pod의 리소스는 requests/limits로 관리하지만, &lt;b&gt;Namespace 수준에서 정책을 강제하려면&lt;/b&gt; LimitRange와 ResourceQuota가 필요합니다. 둘은 역할이 다릅니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;LimitRange &amp;mdash; 기본값과 범위 강제&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너/Pod의 기본값과 최소&amp;middot;최대를 설정합니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;apiVersion: v1
kind: LimitRange
metadata:
  name: defaults
  namespace: dev
spec:
  limits:
  - type: Container
    default:              # limits 기본값
      cpu: &quot;500m&quot;
      memory: &quot;256Mi&quot;
    defaultRequest:       # requests 기본값
      cpu: &quot;200m&quot;
      memory: &quot;128Mi&quot;
    max:
      cpu: &quot;2&quot;
      memory: &quot;1Gi&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ResourceQuota &amp;mdash; 네임스페이스 총량 제한&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Namespace 전체의 총 리소스 사용량을 제한합니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;apiVersion: v1
kind: ResourceQuota
metadata:
  name: dev-quota
  namespace: dev
spec:
  hard:
    requests.cpu: &quot;10&quot;
    requests.memory: &quot;20Gi&quot;
    limits.cpu: &quot;20&quot;
    limits.memory: &quot;40Gi&quot;
    pods: &quot;50&quot;
    persistentvolumeclaims: &quot;10&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리소스 외에 오브젝트 개수도 제한할 수 있어서, Pod&amp;middot;PVC&amp;middot;Service의 개수를 막거나 Secret 개수를 제한하는 식으로 활용합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;두 개를 조합하는 이유&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ResourceQuota가 있으면 모든 Pod에 requests/limits가 필수인데, LimitRange의 기본값이 이걸 자동으로 채워주기 때문에, 둘을 조합하여 사용하는 것이 효과적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개발자가 resources를 깜빡해도 LimitRange가 기본값을 넣어주고, 전체 사용량은 ResourceQuota가 관리&lt;/b&gt;하는 구조입니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;# Quota 사용량 확인
kubectl describe resourcequota dev-quota -n dev

# 노드별 리소스 사용량
kubectl top nodes

# Pod별 리소스 사용량
kubectl top pods -n dev
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 주의: kubectl top 명령은 metrics-server가 설치돼 있어야 동작합니다. 매니지드 쿠버네티스(EKS, GKE 등)는 기본 설치되어 있지만, 직접 구축한 클러스터에서는 별도 설치가 필요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;In-Place Pod Resize&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전통적으로 Pod의 리소스를 변경하려면 Pod를 삭제하고 다시 만들어야 했습니다. Deployment의 template을 수정하면 새 ReplicaSet이 만들어지고 롤링 업데이트가 일어나는 식이었죠. 이게 stateful 워크로드나 시작 시간이 긴 앱에는 큰 부담이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v1.27 alpha &amp;rarr; v1.33 beta &amp;rarr; &lt;b&gt;v1.35에서 GA된 In-Place Pod Resize&lt;/b&gt;는 이 문제를 해결합니다. 실행 중인 Pod의 CPU와 메모리 requests/limits를 컨테이너 재시작 없이 변경할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;# kubectl로 직접 리사이즈 (resize subresource 사용)
kubectl patch pod my-app --subresource resize --patch \
  '{&quot;spec&quot;:{&quot;containers&quot;:[{&quot;name&quot;:&quot;app&quot;,&quot;resources&quot;:{&quot;limits&quot;:{&quot;cpu&quot;:&quot;2&quot;,&quot;memory&quot;:&quot;1Gi&quot;}}}]}}'
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# resizePolicy로 리소스별로 재시작 필요 여부를 명시
spec:
  containers:
  - name: app
    image: myapp:v1
    resizePolicy:
    - resourceName: cpu
      restartPolicy: NotRequired      # CPU 변경은 재시작 없이
    - resourceName: memory
      restartPolicy: RestartContainer # 메모리는 컨테이너 재시작
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CPU는 cgroup만 조정하면 되니 거의 모든 워크로드가 재시작 없이 가능하고, 메모리는 앱에 따라 다릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JVM처럼 시작 시점에 힙 크기를 결정하는 앱은 메모리 변경 시 재시작이 필요합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 주의: In-Place Resize는 cgroup v2 환경과 비교적 최신 커널을 요구합니다. 그리고 노드에 여유 자원이 없으면 resize가 Deferred 상태로 보류됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VPA(Vertical Pod Autoscaler)도 In-Place Resize와 연동되어, 권장값에 따라 자동으로 비파괴적 리사이즈를 수행하는 방향으로 발전하고 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;어떻게 사용해야할까?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리소스 설정을 처음 잡을 때 어디서부터 시작해야 할지 막막한데, 일반적으로 이렇게 접근합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;개발 환경에서 측정&lt;/b&gt; &amp;mdash; kubectl top pods로 실제 사용량을 확인합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;requests는 평상시 사용량의 1.2~1.5배&lt;/b&gt; &amp;mdash; 약간 여유를 두고 잡습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;limits는 피크 사용량의 1.5~2배&lt;/b&gt; &amp;mdash; 너무 빡빡하면 OOMKilled가 자주 발생합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;메모리 limits는 보수적으로&lt;/b&gt; &amp;mdash; 죽이는 것보다 느려지는 게 낫습니다(CPU와 다름).&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 정확한 값을 잡기 어려우니, 운영하면서 메트릭을 보고 조정해가는 게 현실적입니다. VPA(Vertical Pod Autoscaler) 같은 도구가 자동으로 권장값을 제시해주기도 하는데, 이건 오토스케일링 글에서 다시 다루겠습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;트러블슈팅&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리소스 관련해서 가장 자주 만나는 두 가지 문제입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;OOMKilled 디버깅&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;nimrod&quot;&gt;&lt;code&gt;# Pod 상태 확인
kubectl get pod &amp;lt;pod&amp;gt; -o jsonpath='{.status.containerStatuses[*].lastState}'
# {&quot;terminated&quot;:{&quot;exitCode&quot;:137,&quot;reason&quot;:&quot;OOMKilled&quot;,...}}

# 이전 컨테이너 로그 (재시작 직전 로그)
kubectl logs &amp;lt;pod&amp;gt; --previous

# 노드 레벨 메모리 사용량
kubectl top pod &amp;lt;pod&amp;gt; --containers
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;exitCode: 137은 SIGKILL을 받았다는 의미이고, reason: OOMKilled면 메모리 limits 초과입니다. 해결은 두 가지 방향입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;limits를 올린다 (피크 사용량 확인 후)&lt;/li&gt;
&lt;li&gt;앱의 메모리 사용을 줄인다 (메모리 누수 확인)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CPU Throttling 디버signing&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CPU는 죽지 않고 느려지기만 해서 발견이 더 어렵습니다. 응답 시간이 갑자기 늘었다면 throttling을 의심해봐야 합니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;# 컨테이너 내부에서 throttling 통계 확인
kubectl exec &amp;lt;pod&amp;gt; -- cat /sys/fs/cgroup/cpu.stat
# nr_throttled: 12345  (throttle 당한 횟수)
# throttled_usec: 67890 (총 throttle 시간)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;nr_throttled이 계속 증가하면 CPU limits를 올리거나, &lt;b&gt;아예 CPU limits를 빼는 것도 검토&lt;/b&gt;합니다. CPU 제한 없이 requests만 설정하면 노드에 여유가 있을 때 자유롭게 burst할 수 있어요. 다만 다른 Pod와의 공정성 문제가 있으니 클러스터 정책에 따라 판단합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;requests는 스케줄러가, limits는 런타임이 사용합니다. CPU 초과는 스로틀링, 메모리 초과는 OOMKilled라는 차이가 핵심입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;QoS 클래스는 노드 압박 시 퇴거 우선순위를 결정하고,&lt;/li&gt;
&lt;li&gt;Namespace 수준에서는 LimitRange로 기본값을,&lt;/li&gt;
&lt;li&gt;ResourceQuota로 총량을 관리합니다.&lt;/li&gt;
&lt;li&gt;v1.35에서 GA된 In-Place Pod Resize 덕분에 이제 재시작 없이 리소스를 조정할 수도 있고요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 컨테이너가 죽어도 데이터가 사라지지 않도록 하는 영속 스토리지인 Volume, PV, PVC, StorageClass를 다루겠습니다.&lt;/p&gt;</description>
      <category>DevOps/kubernetes</category>
      <category>DevOps</category>
      <category>In-Placed Pod Resize</category>
      <category>K8S</category>
      <category>limitrange</category>
      <category>Limits</category>
      <category>QoS</category>
      <category>requests</category>
      <category>resourcequota</category>
      <category>쿠버네티스</category>
      <category>학습 기록</category>
      <author>okbear3</author>
      <guid isPermaLink="true">https://okbear3.tistory.com/102</guid>
      <comments>https://okbear3.tistory.com/102#entry102comment</comments>
      <pubDate>Sat, 20 Jun 2026 02:05:36 +0900</pubDate>
    </item>
    <item>
      <title>6. ConfigMap과 Secret - 설정을 이미지에서 분리</title>
      <link>https://okbear3.tistory.com/101</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;앞 글에서 Service, Ingress, NetworkPolicy로 Pod 간 통신과 외부 노출을 다뤘습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pod를 띄우고 네트워크도 연결했는데, 아직 한 가지가 빠져 있습니다. 앱에 설정값을 어떻게 넣어주느냐는 문제입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지에 DB 호스트 주소를 하드코딩해두면, 환경이 바뀔 때마다 이미지를 다시 빌드해야 합니다. 패스워드 같은 민감 정보를 이미지에 박아놓는 건 보안 측면에서도 위험하고요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 설정과 민감 정보를 이미지에서 분리하는 두 가지 도구, &lt;b&gt;ConfigMap&lt;/b&gt;과 &lt;b&gt;Secret&lt;/b&gt;을 정리합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ConfigMap &amp;mdash; 설정을 이미지에서 분리하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 필요한가&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 호스트, 로그 레벨, 기능 플래그 같은 설정값을 이미지 안에 넣으면, 값 하나 바꿀 때마다 이미지를 다시 빌드해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발&amp;middot;스테이징&amp;middot;프로덕션 환경마다 설정이 다른데, 환경별로 이미지를 따로 만드는 건 비효율적이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ConfigMap은 &lt;b&gt;설정 데이터를 키-값 쌍으로 저장하는 오브젝트&lt;/b&gt;입니다. 이미지는 하나로 유지하고, 환경별로 ConfigMap만 바꿔서 적용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;생성 방법&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  DB_HOST: &quot;db.prod.example.com&quot;
  DB_PORT: &quot;5432&quot;
  LOG_LEVEL: &quot;info&quot;
  # 여러 줄 설정 파일도 값으로 저장 가능
  app.conf: |
    server.port=8080
    server.timeout=30s
    cache.enabled=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같이 명령형으로도 만들 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 리터럴 값으로
kubectl create configmap app-config \\
  --from-literal=DB_HOST=db.prod.example.com \\
  --from-literal=DB_PORT=5432

# 파일에서 (파일명이 키, 내용이 값)
kubectl create configmap app-config --from-file=app.conf

# 디렉터리 전체 (디렉터리 안 모든 파일이 키로)
kubectl create configmap app-config --from-file=./configs/
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 주의: ConfigMap의 데이터 크기는 최대 1MiB입니다(etcd 객체 크기 제한). 큰 설정 파일이나 바이너리 데이터를 넣으려고 하면 Request entity too large 에러가 발생합니다. 1MiB가 넘는 데이터는 PV에 두거나 외부 스토리지를 써야 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Pod에 주입하기 &amp;mdash; 환경 변수 vs 볼륨 마운트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주입 방식은 두 가지입니다. 단순한 키-값은 환경 변수로, 설정 파일은 볼륨 마운트로 넣는 게 일반적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;환경 변수로 주입&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;spec:
  containers:
  - name: app
    image: myapp:v1
    env:
    - name: DATABASE_HOST
      valueFrom:
        configMapKeyRef:
          name: app-config
          key: DB_HOST
    # 또는 전체를 한 번에
    envFrom:
    - configMapRef:
        name: app-config
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;envFrom을 쓰면 ConfigMap의 모든 키가 환경 변수가 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 키 이름이 환경 변수 규칙(영문자/숫자/언더스코어)에 맞지 않으면 그 키는 건너뛰고 경고만 남기니, ConfigMap 키는 처음부터 UPPER_SNAKE_CASE로 잡는 게 안전합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;볼륨 마운트로 주입&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spec:
  containers:
  - name: app
    image: myapp:v1
    volumeMounts:
    - name: config-volume
      mountPath: /etc/config
      readOnly: true
  volumes:
  - name: config-volume
    configMap:
      name: app-config
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 /etc/config/DB_HOST, /etc/config/app.conf 같은 파일이 생기고, 파일 내용이 ConfigMap의 값입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 방식의 중요한 차이가 하나 있습니다. &lt;b&gt;볼륨 마운트는 ConfigMap을 수정하면 보통 1분 이내, 최대 약 2분 이내에 자동 갱신&lt;/b&gt;됩니다(kubelet sync period + 캐시 TTL의 합).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환경 변수는 Pod를 재시작해야 반영되기 때문에, 자동 갱신이 필요하면 볼륨 마운트, 간단한 값이면 환경 변수를 쓰면 됩니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 주의: subPath로 개별 파일만 마운트하면 자동 갱신이 안 됩니다. 그리고 볼륨 마운트 시 해당 디렉터리의 기존 파일이 전부 사라지니, 기존 파일을 유지하려면 subPath를 써야 합니다. 자동 갱신과 기존 파일 보존은 트레이드오프인 셈입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Immutable ConfigMap &amp;mdash; 대규모 클러스터의 성능 개선&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &quot;ConfigMap을 수정 못 하게 막는 게 무슨 의미가 있지?&quot; 싶었는데, 알고 보니 성능 최적화 목적이 컸습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;kubelet은 ConfigMap을 사용하는 모든 Pod에 대해 변경을 감시(watch)&lt;/b&gt; 하는데, 클러스터에 수만 개의 ConfigMap이 있으면 이 watch 자체가 부담입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;immutable: true로 설정하면 kubelet이 해당 ConfigMap의 watch를 건너뛰어 API 서버 부하와 메모리 사용량이 줄어듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v1.21에서 GA됐고, 변경할 일이 없는 설정에 권장됩니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config-v1
immutable: true              # 한 번 만들면 수정 불가
data:
  DB_HOST: &quot;db.prod.example.com&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;값을 바꿔야 하면 새 ConfigMap을 만들고(app-config-v2) Deployment를 새 이름으로 가리키면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이름에 버전을 붙이는 패턴이 자연스럽게 따라옵니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Secret &amp;mdash; 민감 정보 관리&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ConfigMap과 뭐가 다른가&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ConfigMap은 일반 설정, Secret은 패스워드&amp;middot;토큰&amp;middot;인증서 같은 민감 정보용입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kubernetes가 Secret을 좀 더 조심스럽게 다룹니다. kubectl get에서 값이 마스킹되고, 볼륨 마운트 시 tmpfs(메모리)에만 저장되며, RBAC으로 별도 접근 제어를 걸 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 가지 분명히 해둘 게 있습니다. &lt;b&gt;Secret의 Base64 인코딩은 kubectl get secret -o yaml로 누구나 디코딩할 수 있기 때문에, 암호화가 아닙니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실질적 보호를 위해서는 etcd 암호화(EncryptionConfiguration)를 활성화하거나, HashiCorp Vault 같은 외부 도구와 연동해야 합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Secret의 종류&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Secret에는 여러 type이 있고, 각각 용도가 다릅니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 118px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;type&lt;/td&gt;
&lt;td&gt;용도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;Opaque&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;임의의 키-값. 가장 일반적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;kubernetes.io/tls&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;TLS 인증서 (tls.crt, tls.key 키 필수)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;kubernetes.io/dockerconfigjson&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;프라이빗 컨테이너 레지스트리 인증&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;kubernetes.io/service-account-token&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;ServiceAccount 토큰 (자동 생성)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;kubernetes.io/ssh-auth&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;SSH 인증 (ssh-privatekey 키 필수)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;kubernetes.io/basic-auth&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;기본 인증 (username, password 키)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;type을 명시하면 Kubernetes가 필요한 키가 있는지 검증해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 kubernetes.io/tls 타입인데 tls.key가 빠지면 생성 시점에 에러가 나죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;생성 방법&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
type: Opaque
stringData:                    # 평문으로 작성. Kubernetes가 자동으로 Base64 인코딩
  username: admin
  password: &quot;p@ssw0rd&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;stringData는 편의용 필드입니다. data 필드를 쓰면 직접 Base64 인코딩해야 하는데, stringData는 평문으로 적으면 자동 변환해줍니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 명령형 생성
kubectl create secret generic db-credentials \\
  --from-literal=username=admin \\
  --from-literal=password='p@ssw0rd'

# TLS Secret
kubectl create secret tls example-tls --cert=tls.crt --key=tls.key

# Docker 레지스트리 인증
kubectl create secret docker-registry regcred \\
  --docker-server=registry.example.com \\
  --docker-username=user \\
  --docker-password=pass
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프라이빗 레지스트리에서 이미지를 pull할 때는 Pod 스펙에 imagePullSecrets로 위에서 만든 Secret을 지정합니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;spec:
  imagePullSecrets:
  - name: regcred
  containers:
  - name: app
    image: registry.example.com/private/myapp:v1
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Pod에 주입하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ConfigMap과 거의 동일하게 사용할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spec:
  containers:
  - name: app
    image: myapp:v1
    env:
    - name: DB_PASSWORD
      valueFrom:
        secretKeyRef:
          name: db-credentials
          key: password
    volumeMounts:
    - name: secret-volume
      mountPath: /etc/secrets
      readOnly: true
  volumes:
  - name: secret-volume
    secret:
      secretName: db-credentials
      defaultMode: 0400           # 소유자만 읽기
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 주의: 환경 변수로 주입하면 /proc/&amp;lt;pid&amp;gt;/environ이나 env 명령으로 다른 프로세스가 읽을 수 있기 때문에, 보안이 중요한 값은 볼륨 마운트가 더 안전합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ 주의: &lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;Base64는 쉽게 디코딩되기 때문에 &lt;/span&gt;Secret을 Git에 커밋하지 않도록 주의가 필요합니다. GitOps 워크플로에서는 Sealed Secrets, SOPS, External Secrets Operator 같은 도구로 암호화해서 관리합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Secret 값 디코딩해서 확인
kubectl get secret db-credentials -o jsonpath='{.data.password}' | base64 -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Secret도 ConfigMap과 마찬가지로 immutable: true를 지원하고(v1.21 GA), 자주 변경하지 않는 인증서 같은 경우 immutable로 두면 성능에 도움이 됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ConfigMap vs Secret &amp;mdash; 언제 무엇을 쓰는가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구분 ConfigMap Secret&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;구분&lt;/td&gt;
&lt;td&gt;ConfigMap&lt;/td&gt;
&lt;td&gt;Secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;용도&lt;/td&gt;
&lt;td&gt;일반 설정값&lt;/td&gt;
&lt;td&gt;패스워드, 토큰, 인증서&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;저장 형태&lt;/td&gt;
&lt;td&gt;평문&lt;/td&gt;
&lt;td&gt;Base64 인코딩 (암호화 아님)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;볼륨 마운트 위치&lt;/td&gt;
&lt;td&gt;일반 파일 시스템&lt;/td&gt;
&lt;td&gt;tmpfs (메모리)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kubectl get 출력&lt;/td&gt;
&lt;td&gt;값 노출&lt;/td&gt;
&lt;td&gt;값 마스킹&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;크기 제한&lt;/td&gt;
&lt;td&gt;1MiB&lt;/td&gt;
&lt;td&gt;1MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Immutable 지원&lt;/td&gt;
&lt;td&gt;✅ (v1.21 GA)&lt;/td&gt;
&lt;td&gt;✅ (v1.21 GA)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 판단 기준은 단순합니다. &lt;b&gt;노출돼도 괜찮은 값은 ConfigMap, 노출되면 안 되는 값은 Secret&lt;/b&gt;입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;트러블슈팅&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ConfigMap/Secret을 쓰다가 자주 만나는 에러들입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CreateContainerConfigError&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;kubectl describe pod &amp;lt;pod-name&amp;gt;
# Events에 &quot;configmap &quot;app-config&quot; not found&quot; 같은 메시지
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ConfigMap이나 Secret이 존재하지 않을 때 발생합니다. &lt;b&gt;ConfigMap은 Pod보다 먼저 만들어야 합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Namespace가 다른 경우에도 같은 에러가 나니, 같은 Namespace에 있는지 확인합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;값이 바뀌었는데 반영 안 됨&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 세 가지 중 하나를 의심해볼 수 있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;환경 변수로 주입했다&lt;/b&gt; &amp;mdash; Pod 재시작 필요 (kubectl rollout restart deployment/&amp;lt;name&amp;gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;subPath로 마운트했다&lt;/b&gt; &amp;mdash; 자동 갱신 안 됨. Pod 재시작 필요&lt;/li&gt;
&lt;li&gt;&lt;b&gt;immutable: true로 설정됐다&lt;/b&gt; &amp;mdash; 아예 수정 불가. 새 ConfigMap을 만들어야 함&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;envFrom의 키 누락&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ConfigMap에는 분명히 있는데 환경 변수로 안 들어오는 경우, 키 이름이 환경 변수 규칙을 어겼을 가능성이 큽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 1.config 같은 키는 환경 변수로 변환되지 않습니다. kubectl describe pod의 Events에서 경고 메시지를 확인할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ConfigMap으로 일반 설정, Secret으로 민감 정보를 이미지에서 분리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주입 방식은 환경 변수(재시작 필요)와 볼륨 마운트(자동 갱신) 두 가지가 있고, 용도에 맞게 선택합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Secret의 Base64는 암호화가 아니라는 점, 자주 변경하지 않는 값은 immutable: true로 성능을 개선할 수 있다는 점, GitOps에서는 외부 암호화 도구가 필요하다는 점을 기억해두는게 좋을것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 Pod의 컴퓨팅 자원을 어떻게 관리하는지 - requests, limits, QoS 클래스, LimitRange, ResourceQuota를 다루겠습니다.&lt;/p&gt;</description>
      <category>DevOps/kubernetes</category>
      <category>configmap</category>
      <category>DevOps</category>
      <category>K8S</category>
      <category>Kubernetes</category>
      <category>secret</category>
      <category>쿠버네티스</category>
      <category>학습 기록</category>
      <author>okbear3</author>
      <guid isPermaLink="true">https://okbear3.tistory.com/101</guid>
      <comments>https://okbear3.tistory.com/101#entry101comment</comments>
      <pubDate>Sat, 13 Jun 2026 01:54:42 +0900</pubDate>
    </item>
    <item>
      <title>5. Service와 Ingress - 네트워킹 정리</title>
      <link>https://okbear3.tistory.com/100</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;앞 글에서 StatefulSet, DaemonSet, Job 같은 워크로드 리소스를 정리했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pod를 띄우는 방법은 어느 정도 감이 잡혔는데, 한 가지 빠진 게 있었습니다. 이 Pod들이 서로 어떻게 통신하고, 외부에서는 어떻게 접근하는지를 다루지 않았거든요. Deployment로 Pod 3개를 띄웠다고 합시다. 이 Pod들에게 요청을 보내려면 IP를 알아야 하는데, Pod는 생성될 때마다 IP가 바뀝니다. 롤링 업데이트라도 하면 기존 IP는 전부 사라지게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 어떻게 해결하는지, 그리고 클러스터 외부에서는 어떻게 접근하는지를 이번 글에서 정리합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 id=&quot;service--변하지-않는-접점-만들기&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;14&quot; data-ke-size=&quot;size23&quot;&gt;Service &amp;mdash; 변하지 않는 연결점&lt;/h3&gt;
&lt;h4 id=&quot;pod-ip는-왜-못-쓰나&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;16&quot; data-ke-size=&quot;size20&quot;&gt;Pod IP는 왜 못 쓰는 걸까?&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;Pod에 IP가 할당되긴 합니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;kubectl get pod -o wide로 확인할 수 있죠. 문제는 이 IP가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;일시적&lt;/b&gt;이라는 겁니다. Pod가 재시작되면 다른 IP를 받고, 롤링 업데이트를 하면 기존 Pod가 사라지면서 IP가 통째로 바뀝니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;클라이언트 입장에서 &quot;10.244.1.5로 보내면 되지&quot;라고 하드코딩해두면, 배포 한 번 할 때마다 설정을 고쳐야 하기 때문에, 이렇게는 사용을 할 수 없습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;Service는 이걸 해결하는 추상화 계층입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;고정된 가상 IP(ClusterIP)와 DNS 이름을 제공&lt;/b&gt;하고, 셀렉터에 매칭되는 Pod들로 트래픽을 자동 분산합니다. Pod가 교체되더라도 Service의 주소는 변하지 않으니, 클라이언트는 Service 하나만 바라보면 됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;24&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;동작-원리&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;24&quot; data-ke-size=&quot;size20&quot;&gt;동작 원리&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;Service를 만들면 Kubernetes는 셀렉터에 매칭되는 Pod들의 IP를 수집해서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;EndpointSlice&lt;/b&gt;라는 오브젝트에 저장합니다. 각 노드의 kube-proxy가 이 EndpointSlice를 보고, ClusterIP로 들어온 패킷을 실제 Pod IP로 전달하는 규칙을 커널(nftables/iptables)에 설정합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1540&quot; data-origin-height=&quot;794&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhJT4u/dJMb990XWjR/NDtD1xeFHAG3LreceIY8lK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhJT4u/dJMb990XWjR/NDtD1xeFHAG3LreceIY8lK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhJT4u/dJMb990XWjR/NDtD1xeFHAG3LreceIY8lK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbhJT4u%2FdJMb990XWjR%2FNDtD1xeFHAG3LreceIY8lK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1540&quot; height=&quot;794&quot; data-origin-width=&quot;1540&quot; data-origin-height=&quot;794&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;44&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;44&quot; data-ke-size=&quot;size16&quot;&gt;패킷을 kube-proxy 프로세스가 중계하는 건 아닙니다. kube-proxy는 규칙만 설정하고, 실제 전달은 커널이 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;46&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;clusterip--클러스터-내부-통신&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;46&quot; data-ke-size=&quot;size20&quot;&gt;ClusterIP &amp;mdash; 클러스터 내부 통신&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;48&quot; data-ke-size=&quot;size16&quot;&gt;가장 기본적인 Service 타입이고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;type을 생략하면 기본값입니다. 클러스터 내부에서만 접근할 수 있는 가상 IP를 받습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;apiVersion: v1
kind: Service
metadata:
  name: backend-svc
spec:
  # type: ClusterIP  &amp;larr; 기본값이므로 생략 가능
  selector:
    app: backend       # 이 레이블을 가진 Pod들로 트래픽 전달
  ports:
  - port: 80           # Service가 노출하는 포트
    targetPort: 8080   # Pod가 실제로 수신하는 포트
    protocol: TCP
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;65&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;65&quot; data-ke-size=&quot;size16&quot;&gt;클러스터 내 다른 Pod에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;backend-svc라는 이름으로 접근할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;65&quot; data-ke-size=&quot;size16&quot;&gt;같은 Namespace면 이름만으로 되고, 다른 Namespace의 Service라면&lt;span&gt;&amp;nbsp;&lt;/span&gt;backend-svc.other-namespace까지 적어야 합니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;# Service 생성
kubectl apply -f backend-svc.yaml

# Service 목록 및 ClusterIP 확인
kubectl get svc

# EndpointSlice에 Pod IP가 등록됐는지 확인
kubectl get endpointslices -l kubernetes.io/service-name=backend-svc
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-line=&quot;78&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p data-line=&quot;78&quot; data-ke-size=&quot;size16&quot;&gt;⚠️&amp;nbsp;주의:&amp;nbsp;Service에 접근이 안 되는 가장 흔한 원인은&amp;nbsp;셀렉터와 Pod 레이블 불일치입니다. EndpointSlice에 Pod가 하나도 없다면 Deployment의&amp;nbsp;spec.template.metadata.labels와 Service의&amp;nbsp;spec.selector를 비교해보세요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;80&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;nodeport--노드-ip로-외부-노출&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;80&quot; data-ke-size=&quot;size20&quot;&gt;NodePort &amp;mdash; 노드 IP로 외부 노출&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;82&quot; data-ke-size=&quot;size16&quot;&gt;ClusterIP 위에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;모든 노드의 특정 포트를 열어&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;외부에서 접근할 수 있게 합니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;apiVersion: v1
kind: Service
metadata:
  name: web-nodeport
spec:
  type: NodePort
  selector:
    app: web
  ports:
  - port: 80
    targetPort: 8080
    nodePort: 30080      # 30000-32767 범위. 생략하면 자동 할당
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;99&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;99&quot; data-ke-size=&quot;size16&quot;&gt;외부에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;lt;아무-노드-IP&amp;gt;:30080으로 접근하면 됩니다. 구조는 ClusterIP 위에 한 겹이 더 쌓이는 겁니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;kubectl get svc web-nodeport
# NAME           TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
# web-nodeport   NodePort   10.96.50.120   &amp;lt;none&amp;gt;        80:30080/TCP   5s
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;107&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;107&quot; data-ke-size=&quot;size16&quot;&gt;다만 프로덕션에서는 잘 안 씁니다. 모든 노드에서 같은 포트를 점유하니 포트 충돌 관리가 번거롭고, 노드 IP를 직접 노출하게 되니 보안 면에서도 아쉽습니다. 개발이나 테스트 환경에서 빠르게 외부 접근을 열고 싶을 때 정도가 적절합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;109&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;loadbalancer--프로덕션-외부-노출&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;109&quot; data-ke-size=&quot;size20&quot;&gt;LoadBalancer &amp;mdash; 프로덕션 외부 노출&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;111&quot; data-ke-size=&quot;size16&quot;&gt;클라우드 환경(AWS, GCP, Azure)에서 외부 로드밸런서를 자동으로 생성해줍니다. NodePort + ClusterIP를 포함하면서, 앞단에 클라우드 로드밸런서가 붙는 구조입니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;apiVersion: v1
kind: Service
metadata:
  name: web-lb
spec:
  type: LoadBalancer
  selector:
    app: web
  ports:
  - port: 80
    targetPort: 8080
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;# EXTERNAL-IP가 할당될 때까지 대기 (수십 초~수 분)
kubectl get svc web-lb -w
# NAME     TYPE           CLUSTER-IP     EXTERNAL-IP      PORT(S)        AGE
# web-lb   LoadBalancer   10.96.80.200   203.0.113.50     80:31234/TCP   60s
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-line=&quot;134&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p data-line=&quot;134&quot; data-ke-size=&quot;size16&quot;&gt; &amp;nbsp;참고:&amp;nbsp;온프레미스 환경에서는 클라우드 LB가 없으므로 EXTERNAL-IP가&amp;nbsp;&amp;lt;pending&amp;gt;&amp;nbsp;상태로 남습니다. MetalLB 같은 베어메탈 로드밸런서 구현체를 별도 설치해야 IP가 할당됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;136&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;headless-service--pod에-직접-접근&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;136&quot; data-ke-size=&quot;size20&quot;&gt;Headless Service &amp;mdash; Pod에 직접 접근&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;138&quot; data-ke-size=&quot;size16&quot;&gt;clusterIP: None으로 설정하면 가상 IP 없이 DNS가 Pod IP를 직접 반환합니다. StatefulSet처럼 특정 Pod에 이름으로 접근해야 하는 경우에 씁니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;apiVersion: v1
kind: Service
metadata:
  name: db-headless
spec:
  clusterIP: None          # Headless
  selector:
    app: database
  ports:
  - port: 5432
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;153&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;153&quot; data-ke-size=&quot;size16&quot;&gt;DNS를 조회하면 ClusterIP 대신 Pod IP 목록이 A 레코드로 반환됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;153&quot; data-ke-size=&quot;size16&quot;&gt;이전 글에서 다룬 StatefulSet의&lt;span&gt;&amp;nbsp;&lt;/span&gt;serviceName&lt;span&gt;&amp;nbsp;&lt;/span&gt;필드가 바로 이 Headless Service를 가리키는 거였습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;redis-0.redis-headless.default.svc.cluster.local&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 DNS로 개별 Pod에 접근할 수 있었던 이유가 여기에 있었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;155&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;service-타입-정리&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;155&quot; data-ke-size=&quot;size20&quot;&gt;Service 타입 정리&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;157&quot; data-ke-size=&quot;size16&quot;&gt;정리하면 이렇습니다. ClusterIP &amp;rarr; NodePort &amp;rarr; LoadBalancer가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;계층적으로 쌓이는 구조&lt;/b&gt;입니다.&lt;/p&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;159&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;타입&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;접근 범위&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;용도&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;161&quot;&gt;
&lt;td&gt;&lt;b&gt;ClusterIP&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;클러스터 내부만&lt;/td&gt;
&lt;td&gt;내부 서비스 간 통신&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;162&quot;&gt;
&lt;td&gt;&lt;b&gt;NodePort&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;클러스터 외부 (노드IP:포트)&lt;/td&gt;
&lt;td&gt;개발&amp;middot;테스트 환경&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;163&quot;&gt;
&lt;td&gt;&lt;b&gt;LoadBalancer&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;클러스터 외부 (외부 LB IP)&lt;/td&gt;
&lt;td&gt;프로덕션 외부 트래픽&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;164&quot;&gt;
&lt;td&gt;&lt;b&gt;Headless&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;클러스터 내부 (Pod IP 직접)&lt;/td&gt;
&lt;td&gt;StatefulSet, Pod 직접 접근&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;166&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;coredns--이름으로-통신하는-원리&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;166&quot; data-ke-size=&quot;size20&quot;&gt;CoreDNS &amp;mdash; 이름으로 통신하는 원리&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;168&quot; data-ke-size=&quot;size16&quot;&gt;Service를 만들면 IP가 할당되는데, 실제로는 IP 대신&lt;span&gt;&amp;nbsp;&lt;/span&gt;backend-svc라는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;이름&lt;/b&gt;으로 접근합니다. 이게 되는 이유가 CoreDNS입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;168&quot; data-ke-size=&quot;size16&quot;&gt;CoreDNS는&lt;span&gt;&amp;nbsp;&lt;/span&gt;kube-system&lt;span&gt;&amp;nbsp;&lt;/span&gt;Namespace에서 돌아가는 클러스터 내부 DNS 서버입니다. Pod 안에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;curl http://backend-svc를 치면, Pod의&lt;span&gt;&amp;nbsp;&lt;/span&gt;/etc/resolv.conf에 설정된 CoreDNS로 DNS 쿼리가 가고, CoreDNS가 Service의 ClusterIP를 응답합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;168&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;172&quot; data-ke-size=&quot;size16&quot;&gt;DNS 이름의 전체 형식은 이렇습니다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;&amp;lt;service-name&amp;gt;.&amp;lt;namespace&amp;gt;.svc.cluster.local
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;178&quot; data-ke-size=&quot;size16&quot;&gt;같은 Namespace에서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;backend-svc만으로 충분하고, 다른 Namespace면&lt;span&gt;&amp;nbsp;&lt;/span&gt;backend-svc.other-ns까지 써야 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;178&quot; data-ke-size=&quot;size16&quot;&gt;공식 문서를 찾아보니 Pod의&lt;span&gt;&amp;nbsp;&lt;/span&gt;/etc/resolv.conf에&lt;span&gt;&amp;nbsp;&lt;/span&gt;search default.svc.cluster.local svc.cluster.local cluster.local이 들어 있어서, 짧은 이름을 치면 search domain을 붙여가며 자동으로 찾아줍니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;# Pod의 DNS 설정 확인
kubectl exec -it &amp;lt;pod-name&amp;gt; -- cat /etc/resolv.conf
# nameserver 10.96.0.10
# search default.svc.cluster.local svc.cluster.local cluster.local
# options ndots:5
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;188&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p id=&quot;ndots-설정이-중요한-이유&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;188&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ndots 설정이 중요한 이유&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;190&quot; data-ke-size=&quot;size16&quot;&gt;ndots:5는 &quot;점이 5개 미만인 이름은 search domain을 먼저 붙여서 시도한다&quot;는 뜻입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;190&quot; data-ke-size=&quot;size16&quot;&gt;backend-svc(점 0개)를 조회하면&lt;span&gt;&amp;nbsp;&lt;/span&gt;backend-svc.default.svc.cluster.local부터 시도하니 문제 없지만, 외부 도메인인&lt;span&gt;&amp;nbsp;&lt;/span&gt;api.example.com(점 2개)도 search domain을 먼저 붙여&lt;span&gt;&amp;nbsp;&lt;/span&gt;api.example.com.default.svc.cluster.local부터 시도합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;190&quot; data-ke-size=&quot;size16&quot;&gt;불필요한 DNS 쿼리가 4번이나 더 나가는 거죠.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;192&quot; data-ke-size=&quot;size16&quot;&gt;외부 API 호출이 잦은 앱에서는 FQDN 끝에&lt;span&gt;&amp;nbsp;&lt;/span&gt;.을 붙이면(api.example.com.) search domain을 건너뛰고 바로 조회합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;194&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;dns-디버깅&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;194&quot; data-ke-size=&quot;size20&quot;&gt;DNS 디버깅&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;196&quot; data-ke-size=&quot;size16&quot;&gt;Service 이름으로 접근이 안 될 때 확인하는 순서입니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;# 1. CoreDNS Pod가 돌고 있는지
kubectl get pods -n kube-system -l k8s-app=kube-dns

# 2. 테스트 Pod에서 DNS 조회
kubectl run -it --rm debug --image=busybox:1.36 --restart=Never -- \
  nslookup backend-svc.default.svc.cluster.local

# 3. CoreDNS 로그 확인
kubectl logs -n kube-system -l k8s-app=kube-dns
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;210&quot; data-ke-size=&quot;size16&quot;&gt;nslookup이&lt;span&gt;&amp;nbsp;&lt;/span&gt;NXDOMAIN을 반환하면 Service 이름이 틀렸거나 Namespace가 다른 경우가 대부분입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;kubectl get svc -A로 전체 Service 목록을 확인해봅니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 id=&quot;ingress--http-라우팅과-외부-진입점&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;212&quot; data-ke-size=&quot;size23&quot;&gt;Ingress &amp;mdash; HTTP 라우팅과 외부 진입점&lt;/h3&gt;
&lt;h4 id=&quot;loadbalancer의-한계&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;214&quot; data-ke-size=&quot;size20&quot;&gt;LoadBalancer의 한계&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;214&quot; data-ke-size=&quot;size16&quot;&gt;Service의 LoadBalancer 타입으로 외부 트래픽을 받을 수 있긴 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;216&quot; data-ke-size=&quot;size16&quot;&gt;그런데 서비스가 여러 개면 어떻게 될까요? API 서버, 프론트엔드, 관리 페이지 &amp;mdash; 각각 LoadBalancer Service를 만들면 클라우드 로드밸런서가 3개 생깁니다. 서비스가 10개면 10개. 비용이 선형으로 늘어납니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;218&quot; data-ke-size=&quot;size16&quot;&gt;거기에 &quot;api.example.com은 API 서버로, www.example.com은 프론트엔드로&quot; 같은 호스트 기반 라우팅이나 TLS 인증서 관리도 L4 로드밸런서 수준에서는 할 수 없습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;220&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;220&quot; data-ke-size=&quot;size16&quot;&gt;Ingress는 이 문제를 해결합니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;하나의 진입점&lt;/b&gt;으로 여러 Service에 호스트명&amp;middot;경로 기반으로 트래픽을 분배하고, TLS 종료까지 중앙에서 처리합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;222&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;ingress--규칙--컨트롤러&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;222&quot; data-ke-size=&quot;size20&quot;&gt;Ingress = 규칙 + 컨트롤러&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;224&quot; data-ke-size=&quot;size16&quot;&gt;여기서 놓치기 쉬운 부분인데, Ingress 리소스를 만든다고 바로 동작하는 게 아닙니다. Ingress는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;라우팅 규칙&lt;/b&gt;일 뿐이고, 이 규칙을 실제로 해석해서 프록시(Nginx, Envoy 등)에 반영하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Ingress Controller&lt;/b&gt;가 별도로 설치되어 있어야 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1366&quot; data-origin-height=&quot;682&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/siWhQ/dJMcabLhewq/wWEZiM67skPTRV1ZbycTeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/siWhQ/dJMcabLhewq/wWEZiM67skPTRV1ZbycTeK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/siWhQ/dJMcabLhewq/wWEZiM67skPTRV1ZbycTeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsiWhQ%2FdJMcabLhewq%2FwWEZiM67skPTRV1ZbycTeK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1366&quot; height=&quot;682&quot; data-origin-width=&quot;1366&quot; data-origin-height=&quot;682&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-ingress
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - example.com
    secretName: example-tls       # TLS 인증서가 저장된 Secret
  rules:
  - host: example.com
    http:
      paths:
      - path: /api
        pathType: Prefix
        backend:
          service:
            name: api-svc
            port:
              number: 80
      - path: /
        pathType: Prefix
        backend:
          service:
            name: frontend-svc
            port:
              number: 80
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;268&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;268&quot; data-ke-size=&quot;size16&quot;&gt;example.com/api/*&lt;span&gt;&amp;nbsp;&lt;/span&gt;요청은&lt;span&gt;&amp;nbsp;&lt;/span&gt;api-svc로, 나머지는&lt;span&gt;&amp;nbsp;&lt;/span&gt;frontend-svc로 라우팅됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;268&quot; data-ke-size=&quot;size16&quot;&gt;TLS Secret을 참조하면 Controller가 HTTPS를 종료하고 백엔드에는 HTTP로 전달합니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;# Ingress 확인
kubectl get ingress
kubectl describe ingress app-ingress

# Ingress Controller가 설치되어 있는지 확인
kubectl get ingressclass
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-line=&quot;279&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p data-line=&quot;279&quot; data-ke-size=&quot;size16&quot;&gt;⚠️&amp;nbsp;주의:&amp;nbsp;pathType은 필수 필드입니다. v1 API에서 생략하면 거부됩니다.&amp;nbsp;Prefix(경로 접두사 매칭)와&amp;nbsp;Exact(정확히 일치) 중 선택합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;281&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;ingress-nginx-은퇴와-대안&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;281&quot; data-ke-size=&quot;size20&quot;&gt;Ingress-NGINX 은퇴와 대안&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;283&quot; data-ke-size=&quot;size16&quot;&gt;여기서 중요한 변화가 하나 있습니다. Kubernetes SIG Network는 2026년 3월에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Ingress-NGINX를 공식 은퇴&lt;/b&gt;시켰습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;283&quot; data-ke-size=&quot;size16&quot;&gt;이후 릴리스나 보안 패치가 없습니다. 가장 널리 쓰이던 컨트롤러가 은퇴했으니 꽤 큰 변화입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;285&quot; data-ke-size=&quot;size16&quot;&gt;기존 배포는 계속 동작하지만 유지보수되지 않으므로, 대체할 수 있는 컨트롤러를 설명합니다.&lt;/p&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;287&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Controller&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;특징&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;289&quot;&gt;
&lt;td&gt;&lt;b&gt;Envoy Gateway&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;CNCF 프로젝트, Gateway API 네이티브&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;290&quot;&gt;
&lt;td&gt;&lt;b&gt;Contour&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Envoy 기반, Ingress + Gateway API 지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;291&quot;&gt;
&lt;td&gt;&lt;b&gt;Traefik&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;자동 인증서, Ingress + Gateway API 지원&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;293&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;293&quot; data-ke-size=&quot;size16&quot;&gt;그리고 Ingress 자체도 신규 기능 추가가 중단된 상태입니다. 후계자가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Gateway API&lt;/b&gt;입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 id=&quot;gateway-api--ingress의-후계자&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;295&quot; data-ke-size=&quot;size23&quot;&gt;Gateway API &amp;mdash; Ingress의 대체자&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;297&quot; data-ke-size=&quot;size16&quot;&gt;Gateway API가 나온 이유를 보면, Ingress의 구조적 한계가 있었습니다. 컨트롤러마다 어노테이션이 달라서 이식성이 없었고, 하나의 리소스에 인프라 설정과 라우팅 규칙이 섞여 있어 역할 분리가 안 됐습니다. NGINX에서 Traefik으로 바꾸면 어노테이션을 전부 고쳐야 했죠.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;299&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;299&quot; data-ke-size=&quot;size16&quot;&gt;Gateway API는 이걸 3계층으로 나눴습니다.&lt;/p&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%; height: 72px;&quot; border=&quot;1&quot; data-line=&quot;313&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;리소스&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;관지 주체&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;역할&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot; data-line=&quot;315&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;GatewayClass&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;인프라 제공자&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;어떤 Controller를 쓸지 정의&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot; data-line=&quot;316&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;Gateway&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;클러스터 관리자&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;리스너 설정 &amp;mdash; &quot;어디서 트래픽을 받을까&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot; data-line=&quot;317&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;HTTPRoute&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;앱 개발자&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;호스트&amp;middot;경로 &amp;rarr; Service 매핑 &amp;mdash; &quot;어디로 보낼까&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;319&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;319&quot; data-ke-size=&quot;size16&quot;&gt;인프라팀이 Gateway까지만 관리하고, 개발팀은 HTTPRoute만 작성하면 되는 구조입니다. Ingress에서는 이런 분리가 어려웠습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;# Gateway &amp;mdash; 클러스터 관리자가 생성
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: main-gateway
spec:
  gatewayClassName: envoy
  listeners:
  - name: https
    protocol: HTTPS
    port: 443
    tls:
      mode: Terminate
      certificateRefs:
      - name: example-tls
    allowedRoutes:
      namespaces:
        from: All
---
# HTTPRoute &amp;mdash; 앱 개발자가 생성
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: api-route
spec:
  parentRefs:
  - name: main-gateway
  hostnames:
  - &quot;api.example.com&quot;
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /v1
    backendRefs:
    - name: api-v1-svc
      port: 80
  - matches:
    - path:
        type: PathPrefix
        value: /v2
    backendRefs:
    - name: api-v2-svc
      port: 80
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;368&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;368&quot; data-ke-size=&quot;size16&quot;&gt;v1.36 기준으로 핵심 리소스(GatewayClass, Gateway, HTTPRoute)가 모두 GA(Stable)이니, 신규 프로젝트라면 Gateway API로 시작하는 게 맞습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;368&quot; data-ke-size=&quot;size16&quot;&gt;기존 Ingress 기반 클러스터는 당장 바꿀 필요는 없지만, Ingress-NGINX 은퇴 이후 보안 패치가 없으므로 계획적으로 전환을 준비해야 합니다.&lt;/p&gt;
&lt;blockquote data-line=&quot;370&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p data-line=&quot;370&quot; data-ke-size=&quot;size16&quot;&gt; &amp;nbsp;참고:&amp;nbsp;SIG Network에서 Ingress2Gateway v1.0을 제공합니다. 기존 Ingress 리소스를 Gateway API 리소스로 자동 변환해주는 도구인데, 컨트롤러별 어노테이션은 수동 매핑이 필요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 id=&quot;networkpolicy--pod-간-트래픽-제어&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;372&quot; data-ke-size=&quot;size23&quot;&gt;NetworkPolicy &amp;mdash; Pod 간 트래픽 제어&lt;/h3&gt;
&lt;h4 id=&quot;기본-상태는-모두-허용&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;374&quot; data-ke-size=&quot;size20&quot;&gt;기본 상태는 &quot;모두 허용&quot;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;376&quot; data-ke-size=&quot;size16&quot;&gt;여기까지 Service와 Ingress로 통신 경로를 만드는 방법을 봤습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;376&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;376&quot; data-ke-size=&quot;size16&quot;&gt;그런데 보안 관점에서 한 가지 문제가 있습니다. Kubernetes의 기본 네트워크 모델에서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;모든 Pod가 모든 다른 Pod와 자유롭게 통신할 수 있습니다&lt;/b&gt;. Namespace가 달라도 마찬가지입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;378&quot; data-ke-size=&quot;size16&quot;&gt;프론트엔드 Pod가 데이터베이스 Pod에 직접 접근할 수 있고, 침해된 Pod가 클러스터 전체를 탐색할 수 있다는 뜻입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;378&quot; data-ke-size=&quot;size16&quot;&gt;프로덕션에서는 이게 심각한 문제가 됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;380&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;380&quot; data-ke-size=&quot;size16&quot;&gt;NetworkPolicy는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Pod 수준에서 인바운드/아웃바운드 트래픽을 선언적으로 제어하는 리소스&lt;/b&gt;입니다. 방화벽 규칙처럼 &quot;이 Pod는 특정 소스에서 오는 트래픽만 허용한다&quot;를 정의합니다.&lt;/p&gt;
&lt;blockquote data-line=&quot;382&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p data-line=&quot;382&quot; data-ke-size=&quot;size16&quot;&gt;⚠️&amp;nbsp;주의:&amp;nbsp;NetworkPolicy는&amp;nbsp;CNI 플러그인이 지원해야&amp;nbsp;동작합니다. Calico, Cilium, Weave Net은 지원하지만 Flannel은 지원하지 않습니다. CNI가 지원하지 않으면 NetworkPolicy를 만들어도 실제 차단이 일어나지 않습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;384&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;default-deny--먼저-전부-닫기&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;384&quot; data-ke-size=&quot;size20&quot;&gt;Default Deny &amp;mdash; 먼저 전부 닫기&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;386&quot; data-ke-size=&quot;size16&quot;&gt;프로덕션 Namespace에 가장 먼저 적용하는 정책입니다. 모든 인바운드 트래픽을 거부한 다음, 필요한 것만 하나씩 여는 화이트리스트 방식입니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: prod
spec:
  podSelector: {}              # 빈 셀렉터 = Namespace 내 모든 Pod
  policyTypes:
  - Ingress
  # ingress 규칙 없음 = 모든 인바운드 거부
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;401&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;401&quot; data-ke-size=&quot;size16&quot;&gt;podSelector: {}가 빈 셀렉터인데, 이러면 해당 Namespace의 모든 Pod에 적용됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;403&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;특정-트래픽만-허용&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;403&quot; data-ke-size=&quot;size20&quot;&gt;특정 트래픽만 허용&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;405&quot; data-ke-size=&quot;size16&quot;&gt;Default Deny를 건 다음, 필요한 통신만 열어줍니다. database Pod에 api Pod에서 오는 5432 포트만 허용하는 예시입니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: db-allow-api-only
  namespace: prod
spec:
  podSelector:
    matchLabels:
      app: database            # 정책 적용 대상
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: api             # api Pod만 허용
    ports:
    - protocol: TCP
      port: 5432
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;429&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;429&quot; data-ke-size=&quot;size16&quot;&gt;이 정책이 적용되면&lt;span&gt;&amp;nbsp;&lt;/span&gt;app=database&lt;span&gt;&amp;nbsp;&lt;/span&gt;Pod는&lt;span&gt;&amp;nbsp;&lt;/span&gt;app=api&lt;span&gt;&amp;nbsp;&lt;/span&gt;Pod의 5432/TCP만 수신하고, 나머지는 전부 거부됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;431&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;egress-deny와-dns-함정&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;431&quot; data-ke-size=&quot;size20&quot;&gt;Egress Deny와 DNS 함정&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;433&quot; data-ke-size=&quot;size16&quot;&gt;아웃바운드도 거부할 수 있는데, 여기에 흔한 함정이 있습니다.&lt;/p&gt;
&lt;blockquote data-line=&quot;435&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p data-line=&quot;435&quot; data-ke-size=&quot;size16&quot;&gt;⚠️&amp;nbsp;주의:&amp;nbsp;Egress를 전면 거부하면&amp;nbsp;DNS 조회도 차단됩니다. Pod가 Service 이름으로 통신하려면 CoreDNS에 DNS 쿼리를 보내야 하는데, 이것까지 막혀버리는 거죠. Egress deny를 걸 때는 반드시 DNS(포트 53) 허용 규칙을 함께 추가해야 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: prod
spec:
  podSelector: {}
  policyTypes:
  - Egress
  egress:
  - to:
    - namespaceSelector: {}    # 모든 Namespace (kube-system의 CoreDNS 포함)
    ports:
    - protocol: UDP
      port: 53
    - protocol: TCP
      port: 53
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;457&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p id=&quot;and와-or-구분&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;457&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AND와 OR 구분&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;459&quot; data-ke-size=&quot;size16&quot;&gt;다른 Namespace에서 오는 트래픽을 허용할 때,&lt;span&gt;&amp;nbsp;&lt;/span&gt;namespaceSelector와&lt;span&gt;&amp;nbsp;&lt;/span&gt;podSelector를 어떻게 배치하느냐에 따라 의미가 완전히 달라집니다. 이 부분이 직관적이지 않아서 꽤 헤맸습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;  # AND: monitoring Namespace의 prometheus Pod만 허용
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          purpose: monitoring
      podSelector:             # 같은 from 항목 = AND
        matchLabels:
          app: prometheus

  # OR: monitoring Namespace의 모든 Pod 또는 아무 곳의 prometheus Pod
  ingress:
  - from:
    - namespaceSelector:       # 별도 from 항목 = OR
        matchLabels:
          purpose: monitoring
    - podSelector:
        matchLabels:
          app: prometheus
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;483&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;483&quot; data-ke-size=&quot;size16&quot;&gt;같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;from&lt;span&gt;&amp;nbsp;&lt;/span&gt;항목 안에 나란히 쓰면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;AND&lt;/b&gt;(둘 다 만족), 별도 항목으로 분리하면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;OR&lt;/b&gt;(하나만 만족해도 허용)입니다. YAML 들여쓰기 하나 차이로 의미가 바뀌니 주의가 필요합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;485&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;적용-권장-순서&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;485&quot; data-ke-size=&quot;size20&quot;&gt;적용 권장 순서&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1.&amp;nbsp;Default&amp;nbsp;Deny&amp;nbsp;Ingress&amp;nbsp;+&amp;nbsp;Egress&amp;nbsp;(Namespace&amp;nbsp;전체)&lt;br /&gt;2.&amp;nbsp;DNS&amp;nbsp;Egress&amp;nbsp;허용&amp;nbsp;(UDP/TCP&amp;nbsp;53)&lt;br /&gt;3.&amp;nbsp;필요한&amp;nbsp;Pod&amp;nbsp;간&amp;nbsp;통신만&amp;nbsp;개별&amp;nbsp;허용&lt;br /&gt;4.&amp;nbsp;외부&amp;nbsp;접근이&amp;nbsp;필요한&amp;nbsp;Pod만&amp;nbsp;Egress&amp;nbsp;추가&amp;nbsp;허용&lt;/p&gt;
&lt;pre class=&quot;vala&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;# NetworkPolicy 목록
kubectl get networkpolicy -n prod

# 연결 테스트 (차단됐는지 확인)
kubectl run -n prod test-pod --image=curlimages/curl --rm -it -- \
  curl -s --connect-timeout 3 http://database:5432
# 타임아웃이면 NetworkPolicy에 의해 차단된 것
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 id=&quot;정리&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;504&quot; data-ke-size=&quot;size23&quot;&gt;정리&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;506&quot; data-ke-size=&quot;size16&quot;&gt;Pod 간 통신은 Service가 고정 IP와 DNS를 제공하고, CoreDNS가 이름을 IP로 해석해줍니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;506&quot; data-ke-size=&quot;size16&quot;&gt;외부 HTTP 트래픽은 Ingress(또는 Gateway API)가 호스트&amp;middot;경로 기반으로 라우팅하고, NetworkPolicy가 Pod 간 트래픽을 화이트리스트 방식으로 제어합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;508&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;508&quot; data-ke-size=&quot;size16&quot;&gt;다음 글에서는 ConfigMap, Secret, Volume 등 설정과 스토리지를 다루겠습니다.&lt;/p&gt;</description>
      <category>DevOps/kubernetes</category>
      <category>CoreDNS</category>
      <category>DevOps</category>
      <category>gateway api</category>
      <category>Ingress</category>
      <category>K8S</category>
      <category>Kubernetes</category>
      <category>NetworkPolicy</category>
      <category>service</category>
      <category>쿠버네티스</category>
      <category>학습 기록</category>
      <author>okbear3</author>
      <guid isPermaLink="true">https://okbear3.tistory.com/100</guid>
      <comments>https://okbear3.tistory.com/100#entry100comment</comments>
      <pubDate>Sat, 6 Jun 2026 20:18:45 +0900</pubDate>
    </item>
    <item>
      <title>4. StatefulSet 부터 CronJob 까지</title>
      <link>https://okbear3.tistory.com/98</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;앞 글에서 Pod &amp;rarr; ReplicaSet &amp;rarr; Deployment로 이어지는 워크로드 기초를 다뤘습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Deployment가 실무 표준이라는 건 알겠는데, 문제는 모든 앱이 Deployment에 딱 맞는 건 아니라는 점입니다. 데이터베이스처럼 상태를 가진 앱은? 모든 노드에 하나씩 띄워야 하는 에이전트는? 한 번 돌고 끝나는 배치 작업은? 이런 경우에는 어떻게 해야할까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 용도별로 나뉘는 워크로드 리소스들과, 컨테이너의 상태를 어떻게 판단하는지(Probe), 그리고 한 Pod 안에 여러 컨테이너를 두는 패턴(Init/Sidecar)까지 정리합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;StatefulSet &amp;mdash; 상태가 필요한 앱을 위한 리소스&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Deployment로 데이터베이스를 띄우면 어떤 문제가 생길까요? Pod가 재시작되면 이름과 IP가 바뀌고, 이전에 쓰던 디스크와의 연결도 끊어집니다. 분산 시스템에서 &quot;redis-0이 마스터고 redis-1, redis-2가 슬레이브&quot;라는 구조가 필요한데, Pod 이름이 매번 바뀌면 이걸 유지할 수 없죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;StatefulSet은 이 문제를 세 가지로 해결합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;안정적인 네트워크 ID&lt;br /&gt;&lt;/b&gt;Pod 이름이 redis-0, redis-1, redis-2처럼 고정됩니다. 재시작돼도 같은 이름을 받습니다. Headless Service와 함께 쓰면 redis-0.redis-headless.default.svc.cluster.local 같은 DNS로 특정 Pod에 직접 접근할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;안정적인 스토리지&lt;br /&gt;&lt;/b&gt;volumeClaimTemplates로 Pod마다 별도의 PVC가 자동 생성됩니다. Pod가 죽었다 다시 뜨면 같은 PVC에 다시 연결되므로 데이터가 보존됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;순서 보장&lt;br /&gt;&lt;/b&gt;기본적으로 0번부터 순서대로 생성하고, 역순으로 삭제합니다. 이전 Pod가 Ready 상태가 되어야 다음 Pod가 만들어집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# Headless Service (ClusterIP: None)
apiVersion: v1
kind: Service
metadata:
  name: redis-headless
spec:
  clusterIP: None
  selector:
    app: redis
  ports:
  - port: 6379
---
# StatefulSet
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis
spec:
  serviceName: redis-headless    # Headless Service와 연결
  replicas: 3
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
      - name: redis
        image: redis:7.4
        ports:
        - containerPort: 6379
        volumeMounts:
        - name: data
          mountPath: /data
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: [&quot;ReadWriteOnce&quot;]
      storageClassName: standard
      resources:
        requests:
          storage: 10Gi
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# Pod가 순서대로 생성되는 걸 확인
kubectl get pods -l app=redis -w
# redis-0   Running
# redis-1   Running
# redis-2   Running

# Pod마다 별도 PVC가 생성됨
kubectl get pvc
# data-redis-0, data-redis-1, data-redis-2
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;주의&lt;/b&gt;: StatefulSet을 삭제해도 PVC는 자동으로 삭제되지 않습니다. 데이터 보호를 위한 설계인데, 완전히 정리하려면 PVC를 별도로 삭제해야 합니다. 삭제를 깜빡하면 스토리지 비용이 계속 나가니 주의가 필요합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Deployment vs StatefulSet 선택 기준&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단하게 정리하면 이렇습니다. 앱이 상태를 내부에 저장하지 않으면(웹 서버, API 서버) Deployment, 상태를 저장하고 Pod 간 구분이 필요하면(DB, 메시지 큐, 분산 캐시) StatefulSet입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DaemonSet &amp;mdash; 모든 노드에 하나씩&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DaemonSet은 클러스터의 모든 노드(또는 선택한 노드)에 Pod를 하나씩 배포합니다. 새 노드가 추가되면 자동으로 Pod가 생기고, 노드가 제거되면 함께 사라집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노드마다 반드시 하나씩 돌아야 하는 인프라 에이전트같은 앱을 실행할 때 사용할 수 있습니다. 재밌는 건 kube-proxy 자체도 DaemonSet으로 배포된다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적인 사용 사례는 이렇습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로그 수집 에이전트 (Fluent Bit, Filebeat)&lt;/li&gt;
&lt;li&gt;모니터링 에이전트 (Prometheus Node Exporter, Datadog Agent)&lt;/li&gt;
&lt;li&gt;네트워크 플러그인 (Calico, Cilium)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluent-bit
  namespace: logging
spec:
  selector:
    matchLabels:
      app: fluent-bit
  template:
    metadata:
      labels:
        app: fluent-bit
    spec:
      tolerations:
      - key: node-role.kubernetes.io/control-plane
        operator: Exists
        effect: NoSchedule     # 컨트롤 플레인 노드에도 배포
      containers:
      - name: fluent-bit
        image: fluent/fluent-bit:3.2
        resources:
          requests:
            cpu: &quot;50m&quot;
            memory: &quot;50Mi&quot;
          limits:
            cpu: &quot;200m&quot;
            memory: &quot;200Mi&quot;
        volumeMounts:
        - name: varlog
          mountPath: /var/log
          readOnly: true
      volumes:
      - name: varlog
        hostPath:
          path: /var/log
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤 플레인 노드에는 기본적으로 Taint가 걸려 있어서 일반 Pod가 배치되지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그 수집처럼 모든 노드에서 돌아야 하는 DaemonSet은 Toleration을 추가해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 모든 노드에 하나씩 배포됐는지 확인
kubectl get daemonset -n logging
# DESIRED == CURRENT == READY == 노드 수
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Job &amp;mdash; 한 번 돌고 끝나는 작업&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Deployment는 Pod를 계속 Running 상태로 유지하려고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 DB 마이그레이션이나 배치 데이터 처리는 한 번 실행하고 끝나야 하는 작업이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Job은 Pod가 성공적으로 종료(exit 0)되면 완료로 간주합니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;apiVersion: batch/v1
kind: Job
metadata:
  name: db-migration
spec:
  backoffLimit: 3               # 실패 시 최대 재시도 횟수
  activeDeadlineSeconds: 300    # 전체 작업 타임아웃
  template:
    spec:
      restartPolicy: OnFailure  # Job에서는 Always 사용 불가
      containers:
      - name: migration
        image: myapp:v2
        command: [&quot;python&quot;, &quot;manage.py&quot;, &quot;migrate&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;kubectl apply -f job.yaml
kubectl get job db-migration    # COMPLETIONS: 0/1 &amp;rarr; 1/1
kubectl logs -l job-name=db-migration
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 데이터를 동시에 처리하고 싶으면 parallelism과 completions를 조합합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;completions: 10, parallelism: 3이면 최대 3개의 Pod가 동시에 돌면서 총 10번 완료할 때까지 실행됩니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;주의&lt;/b&gt;: activeDeadlineSeconds를 설정하지 않으면 실패한 Pod가 무한 재시도에 빠질 수 있습니다. 배치 작업에는 반드시 타임아웃을 걸어둡니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;완료된 Job과 Pod는 자동 삭제되지 않아 로그를 확인할 수 있지만, 쌓이면 관리가 번거롭습니다. ttlSecondsAfterFinished: 600을 설정하면 완료 10분 후 자동으로 정리됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;CronJob &amp;mdash; 스케줄 기반 반복 Job&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CronJob은 유닉스 cron 표현식으로 Job을 반복 생성합니다. 계층은 CronJob &amp;rarr; Job &amp;rarr; Pod 순서입니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;apiVersion: batch/v1
kind: CronJob
metadata:
  name: db-backup
spec:
  schedule: &quot;0 2 * * *&quot;            # 매일 새벽 2시
  timeZone: &quot;Asia/Seoul&quot;           # v1.27 GA. 이전엔 UTC 기준으로 계산해야 했음
  concurrencyPolicy: Forbid        # 이전 Job이 아직 돌고 있으면 새 Job 생성 안 함
  startingDeadlineSeconds: 300     # 5분 안에 시작 못하면 스킵
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 1800
      template:
        spec:
          restartPolicy: OnFailure
          containers:
          - name: backup
            image: backup-tool:v1
            command: [&quot;/bin/sh&quot;, &quot;-c&quot;, &quot;backup.sh&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;concurrencyPolicy는 중요한 설정입니다. 기본값인 Allow는 이전 Job이 안 끝나도 새 Job을 만듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백업 스크립트가 30분 넘게 걸리는데 1시간마다 돌린다면 Job이 겹칠 수 있기 때문에, 이런 경우 Forbid로 설정하면 이전 것이 끝날 때까지 새 것은 스킵됩니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;# 테스트용 수동 실행
kubectl create job --from=cronjob/db-backup manual-backup

# CronJob 일시 중지 (배포 등 특정 기간)
kubectl patch cronjob db-backup -p '{&quot;spec&quot;:{&quot;suspend&quot;:true}}'
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;주의&lt;/b&gt;: CronJob은 지정 시각에 정확히 실행되지 않을 수 있습니다. 컨트롤러 재시작이나 네트워크 지연으로 수 초~수 분 늦어질 수 있어서, 작업은 멱등성(idempotent)이 있게 설계해야 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Probe &amp;mdash; 컨테이너 헬스 체크&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 워크로드 리소스를 다뤘는데, 이 리소스들이 Pod를 관리할 때 &quot;이 컨테이너가 정상인지&quot;를 어떻게 판단할까요? 컨테이너가 Running이라고 해서 반드시 정상인 건 아닙니다. 앱이 데드락에 빠졌거나, DB 연결이 끊어졌거나, 아직 시작이 안 끝났을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠버네티스는 세 가지 Probe를 제공합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;livenessProbe &amp;mdash; &quot;살아 있는가?&quot; 실패하면 컨테이너를 재시작합니다.&lt;/li&gt;
&lt;li&gt;readinessProbe &amp;mdash; &quot;트래픽을 받을 준비가 됐는가?&quot; 실패하면 Service 엔드포인트에서 빼서 트래픽을 차단합니다. 컨테이너를 재시작하진 않습니다.&lt;/li&gt;
&lt;li&gt;startupProbe &amp;mdash; &quot;시작이 끝났는가?&quot; 이게 성공할 때까지 liveness와 readiness를 비활성화합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;containers:
- name: app
  image: myapp:v1
  ports:
  - containerPort: 8080

  startupProbe:                # 시작 완료 대기 (최대 300초)
    httpGet:
      path: /healthz
      port: 8080
    failureThreshold: 30
    periodSeconds: 10

  livenessProbe:               # 살아 있는지 감시
    httpGet:
      path: /healthz
      port: 8080
    periodSeconds: 10
    failureThreshold: 3

  readinessProbe:              # 트래픽을 받을 준비가 됐는지
    httpGet:
      path: /ready
      port: 8080
    periodSeconds: 5
    failureThreshold: 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 /healthz와 /ready를 왜 분리하는지 헷갈렸습니다. 생각해보면 DB 연결이 끊어졌을 때, 앱 자체는 살아 있으니 재시작할 필요가 없고(livenessProbe 성공), 대신 요청을 처리할 수 없으니 트래픽만 차단하면 됩니다(readinessProbe 실패).&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;주의&lt;/b&gt;: livenessProbe에서 외부 의존성(DB, 외부 API)을 체크하면 안 됩니다. 외부가 죽었다고 앱을 재시작해봤자 외부가 복구되지 않고, 오히려 모든 Pod가 동시에 재시작되면서 장애가 확산됩니다. 외부 의존성은 readinessProbe에서 체크합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⚠️ &lt;b&gt;주의&lt;/b&gt;: JVM이나 대용량 데이터 로딩 같이 시작이 느린 앱에 startupProbe 없이 livenessProbe만 설정하면 &quot;아직 뜨는 중인데 죽었다고 판단 &amp;rarr; 재시작 &amp;rarr; 또 안 떠서 재시작&quot; 무한 루프에 빠집니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Init Container &amp;amp; Sidecar &amp;mdash; 멀티 컨테이너 패턴&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pod에 컨테이너가 하나만 들어가는 건 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 앱 시작 전에 사전 작업을 하거나, 메인 앱 옆에서 보조 기능을 수행하는 컨테이너를 넣을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Init Container &amp;mdash; 시작 전 준비&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Init Container는 메인 컨테이너보다 먼저 실행되고, 성공하면 종료됩니다. 여러 개를 정의하면 순서대로 하나씩 실행됩니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;spec:
  initContainers:
  - name: wait-for-db
    image: busybox
    command: ['sh', '-c', 'until nc -z db-svc 5432; do sleep 2; done']
  - name: run-migrations
    image: myapp-migrations:v1
    command: ['python', 'manage.py', 'migrate']
  containers:
  - name: app
    image: myapp:v1
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB가 준비될 때까지 기다리고, 마이그레이션을 실행하고, 그 다음에야 메인 앱이 뜹니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;네이티브 Sidecar&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사이드카는 메인 앱과 함께 계속 돌면서 보조 기능(로그 수집, 프록시 등)을 수행합니다. &lt;br /&gt;v1.33부터 initContainers에 restartPolicy: Always를 설정하는 방식으로 공식 지원됩니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;spec:
  initContainers:
  - name: log-collector
    image: fluentbit:latest
    restartPolicy: Always        # 사이드카의 핵심. 종료 안 하고 계속 실행
    volumeMounts:
    - name: logs
      mountPath: /var/log/app
  containers:
  - name: app
    image: myapp:v1
    volumeMounts:
    - name: logs
      mountPath: /var/log/app
  volumes:
  - name: logs
    emptyDir: {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네이티브 사이드카가 나오기 전에는 일반 containers에 사이드카를 넣었는데, 이러면 Job/CronJob에서 문제가 생겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 컨테이너가 끝나도 사이드카가 살아 있어서 Job이 완료 처리가 안 되는 거죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네이티브 사이드카는 Job 완료 판정에서 제외되어 이 문제가 해결됐습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;워크로드 선택 가이드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 리소스를 써야 할지 한눈에 보이도록 정리했습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;리소스&lt;/td&gt;
&lt;td&gt;언제 쓰는가&lt;/td&gt;
&lt;td&gt;핵심 특징&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;td&gt;Stateless 앱 (웹 서버, API)&lt;/td&gt;
&lt;td&gt;롤링 업데이트, 롤백&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StatefulSet&lt;/td&gt;
&lt;td&gt;Stateful 앱 (DB, 메시지 큐)&lt;/td&gt;
&lt;td&gt;고정 이름, 안정적 스토리지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DaemonSet&lt;/td&gt;
&lt;td&gt;노드당 1개 (로그, 모니터링)&lt;/td&gt;
&lt;td&gt;노드 추가 시 자동 배포&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Job&lt;/td&gt;
&lt;td&gt;일회성 배치 작업&lt;/td&gt;
&lt;td&gt;완료 후 종료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CronJob&lt;/td&gt;
&lt;td&gt;정기 반복 작업&lt;/td&gt;
&lt;td&gt;cron 스케줄로 Job 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Deployment가 모든 걸 커버할 수 없기 때문에, 상황에 따라 알맞는 서비스를 사용할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상태가 필요하면 StatefulSet, 노드당 하나면 DaemonSet,&lt;/li&gt;
&lt;li&gt;한 번 돌고 끝이면 Job/CronJob.&lt;/li&gt;
&lt;li&gt;Probe로 컨테이너 상태를 감시하되 liveness에는 외부 의존성을 넣지 않는 게 핵심이고,&lt;/li&gt;
&lt;li&gt;멀티 컨테이너가 필요하면 Init Container와 네이티브 Sidecar를 활용합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 이 Pod들이 서로, 그리고 외부와 어떻게 통신하는지 &amp;mdash; Service, Ingress, NetworkPolicy를 다루겠습니다.&lt;/p&gt;</description>
      <category>DevOps/kubernetes</category>
      <category>cronjob</category>
      <category>daemonset</category>
      <category>DevOps</category>
      <category>Job</category>
      <category>K8S</category>
      <category>Kubernetes</category>
      <category>probe</category>
      <category>Sidecar</category>
      <category>statefulset</category>
      <category>학습 기록</category>
      <author>okbear3</author>
      <guid isPermaLink="true">https://okbear3.tistory.com/98</guid>
      <comments>https://okbear3.tistory.com/98#entry98comment</comments>
      <pubDate>Sat, 30 May 2026 01:15:21 +0900</pubDate>
    </item>
    <item>
      <title>3. Pod에서 Deployment 까지</title>
      <link>https://okbear3.tistory.com/97</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;앞 글에서 쿠버네티스의 클러스터 아키텍처를 정리했습니다. 컨트롤 플레인과 워커 노드가 어떻게 역할을 나누는지는 이해했는데, 정작 &quot;그래서 내 애플리케이션은 어떻게 올리지?&quot;하는 궁금증이 생겼습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이번 글에서는 쿠버네티스에서 앱을 실행하는 기본 단위인 Pod부터 시작해서, ReplicaSet을 거쳐 Deployment까지 왜 이런 계층이 필요한지를 단계적으로 정리합니다. Labels와 Selectors가 이 리소스들을 어떻게 연결하는지도 함께 다룹니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Pod &amp;mdash; 쿠버네티스의 최소 실행 단위&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Docker에서는 컨테이너가 기본 단위였는데, 쿠버네티스에서는 Pod가 그 역할을 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Pod는 하나 이상의 컨테이너를 감싸는 래퍼(wrapper)인데, 같은 Pod 안의 컨테이너들은 네트워크(IP와 포트)와 스토리지 볼륨을 공유합니다. 대부분의 경우 Pod 하나에 컨테이너 하나가 들어갑니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;간단한 Pod 하나를 띄워보면 이렇습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;apiVersion: v1
kind: Pod
metadata:
  name: nginx-pod
  labels:
    app: nginx
spec:
  containers:
  - name: nginx
    image: nginx:1.27
    ports:
    - containerPort: 80
    resources:
      requests:
        cpu: &quot;100m&quot;
        memory: &quot;64Mi&quot;
      limits:
        cpu: &quot;200m&quot;
        memory: &quot;128Mi&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;maxima&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;kubectl apply -f pod.yaml
kubectl get pods
kubectl describe pod nginx-pod
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;여기서 resources.requests와 limits를 설정한 이유가 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;requests는 스케줄러가 &quot;이 Pod를 어느 노드에 배치할지&quot; 판단할 때 기준으로 쓰고, limits는 런타임에 컨테이너가 쓸 수 있는 자원의 상한선입니다. CPU limits를 초과하면 스로틀링(느려짐), 메모리 limits를 초과하면 OOMKilled(강제 종료)가 발생합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 차이는 나중에 리소스 관리 글에서 다시 다룰 예정입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Pod를 직접 쓰면 안 되는 이유&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음 공부할 때 &quot;Pod를 만들면 앱이 뜨니까 이걸로 충분하지 않나?&quot; 싶었는데, 셀프힐링이 되지 않는 문제가 있었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Pod를 직접 만들면 노드가 죽거나 Pod가 비정상 종료됐을 때 아무도 다시 띄워주지 않습니다. 쿠버네티스의 셀프힐링이 동작하려면 &quot;이 Pod를 몇 개 유지해라&quot;고 선언하는 상위 리소스가 필요합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 Pod를 직접 만드는 건 디버깅이나 일회성 테스트 정도에만 쓰고, 프로덕션 워크로드는 Deployment 같은 상위 리소스를 통해 관리합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Pod 생명주기와 디버깅&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Pod의 상태(phase)는 이런 흐름을 따릅니다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;Pending &amp;rarr; Running &amp;rarr; Succeeded / Failed
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Pending이면 스케줄링 대기 중이거나 이미지를 받고 있는 것이고, Running이면 컨테이너가 실행 중인 상태입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;다만 kubectl get pods에서 STATUS 컬럼에 보이는 ImagePullBackOff, CrashLoopBackOff, ContainerCreating 같은 값들은 엄밀히는 phase가 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;컨테이너의 상태(reason)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 둘이 같은 컬럼에 섞여 나오기 때문에 헷갈리기 쉬운데, phase는 Pod 전체의 거시적 상태고, reason은 컨테이너 레벨의 세부 상태라고 구분해두면 트러블슈팅할 때 편합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;문제가 생겼을 때 쓰는 명령 몇 가지를 적어둡니다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;# Pod 내부 쉘 접속
kubectl exec -it &amp;lt;pod-name&amp;gt; -- /bin/sh

# 로그 확인
kubectl logs &amp;lt;pod-name&amp;gt;
kubectl logs &amp;lt;pod-name&amp;gt; --previous   # 재시작 전 로그

# 일회용 디버그 Pod
kubectl run debug --image=curlimages/curl -it --rm -- sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;--previous 옵션은 컨테이너가 CrashLoopBackOff로 재시작됐을 때 이전 로그를 볼 수 있어서 꽤 유용합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Labels와 Selectors &amp;mdash; 리소스를 식별하고 연결&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Pod에서 Deployment로 넘어가기 전에 Labels와 Selectors를 먼저 짚어야 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;쿠버네티스에서 리소스 간 연결은 거의 다 이걸로 동작하기 떄문입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Label&lt;/b&gt;&lt;br /&gt;리소스에 붙이는 키-값 쌍입니다.&amp;nbsp; app: web, env: production 같은 식으로 태그를 다는 거죠.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Selector&lt;/b&gt;&lt;br /&gt;Label을 기준으로 리소스를 골라내는 필터입니다. Service가 &quot;app=web인 Pod에 트래픽을 보내라&quot;, Deployment가 &quot;app=web인 Pod를 관리하라&quot; &amp;mdash; 이런 연결이 전부 Selector로 이루어집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;# 특정 Label이 있는 Pod만 조회
kubectl get pods -l app=web

# Label 추가
kubectl label pod nginx-pod env=production

# Label 삭제 (키 뒤에 - 붙임)
kubectl label pod nginx-pod env-
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;여기서 주의할 점이 있는데, 실행 중인 Pod에서 Label을 빼면 즉시 영향이 갑니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 app=web Label을 제거하면, 그 Pod는 Service 엔드포인트에서 빠지고 ReplicaSet 관리 대상에서도 벗어납니다. ReplicaSet은 &quot;Pod가 하나 줄었다&quot;고 판단해서 새 Pod를 만들게 됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Annotation은 Label과 비슷하게 키-값 쌍이지만, Selector로 선택할 수 없고 단순히 정보를 저장하는 용도입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;빌드 커밋 해시나 Prometheus 스크래핑 설정 같은 걸 넣습니다. &quot;이 값으로 리소스를 필터링할 일이 있나?&quot; &amp;mdash; 있으면 Label, 없으면 Annotation이라고 생각하면 됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;ReplicaSet &amp;mdash; Pod 수를 유지하는 컨트롤러&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;ReplicaSet은 &quot;이 Pod를 n개 유지해라&quot;라는 선언을 이행하는 컨트롤러입니다. Pod가 삭제되면 즉시 새로 만들고, 초과되면 지웁니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: web-rs
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web          # 이 Label을 가진 Pod를 관리
  template:
    metadata:
      labels:
        app: web        # selector와 반드시 일치해야 함
    spec:
      containers:
      - name: nginx
        image: nginx:1.27
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;# Pod 하나를 강제로 지우면
kubectl delete pod &amp;lt;pod-name&amp;gt;

# 즉시 새 Pod가 생성되는 걸 확인
kubectl get pods -l app=web -w
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;앞 글에서 셀프힐링의 정체가 컨트롤러의 루프라고 했는데, ReplicaSet이 바로 그 대표적인 예시입니다. ReplicaSet Controller가 현재 Pod 수와 원하는 수를 비교하고, 차이가 있으면 맞춰줍니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그런데 ReplicaSet에는 치명적인 한계가 있습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;이미지를 업데이트해도 기존 Pod가 교체되지 않습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;template의 이미지를 nginx:1.28로 바꿔도 이미 돌고 있는 Pod는 nginx:1.27 그대로입니다. 새 이미지를 적용하려면 기존 Pod를 하나하나 수동으로 지워야 합니다.&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666; text-align: left;&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-ke-size=&quot;size16&quot;&gt;⚠️ 주의: ReplicaSet의 selector는 생성 후 변경할 수 없습니다(immutable). selector를 바꾸려면 삭제하고 다시 만들어야 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하는 게 바로 Deployment입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Deployment &amp;mdash; 실무에서 쓰는 배포 단위&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Deployment는 ReplicaSet 위에 롤링 업데이트와 롤백 기능을 얹은 리소스입니다. 이미지를 변경하면 새 ReplicaSet을 만들어서 점진적으로 Pod를 교체하고, 문제가 생기면 이전 ReplicaSet으로 되돌릴 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;계층 구조는 이렇습니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;Deployment: web-app
├── ReplicaSet (현재, hash:xyz) &amp;mdash; replicas: 3
│   ├── Pod 1
│   ├── Pod 2
│   └── Pod 3
└── ReplicaSet (이전, hash:abc) &amp;mdash; replicas: 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Deployment가 직접 Pod를 관리하는 게 아니라, ReplicaSet을 통해 간접 관리합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;업데이트가 발생하면 새 ReplicaSet을 만들고, 이전 ReplicaSet은 replicas: 0으로 유지해서 롤백에 대비합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기본 매니페스트&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web-app         # 불변. 생성 후 변경 불가
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # 업데이트 중 초과 허용 Pod 수
      maxUnavailable: 0    # 0이면 항상 3개는 유지
  template:
    metadata:
      labels:
        app: web-app       # selector와 일치 필수
    spec:
      containers:
      - name: app
        image: myapp:v1
        ports:
        - containerPort: 8080
        resources:
          requests:
            cpu: &quot;100m&quot;
            memory: &quot;128Mi&quot;
          limits:
            cpu: &quot;500m&quot;
            memory: &quot;256Mi&quot;
        readinessProbe:
          httpGet:
            path: /ready
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote style=&quot;color: #666666; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-ke-size=&quot;size16&quot;&gt;⚠️&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;주의&lt;/b&gt;: spec.selector는 불변(immutable)입니다. 생성 후 변경할 수 없고, template.metadata.labels는 이 selector를 반드시 포함해야 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;롤링 업데이트와 롤백&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이미지를 변경하면 롤링 업데이트가 자동으로 진행됩니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;# 이미지 업데이트
kubectl set image deployment/web-app app=myapp:v2

# 롤아웃 진행 상태 확인
kubectl rollout status deployment/web-app

# ReplicaSet 히스토리 확인 &amp;mdash; 구 RS는 replicas=0
kubectl get replicasets -l app=web-app
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;문제가 생기면 아래의 명령어로 롤백을 진행할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;# 히스토리 확인
kubectl rollout history deployment/web-app

# 직전 버전으로 롤백
kubectl rollout undo deployment/web-app

# 특정 리비전으로 롤백
kubectl rollout undo deployment/web-app --to-revision=2
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;rollout undo를 치면 이전 ReplicaSet의 replicas를 다시 올리고 현재 ReplicaSet을 줄이는 방식으로 동작합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이전 ReplicaSet이 남아 있기 때문에 가능한 건데, 이걸 몇 개까지 보관할지가 revisionHistoryLimit(기본값 10)입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;너무 낮게 잡으면 오래된 버전으로 롤백이 안 되니 기본값을 유지하는 게 좋습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;업데이트 전략 &amp;mdash; RollingUpdate vs Recreate&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;RollingUpdate&lt;/b&gt;(기본값)는 이전 Pod를 하나씩 교체합니다.&lt;br /&gt;maxUnavailable: 0, maxSurge: 1로 설정하면 항상 원하는 수 이상의 Pod가 떠 있으므로 무중단 배포가 됩니다.&lt;br /&gt;&lt;br /&gt;단, 이게 진짜 &quot;무중단&quot;이 되려면 아래의 세 가지 조건이 갖춰져야 합니다.&lt;br /&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;readinessProbe가 설정되어 있을 것 (새 Pod가 준비 완료됐는지 확인)&lt;/li&gt;
&lt;li&gt;앱이 graceful shutdown을 처리할 것 (SIGTERM 받으면 진행 중 요청을 마무리)&lt;/li&gt;
&lt;li&gt;maxUnavailable이 0일 것&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Recreate&lt;/b&gt;는 기존 Pod를 전부 삭제하고 새로 만듭니다. 다운타임이 생기지만, 구버전과 신버전이 동시에 떠 있으면 안 되는 경우(DB 스키마 마이그레이션 등)에 씁니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;일시 중지와 재개&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;여러 변경을 한꺼번에 적용하고 싶을 때 유용합니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;# 업데이트 일시 중지
kubectl rollout pause deployment/web-app

# 변경 여러 개 적용 (아직 반영 안 됨)
kubectl set image deployment/web-app app=myapp:v3
kubectl set resources deployment/web-app -c=app --limits=cpu=1,memory=512Mi

# 재개 &amp;mdash; 위 변경이 한 번에 롤아웃
kubectl rollout resume deployment/web-app
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;스케일 조정&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;# 수동 스케일
kubectl scale deployment/web-app --replicas=5

# 오토스케일러 연동 (HPA &amp;mdash; 별도 글에서 다룸)
kubectl autoscale deployment/web-app --min=2 --max=10 --cpu-percent=70
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Pod &amp;rarr; ReplicaSet &amp;rarr; Deployment 계층이 필요한 이유&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 &quot;왜 이렇게 복잡하게 관리하는걸까?&quot; 하는 생각이 들었는데,&lt;span&gt;&amp;nbsp;&lt;/span&gt;정리해보니 각 계층이 명확한 역할을 하고 있었습니다.&lt;/p&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-style=&quot;style1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;계층&lt;/td&gt;
&lt;td&gt;역할&lt;/td&gt;
&lt;td&gt;한계&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pod&lt;/td&gt;
&lt;td&gt;컨테이너 실행의 최소 단위&lt;/td&gt;
&lt;td&gt;셀프힐링 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReplicaSet&lt;/td&gt;
&lt;td&gt;Pod 수 유지 (셀프힐링)&lt;/td&gt;
&lt;td&gt;이미지 업데이트 시 자동 교체 안 됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;td&gt;롤링 업데이트 + 롤백&lt;/td&gt;
&lt;td&gt;&amp;mdash; (실무 표준)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;각 계층의 한계가 다음 계층이 존재하는 이유입니다. Pod만으로는 복구가 안 되고, ReplicaSet만으로는 업데이트가 불편하고, 그래서 Deployment가 나온 거죠.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트러블슈팅 팁 :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;b&gt;롤아웃이 멈췄을 때&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;stata&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;kubectl rollout status deployment/web-app
kubectl describe deployment web-app
kubectl get pods -l app=web-app
kubectl logs &amp;lt;새-pod-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;새 Pod가 Ready 상태가 안 되면 롤아웃이 진행되지 않습니다. 이미지 태그 오타, readinessProbe 실패, 리소스 부족이 흔한 원인입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Pod가 ImagePullBackOff인 경우&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이미지 이름이나 태그를 잘못 적었거나, 프라이빗 레지스트리 인증이 안 된 경우입니다. kubectl describe pod &amp;lt;name&amp;gt;의 Events 섹션에서 정확한 원인이 나옵니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;정리&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Pod는 최소 실행 단위이지만 셀프힐링이 없기 때문에 직접 쓰지 않고,&lt;/li&gt;
&lt;li&gt;ReplicaSet이 Pod 수를 유지해주고,&lt;/li&gt;
&lt;li&gt;Deployment가 ReplicaSet 위에서 롤링 업데이트와 롤백을 관리합니다.&lt;/li&gt;
&lt;li&gt;Labels와 Selectors가 이 리소스들을 연결하는 핵심 메커니즘입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;다음 글에서는 StatefulSet, DaemonSet, Job, CronJob 등 용도별 워크로드 리소스를 다루겠습니다.&lt;/p&gt;</description>
      <category>DevOps/kubernetes</category>
      <category>Cluster</category>
      <category>docker</category>
      <category>K8S</category>
      <category>kube-proxy</category>
      <category>kubelet</category>
      <category>Kubernetes</category>
      <category>기록</category>
      <author>okbear3</author>
      <guid isPermaLink="true">https://okbear3.tistory.com/97</guid>
      <comments>https://okbear3.tistory.com/97#entry97comment</comments>
      <pubDate>Sat, 16 May 2026 01:10:24 +0900</pubDate>
    </item>
    <item>
      <title>2. Kubernetes 클러스터 아키텍처</title>
      <link>https://okbear3.tistory.com/95</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;앞 글에서 쿠버네티스의 핵심 기능으로 스케줄링, 셀프힐링, 오토스케일링을 꼽았는데, 정작 이걸 누가 어떻게 수행하는지는 짚지 않았습니다. 트러블슈팅이나 클러스터 설계 단계에서 막히지 않으려면 내부 구조를 알아둘 필요가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 쿠버네티스 클러스터의 전체 구조와 각 컴포넌트의 역할을 정리합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 id=&quot;클러스터의-두-영역--컨트롤-플레인과-워커-노드&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;14&quot; data-ke-size=&quot;size23&quot;&gt;클러스터의 두 영역 &amp;mdash; 컨트롤 플레인과 워커 노드&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;쿠버네티스 클러스터는 크게&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;컨트롤 플레인(Control Plane)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;과&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;워커 노드(Worker Node)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 나뉩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;컨트롤 플레인은 클러스터의 두뇌입니다. &quot;Pod를 어디에 띄울지&quot;, &quot;지금 상태가 원하는 상태와 맞는지&quot;, &quot;새 요청이 들어왔는데 권한은 있는지&quot; 같은 판단을 내리는 곳이죠. 워커 노드는 실제 작업이 수행되는 곳입니다.. 컨트롤 플레인이 &quot;여기서 이걸 실행해&quot;라고 지시하면, 워커 노드가 컨테이너를 띄우고 관리합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1367&quot; data-origin-height=&quot;445&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bg74OR/dJMcahLqAt1/MP9OtZsaRG2vOiPCrc0NG1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bg74OR/dJMcahLqAt1/MP9OtZsaRG2vOiPCrc0NG1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bg74OR/dJMcahLqAt1/MP9OtZsaRG2vOiPCrc0NG1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbg74OR%2FdJMcahLqAt1%2FMP9OtZsaRG2vOiPCrc0NG1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1367&quot; height=&quot;445&quot; data-origin-width=&quot;1367&quot; data-origin-height=&quot;445&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;이 그림에서 한 가지 포인트가 있습니다. 모든 화살표가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;kube-apiserver&lt;/b&gt;를 중심으로 연결되어 있다는 점입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;사용자(kubectl)도, 내부 컴포넌트(스케줄러, 컨트롤러)도, 워커 노드(kubelet)도 전부 API 서버를 거쳐 통신합니다. 컴포넌트끼리 직접 통신하는 경우는 없습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;처음엔 &quot;비효율적이지 않나?&quot; 싶었는데, 이렇게 하면 모든 접근을 한 곳에서 인증&amp;middot;인가&amp;middot;감사할 수 있어서 보안과 일관성 면에서 훨씬 낫다고 합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 id=&quot;컨트롤-플레인-컴포넌트&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;53&quot; data-ke-size=&quot;size23&quot;&gt;Control-Plane 컴포넌트&lt;/h3&gt;
&lt;h4 id=&quot;kube-apiserver--모든-요청이-지나가는-관문&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;55&quot; data-ke-size=&quot;size20&quot;&gt;kube-apiserver &amp;mdash; 모든 요청이 지나가는 관문&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;57&quot; data-ke-size=&quot;size16&quot;&gt;kube-apiserver는 클러스터의 &quot;현관문&quot;입니다. kubectl apply -f deployment.yaml을 치면 그 요청이 가장 먼저 도착하는 곳이 바로 여기입니다. &lt;b&gt;그리고 이 현관문은 단순히 요청을 통과시키는 게 아니라, 4단계 검문을 거칩니다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;57&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;59&quot; data-ke-size=&quot;size16&quot;&gt;API 서버가 요청을 처리하는 순서는 이렇습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #333333; text-align: start;&quot; data-line=&quot;61&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;61&quot;&gt;&lt;b&gt;인증(Authentication)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 요청한 사람이 누구인지 확인합니다. 인증서, 토큰, OIDC 등 여러 방식을 지원합니다.&lt;/li&gt;
&lt;li data-line=&quot;62&quot;&gt;&lt;b&gt;인가(Authorization)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 그 사람이 이 작업을 할 권한이 있는지 RBAC 정책으로 확인합니다.&lt;/li&gt;
&lt;li data-line=&quot;63&quot;&gt;&lt;b&gt;어드미션 컨트롤(Admission Control)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 요청 내용을 최종 검증하거나 수정합니다. &quot;CPU limits가 없으면 거부한다&quot; 같은 정책을 여기서 적용합니다.&lt;/li&gt;
&lt;li data-line=&quot;64&quot;&gt;&lt;b&gt;etcd에 저장&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 통과한 요청을 etcd에 기록합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;66&quot; data-ke-size=&quot;size16&quot;&gt;중요한 건 API 서버 자체는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;상태를 저장하지 않는다(stateless)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;는 점입니다. 모든 데이터는 etcd에 있고, API 서버는 요청을 받아서 검증하고 etcd에 넘기는 중계자 역할만 합니다. 그래서 API 서버를 여러 대 띄워서 로드밸런서 뒤에 두는 수평 확장이 가능합니다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;# API 서버 주소 확인
kubectl cluster-info

# 사용 가능한 API 리소스 목록
kubectl api-resources
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;76&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h4 id=&quot;etcd--클러스터의-기억-장치&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;76&quot; data-ke-size=&quot;size20&quot;&gt;etcd &amp;mdash; 클러스터의 기억 장치&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;78&quot; data-ke-size=&quot;size16&quot;&gt;etcd는 클러스터의 모든 상태를 저장하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;분산 키-값 스토어&lt;/b&gt;입니다. Pod 목록, Service 정보, ConfigMap, Secret, 노드 상태까지 전부 여기에 들어 있습니다. etcd 데이터가 날아가면 클러스터는 말 그대로 기억상실 상태가 됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;80&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;80&quot; data-ke-size=&quot;size16&quot;&gt;공식 문서를 찾아보니, etcd는 Raft라는 합의 알고리즘으로 여러 노드 간 데이터 일관성을 유지합니다. 과반수(quorum)가 살아 있어야 쓰기가 가능하므로, 프로덕션에서는 홀수 노드로 구성하는 게 기본입니다.&lt;/p&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%; height: 71px;&quot; border=&quot;1&quot; data-line=&quot;82&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;노드 수&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;허용 장애 노드 수&lt;/td&gt;
&lt;td style=&quot;height: 17px;&quot;&gt;용도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot; data-line=&quot;84&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;0&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;개발&amp;middot;테스트 전용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot; data-line=&quot;85&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;소규모 프로덕션&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot; data-line=&quot;86&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;5&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;일반 프로덕션 권장&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;88&quot; data-ke-size=&quot;size16&quot;&gt;4대를 넣으면 허용 장애 수가 3대일 때와 동일하기 때문에 홀수로 구성하는 것이 권장됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;88&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;88&quot; data-ke-size=&quot;size16&quot;&gt;etcd에는 kube-apiserver만 직접 접근할 수 있고, 다른 컴포넌트가 etcd에 직접 읽고 쓰는 일은 없습니다. 이 덕분에 접근 제어와 감사 로그를 API 서버 한 곳에서 관리할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;90&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;92&quot; data-ke-size=&quot;size16&quot;&gt;프로덕션 운영에서 가장 중요한 건&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;정기 백업&lt;/b&gt;입니다. etcd가 죽으면 클러스터 전체가 멈추기 때문입니다.&lt;/p&gt;
&lt;pre class=&quot;haml&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;# etcd 스냅샷 백업
ETCDCTL_API=3 etcdctl snapshot save /backup/etcd-snapshot.db \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-line=&quot;103&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p data-line=&quot;103&quot; data-ke-size=&quot;size16&quot;&gt;⚠️&amp;nbsp;주의:&amp;nbsp;etcd는 쓰기마다 디스크에 즉시 반영(fsync)합니다. 디스크 지연이 크면 리더 선출 타임아웃이 발생할 수 있어서, SSD 사용이 사실상 필수입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;105&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h4 id=&quot;kube-scheduler--pod를-어느-노드에-띄울지-결정&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;105&quot; data-ke-size=&quot;size20&quot;&gt;kube-scheduler &amp;mdash; Pod를 어느 노드에 띄울지 결정&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;107&quot; data-ke-size=&quot;size16&quot;&gt;Pod를 만들면 처음에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;spec.nodeName이 비어 있는 상태로 etcd에 저장됩니다. kube-scheduler는 이런 미배치 Pod를 감지하고, &quot;어떤 노드에서 실행하면 좋겠다&quot;를 결정합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;107&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;109&quot; data-ke-size=&quot;size16&quot;&gt;결정 과정은 두 단계입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;111&quot;&gt;&lt;b&gt;Filtering&lt;/b&gt;&lt;br /&gt;먼저 실행이 불가능한 노드를 걸러냅니다. CPU/메모리가 부족한 노드, 노드셀렉터와 일치하지 않는 노드, Taint 때문에 접근할 수 없는 노드가 여기서 탈락합니다.&lt;/li&gt;
&lt;li data-line=&quot;113&quot;&gt;&lt;b&gt;Scoring&lt;/b&gt;&lt;br /&gt;남은 노드에 점수를 매겨서 가장 높은 노드를 선택합니다. 리소스가 얼마나 균등하게 분배되는지, 이미지가 이미 캐시되어 있는지 같은 요소가 점수에 반영됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;115&quot; data-ke-size=&quot;size16&quot;&gt;스케줄러가 결정하면 해당 Pod의&lt;span&gt;&amp;nbsp;&lt;/span&gt;spec.nodeName에 노드를 기록하고, 그 노드의 kubelet이 이를 감지해서 컨테이너를 실행합니다. 스케줄러가 직접 컨테이너를 띄우는 건 아닙니다. &quot;어디서 실행할지&quot;만 정하는 거죠.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;117&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;117&quot; data-ke-size=&quot;size16&quot;&gt;Pod가 Pending 상태로 오래 머무르면 스케줄러가 배치할 노드를 못 찾은 겁니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;# Pending 원인 확인
kubectl describe pod &amp;lt;pod-name&amp;gt;
# Events 섹션에 &quot;0/3 nodes are available: ...&quot; 같은 메시지가 나옵니다
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;125&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h4 id=&quot;kube-controller-manager--선언적-상태를-유지하는-실행자&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;125&quot; data-ke-size=&quot;size20&quot;&gt;kube-controller-manager &amp;mdash; 선언적 상태를 유지하는 관리자&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;127&quot; data-ke-size=&quot;size16&quot;&gt;앞 글에서 쿠버네티스의 핵심이 &quot;선언적 관리&quot;라고 했는데, 그 선언을 실제로 이행하는 게 바로 kube-controller-manager입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;129&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;129&quot; data-ke-size=&quot;size16&quot;&gt;내부에는 수십 개의 컨트롤러가 들어 있고, 각각 같은 패턴으로 동작합니다. 현재 상태를 관찰하고, 원하는 상태와 비교해서, 차이가 있으면 조치를 취합니다. 이걸&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;컨트롤 루프(reconciliation loop)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;라고 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;131&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어 ReplicaSet Controller는 &quot;Pod 3개를 유지해라&quot;라는 선언이 있으면, 현재 Pod 수를 확인하고, 2개밖에 없으면 1개를 더 만들고, 4개가 있으면 1개를 지웁니다. &lt;span data-newtext-seq=&quot;2&quot;&gt;이걸 &lt;/span&gt;&lt;span data-newtext-seq=&quot;5&quot;&gt;이벤트가 발생할 &lt;/span&gt;&lt;span data-newtext-seq=&quot;14&quot;&gt;때마다, 그리고 &lt;/span&gt;&lt;span data-newtext-seq=&quot;23&quot;&gt;주기적으로 &lt;/span&gt;&lt;span data-newtext-seq=&quot;29&quot;&gt;반복합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;133&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;133&quot; data-ke-size=&quot;size16&quot;&gt;주요 컨트롤러 몇 개만 짚어보면 이렇습니다.&lt;/p&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%; height: 108px;&quot; border=&quot;1&quot; data-line=&quot;135&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;컨트롤러&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;하는 일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot; data-line=&quot;137&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;ReplicaSet Controller&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;지정된 수의 Pod를 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot; data-line=&quot;138&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;Deployment Controller&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;롤링 업데이트와 롤백 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot; data-line=&quot;139&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;Node Controller&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;노드 상태 모니터링, 응답 없으면 NotReady 표시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot; data-line=&quot;140&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;Job Controller&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;일회성 작업 완료까지 Pod 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot; data-line=&quot;141&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;EndpointSlice Controller&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;Service에 연결된 Pod IP 목록 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;# Pod 하나를 강제로 지워보면 컨트롤러가 바로 새 걸 만드는 걸 확인할 수 있습니다
kubectl create deployment web --image=nginx --replicas=3
kubectl delete pod &amp;lt;pod-name&amp;gt;
kubectl get pods -w   # 새 Pod가 즉시 생성되는 걸 볼 수 있음
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;150&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;150&quot; data-ke-size=&quot;size16&quot;&gt;이게 셀프힐링의 원리입니다. 마법처럼 보이지만 실은 컨트롤러가 루프를 돌면서 차이를 감지하고 자동으로 조치하고 있는 것 뿐입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 id=&quot;워커-노드-컴포넌트&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;152&quot; data-ke-size=&quot;size23&quot;&gt;Worker Node 컴포넌트&lt;/h3&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;154&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 id=&quot;kubelet--노드의-pod-관리자&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;154&quot; data-ke-size=&quot;size20&quot;&gt;kubelet &amp;mdash; 노드의 Pod 관리자&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;156&quot; data-ke-size=&quot;size16&quot;&gt;kubelet은 각 워커 노드에서 돌아가는 에이전트입니다. 컨트롤 플레인이 &quot;이 노드에서 이 Pod를 실행해&quot;라고 결정하면, kubelet이 그 지시를 받아서 컨테이너 런타임(containerd 등)에 실제 컨테이너 실행을 요청합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;158&quot; data-ke-size=&quot;size16&quot;&gt;다른 컴포넌트는 Pod 안에서 돌아가는 것도 있는데, kubelet만은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;systemd 데몬으로 노드에 직접 실행&lt;/b&gt;됩니다. 컨테이너를 관리하는 주체가 자기 자신도 컨테이너면 순환 참조가 되니까요.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;160&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;160&quot; data-ke-size=&quot;size16&quot;&gt;kubelet이 하는 일은 크게 세 가지입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;162&quot;&gt;&lt;b&gt;Pod 실행&lt;/b&gt;&lt;br /&gt;API 서버에서 이 노드에 배치된 Pod 명세를 받아 컨테이너 런타임에 실행을 지시합니다.&lt;/li&gt;
&lt;li data-line=&quot;164&quot;&gt;&lt;b&gt;헬스 체크&lt;/b&gt;&lt;br /&gt;Probe를 통해 컨테이너가 살아 있는지(livenessProbe), 트래픽을 받을 준비가 됐는지(readinessProbe), 아직 시작 중인지(startupProbe)를 계속 확인합니다. livenessProbe가 실패하면 컨테이너를 재시작하고, readinessProbe가 실패하면 Service 엔드포인트에서 빼버립니다.&lt;/li&gt;
&lt;li data-line=&quot;166&quot;&gt;&lt;b&gt;상태 보고&lt;br /&gt;&lt;/b&gt;노드의 리소스 상황과 Pod 상태를 주기적으로 API 서버에 보고합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;properties&quot; style=&quot;background-color: #000000; color: #333333; text-align: start;&quot;&gt;&lt;code&gt;# 노드에 SSH 접속해서 kubelet 상태 확인
systemctl status kubelet
journalctl -u kubelet -f
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-line=&quot;174&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p data-line=&quot;174&quot; data-ke-size=&quot;size16&quot;&gt;⚠️&amp;nbsp;주의:&amp;nbsp;앱 시작 시간이 긴 경우(JVM, 모델 로딩 등) livenessProbe만 설정하면 시작 중에 실패로 판정되어 무한 재시작에 빠질 수 있습니다. startupProbe를 함께 설정해서 시작 완료를 기다린 뒤 livenessProbe가 동작하도록 구성해야 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;176&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h4 id=&quot;kube-proxy--service-트래픽을-pod로-전달&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;176&quot; data-ke-size=&quot;size20&quot;&gt;kube-proxy &amp;mdash; Service 트래픽을 Pod로 전달&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;178&quot; data-ke-size=&quot;size16&quot;&gt;Pod는 재시작될 때마다 IP가 바뀝니다. 그런데 다른 Pod나 외부에서 이 Pod에 안정적으로 접근하려면 고정된 주소가 필요하겠죠? 그래서 Service라는 추상화가 있고, kube-proxy가 그 Service의 고정 IP(ClusterIP)로 들어온 트래픽을 실제 Pod로 전달하는 네트워크 규칙을 관리합니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;180&quot; data-ke-size=&quot;size16&quot;&gt;여기서 오해하기 쉬운 게 있는데, kube-proxy가 트래픽을 직접 중계하는 건 아닙니다. kube-proxy는 iptables나 nftables&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;규칙을 작성&lt;/b&gt;하고, 실제 패킷 처리는 OS 커널이 합니다. 그래서 kube-proxy가 잠시 재시작돼도 이미 작성된 규칙은 유지되어 트래픽이 끊기지 않습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;180&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;182&quot; data-ke-size=&quot;size16&quot;&gt;v1.36 기준으로 동작 모드는 iptables와 nftables 두 가지입니다.&lt;/p&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;184&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;모드&lt;/td&gt;
&lt;td&gt;특징&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;186&quot;&gt;
&lt;td&gt;&lt;b&gt;iptables&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(기본값)&lt;/td&gt;
&lt;td&gt;단순하지만 Service가 수천 개를 넘으면 선형 탐색으로 인해 성능이 떨어짐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;187&quot;&gt;
&lt;td&gt;&lt;b&gt;nftables&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(v1.33 GA)&lt;/td&gt;
&lt;td&gt;iptables보다 성능이 우수. 대규모 클러스터에 권장 (kernel 5.13 이상 필요)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-line=&quot;189&quot; data-ke-style=&quot;style2&quot;&gt;IPVS 모드는 v1.35에서 deprecated된 후 v1.36에서 완전히 제거됐습니다.&lt;br /&gt;기존에 IPVS를 쓰고 있었다면 nftables로 마이그레이션해야 v1.36 이상으로 올릴 수 있습니다.&lt;/blockquote&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;189&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;container-runtime--실제로-컨테이너를-띄우는-엔진&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;191&quot; data-ke-size=&quot;size20&quot;&gt;Container Runtime &amp;mdash; 실제로 컨테이너를 띄우는 엔진&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;193&quot; data-ke-size=&quot;size16&quot;&gt;kubelet은 컨테이너를 직접 만들지 않습니다. CRI(Container Runtime Interface)라는 표준 인터페이스를 통해 컨테이너 런타임에 요청하는 방식입니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;193&quot; data-ke-size=&quot;size16&quot;&gt;대표적인 런타임은 containerd와 CRI-O가 있고, 대부분의 클러스터에서는 containerd를 씁니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;195&quot; data-ke-size=&quot;size16&quot;&gt;예전에는 Docker 자체가 런타임이었는데(dockershim), v1.24에서 제거됐습니다. Docker가 내부적으로 containerd를 쓰고 있어서, 중간 계층(dockershim)을 걷어내고 containerd에 직접 연결하는 게 효율적이었기 때문입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 id=&quot;pod가-생성되기까지의-전체-흐름&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;197&quot; data-ke-size=&quot;size23&quot;&gt;Pod가 생성되기까지의 전체 흐름&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;199&quot; data-ke-size=&quot;size16&quot;&gt;지금까지 다룬 컴포넌트가 어떻게 협력하는지,&lt;span&gt;&amp;nbsp;&lt;/span&gt;kubectl apply -f deployment.yaml을 쳤을 때의 흐름을 따라가 보겠습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #333333; text-align: start;&quot; data-line=&quot;201&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;201&quot;&gt;&lt;b&gt;kubectl&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; kube-apiserver에 Deployment 생성 요청 전송&lt;/li&gt;
&lt;li data-line=&quot;202&quot;&gt;&lt;b&gt;kube-apiserver&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; 인증&amp;middot;인가&amp;middot;어드미션 컨트롤 통과 &amp;rarr; etcd에 Deployment 저장&lt;/li&gt;
&lt;li data-line=&quot;203&quot;&gt;&lt;b&gt;Deployment Controller&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; 새 Deployment 감지 &amp;rarr; ReplicaSet 생성&lt;/li&gt;
&lt;li data-line=&quot;204&quot;&gt;&lt;b&gt;ReplicaSet Controller&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; ReplicaSet 감지 &amp;rarr; Pod 오브젝트 생성 (아직&lt;span&gt;&amp;nbsp;&lt;/span&gt;nodeName&lt;span&gt;&amp;nbsp;&lt;/span&gt;없음)&lt;/li&gt;
&lt;li data-line=&quot;205&quot;&gt;&lt;b&gt;kube-scheduler&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; 미배치 Pod 감지 &amp;rarr; 노드 선택 &amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;spec.nodeName&lt;span&gt;&amp;nbsp;&lt;/span&gt;기록&lt;/li&gt;
&lt;li data-line=&quot;206&quot;&gt;&lt;b&gt;kubelet&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(해당 노드) &amp;rarr; 자기 노드에 배치된 Pod 감지 &amp;rarr; 컨테이너 런타임에 컨테이너 실행 지시&lt;/li&gt;
&lt;li data-line=&quot;207&quot;&gt;&lt;b&gt;kube-proxy&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; Service가 있으면 해당 Pod로의 네트워크 규칙 업데이트&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;209&quot; data-ke-size=&quot;size16&quot;&gt;처음 이 흐름을 봤을 때, 단순히 &quot;Pod를 하나 띄운다&quot;는 것도 이렇게 여러 단계를 거치는구나 싶었습니다. 하지만 이 분리 덕분에 각 컴포넌트가 자기 역할만 하면 되고, 하나가 죽어도 나머지는 정상 동작합니다. 예를 들어 스케줄러가 잠시 죽어도 이미 배치된 Pod는 영향 없이 돌아가고, 새 Pod 배치만 지연될 뿐입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 id=&quot;정리&quot; style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;211&quot; data-ke-size=&quot;size23&quot;&gt;정리&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;213&quot; data-ke-size=&quot;size16&quot;&gt;쿠버네티스 클러스터는 컨트롤 플레인(판단)과 워커 노드(실행)로 나뉘고, 모든 통신은 kube-apiserver를 중심으로 이루어집니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-line=&quot;213&quot; data-ke-size=&quot;size16&quot;&gt;API 서버가 요청을 검증하고, etcd가 상태를 저장하고, 스케줄러가 배치를 결정하고, 컨트롤러 매니저가 선언적 상태를 유지하고, kubelet이 실제 컨테이너를 실행합니다.&lt;/p&gt;</description>
      <category>DevOps/kubernetes</category>
      <category>control-plane</category>
      <category>DevOps</category>
      <category>etcd</category>
      <category>K8S</category>
      <category>kube-controller-manager</category>
      <category>kube-scheduler</category>
      <category>Kubernetes</category>
      <category>쿠버네티스</category>
      <category>클러스터 아키텍처</category>
      <category>학습기록</category>
      <author>okbear3</author>
      <guid isPermaLink="true">https://okbear3.tistory.com/95</guid>
      <comments>https://okbear3.tistory.com/95#entry95comment</comments>
      <pubDate>Sat, 9 May 2026 00:58:43 +0900</pubDate>
    </item>
    <item>
      <title>3-2. ArgoCD로 GitOps 배포 파이프라인 구축하기</title>
      <link>https://okbear3.tistory.com/92</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글에서 Helm으로 ArgoCD를 설치하고, 그 과정을 Ansible로 자동화했습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &quot;엔터프라이즈 JVM 애플리케이션의 Cloud-Native 전환기&quot; 시리즈의 3편 후기입니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 설치한 ArgoCD를 이용하여 helm chart를 사용한 Jira를 배포하는 과정을 정리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;helm으로 ArgoCD를 설치한 방법을, 이번에는 Github에 설정 파일을 올리는 방식으로 ArgoCD가 대신 실행하게 됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ArgoCD란 무엇인가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD를 한 줄로 표현하면 &lt;b&gt;&quot;GitHub을 바라보는 K8s 전용 배포 자동화 도구&amp;rdquo;&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 기존에 쓰던 Jenkins 같은 CI/CD 도구와는 무엇이 다를까 궁금했는데, 차이는 배포 방향에 있었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;기존 방식 (Push 모델)&lt;/b&gt;은 외부 파이프라인(Jenkins)이 K8s 클러스터의 관리자 권한을 가져와서 직접 밀어 넣습니다. 클러스터 인증서가 외부 시스템에 노출된다는 보안 문제가 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ArgoCD 방식 (Pull 모델)&lt;/b&gt;은 ArgoCD가 K8s 클러스터 내부에 설치되어, 주기적으로 GitHub 저장소를 감시하다가 변경이 생기면 스스로 당겨와서 반영합니다. 클러스터 인증서가 밖으로 나갈 필요가 없습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 GitOps의 핵심 철학이 더해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;GitHub에 적힌 상태가 유일한 진실(Single Source of Truth)이다.&quot;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;누군가 터미널에서 파드 설정을 몰래 바꿔도, &lt;b&gt;(kubectl edit)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;ArgoCD는 즉시 &quot;Git과 현재 상태가 다르다&quot;는 것을 감지하고 &lt;b&gt;(Drift Detection)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;GitHub에 적힌 상태로 자동 복구합니다. &lt;b&gt;(Self-Healing)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 중 발생하는 설정 변경 장애가 원천적으로 차단되는 것입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ArgoCD의 주요 구성요소&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD는 세 가지 핵심 컴포넌트로 동작합니다. 각각의 역할을 이해해두면, 배포가 어디서 막혔는지 파악할 때 큰 도움이 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;argocd-server&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 직접 마주하는 진입점입니다. 웹 UI, CLI(argocd 명령어), REST/gRPC API 요청을 모두 이 컴포넌트가 받습니다. &quot;ArgoCD에 로그인한다&quot;, &quot;애플리케이션 상태를 확인한다&quot;는 행위는 전부 argocd-server를 통해 이루어집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;argocd-repo-server&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub 저장소에서 실제로 파일을 가져오는 역할을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 파일을 내려받는 것에서 그치지 않고, Helm 차트라면 values.yaml을 적용해 최종 K8s 매니페스트로 렌더링하고, Kustomize라면 오버레이를 합쳐서 최종 YAML을 만들어냅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;argocd-application-controller가 &quot;지금 Git에는 어떤 상태여야 해?&quot;라고 물으면, repo-server가 렌더링한 결과를 돌려주는 방식으로 실행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;argocd-application-controller&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD의 핵심 두뇌입니다. repo-server로부터 받은 Git의 목표 상태(Desired State)와 실제 K8s 클러스터의 현재 상태(Actual State)를 지속적으로 비교합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차이가 생기면 이를 OutOfSync로 표시하고, &lt;b&gt;selfHeal: true&lt;/b&gt; 설정이 되어 있다면 사람이 개입하기 전에 자동으로 동기화합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 설명한 &lt;b&gt;Drift Detection&lt;/b&gt;과 &lt;b&gt;Self-Healing&lt;/b&gt;이 바로 이 컴포넌트에서 일어납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 컴포넌트의 흐름을 순서로 정리하면 이렇습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;repo-server&lt;/b&gt;가 GitHub을 주기적으로 확인해 최신 매니페스트를 렌더링합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;application-controller&lt;/b&gt;가 렌더링된 결과와 현재 클러스터 상태를 비교합니다.&lt;/li&gt;
&lt;li&gt;차이가 있으면 &lt;b&gt;application-controller&lt;/b&gt;가 클러스터에 변경을 적용합니다.&lt;/li&gt;
&lt;li&gt;그 과정과 결과를 &lt;b&gt;argocd-server&lt;/b&gt;가 UI와 API로 노출합니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Ansible과 ArgoCD의 역할 분담&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD를 구성하기 전에 Ansible과의 차이점이 무엇일까 하는 의문이 생겨 두 도구의 역할을 정리해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 도구의 역할은 아래와 같습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;td&gt;Ansible&lt;/td&gt;
&lt;td&gt;ArgoCD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;역할&lt;/td&gt;
&lt;td&gt;서버 OS 세팅, K3s 클러스터 구축, ArgoCD 설치&lt;/td&gt;
&lt;td&gt;Jira, DB 등 애플리케이션의 지속적 배포와 상태 감시&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실행 시점&lt;/td&gt;
&lt;td&gt;인프라를 처음 세울 때 (1회성)&lt;/td&gt;
&lt;td&gt;코드가 바뀔 때마다 (지속적)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;설정 위치&lt;/td&gt;
&lt;td&gt;Playbook 파일&lt;/td&gt;
&lt;td&gt;GitHub 저장소&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Ansible이 하는 일&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ansible은 서버가 처음 세팅될 때 실행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영체제 기본 설정, 방화벽 규칙, K3s 설치, ArgoCD 설치와 같이 서버를 구성하기 위해 필요한 설정 파일을 작업하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇기 때문에 ansible-playbook은 한 번 실행하고 나면 다시 건드릴 일이 거의 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 새로 프로비저닝하거나, K3s 버전을 올리거나, 클러스터 노드를 추가할 때처럼 인프라 자체가 바뀔 때만 다시 실행합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD도 Ansible로 설치했는데, ArgoCD 자체는 K8s 위에 올라가는 인프라성 도구이기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;ArgoCD를 관리하는 도구는 ArgoCD가 아니다&quot;라는 원칙을 지킨 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ArgoCD가 하는 일&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD는 인프라가 준비된 이후부터 역할을 맡습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jira, PostgreSQL처럼 클러스터 위에서 실제로 돌아가는 애플리케이션들을 관리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ansible과 가장 다른 점은 실행 시점입니다. Ansible은 사람이 명령어를 칠 때 실행되지만, ArgoCD는 GitHub에 변경이 생길 때마다 자동으로 클러스터에 반영합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정이 반복될수록 GitHub 저장소에는 애플리케이션의 변경 이력이 쌓이기 때문에, &quot;언제, 누가, 무엇을 바꿨는지&quot;가 자연스럽게 기록됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 Ansible로 Jira를 배포하지 않았을까&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &quot;Ansible로 ArgoCD까지 설치했으니, Jira도 Ansible Playbook으로 배포하면 되지 않을까&quot;하는 질문이 자연스럽게 떠올랐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술적으로는 가능하지만, Playbook을 실행한 뒤에 누군가 Jira 설정을 손으로 바꾸면 Ansible은 그 변화를 모르기 때문에,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음번 Playbook 실행 전까지 실제 상태와 코드가 어긋난 채로 운영됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD는 GitHub과 클러스터 상태를 &lt;b&gt;지속적으로&lt;/b&gt; 비교하기 때문에, 설정이 어긋나는 순간 감지하고 자동으로 복구합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션처럼 설정이 자주 바뀌고, 항상 일관된 상태를 유지해야 하는 대상에는 ArgoCD가 훨씬 적합한 이유입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;GitOps 저장소 구조 설계&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD가 감시할 GitHub 저장소를 먼저 만들었습니다.&lt;/p&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;atlassian-k8s-gitops/
├── gitops/
│   ├── bootstrap/
│   │   └── root-app.yaml      # ArgoCD에 최초 1회 등록
│   ├── apps/
│   │   └── jira.yaml          # root-app이 이 폴더를 감시
│   └── charts/
│       └── jira/
│           ├── Chart.yaml
│           └── values.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조를 세개의 폴더로 나눈 이유는 아래와 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;bootstrap :&lt;/b&gt; ArgoCD에 딱 한 번만 등록하는 파일을 담아두는 곳 &lt;br /&gt;root-app.yaml 하나만 있으면 됩니다. &lt;br /&gt;이 파일을 kubectl apply로 등록하는 순간, 이후의 모든 배포는 ArgoCD가 자동으로 처리합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;apps :&lt;/b&gt; 배포할 애플리케이션 목록을 관리하는 폴더 &lt;br /&gt;root-app이 이 폴더를 감시하고 있다가, 새 파일이 올라오면 자동으로 배포를 시작합니다. &lt;br /&gt;Jira를 추가하고 싶으면 jira.yaml을, Confluence를 추가하고 싶으면 confluence.yaml을 이 폴더에 올리면 됩니다. &lt;br /&gt;터미널에서 명령어를 칠 필요 없이, GitHub에 파일을 추가하는 것만으로 새 애플리케이션 배포가 시작됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;charts :&lt;/b&gt; 실제 Helm 설정 파일이 위치하는 곳 &lt;br /&gt;apps/ 폴더의 파일이 &quot;Jira를 배포해줘&quot;라는 지시서라면, &lt;br /&gt;charts/ 폴더는 &quot;Jira를 이렇게 설정해서 배포해줘&quot;라는 구체적인 설계도입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;App of Apps 패턴&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조의 핵심은 &lt;b&gt;App of Apps 패턴&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;a href=&quot;https://argo-cd.readthedocs.io/en/latest/operator-manual/cluster-bootstrapping/#app-of-apps-pattern-alternative&quot;&gt;https://argo-cd.readthedocs.io/en/latest/operator-manual/cluster-bootstrapping/#app-of-apps-pattern-alternative&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 ArgoCD에 애플리케이션을 등록하려면 CLI나 UI에서 직접 하나씩 추가해야 하기 때문에, 애플리케이션이 늘어날수록 수동 등록 작업도 함께 늘어납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App of Apps 패턴은 이 문제를 해결하기 위해 나온 방법입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;root-app 하나를 ArgoCD에 등록해두면, 이 앱이 apps/ 폴더 전체를 감시합니다. 그 폴더 안의 YAML 파일 하나하나를 ArgoCD Application으로 인식하고 자동 등록합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름을 순서로 정리하면 이렇습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. kubectl apply -f bootstrap/root-app.yaml  (최초 1회, 사람이 직접)
        &amp;darr;
2. root-app이 apps/ 폴더 감시 시작
        &amp;darr;
3. apps/jira.yaml 감지
        &amp;darr;
4. ArgoCD가 jira Application 자동 등록
        &amp;darr;
5. charts/jira/ 의 Helm 차트를 읽어서 K8s에 배포
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조의 장점은 확장성에 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 Confluence를 추가한다면 apps/confluence.yaml과 charts/confluence/를 만들어서 GitHub에 push하는 것만으로 끝입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD 설정을 건드리거나 터미널에 접속할 필요가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 변경 이력이 Git에 남기 때문에 &quot;언제, 무엇을, 왜 추가했는지&quot;도 자연스럽게 기록됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ArgoCD의 배포 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 작성한 Apps of Apps 패턴으로 구성한 파일을 참고하여 ArgoCD에서 배포를 어떻게 진행할까? 하는 의문이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD는 배포할 설정을 반드시 공식 Helm 저장소에서만 가져오지 않습니다. Git 저장소 자체를 소스로 사용하는 것이 일반적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;크게 세 가지 방식이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Git 저장소 내의 Helm 차트 (Helm in Git)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서 사용한 방식입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사내 Git 저장소의 특정 폴더에 Helm 차트 파일(Chart.yaml, values.yaml 등)을 업로드해 두는 방식입니다.&lt;/li&gt;
&lt;li&gt;ArgoCD가 해당 경로를 바라보도록 설정하면, 자체적으로 Helm 템플릿을 렌더링하여 배포합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 순수 Kubernetes YAML 파일 (Raw Manifests)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Helm을 사용하지 않고, 배포에 필요한 순수한 YAML 파일(Deployment, Service 등)을 Git에 올려두는 방식입니다.&lt;/li&gt;
&lt;li&gt;ArgoCD가 이 파일들을 그대로 클러스터에 적용(Apply)합니다.&lt;/li&gt;
&lt;li&gt;간단한 애플리케이션이나 커스텀 리소스를 직접 관리할 때 주로 씁니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Kustomize 방식&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Git 저장소에 기본 YAML 파일들을 두고, kustomization.yaml을 통해 환경(Dev, Staging, Prod 등)에 맞게 설정값을 덮어쓰는 방식입니다. ArgoCD는 Kustomize도 기본으로 지원합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ArgoCD 내부 동작 흐름&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD가 GitHub의 파일을 읽어서 실제로 K8s에 배포하기까지 내부에서는 크게 네 단계가 진행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1단계: Git 저장소 읽기 (Clone &amp;amp; Fetch)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ArgoCD는 주기적으로 설정된 Git 저장소를 확인합니다.&lt;/li&gt;
&lt;li&gt;변경이 감지되면 해당 경로(gitops/charts/jira)의 파일들을 내부 캐시로 가져옵니다.&lt;/li&gt;
&lt;li&gt;이 시점에서 가져오는 파일은 Chart.yaml과 values.yaml뿐입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2단계: 의존성 다운로드 (Helm Dependency Build)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ArgoCD는 Chart.yaml의 dependencies 항목을 확인합니다.&lt;/li&gt;
&lt;li&gt;의존하는 차트가 있으면 해당 저장소 URL로 요청을 보내 차트 압축 파일(jira-1.21.1.tgz)을 내부 임시 공간으로 내려받고 압축을 풉니다.&lt;/li&gt;
&lt;li&gt;터미널에서 helm dependency build를 직접 치는 것과 동일한 작업을 ArgoCD가 백그라운드에서 대신 처리하는 것입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3단계: 매니페스트 렌더링&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공식 차트 원본과 우리가 작성한 values.yaml이 모두 준비되면, ArgoCD는 내부적으로 helm template을 실행해 두 파일을 합칩니다.&lt;/li&gt;
&lt;li&gt;Helm 문법이 모두 사라지고, K8s가 이해할 수 있는 순수한 YAML 파일이 만들어집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4단계: 클러스터와 비교 및 적용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;렌더링된 YAML과 현재 클러스터의 실제 상태를 비교합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;다르면 (Out of Sync)&amp;rarr; Out of Sync로 표시하고, 자동 동기화 설정 시 즉시 kubectl apply를 수행합니다.&lt;/li&gt;
&lt;li&gt;같으면 (Synced) &amp;rarr; 현재의 동기화 상태를 그대로 유지합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RBAC 권한 구성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD에는 기본적으로 두 가지 역할이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;role:admin은 모든 작업이 가능하고, role:readonly는 조회만 가능합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트는 혼자 진행한 로컬 환경이라 기본 admin 계정만 사용했지만, 실제 운영 환경에서는 역할별 권한 분리가 필수입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD는 argocd-rbac-cm이라는 ConfigMap으로 권한을 관리합니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-rbac-cm
  namespace: argocd
data:
  policy.default: role:readonly     # 기본은 읽기 전용
  policy.csv: |
    # 운영자: 배포 및 동기화 가능, 설정 변경 불가
    p, role:operator, applications, sync, */*, allow
    p, role:operator, applications, get, */*, allow

    # 개발자: 조회와 수동 동기화만 허용
    p, role:developer, applications, get, */*, allow
    p, role:developer, applications, sync, */*, allow

    # 역할 할당
    g, ops-team, role:operator
    g, dev-team, role:developer
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 구성하면 개발자는 배포 상태를 확인하고 수동 동기화는 할 수 있지만, ArgoCD 자체 설정이나 저장소 연결은 건드릴 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영자는 배포와 동기화가 가능하지만, admin 권한이 필요한 시스템 설정은 제한됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 Atlassian 제품을 운영하면서 느낀 것은, 배포 도구의 권한이 느슨하면 &quot;누가 언제 뭘 바꿨는지&quot; 추적이 어렵다는 점입니다. GitOps 방식에서는 모든 변경이 Git 커밋으로 남지만, ArgoCD 자체에서 수동으로 조작한 내용은 Git에 기록되지 않습니다. RBAC으로 수동 조작 권한 자체를 제한하는 것이 GitOps 원칙을 지키는 방법입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ArgoCD CLI 설치 및 GitHub 연동&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD UI에서 대부분의 작업이 가능하지만, 저장소 연동과 초기 설정은 CLI로 하는 것이 빠릅니다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;# master-node에서 설치
curl -sSL -o /tmp/argocd &amp;lt;https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-arm64&amp;gt;
sudo install -m 555 /tmp/argocd /usr/local/bin/argocd

# ArgoCD 서버 로그인
argocd login argocd.orb.local --username admin --password &amp;lt;초기비밀번호&amp;gt; --insecure --grpc-web

# GitHub 저장소 연동
argocd repo add  --grpc-web

# 연동 확인
argocd repo list --grpc-web
TYPE  NAME  REPO           STATUS
git           Successful
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;-insecure : 로컬 환경에서 HTTPS 인증서 없이 HTTP로 접속하기 때문에 필요합니다.&lt;/li&gt;
&lt;li&gt;-grpc-web : Traefik Ingress 뒤에 ArgoCD가 있을 때 통신 방식을 맞춰주는 옵션입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;argocd CLI 설치 : &lt;a href=&quot;https://argo-cd.readthedocs.io/en/stable/cli_installation/&quot;&gt;https://argo-cd.readthedocs.io/en/stable/cli_installation/&lt;/a&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ArgoCD로 Jira 배포하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 데이터베이스 준비&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jira가 사용할 데이터베이스를 infra-node의 PostgreSQL에 미리 생성합니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;orb -m infra-node

sudo -u postgres psql &amp;lt;&amp;lt;EOF
CREATE DATABASE jiradb
  WITH ENCODING 'UNICODE'
  LC_COLLATE 'C'
  LC_CTYPE 'C'
  TEMPLATE template0;
CREATE USER jira WITH PASSWORD 'jira';
GRANT ALL PRIVILEGES ON DATABASE jiradb TO jira;
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Helm 차트 구성&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD가 Jira를 배포할 때 사용할 Helm 설정을 작성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Chart.yaml에는 사용할 공식 차트와 버전을 선언합니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# gitops/charts/jira/Chart.yaml
apiVersion: v2
name: jira
description: Atlassian Jira on K8s
type: application
version: 1.0.0

dependencies:
  - name: jira
    version: &quot;1.21.1&quot;
    repository: &quot;&amp;lt;https://atlassian.github.io/data-center-helm-charts&amp;gt;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;values.yaml에는 우리 환경에 맞게 덮어쓸 설정을 작성합니다. DB 연결 정보, NFS 공유 스토리지 경로, Ingress 설정이 핵심입니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;# gitops/charts/jira/values.yaml
jira:
  image:
    repository: atlassian/jira-software
    tag: &quot;9.12.0&quot;

  jvmConfig:
    jvmMinHeapSize: &quot;384m&quot;
    jvmMaxHeapSize: &quot;768m&quot;

  resources:
    requests:
      cpu: &quot;500m&quot;
      memory: &quot;1Gi&quot;
    limits:
      cpu: &quot;2000m&quot;
      memory: &quot;1.5Gi&quot;

  datasource:
    url: &quot;jdbc:postgresql://infra-node.orb.local:5432/jiradb&quot;
    username: &quot;jira&quot;
    password: &quot;jira&quot;
    driver: &quot;org.postgresql.Driver&quot;

  sharedHome:
    nfsPermissionFixer:
      enabled: true
    customVolume:
      nfs:
        server: &quot;infra-node.orb.local&quot;
        path: &quot;/data/shared-home/jira-shared-home&quot;

  ingress:
    create: true
    className: &quot;traefik&quot;
    host: &quot;jira.orb.local&quot;
    https: false

  readinessProbe:
    initialDelaySeconds: 120
    failureThreshold: 30
  livenessProbe:
    initialDelaySeconds: 180
    failureThreshold: 10
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;helm chart 구성 : &lt;a href=&quot;https://atlassian.github.io/data-center-helm-charts/&quot;&gt;https://atlassian.github.io/data-center-helm-charts/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. ArgoCD 매니페스트 작성&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;apps/jira.yaml은 ArgoCD에게 &quot;이 GitHub 경로의 Helm 차트를 저 네임스페이스에 배포해줘&quot;라고 지시하는 파일입니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# gitops/apps/jira.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: jira
  namespace: argocd
spec:
  project: default

  source:
    repoURL: &quot;&quot;
    targetRevision: main
    path: gitops/charts/jira
    helm:
      valueFiles:
        - values.yaml

  destination:
    server: &quot;&amp;lt;https://kubernetes.default.svc&amp;gt;&quot;
    namespace: atlassian

  syncPolicy:
    automated:
      prune: true       # Git에서 삭제된 리소스는 클러스터에서도 삭제
      selfHeal: true    # Git과 달라진 상태는 자동으로 복구
    syncOptions:
      - CreateNamespace=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. root-app 등록 (최초 1회)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 파일을 GitHub에 push한 뒤, root-app을 ArgoCD에 등록합니다. 이 작업만 직접 수행하고, 이후에는 ArgoCD가 알아서 처리합니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# Mac /etc/hosts 등록
sudo sh -c 'echo &quot;192.168.139.219 jira.orb.local&quot; &amp;gt;&amp;gt; /etc/hosts'

# root-app 등록
kubectl apply -f gitops/bootstrap/root-app.yaml

# root-app이 apps/ 폴더를 감지하고 Jira를 자동 등록하는지 확인
kubectl get applications -n argocd
NAME       SYNC STATUS   HEALTH STATUS
root-app   Synced        Healthy
jira       Synced        Healthy
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상적으로 배포되면 Ingress가 생성되고 &lt;a href=&quot;http://jira.orb.local&quot;&gt;http://jira.orb.local&lt;/a&gt;로 접속할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;$ kubectl get ingress -n atlassian
NAME   CLASS     HOSTS            ADDRESS
jira   traefik   jira.orb.local   192.168.139.139,192.168.139.160
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2978&quot; data-origin-height=&quot;1498&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ObcOU/dJMcadO3jTW/2ljKc2kAkZaIl8CQGsxfc0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ObcOU/dJMcadO3jTW/2ljKc2kAkZaIl8CQGsxfc0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ObcOU/dJMcadO3jTW/2ljKc2kAkZaIl8CQGsxfc0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FObcOU%2FdJMcadO3jTW%2F2ljKc2kAkZaIl8CQGsxfc0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2978&quot; height=&quot;1498&quot; data-origin-width=&quot;2978&quot; data-origin-height=&quot;1498&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Trouble Shooting&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. app path does not exist&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;ComparisonError: apps: app path does not exist
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;root-app.yaml의 path를 apps로 설정했는데, 실제 저장소 구조가 gitops/apps였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD의 경로는 저장소 루트 기준이므로 디렉토리 구조와 정확히 일치해야 하기 때문에 path: gitops/apps로 수정한 뒤 해결됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Chart.yaml: no such file or directory&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;ComparisonError: error reading helm chart from .../Chart.yaml: no such file or directory
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일명이 chart.yaml(소문자)로 저장되어 있어 발생한 문제입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Helm은 반드시 Chart.yaml(대문자 C)을 요구합니다. 파일명 수정 후 push하니 ArgoCD가 바로 감지해서 배포를 재시도했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 오류 모두 ArgoCD가 에러 메시지를 UI와 CLI 양쪽에서 명확하게 보여줬기 때문에 원인을 빠르게 파악할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에서 kubectl describe를 사용하여 헤메던 것과 비교하면 디버깅 경험 자체가 달랐습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;마치며&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지는 Jira 설정을 바꿔야 할 때마다, 서버에서 파일을 수정 후에 서비스를 재시작하고 반영 여부를 확인하는 과정을 반복했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작은 설정 하나를 바꾸는 데도 서비스 중단 공지 후에 서비스 재기동을 진행해야 했고, 손으로 파일을 직접 건드리다 보니 오타와 같은 사소한 문제가 서비스 장애로 이어지는 경우도 적지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 경험이 있었기 때문에 GitOps 방식으로 k3s에 Atlassian 제품을 올려보는 작업을 하면서 체감되는 차이가 매우 컸습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업을 마치고 나서 GitOps가 왜 주목받는지 체감할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jira 설정을 바꾸고 싶을 때, 서버에 접속하거나 명령어를 칠 필요가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitHub에서 values.yaml의 숫자 하나를 바꾸고 commit하면, ArgoCD가 감지하고 알아서 클러스터에 반영합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인프라와 애플리케이션의 모든 상태가 GitHub에 기록되고, 그 기록이 곧 현재 운영 중인 상태와 동일하게 유지됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해, 애플리케이션을 구성하는 요소들의 변경사항을 코드로 추적하고, 관리할 수 있다는 이점을 알게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 세 편에 걸쳐 인프라 자동화의 큰 그림을 따라왔습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Ansible : k3s 클러스터 구축 자동화&lt;/li&gt;
&lt;li&gt;helm + Ansible : ArgoCD 설치 및 배포 자동화&lt;/li&gt;
&lt;li&gt;ArgoCD : Jira, Confluence 등의 애플리케이션 배포 자동화&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 도구가 각자의 역할을 맡아 하나의 파이프라인을 이루는 구조를 구성하면서, 개념으로만 알고 있던 GitOps를 실제 운영 경험과 연결해서 이해하게 된 좋은 경험이 되었습니다.&lt;/p&gt;</description>
      <category>프로젝트/엔터프라이즈 JVM 애플리케이션의 Cloud-Native 전환기</category>
      <category>ansible</category>
      <category>argocd</category>
      <category>Atlassian</category>
      <category>gitops</category>
      <category>Helm</category>
      <category>Ingress</category>
      <category>JIRA</category>
      <category>k3s</category>
      <category>K8S</category>
      <author>okbear3</author>
      <guid isPermaLink="true">https://okbear3.tistory.com/92</guid>
      <comments>https://okbear3.tistory.com/92#entry92comment</comments>
      <pubDate>Wed, 6 May 2026 20:29:34 +0900</pubDate>
    </item>
    <item>
      <title>3-1. Helm으로 ArgoCD 설치하고, Ansible로 자동화하기</title>
      <link>https://okbear3.tistory.com/91</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글은 Kubernetes 클러스터에 ArgoCD를 올리는 과정을 정리한 글입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 직접 손으로 명령어를 치면서 동작 원리를 파악하고, 그 다음에 같은 과정을 Ansible Playbook으로 옮기는 순서로 진행했습니다. 직접 터미널에서 환경을 구성하고 &amp;rarr; 자동화하는 흐름이 개념을 이해하는 데 훨씬 도움이 됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환경은 Mac 위에서 OrbStack으로 K3s 클러스터를 구성한 로컬 환경입니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Helm이란 무엇인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kubernetes에 애플리케이션을 배포하려면 Deployment, Service, ConfigMap, Ingress 같은 리소스를 하나하나 YAML로 작성해야 합니다. 애플리케이션 하나를 올리는 데 수십 개의 파일이 필요하기도 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Helm은 이 과정을 단순하게 만들어주는 Kubernetes 전용 패키지 매니저입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;npm이 Node.js 패키지를 한 줄로 설치하듯, Helm은 복잡한 K8s 리소스 묶음을 명령어 하나로 설치할 수 있게 해줍니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Chart와 values.yaml&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Helm에서 핵심 개념은 &lt;b&gt;Chart&lt;/b&gt;와 &lt;b&gt;values.yaml&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Chart는 애플리케이션 설치에 필요한 K8s 리소스 템플릿의 묶음입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD 공식 Helm 차트 안에는 수천 줄짜리 기본 values.yaml이 들어있고, &quot;Redis를 고가용성 모드로 3대 띄워라&quot;, &quot;Ingress는 꺼둬라&quot; 같은 기본값들이 미리 정의되어 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 우리 환경은 로컬 개발용이라 리소스를 아껴야 했고, K3s에 내장된 Traefik Ingress를 통해 도메인으로 접속해야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 프로젝트 환경에 맞게 구성하기 위해 사용할 수 있는 것이 &lt;b&gt;values.yaml&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바꾸고 싶은 부분만 골라서 파일로 작성하고, -f 옵션으로 Helm에 넘겨주면 기본값을 덮어씁니다.&lt;/p&gt;
&lt;pre class=&quot;taggerscript&quot;&gt;&lt;code&gt;helm install argocd argo/argo-cd \\\\
  -n argocd \\\\
  --version 7.7.0 \\\\
  -f argocd-values.yaml     # 이 파일의 값으로 기본값을 덮어써라
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조가 처음에는 낯설었는데, 나중에 ArgoCD로 다른 애플리케이션을 배포할 때도 똑같은 방식을 사용하기 때문에 확실히 이해하고 넘어가는 게 중요한것 같습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Helm 설치&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Helm 공식 설치 스크립트를 사용하면 OS에 맞는 최신 버전을 자동으로 설치합니다.&lt;/p&gt;
&lt;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;curl -fsSL &amp;lt;https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3&amp;gt; | bash
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Helm은 기본적으로 ~/.kube/config에서 클러스터 인증서를 읽는데, K3s는 인증서를 /etc/rancher/k3s/k3s.yaml이라는 경로에 따로 보관합니다. Helm이 K3s 클러스터와 통신하려면 인증서를 복사해줘야 합니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config
chmod 600 ~/.kube/config
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 설치와 연동이 잘 됐는지 확인합니다.&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;$ helm version
version.BuildInfo{Version:&quot;v3.20.1&quot;, ...}

$ helm ls -A
NAME          NAMESPACE    STATUS    CHART
traefik       kube-system  deployed  traefik-39.0.201
traefik-crd   kube-system  deployed  traefik-crd-39.0.201
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 없이 클러스터의 목록이 나오면 정상입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Helm으로 ArgoCD 배포하기&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Traefik(Ingress Controller) 확인&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ArgoCD를 도메인으로 접속하려면 외부 요청을 내부 서비스로 연결해주는 Ingress Controller가 필요합니다. K3s는 Traefik을 기본으로 내장하고 있어서 따로 설치할 필요가 없습니다. 먼저 잘 떠있는지 확인합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;kubectl get pods -n kube-system -l app.kubernetes.io/name=traefik

NAME                       READY   STATUS    RESTARTS
traefik-788bc4688c-z4fk6   1/1     Running   4
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;values.yaml 작성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 이야기한 대로, 로컬 환경에 맞게 덮어쓸 설정을 파일로 작성합니다.&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;# argocd-values.yaml
global:
  domain: &quot;argocd.orb.local&quot;

configs:
  params:
    # ArgoCD는 HTTP로 서비스하고, SSL 처리는 Traefik에 맡깁니다.
    # 이 옵션이 없으면 ArgoCD가 HTTPS로 리다이렉트해서 무한 루프가 발생합니다.
    server.insecure: &quot;true&quot;

server:
  ingress:
    enabled: true
    ingressClassName: &quot;traefik&quot;
    annotations:
      traefik.ingress.kubernetes.io/service.serversscheme: &quot;http&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;values.yml 파일을 명령어로 가져와서 구성할 수 있습니다. (postgresql 예시)&lt;/p&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;# 1. Bitnami 저장소 추가 (안 되어 있다면)
helm repo add bitnami &amp;lt;https://charts.bitnami.com/bitnami&amp;gt;

# 2. values.yml 원본 파일 저장
helm show values bitnami/postgresql &amp;gt; original-postgres-values.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;argocd 배포&lt;/h4&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# 네임스페이스 생성
kubectl create namespace argocd

# Argo 공식 저장소 추가
helm repo add argo &amp;lt;https://argoproj.github.io/argo-helm&amp;gt;
helm repo update

# 작성한 values.yaml을 적용하여 배포!
helm install argocd argo/argo-cd -n argocd --version 7.7.0 -f argocd-values.yaml
	NAME: argocd
	LAST DEPLOYED: Mon Mar 23 14:39:03 2026
	NAMESPACE: argocd
	STATUS: deploye
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파드가 모두 Running 상태인지 확인합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;$ kubectl get pods -n argocd -w

NAME                                                READY   STATUS
argocd-application-controller-0                     1/1     Running
argocd-applicationset-controller-669cbf7ff5-r6mnm   1/1     Running
argocd-dex-server-86b76cc5d5-zcf2c                  1/1     Running
argocd-notifications-controller-6476d694c7-p94hw    1/1     Running
argocd-redis-f9d86dcc5-hdznt                        1/1     Running
argocd-repo-server-66c4fc5467-9klr4                 1/1     Running
argocd-server-8645744799-g9xk4                      1/1     Running
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ingress도 정상 등록되어 있는지 봅니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;$ kubectl get ingress -n argocd

NAME            CLASS     HOSTS              ADDRESS
argocd-server   traefik   argocd.orb.local   192.168.139.219
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 관리자 비밀번호를 추출하고, Mac의 /etc/hosts에 도메인을 등록하면 접속할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 초기 비밀번호 추출
kubectl -n argocd get secret argocd-initial-admin-secret \\\\
  -o jsonpath=&quot;{.data.password}&quot; | base64 -d &amp;amp;&amp;amp; echo

# Mac /etc/hosts 등록
sudo sh -c 'echo &quot;192.168.139.219 argocd.orb.local&quot; &amp;gt;&amp;gt; /etc/hosts'
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에서 &lt;a href=&quot;http://argocd.orb.local&quot;&gt;http://argocd.orb.local&lt;/a&gt;로 접속하면 ArgoCD UI가 나옵니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;(Trouble Shooting) 권한 충돌 문제&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에는 에러가 없는데, kubectl로 확인하면 아무것도 배포되지 않은 문제가 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 sudo와 일반 계정이 서로 다른 인증서를 바라보고 있었던 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;sudo kubectl은 K3s 기본 경로(/etc/rancher/k3s/k3s.yaml)의 인증서를 사용하고, 일반 계정의 helm은 ~/.kube/config를 찾습니다. ~/.kube/config가 없거나 권한이 꼬여 있으면, Helm은 클러스터에 접속 자체를 못합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;K3s 설치 시 시스템 전체에 KUBECONFIG=/etc/rancher/k3s/k3s.yaml 환경변수가 자동으로 설정되는 것도 원인 중 하나였습니다. ~/.kube/config에 인증서를 복사해뒀어도 환경변수가 원본 경로를 가리키고 있으면 kubectl과 helm이 둘 다 원본을 읽으려다 권한 오류로 튕겨나갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결은 환경변수를 명시적으로 바꿔주는 것이었습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# 현재 세션에 적용
export KUBECONFIG=~/.kube/config

# 영구 적용
echo 'export KUBECONFIG=~/.kube/config' &amp;gt;&amp;gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후에는 sudo 없이 kubectl과 helm이 같은 클러스터를 바라보게 됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Ansible로 자동화하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널에서 helm을 설치하고 argocd를 배포하는 과정을 확인했으니, 이 내용을 ansible-playbook으로 옮기려고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추후에 같은 작업을 다시 할 때 실수 없이 재현할 수 있고, 운영 환경으로 확장할 때도 변수만 바꾸면 되기 때문입니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;디렉토리 구조&lt;/h4&gt;
&lt;pre class=&quot;fortran&quot;&gt;&lt;code&gt;ansible/
├── site.yml
├── inventories/
│   └── dev/
│       └── group_vars/
│           └── all.yml        # 환경별 변수 선언
└── roles/
    ├── helm/
    │   └── tasks/
    │       └── main.yml
    └── argocd/
        ├── tasks/
        │   ├── main.yml
        │   ├── kubeconfig.yml
        │   ├── deploy.yml
        │   └── verify.yml
        └── templates/
            └── argocd-values.yaml.j2
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;환경 변수 분리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;group_vars/all.yml에 환경마다 달라질 수 있는 값을 변수로 선언합니다.&lt;/p&gt;
&lt;pre class=&quot;avrasm&quot;&gt;&lt;code&gt;argocd_namespace: &quot;argocd&quot;
argocd_chart_version: &quot;7.7.0&quot;
argocd_domain: &quot;argocd.orb.local&quot;
argocd_ingress_class: &quot;traefik&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 환경으로 전환할 때는 inventories/prod/group_vars/all.yml에서 도메인이나 버전만 바꾸면 됩니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;site.yml &amp;mdash; 실행 순서와 권한 설계&lt;/h4&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;- name: 7. Helm 설치
  hosts: k3s_master
  become: yes              # 바이너리 설치는 sudo 필요
  roles: [ helm ]

- name: 8. ArgoCD 배포
  hosts: k3s_master
  become: no               # Helm 실행은 반드시 일반 유저로
  environment:
    KUBECONFIG: &quot;/home/{{ ansible_user }}/.kube/config&quot;
  roles: [ argocd ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;become: no 옵션은 root 권한이 아닌 실행 유저 권한으로 실행하는 옵션입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;become: yes로 실행하면 Helm이 root의 kubeconfig를 찾다가 아무것도 반영되지 않는 현상이 그대로 재현됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;environment를 Play 레벨에 선언하면 해당 Play의 모든 Task가 동일한 환경변수를 공유하기 때문에 Task마다 반복해서 쓸 필요가 없습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Helm 설치 Role&lt;/h4&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# roles/helm/tasks/main.yml
- name: Helm 설치 여부 확인
  command: which helm
  register: helm_check
  failed_when: false
  changed_when: false

- name: Helm 3 설치
  shell: curl -fsSL &amp;lt;https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3&amp;gt; | bash
  when: helm_check.rc != 0

- name: Argo Helm 저장소 추가
  command: helm repo add argo &amp;lt;https://argoproj.github.io/argo-helm&amp;gt;
  register: repo_add
  failed_when:
    - repo_add.rc != 0
    - &quot;'already exists' not in repo_add.stderr&quot;
  changed_when: repo_add.rc == 0

- name: Helm 저장소 업데이트
  command: helm repo update
  changed_when: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;which helm으로 설치 여부를 먼저 확인하고 없을 때만 설치합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ansible은 기본적으로 멱등성을 지향하기 때문에, 같은 Playbook을 여러 번 실행해도 결과가 동일하게 유지되어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;changed_when을 명시적으로 설정한 것도 같은 이유입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;which나 repo update처럼 상태를 바꾸지 않는 명령이 매번 changed로 표시되는 것을 막기 위해서입니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ArgoCD Role &amp;mdash; kubeconfig 설정&lt;/h4&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# roles/argocd/tasks/kubeconfig.yml
- name: .kube 디렉토리 생성
  file:
    path: &quot;/home/{{ ansible_user }}/.kube&quot;
    state: directory
    mode: '0755'

- name: k3s.yaml을 사용자 kubeconfig로 복사
  become: yes              # 이 Task만 잠깐 sudo 권한을 빌립니다
  copy:
    src: /etc/rancher/k3s/k3s.yaml
    dest: &quot;/home/{{ ansible_user }}/.kube/config&quot;
    remote_src: yes
    owner: &quot;{{ ansible_user }}&quot;
    group: &quot;{{ ansible_user }}&quot;
    mode: '0600'

- name: KUBECONFIG 환경변수 영구 설정
  blockinfile:
    path: &quot;/home/{{ ansible_user }}/.bashrc&quot;
    marker: &quot;# {mark} ANSIBLE MANAGED BLOCK: kubeconfig&quot;
    block: |
      export KUBECONFIG=~/.kube/config
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;become: yes를 Play 전체가 아닌 Task 단위로 좁혀서 적용했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인증서 복사할 때만 잠깐 sudo 권한을 빌리고, 나머지는 일반 유저로 처리합니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ArgoCD Role &amp;mdash; 배포&lt;/h4&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# roles/argocd/tasks/deploy.yml
- name: argocd 네임스페이스 생성
  command: kubectl create namespace {{ argocd_namespace }}
  register: ns_create
  failed_when:
    - ns_create.rc != 0
    - &quot;'already exists' not in ns_create.stderr&quot;
  changed_when: ns_create.rc == 0

- name: argocd-values.yaml 생성
  template:
    src: argocd-values.yaml.j2
    dest: &quot;/home/{{ ansible_user }}/argocd-values.yaml&quot;

- name: ArgoCD 배포 (설치 또는 업그레이드)
  command: &amp;gt;
    helm upgrade --install argocd argo/argo-cd
    -n {{ argocd_namespace }}
    --version {{ argocd_chart_version }}
    -f /home/{{ ansible_user }}/argocd-values.yaml
    --create-namespace
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;helm upgrade --install을 사용하면, install/upgrade를 분기하지 않더라도 두 경우를 모두 처리할 수 있어 코드가 훨씬 단순해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포에 사용하는 argocd-values.yaml.j2는 Jinja2 템플릿입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jinja2는 group_vars에 선언한 변수를 그대로 주입받기 때문에, 환경이 달라져도 템플릿 파일 자체는 수정하지 않아도 됩니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# roles/argocd/templates/argocd-values.yaml.j2
global:
  domain: &quot;{{ argocd_domain }}&quot;

configs:
  params:
    server.insecure: &quot;true&quot;

server:
  ingress:
    enabled: true
    ingressClassName: &quot;{{ argocd_ingress_class }}&quot;
    annotations:
      traefik.ingress.kubernetes.io/service.serversscheme: &quot;http&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ArgoCD Role &amp;mdash; 검증&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포가 끝난 뒤 파드 상태와 접속 정보를 자동으로 출력하도록 했습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;# roles/argocd/tasks/verify.yml
- name: ArgoCD 파드 Ready 대기 (최대 3분)
  command: &amp;gt;
    kubectl wait pod
    -n {{ argocd_namespace }}
    --for=condition=Ready
    --all
    --timeout=180s

- name: 초기 관리자 비밀번호 추출
  shell: &amp;gt;
    kubectl -n {{ argocd_namespace }}
    get secret argocd-initial-admin-secret
    -o jsonpath=&quot;{.data.password}&quot; | base64 -d
  register: argocd_password
  changed_when: false

- name: 접속 정보 출력
  debug:
    msg:
      - &quot;URL      : http://{{ argocd_domain }}&quot;
      - &quot;ID       : admin&quot;
      - &quot;Password : {{ argocd_password.stdout }}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실행 방법&lt;/h3&gt;
&lt;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;# Helm만 설치
ansible-playbook site.yml --tags &quot;helm&quot;

# ArgoCD만 배포 (Helm이 이미 설치된 경우)
ansible-playbook site.yml --tags &quot;argocd&quot;

# Helm 설치 + ArgoCD 배포 한 번에
ansible-playbook site.yml --limit k3s_master
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Playbook이 정상적으로 완료되면 다음과 같이 접속 정보가 출력됩니다.&lt;/p&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;TASK [argocd : 접속 정보 출력]
ok: [master-node.orb.local] =&amp;gt; {
    &quot;msg&quot;: [
        &quot;URL      : &amp;lt;http://argocd.orb.local&amp;gt;&quot;,
        &quot;ID       : admin&quot;,
        &quot;Password : ZyYW4h0PB1M4Nh8j&quot;
    ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 정보로 접속을 하게 되면, 아래와 같은 argocd 화면을 확인하실 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2092&quot; data-origin-height=&quot;1460&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3LgKn/dJMcafzoAhb/dhGr8kiIeSebi45CbgIz1k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3LgKn/dJMcafzoAhb/dhGr8kiIeSebi45CbgIz1k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3LgKn/dJMcafzoAhb/dhGr8kiIeSebi45CbgIz1k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3LgKn%2FdJMcafzoAhb%2FdhGr8kiIeSebi45CbgIz1k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2092&quot; height=&quot;1460&quot; data-origin-width=&quot;2092&quot; data-origin-height=&quot;1460&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자세한 코드 내용은 아래 깃허브에서 확인하실 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/hweyoung/atlassian-k8s-gitops/tree/main/ansible&quot;&gt;https://github.com/hweyoung/atlassian-k8s-gitops/tree/main/ansible&lt;/a&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;마치며&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업에서 가장 크게 느낀 것은, &lt;b&gt;직접 손으로 쳐보지 않으면 자동화 코드를 제대로 쓸 수 없다&lt;/b&gt;는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한 충돌 문제도 터미널에서 직접 부딪혀봤기 때문에 Ansible에서 become: no로 설정해야 하는 이유를 이해할 수 있었고, server.insecure: &quot;true&quot;도 무한 리다이렉트를 실제로 경험한 뒤에야 왜 필요한지 납득이 됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 이해하고 나면 한 가지가 눈에 들어옵니다. 우리가 이번에 한 작업을 다시 보면 이렇습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;helm upgrade --install argocd argo/argo-cd -f argocd-values.yaml
# &quot;공식 차트를 가져오되, 이 파일의 내용으로 기본값을 덮어써서 배포해 줘&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;f 옵션 하나로 공식 차트의 기본값 위에 우리 환경에 맞는 설정을 얹는 것이 Helm 배포의 핵심입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이 구조는 argocd 배포 과정에서도 유사하게 적용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서는 이렇게 설치한 ArgoCD를 실제로 사용해서 GitHub 저장소와 연동하고, GitOps 방식으로 배포하는 과정을 정리하겠습니다.&lt;/p&gt;</description>
      <category>프로젝트/엔터프라이즈 JVM 애플리케이션의 Cloud-Native 전환기</category>
      <category>ansible</category>
      <category>ansible-playbook</category>
      <category>argocd</category>
      <category>gitops</category>
      <category>Helm</category>
      <category>k3s</category>
      <category>K8S</category>
      <category>학습 기록</category>
      <author>okbear3</author>
      <guid isPermaLink="true">https://okbear3.tistory.com/91</guid>
      <comments>https://okbear3.tistory.com/91#entry91comment</comments>
      <pubDate>Wed, 29 Apr 2026 20:16:42 +0900</pubDate>
    </item>
    <item>
      <title>1. 쿠버네티스란 무엇일까?</title>
      <link>https://okbear3.tistory.com/94</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Docker로 컨테이너를 다루는 데 어느 정도 익숙해진 뒤, 자연스럽게 드는 의문이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너 하나 두 개는 docker run으로 충분한데, 이게 수십 개가 되면 어떻게 관리하지? 어떤 서버에 띄울지, 하나가 죽으면 누가 다시 살리는지, 트래픽이 몰리면 어떻게 늘리는지. 이런 문제들을 손으로 하나하나 해결하는 건 한계가 있다는 걸 느꼈고, 그래서 쿠버네티스(Kubernetes, 줄여서 k8s)를 본격적으로 공부하기 시작했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 쿠버네티스가 등장하게 된 배경과, 이 도구가 실제로 어떤 문제를 해결해주는지를 정리해보려고 합니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;컨테이너는 어떻게 격리되는가&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠버네티스 이야기로 들어가기 전에, 컨테이너가 왜 &quot;격리된&quot; 환경이라고 부르는지부터 짚고 가겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너는 가상머신처럼 별도의 OS를 띄우는 게 아니라, &lt;b&gt;호스트 OS의 커널 하나를 공유하면서도 서로 영향을 주지 않는 프로세스&lt;/b&gt;입니다. 그게 어떻게 가능할까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리눅스는 두 가지 메커니즘으로 이 격리를 제공합니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 54px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;매커니즘&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;역할&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;cgroup (Control Group)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;CPU, 메모리, 디스크 I/O 등 시스템 자원을 프로세스 단위로 분리해 할당하고 제한합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;&lt;b&gt;namespace&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;PID, 네트워크, 마운트, UTS 등 실행 공간을 프로세스별로 격리해 관리합니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;cgroup이 자원을 나누고, namespace가 실행 공간(보이는 프로세스, 네트워크 등)을 나눈다고 생각하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 둘 덕분에 컨테이너 내부에서 실행되는 애플리케이션들은 서로 영향을 주지 않고 독립적으로 동작할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이걸 직접 다루려면 파일 시스템 설정부터 자원 관리까지 복잡한 과정을 손수 처리해야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 복잡한 과정을 명령어 몇 줄로 추상화해 누구나 쉽게 쓸 수 있게 만든 도구가 바로 Docker입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;컨테이너만으로는 부족했던 이유&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker가 컨테이너 기술을 대중화한 건 맞지만, Docker 자체는 &quot;하나의 호스트에서 컨테이너를 만들고 실행하는 도구&quot;이기 때문에, 서비스가 커지면 문제는 금방 복잡해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 서버가 3대 있고 컨테이너가 20개라고 해봅시다. 어떤 컨테이너를 어느 서버에 올릴지 사람이 판단해야 하고, 서버 하나가 다운되면 그 위에 있던 컨테이너를 다른 서버로 수동으로 옮겨야 합니다. 트래픽이 갑자기 늘면 컨테이너를 더 띄워야 하는데, 그것도 사람이 모니터링하다가 직접 docker run을 치는 수밖에 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 한 가지 문제가 더 있습니다. 여러 사람이 수십 대의 서버를 관리하다 보면, 각자 패키지를 업데이트하거나 설정을 바꾸면서 서버마다 환경이 조금씩 달라지기 시작합니다. 이렇게 설정의 일관성이 깨진 서버를 &lt;b&gt;SnowFlake 서버&lt;/b&gt;라고 부르는데(눈송이처럼 모양이 다 다르다는 의미), 시간이 지날수록 &quot;왜 A 서버에선 되는데 B 서버에선 안 되지?&quot; 같은 미스터리한 문제가 늘어납니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너로 모든 의존성을 이미지에 포함시키면 이 SnowFlake 문제는 상당 부분 해결됩니다. 하지만 서버 위에 띄운 컨테이너들 자체를 관리하는 문제는 여전히 남게 됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 3대에 컨테이너 20개면 어찌어찌 되겠지만, 실무에서는 수십~수백 대의 노드에 수천 개의 컨테이너가 돌아갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 사람이 관리하는 건 사실상 불가능하고, 그래서 &lt;b&gt;컨테이너 오케스트레이션(Container Orchestration)&lt;/b&gt; 이라는 개념이 나왔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 서버에 걸쳐 컨테이너의 배포, 스케일링, 네트워킹, 장애 복구를 자동화하는 시스템인 셈입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;컨테이너 인프라 환경의 3가지 구성 요소&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨테이너 기반 인프라를 운영한다고 하면 보통 세 가지 도구가 한 세트로 따라옵니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;컨테이너 엔진&lt;/b&gt;&lt;br /&gt;컨테이너의 빌드, 실행, 중지, 삭제 등 생명주기를 담당하는 소프트웨어입니다. 대표적으로 Docker, containerd, CRI-O가 있습니다. &lt;br /&gt;참고로 쿠버네티스는 v1.20에서 Docker를 런타임으로 직접 사용하는 방식을 deprecated 처리했고, v1.24에서 완전히 제거했습니다. 지금은 대부분의 클러스터가 containerd를 씁니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;컨테이너 이미지&lt;/b&gt;&lt;br /&gt;애플리케이션 실행에 필요한 라이브러리, 설정 파일, 실행 파일을 하나로 패키징한 것입니다. &lt;br /&gt;어떤 환경에서 실행하더라도 동일한 결과를 보장하는 게 핵심이고, 레이어(Layer) 구조로 이루어져 있어서 변경된 부분만 새로 빌드하고 나머지는 캐시를 재사용합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;오케스트레이션 도구&lt;/b&gt;&lt;br /&gt;여러 컨테이너를 조정하고 관리하는 도구입니다. &lt;br /&gt;Kubernetes, Docker Swarm, Apache Mesos 등이 있지만, 현재는 Kubernetes가 사실상 표준입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 마지막 단계인 오케스트레이션, 그중에서도 쿠버네티스에 대한 이야기입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;쿠버네티스는 뭘 해결하는가&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠버네티스는 Google이 내부에서 쓰던 Borg라는 컨테이너 관리 시스템의 경험을 바탕으로 만든 오픈소스 프로젝트입니다. 2014년에 공개됐고, 이름은 그리스어로 &quot;조타수(키잡이)&quot;를 의미합니다(로고의 방향타가 여기서 유래했습니다). 이후 CNCF(Cloud Native Computing Foundation)에 기증되면서 AWS, Azure, GCP 등 주요 클라우드 벤더가 모두 지원하는 표준으로 자리잡았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠버네티스가 해결하는 핵심 문제를 세 가지로 정리해봤습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;스케줄링&lt;/b&gt;&lt;br /&gt;컨테이너를 어디에 띄울지 자동으로 결정합니다. 노드마다 남은 CPU, 메모리가 다를 텐데, 쿠버네티스가 각 노드의 리소스 상황을 보고 가장 적절한 곳에 컨테이너를 배치합니다. &lt;br /&gt;사람이 &quot;이 서버에 여유가 있으니까 여기에 올려야지&quot; 하고 판단할 필요가 없는 거죠.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;셀프힐링&lt;/b&gt;&lt;br /&gt;장애가 나면 알아서 복구합니다.. 컨테이너가 죽으면 자동으로 재시작하고, 노드 자체가 다운되면 그 위의 컨테이너들을 다른 노드로 옮깁니다. &quot;3개의 Pod를 유지해라&quot;라고 선언해두면 하나가 죽어도 쿠버네티스가 알아서 새 걸 하나 더 띄워줍니다. &lt;br /&gt;이게 쿠버네티스의 핵심 철학인 선언적 관리(Desired State) 와 연결되는데, 잠시 뒤에 다시 다루겠습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;오토스케일링&lt;/b&gt;&lt;br /&gt;트래픽에 따라 컨테이너 수를 조절합니다.. CPU 사용률이 70%를 넘으면 Pod를 추가로 띄우고, 트래픽이 줄면 다시 줄입니다. &lt;br /&gt;사람이 모니터링 화면을 쳐다보면서 kubectl scale을 칠 필요가 없어지게 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 세 가지만 놓고 봐도, 컨테이너를 대규모로 운영할 때 쿠버네티스가 왜 필요한지 느낌이 옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;선언적 관리라는 철학&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠버네티스를 공부하면서 가장 인상 깊었던 건 &quot;선언적(Declarative)&quot;이라는 접근 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Docker에서는 컨테이너를 직접 만들고, 멈추고, 지우는 &lt;b&gt;명령형(Imperative)&lt;/b&gt; 방식이었습니다. docker run으로 띄우고, docker stop으로 멈추고, 뭔가 잘못되면 사람이 개입해서 고쳐야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠버네티스는 다릅니다. &quot;나는 이 애플리케이션이 3개의 Pod로 돌아가길 원한다&quot;라고 YAML 파일에 적어서 제출하면, 쿠버네티스가 현재 상태를 확인하고 원하는 상태에 맞추는 작업을 계속 반복합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pod가 2개밖에 없으면 하나를 더 만들고, 4개가 되어 있으면 하나를 지웁니다. 이 &quot;원하는 상태를 선언하면 시스템이 맞춰간다&quot;는 게 쿠버네티스의 핵심 동작 원리입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 YAML 파일 작성이 번거롭게 느껴졌는데, 여러 사람들이 관리하는 프로덕션 환경에서 인프라를 코드로 관리(IaC)할 수 있는 큰 장점이 있는 것이 큰 장점인 것을 알게 되었습니다. Git에 넣어서 버전 관리도 되고, 팀원 간에 리뷰도 가능하기 때문에 운영 측면에서 특히 큰 장점이 되는 것 같습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;핵심 개념 빠르게 훑기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠버네티스를 공부하면서 다양한 용어들을 알아야 하기 때문에 복잡한데, 우선 아래의 다섯가지를 정리해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각의 상세한 내용은 이후 글에서 하나씩 정리할 예정입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;클러스터(Cluster)는&amp;nbsp;쿠버네티스가 관리하는 서버 묶음 전체를 말합니다.&lt;/b&gt; &lt;br /&gt;크게 두 부분으로 나뉘는데, 클러스터의 두뇌 역할을 하는 컨트롤 플레인(Control Plane) 과 실제로 컨테이너가 돌아가는 워커 노드(Worker Node) 입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Pod는 쿠버네티스에서 배포할 수 있는 가장 작은 단위입니다.&lt;/b&gt; &lt;br /&gt;Docker에서는 컨테이너 하나가 기본 단위였다면, 쿠버네티스에서는 Pod가 그 역할을 합니다. 보통 하나의 Pod에 하나의 컨테이너가 들어가지만, 밀접하게 관련된 컨테이너를 같은 Pod에 넣을 수도 있습니다. 같은 Pod 안의 컨테이너들은 네트워크와 스토리지 볼륨을 공유합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Deployment 는 &quot;이 애플리케이션을 몇 개의 Pod로 실행해라&quot;라고 선언하는 리소스입니다.&lt;/b&gt; &lt;br /&gt;Pod를 직접 하나씩 만들지 않고 Deployment에 원하는 상태를 적어두면, 쿠버네티스가 그 상태를 유지해줍니다. 업데이트할 때도 Deployment를 수정하면 롤링 업데이트가 자동으로 진행됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Service 는 Pod들 앞에 놓이는 고정 주소입니다.&lt;/b&gt; &lt;br /&gt;Pod는 생겼다 사라졌다 하면서 IP가 계속 바뀌는데, Service가 고정된 IP와 DNS를 제공해서 다른 Pod나 외부에서 안정적으로 접근할 수 있게 해줍니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Namespace 는 클러스터를 논리적으로 나누는 칸막이입니다.&lt;/b&gt; &lt;br /&gt;같은 클러스터 안에서 팀별로, 환경별로(dev/staging/prod) 리소스를 격리할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;쿠버네티스의 전체 구조&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 글에서 각 컴포넌트를 자세히 다룰 건데, 여기서는 큰 그림만 잡아두겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1154&quot; data-origin-height=&quot;478&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4557H/dJMcabRVYak/v5Y0VQ28R5OuQr8WblT0i0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4557H/dJMcabRVYak/v5Y0VQ28R5OuQr8WblT0i0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4557H/dJMcabRVYak/v5Y0VQ28R5OuQr8WblT0i0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4557H%2FdJMcabRVYak%2Fv5Y0VQ28R5OuQr8WblT0i0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1154&quot; height=&quot;478&quot; data-origin-width=&quot;1154&quot; data-origin-height=&quot;478&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;컨트롤 플레인(Control-plane)&lt;/b&gt;에는 클러스터 전체의 상태를 관리하고 의사결정을 내리는 컴포넌트들이 모여 있습니다. &lt;br /&gt;모든 요청이 거치는 진입점인 API Server, 클러스터의 모든 상태를 저장하는 분산 키-값 데이터베이스 etcd, 새 Pod를 어느 노드에 배치할지 결정하는 Scheduler, 원하는 상태를 유지하도록 지속적으로 감시하는 Controller Manager가 여기에 속합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;워커 노드(Worker-Node)&lt;/b&gt;에는 실제 컨테이너를 실행하고 관리하는 컴포넌트들이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노드 위의 Pod를 관리하는 에이전트 kubelet, Service 기반의 네트워크 규칙을 관리하는 kube-proxy, 실제 컨테이너를 띄우는 Container Runtime(containerd 등)이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;kubernetes에서 발생하는 모든 통신은 API 서버를 중심으로 이루어집니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 kubectl 명령을 치면 API 서버로 요청이 가고, API 서버가 etcd에 상태를 저장하고, 스케줄러와 컨트롤러가 그 상태를 보고 필요한 작업을 수행한다는 흐름으로 진행됩니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;정리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿠버네티스는 &quot;컨테이너가 많아지면 사람이 관리할 수 없다&quot;는 문제를 스케줄링, 셀프힐링, 오토스케일링으로 해결하는 도구입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원하는 상태를 선언하면 시스템이 그 상태를 유지한다는 선언적 관리가 핵심 철학이고, 이 뒤의 글에서는 이 선언적 관리를 가능하게 하는 각 컴포넌트를 하나씩 정리해보려고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>DevOps/kubernetes</category>
      <category>DevOps</category>
      <category>K8S</category>
      <category>Kubernetes</category>
      <category>컨테이너 오케스트레이션</category>
      <category>쿠버네티스</category>
      <category>학습 기록</category>
      <author>okbear3</author>
      <guid isPermaLink="true">https://okbear3.tistory.com/94</guid>
      <comments>https://okbear3.tistory.com/94#entry94comment</comments>
      <pubDate>Sat, 25 Apr 2026 00:53:40 +0900</pubDate>
    </item>
  </channel>
</rss>