<?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>Fri, 19 Jun 2026 03:24:26 +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>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>
    <item>
      <title>2-2. ansible-playbook 역할 기반으로 재구성하기</title>
      <link>https://okbear3.tistory.com/90</link>
      <description>&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 플레이북 업그레이드 &amp;mdash; 단일 파일에서 Role 기반 구조로&lt;/b&gt;&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; 시리즈의 2편 후기입니다.&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;p data-ke-size=&quot;size16&quot;&gt;지난 편에서 site_orbstack.yml 하나로 k3s 클러스터를 구축했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플레이북이 정상적으로 동작하는 걸 확인하고 나서, 한 가지 생각이 들었습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;이 파일, 나중에 클라우드 환경에서도 재사용할 수 있을까?&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;솔직히 말하면 어렵습니다. DB 설정을 조금 고치려다 실수로 쉼표 하나를 지우면 k3s 클러스터 설치 전체가 영향을 받을 수 있고, 개발 환경과 운영 환경의 JVM 메모리 크기가 다른데 그걸 코드 안에서 분리할 방법도 마땅치 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 이 프로젝트의 목표가 &lt;b&gt;run-book으로 재사용할 수 있는 템플릿&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;단일 파일(site_orbstack.yml)의 가장 큰 문제는 &lt;b&gt;장애 반경(Blast Radius)&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;예를 들어 Master와 Worker 처럼 타겟이 다른 작업들을 한 파일에 담아둔다면, 이런 코드를 재사용하기 위해 모든 Task마다 조건문을 달게 될 수도 있습니다.&lt;/p&gt;
&lt;pre class=&quot;shell&quot; data-ke-language=&quot;shell&quot;&gt;&lt;code&gt;- name: K3s 마스터 설치
  shell: &quot;curl -sfL &amp;lt;https://get.k3s.io&amp;gt; | sh -&quot;
  when: inventory_hostname in groups['k3s_master']  # 마스터일 때만!

- name: K3s 워커 설치
  shell: &quot;curl -sfL &amp;lt;https://get.k3s.io&amp;gt; | K3S_URL=... sh -&quot;
  when: inventory_hostname in groups['k3s_worker']  # 워커일 때만!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드가 길어질수록 when 조건문이 쌓이고, 가독성이 떨어지며, 실수할 확률이 매우 올라갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것을 해결하는 방법이 Ansible의 &lt;b&gt;Role 기반 구조&lt;/b&gt;와 &lt;b&gt;인벤토리 분리&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;핵심 아이디어는 딱 하나입니다. &lt;b&gt;로직(Logic)과 데이터(Data)를 완전히 분리한다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;shell&quot; data-ke-language=&quot;shell&quot;&gt;&lt;code&gt;ansible/
├── ansible.cfg                      # Ansible 엔진 동작 방식 설정
├── site.yml                         # 진입점 &amp;mdash; Role만 호출, 세부 로직 없음
├── inventories/                     # 환경별 서버 목록과 변수
│   └── dev/
│       ├── hosts.ini                # 개발 환경 서버 주소록
│       └── group_vars/
│           └── all.yml              # 개발 환경 전역 변수
└── roles/                           # 역할별로 쪼개진 모듈 블록
    ├── base/                        # 모든 노드 공통 (방화벽, alias 등)
    ├── nfs_server/                  # NFS 서버(Infra 노드)
    ├── nfs_client/                  # NFS 클라이언트(NFS 사용 노드)
    ├── postgresql/                  # DB 서버
    ├── k3s_firewall/                # K3s 전용 방화벽 규칙
    ├── k3s_master/                  # K3s 마스터 설치 및 권한 설정
    └── k3s_worker/                  # K3s 워커 설치 및 마스터 연결&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;site.yml은 &quot;무엇을 어떤 순서로 실행할지&quot;만 선언하고, 실제 로직은 전부 roles/ 안으로 들어갑니다. inventories/는 환경마다 다른 값(서버 IP, JVM 크기, DB 비밀번호 등)을 담는 공간입니다.&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;Role을 분리하는 기준&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;폴더(Role)를 새로 파는 기준 &amp;mdash; &quot;이것은 독립된 서비스인가?&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;타겟 서버가 다를 때&lt;/b&gt; 는 무조건 분리합니다. k3s 마스터와 워커는 설치 명령어가 비슷해 보이지만 역할이 완전히 다릅니다. 마스터는 &quot;토큰을 발행하는 방장&quot;이고, 워커는 &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;독립적인 생명주기를 가진 소프트웨어일 때&lt;/b&gt;도 분리합니다. PostgreSQL과 NFS는 둘 다 infra-node에 올라가지만, DB 버전을 올린다고 NFS 서버가 영향을 받으면 안 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 방화벽 포트 개방이나 시스템 alias 설정은 k3s나 DB 같은 서비스가 돌아가기 위한 사전 준비물이므로, 해당 Role 안으로 함께 두었습니다.&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;파일(Task)을 쪼개는 기준 &amp;mdash; &quot;이 작업만 단독으로 실행할 일이 있는가?&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나의 Role 안에서도 파일을 더 잘게 쪼갤 수 있습니다. PostgreSQL Role을 예로 들면 이렇게 나눌 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;roles/postgresql/
├── tasks/
│   ├── main.yml       # 지휘자 &amp;mdash; 전체 흐름 제어
│   ├── install.yml    # 패키지 설치
│   ├── configure.yml  # pg_hba.conf 등 설정 변경
│   ├── service.yml    # systemd 재시작
│   ├── users.yml      # DB/유저 생성
│   └── backup.yml     # 정기 백업 (평소엔 실행 안 됨)
├── templates/
│   └── pg_hba.conf.j2
└── defaults/
    └── main.yml       # 기본 변수 (DB 포트, 기본 유저명 등)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 쪼개두면 운영 중에 pg_hba.conf만 수정했을 때, 수백 줄짜리 플레이북을 통째로 돌리지 않고 설정 파일만 딱 집어서 배포할 수 있습니다.&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;main.yml&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Role 안의 main.yml은 직접 실행 명령어를 담는 대신, 다른 파일들을 순서대로 불러오는 &lt;b&gt;지휘자(Dispatcher)&lt;/b&gt; 역할을 합니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# roles/postgresql/tasks/main.yml
---
- name: 1. PostgreSQL 패키지 설치
  include_tasks: install.yml
  tags: ['install', 'setup']

- name: 2. PostgreSQL 환경 설정
  include_tasks: configure.yml
  tags: ['configure', 'setup']

- name: 3. PostgreSQL 서비스 관리
  include_tasks: service.yml
  tags: ['service', 'setup']

- name: 4. Jira DB 및 유저 프로비저닝
  include_tasks: users.yml
  tags: ['users', 'setup']
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 Task에 setup이라는 공통 태그를 달아뒀습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 서버를 구축할 때는 setup 태그 하나만 부르면 1~4번이 순서대로 실행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ansible은 roles: 키워드에서 Role 이름을 만나는 순간, 해당 폴더 안의 tasks/main.yml을 자동으로 찾아 실행하기 때문에, 경로를 직접 지정하지 않아도 됩니다.&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;태그(Tags) &amp;mdash; 필요한 작업만 골라 실행하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일을 잘게 쪼개고 태그를 달아두면, 실행 시에 원하는 작업만 핀셋처럼 집어서 돌릴 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;# DB 접속 설정(pg_hba.conf)만 바꿨을 때 &amp;mdash; 설정 파일만 배포
ansible-playbook site.yml --tags &quot;configure&quot;

# 특정 태그만 제외하고 실행
ansible-playbook site.yml --skip-tags &quot;install&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GitLab CI에서 특정 Job만 실행하는 것과 개념이 유사합니다. CI/CD 파이프라인과 Ansible 태그를 1:1로 매칭시키면, 파이프라인 스크립트가 눈에 띄게 깔끔해집니다.&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;tasks_from &amp;mdash; 특정 파일을 함수처럼 호출하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;태그가 &quot;어떤 작업을 실행할지&quot;를 고르는 방법이라면, tasks_from은 Role 안의 &quot;특정 파일만 직접 호출하는&quot; 방법입니다. 예를 들어 매일 새벽 3시에 DB 백업만 단독으로 실행하고 싶다면 이렇게 씁니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# ops_backup.yml
---
- hosts: infra
  become: yes
  tasks:
    - name: DB 정기 백업 함수 호출
      include_role:
        name: postgresql
        tasks_from: backup.yml  # main.yml을 거치지 않고 backup.yml만 실행
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;main.yml의 흐름을 완전히 무시하고, 마치 모듈 안의 특정 함수를 호출하듯 유연하게 아키텍처를 운용할 수 있습니다.&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;런타임 메모리 공유 &amp;mdash; 워커는 마스터의 토큰을 어떻게 알까&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일이 Role별로 쪼개져 있는데, 워커 Role이 마스터 Role에서 생성한 토큰 값을 어떻게 가져다 쓸 수 있을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것이 Ansible의 &lt;b&gt;글로벌 변수 컨텍스트(hostvars)&lt;/b&gt; 덕분입니다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;# roles/k3s_master/tasks/main.yml
- name: Master 노드 토큰 읽어오기
  slurp: { src: /var/lib/rancher/k3s/server/node-token }
  register: master_token  # Ansible 엔진의 메모리에 저장
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;# roles/k3s_worker/tasks/main.yml
- name: K3s 워커 설치 및 마스터 연결
  shell: &amp;gt;
    curl -sfL &amp;lt;https://get.k3s.io&amp;gt; |
    K3S_URL=https://{{ hostvars[groups['k3s_master'][0]]['inventory_hostname'] }}:{{ k3s_api_port }}
    K3S_TOKEN={{ hostvars[groups['k3s_master'][0]]['master_token']['content'] | b64decode | trim }}
    sh -
  args: { creates: /usr/local/bin/k3s }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일은 물리적으로 분리되어 있지만 Ansible 프로세스는 플레이북이 끝날 때까지 계속 살아있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마스터 Role이 register: master_token으로 값을 메모리에 저장해두면, 워커 Role이 나중에 hostvars를 통해 그 값을 꺼내 씁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일이 아무리 분리되어 있어도 &lt;b&gt;런타임 메모리는 공유&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;site.yml&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# ansible/site.yml
---
- name: 1. 필수 패키지 설치 및 공통 설정
  hosts: all
  become: yes
  roles: [ base ]

- name: 2. Infra Node 구축
  hosts: infra
  become: yes
  roles: [ infra ]

- name: 3. K3s 전용 방화벽 핀포인트 개방
  hosts: k3s_cluster
  become: yes
  roles: [ k3s_firewall ]

- name: 4. K3s Master 구성
  hosts: k3s_master
  become: yes
  roles: [ k3s_master ]

- name: 5. K3s Worker 구성
  hosts: k3s_worker
  become: yes
  roles: [ k3s_worker ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;site.yml에는 세부 로직이 없습니다. 환경이 바뀌어도 이 파일은 수정하지 않습니다.&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.cfg와 group_vars &amp;mdash; 설정과 데이터의 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조를 다듬으면서 한 가지를 더 정리했습니다. 이전 편에서 hosts.ini에 ansible_ssh_common_args='-o StrictHostKeyChecking=no'를 넣었는데, 사실 이건 &quot;서버에 어떤 값을 전달하느냐&quot;가 아니라 &quot;Ansible이 SSH 접속을 어떻게 하느냐&quot;에 대한 설정입니다. 위치가 잘못된 겁니다.&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;width: 18.0233%; height: 18px;&quot;&gt;파일&lt;/td&gt;
&lt;td style=&quot;width: 48.9535%; height: 18px;&quot;&gt;역할&lt;/td&gt;
&lt;td style=&quot;width: 32.907%; height: 18px;&quot;&gt;의존도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 18.0233%; height: 18px;&quot;&gt;ansible.cfg&lt;/td&gt;
&lt;td style=&quot;width: 48.9535%; height: 18px;&quot;&gt;Ansible 프로그램 자체의 동작 방식 제어&lt;/td&gt;
&lt;td style=&quot;width: 32.907%; height: 18px;&quot;&gt;Dev/Prod 동일하게 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 18.0233%; height: 18px;&quot;&gt;group_vars&lt;/td&gt;
&lt;td style=&quot;width: 48.9535%; height: 18px;&quot;&gt;타겟 서버에 전달할 데이터와 변수&lt;/td&gt;
&lt;td style=&quot;width: 32.907%; height: 18px;&quot;&gt;환경마다 값이 완전히 다름&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# ansible/ansible.cfg
[defaults]
inventory = inventories/dev/hosts.ini  # 기본 인벤토리 경로 (-i 생략 가능)
host_key_checking = False              # SSH Key 확인 프롬프트 생략
stdout_callback = yaml                 # 에러 로그를 읽기 쉽게 출력

[ssh_connection]
pipelining = True                      # SSH 세션 재사용으로 배포 속도 향상
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;crmsh&quot;&gt;&lt;code&gt;# inventories/dev/hosts.ini
[k3s_master]
master-node.orb.local

[k3s_worker]
worker-node.orb.local

[infra]
infra-node.orb.local

[k3s_cluster:children]
k3s_master
k3s_worker
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;shell&quot; data-ke-language=&quot;shell&quot;&gt;&lt;code&gt;# inventories/dev/group_vars/all.yml
---
env_name: &quot;dev&quot;
k3s_api_port: &quot;6443&quot;&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 data-ke-size=&quot;size23&quot;&gt;멀티 환경 대응 &amp;mdash; 코드는 그대로, 값만 갈아 끼운다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조의 진짜 가치는 운영 환경이 추가될 때 드러납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OrbStack 개발 환경과 사내 VM 운영 환경은 서버 IP도 다르고, Jira에 할당할 JVM 메모리도 다릅니다. 기존 구조였다면 파일 안을 직접 수정해야 했겠지만, 이제는 inventories/ 아래에 폴더 하나를 추가하면 끝납니다.&lt;/p&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;inventories/
├── dev/
│   ├── hosts.ini
│   └── group_vars/
│       └── all.yml
│           # env_name: &quot;Dev (OrbStack)&quot;
│           # jira_jvm_min: &quot;1024m&quot;
│           # jira_jvm_max: &quot;2048m&quot;
│           # jira_db_host: &quot;infra-node.orb.local&quot;
└── prod/
    ├── hosts.ini
    └── group_vars/
        └── all.yml
            # env_name: &quot;사내 운영 환경 (VM)&quot;
            # jira_jvm_min: &quot;8192m&quot;
            # jira_jvm_max: &quot;16384m&quot;
            # jira_db_host: &quot;192.168.X.232&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Role 안의 코드는 변수만 참조합니다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# roles/k3s_firewall/tasks/main.yml
- name: K3s 필수 포트 개방
  firewalld:
    port: &quot;{{ item }}&quot;
    permanent: yes
    immediate: yes
    state: enabled
  loop:
    - &quot;{{ k3s_api_port }}/tcp&quot;   # group_vars에서 주입
    - &quot;10250/tcp&quot;
    - &quot;8472/udp&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행할 때는 인벤토리 경로만 바꾸면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;# 개발 환경 배포
ansible-playbook -i inventories/dev/hosts.ini site.yml

# 운영 환경 배포
ansible-playbook -i inventories/prod/hosts.ini site.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;site.yml과 roles/ 안의 로직은 한 글자도 건드리지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ansible이 지정된 인벤토리 폴더 안의 group_vars를 읽어서 {{ jira_jvm_max }}에 맞는 값을 알아서 주입해 줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 투입할 Jira Helm 배포 Task에서 {{ jira_jvm_max }}를 불러다 쓰기만 하면, 환경에 맞춰 K8s 파드의 크기가 자동으로 변형되어 배포됩니다.&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;구조가 바뀌었지만 실행 방법은 이전과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 문법 검증
ansible-playbook site.yml --syntax-check

# 시뮬레이션 &amp;mdash; 실제 서버를 건드리지 않고 결과 미리 확인
ansible-playbook site.yml --check

# 실제 실행
ansible-playbook site.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ansible.cfg에 기본 인벤토리 경로를 지정해뒀기 때문에 -i 옵션도 생략할 수 있습니다.&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 title=&quot;https://github.com/hweyoung/atlassian-k8s-gitops/tree/main/ansible&quot; href=&quot;https://github.com/hweyoung/atlassian-k8s-gitops/tree/main/ansible&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&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;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단일 파일에서 Role 기반 구조로 바꾸는 데 들인 시간이 아깝지 않은 이유는 하나입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;환경이 바뀌어도 로직은 그대로 재사용하고, 필요한 Role만 골라 실행하고, 운영 중에 설정 파일 하나만 배포하고 싶으면 태그로 딱 집어서 돌릴 수 있는 구조가 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 목표로 삼았던 run-book 템플릿에 한 걸음 더 가까워진 셈입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 편에서는 ArgoCD를 사용해서 &lt;b&gt;GitOps 배포 파이프라인을 구성하는 과정&lt;/b&gt;을 다루겠습니다.&lt;/p&gt;</description>
      <category>프로젝트/엔터프라이즈 JVM 애플리케이션의 Cloud-Native 전환기</category>
      <category>ansible</category>
      <category>ansible-playbook</category>
      <category>gitops</category>
      <category>k3s</category>
      <category>K8S</category>
      <category>Kubernetes</category>
      <category>nfs</category>
      <category>PostgreSQL</category>
      <category>학습기록</category>
      <author>okbear3</author>
      <guid isPermaLink="true">https://okbear3.tistory.com/90</guid>
      <comments>https://okbear3.tistory.com/90#entry90comment</comments>
      <pubDate>Wed, 22 Apr 2026 19:09:47 +0900</pubDate>
    </item>
    <item>
      <title>[Github] Github Projects를 Jira처럼 세팅하기 (사이드 프로젝트 관리)</title>
      <link>https://okbear3.tistory.com/93</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Atlassian 엔지니어로 근무하며 기업들의 협업 도구(Jira, Confluence 등)를 운영하다 보니, 자연스럽게 체계적인 프로젝트 관리에 많은 관심을 가지게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 사이드 프로젝트를 시작하면서 진행 상황을 효율적으로 관리할 시스템이 필요했고, 이슈 관리 툴로 &lt;b&gt;GitHub Projects&lt;/b&gt;를 선택하게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 엔지니어의 관점에서 왜 GitHub Projects를 선택했는지에 대한 고찰과, 이를 Jira의 체계적인 이슈 관리 형태처럼 구성한 방법을 기록으로 남기고자 합니다.&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;왜 GitHub Projects인가?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jira는 대규모 프로젝트와 엔터프라이즈 환경에서 매우 강력한 도구입니다. 하지만 소규모 인원이 빠르게 진행해야 하는 사이드 프로젝트 환경에서는 다음과 같은 점들을 고려해야 했습니다.&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;컨텍스트 스위칭(Context Switching) 제로:&lt;/b&gt; 개발자에게 탭 이동은 곧 피로도입니다. GitHub Projects는 코드가 있는 레포지토리 바로 옆에 붙어 있습니다. 브랜치 생성, 커밋, PR 상태 확인을 한 곳에서 끝낼 수 있다는 게 가장 큰 매력입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;가볍고 강력한 자동화:&lt;/b&gt; 복잡한 워크플로우를 설계할 필요 없이, GitHub 내장 기능(Workflows)만으로 &amp;lsquo;PR 올리면 Review로 이동&amp;rsquo;, &amp;lsquo;Merge 되면 Done으로 이동&amp;rsquo; 같은 필수 자동화를 쉽게 세팅할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;애자일(Agile) 요소의 완벽한 대체:&lt;/b&gt; GitHub의 Milestone, Iteration 기능을 잘 조합하면 Jira의 Sprint나 Epic 구조를 충분히 흉내 낼 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로, **&quot;Jira의 체계적인 이슈 계층(Epic-Feature-Task) 구조의 장점은 가져오되, GitHub의 코드 밀착형 장점을 극대화하자&quot;**는 것이 이번 구성의 핵심 목표였습니다.&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;2. Milestone(배포)과 Iteration(스프린트)의 분리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 GitHub의 Milestone을 스프린트처럼 쓸까 고민했지만, 프로젝트를 기획하다 보니 명확한 분리가 필요했습니다. Jira의 구조를 빌려와 다음과 같이 역할을 나눴습니다.&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;Milestone(Release 목표) :&lt;/b&gt; 큰 단위의 배포나 프로젝트의 중요 체크포인트를 의미합니다. (Jira의 Fix Version 개념)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;예시: v1.0.0 - MVP (핵심 기능 1차 개발), v1.0.0 - RC (출시 준비)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Iteration(Sprint):&lt;/b&gt; 실제 개발이 돌아가는 짧은 주기(예: 1주~2주)를 의미합니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;예시: Iteration 1 (1주차 개발 진행)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 나누면 &quot;이번 1.0.0 배포(Milestone)를 위해, 이번 주 스프린트(Iteration)에는 이 이슈들을 처리하자&quot;라는 입체적인 관리가 가능해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt; &amp;nbsp;Agile하게 Iterator(Sprint)를 활용하기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 필드만 만들어 둔다고 애자일이 되는 것은 아닙니다. Atlassian 환경에서 수많은 팀의 스크럼(Scrum)을 가이드하면서 느낀 점은, &lt;b&gt;'시간의 제약(Time-box)'을 명확히 하고 그 안에서 집중력을 끌어올리는 것&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;GitHub Projects의 Iteration 필드를 활용해 애자일의 스프린트를 어떻게 운영하는지 그 사이클을 정리해봤습니다.&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;애자일(Agile)에서 말하는 스프린트/이터레이션이란?&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;Time-boxing:&lt;/b&gt; 보통 1주에서 길면 4주 정도의 고정된 기간을 정해두고 개발을 진행합니다. (사이드 프로젝트는 주말 작업이 많아 보통 &lt;b&gt;1주~2주 단위&lt;/b&gt;를 추천합니다.)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;작은 성공의 반복:&lt;/b&gt; Milestone(예: 1.0.0 출시)을 단번에 구현하기엔 작업의 범위가 크기 때문에, 2주 단위의 이터레이션으로 쪼개어 &quot;이번 2주 동안은 로그인/회원가입만 완벽하게 끝내자&quot;라는 식으로 팀의 포커스를 맞춥니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유연한 대처:&lt;/b&gt; 2주가 끝난 뒤 회고를 통해 일정을 조정하거나, 예상치 못한 버그나 기획 변경에 빠르게 대응할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;GitHub Projects에 Iteration 설정하고 운영하기&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정(Settings)에서 Iteration 필드를 추가하면, &lt;b&gt;시작일(Start Date)과 주기(Duration, 예: 2 weeks)&lt;/b&gt;를 지정할 수 있으며, 시스템이 자동으로 날짜를 계산해 Iteration 1, Iteration 2를 생성해 줍니다.&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;실제 저희 팀의 2주 단위 사이클은 다음과 같이 돌아갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 스프린트 플래닝 (Sprint Planning)&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;새로운 Iteration이 시작되는 날(ex. 격주 월요일), 팀원들이 모여 대시보드의 Backlog를 엽니다.&lt;/li&gt;
&lt;li&gt;이번 달 목표인 Milestone(v1.0.0 - MVP)에 속한 이슈 중, &lt;b&gt;이번 2주 안에 끝낼 수 있는 만큼의 Feature와 Task&lt;/b&gt;를 선별합니다.&lt;/li&gt;
&lt;li&gt;선별된 이슈들의 Iteration 필드를 Iteration 1로 지정하고, 상태(Status)를 Ready로 옮깁니다. &quot;이번 주 주말 전까지 이것만큼은 꼭 끝내자!&quot;라는 약속입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 집중과 실행 (Daily &amp;amp; Execution)&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;Projects 보드 상단에 있는 필터 바에 iteration:@current 라고 검색 조건을 걸어두고 뷰(View)를 저장해 둡니다.&lt;/li&gt;
&lt;li&gt;이렇게 하면 수많은 백로그는 숨겨지고, &lt;b&gt;오직 '이번 스프린트(현재 기간)에 해야 할 일'만&lt;/b&gt; 보드에 깔끔하게 남습니다. 개발자는 이 뷰만 보며 Ready -&amp;gt; In Progress -&amp;gt; Done으로 이슈를 밀어내는 데만 집중합니다.&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;Iteration의 마지막 날, 지금까지 작업한 내용들을 되돌아 보며 회고 진행합니다.&lt;/li&gt;
&lt;li&gt;만약 예상보다 작업이 길어져서 다 끝내지 못한 이슈가 있다면? 이를 스필오버(Spillover)라고 부릅니다.&lt;/li&gt;
&lt;li&gt;못 다한 이슈는 상태를 다시 점검한 뒤, 다음 스프린트인 Iteration 2로 필드 값을 수정하여 이월시킵니다. 이를 통해 우리 팀이 2주 동안 실제로 소화할 수 있는 작업량(Velocity)을 객관적으로 파악하게 됩니다.&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;3. Epic - Feature - Task : 3단계 이슈 계층 구조 잡기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단일 이슈 리스트만으로는 프로젝트의 큰 그림을 보기 어렵습니다. 그래서 Jira의 핵심인 계층 구조를 그대로 가져와서 &lt;b&gt;Epic &amp;gt; Feature &amp;gt; Task&lt;/b&gt; 3단계로 명확히 나눴습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1368&quot; data-origin-height=&quot;596&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cZZ2xC/dJMcabYaCqN/PI2j1QdMmkIJQH4iLT7zKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cZZ2xC/dJMcabYaCqN/PI2j1QdMmkIJQH4iLT7zKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cZZ2xC/dJMcabYaCqN/PI2j1QdMmkIJQH4iLT7zKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcZZ2xC%2FdJMcabYaCqN%2FPI2j1QdMmkIJQH4iLT7zKk%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;580&quot; height=&quot;253&quot; data-origin-width=&quot;1368&quot; data-origin-height=&quot;596&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;아래는 User 도메인을 개발할 때의 예시입니다.&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;Milestone:&lt;/b&gt; V1.0.0 - MVP 구현
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Epic:&lt;/b&gt; User 도메인 개발
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Feature:&lt;/b&gt; 사용자 토큰 관리 공통 Util 개발
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Task:&lt;/b&gt; Security 로그인 개발&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Task:&lt;/b&gt; JWT 토큰 발급 로직 개발&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Feature:&lt;/b&gt; Apple 로그인 개발
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Task:&lt;/b&gt; Apple 개발자 계정 등록&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Task:&lt;/b&gt; Apple API를 이용하여 사용자 조회&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&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;4. 대시보드 및 워크플로우 구성 (Projects Board)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 잡은 뼈대를 바탕으로 실제 GitHub Projects 대시보드를 구성한 모습입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1450&quot; data-origin-height=&quot;848&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cq9GFY/dJMcaiwbMce/Wp0KGI1yP0ovC4OYzSBjj1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cq9GFY/dJMcaiwbMce/Wp0KGI1yP0ovC4OYzSBjj1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cq9GFY/dJMcaiwbMce/Wp0KGI1yP0ovC4OYzSBjj1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcq9GFY%2FdJMcaiwbMce%2FWp0KGI1yP0ovC4OYzSBjj1%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;1450&quot; height=&quot;848&quot; data-origin-width=&quot;1450&quot; data-origin-height=&quot;848&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;Epic 단위로 묶어보기 (Swimlane 적용)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jira 보드의 큰 장점인 Epic 단위 묶어보기를 구현했습니다. &lt;br /&gt;&lt;b&gt;Group by&lt;/b&gt; 설정을 Parent issue로 지정하면, 위 사진처럼 [EPIC] Infra / Common, [EPIC] Member 등 에픽 단위로 스윔레인(Swimlane)이 만들어집니다. 어떤 도메인의 작업이 어디까지 진행됐는지 직관적으로 보입니다.&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;상태(Status) 관리 및 흐름&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 진행 상태는 코딩 사이클과 맞물리도록 다음과 같이 구성했습니다. &lt;br /&gt;Jira의 워크플로우와 마찬가지로 Github Action을 사용하여 자동화 로직을 구성할 수 있습니다.&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;Backlog (이슈 생성) :&lt;/b&gt; 마크다운 템플릿을 사용하여 이슈를 생성하고, Milestone(배포 목표)과 커스텀 필드(우선순위, Estimate 등)를 입력합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Ready (계획) :&lt;/b&gt; Projects 보드의 Backlog에 자동 추가된 이슈들을 확인하고, 이번 스프린트(Iteration) 계획 시 Ready 컬럼으로 이동시킵니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;In Progress (진행) :&lt;/b&gt; 작업자가 할당되고 실제 작업이 시작되면 상태를 변경합니다. 이후 지정된 네이밍 규칙(feature/#&amp;lt;이슈번호&amp;gt;-&amp;lt;기능설명&amp;gt;)에 따라 브랜치를 생성합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;커밋:&lt;/b&gt; 커밋 컨벤션(git commit -m &quot;feat: [#이슈번호] 기능 설명&quot;)을 철저히 준수하여 코드를 작성합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;In Review (코드 리뷰) :&lt;/b&gt; 작업 내역을 바탕으로 &lt;b&gt;PR(Pull Request)을 생성하면, 보드의 이슈가 In Review 컬럼으로 자동 이동&lt;/b&gt;합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Done (완료) :&lt;/b&gt; 코드 리뷰 및 승인(Approve) 후 메인 브랜치에 &lt;b&gt;Merge가 완료되면 Done 컬럼으로 자동 이동&lt;/b&gt;하며 이슈가 닫힙니다. (필요시 Wiki를 업데이트하고 작업을 최종 마무리합니다.)&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;5. 필드(Fields) 구성과 마크다운 이슈 템플릿&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이슈를 꼼꼼하게 추적하기 위해 다양한 Custom Field를 활용하고, 규격화된 문서 작성을 위해 GitHub Issue Template을 적용했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1443&quot; data-origin-height=&quot;847&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDvVJM/dJMcacQiKVA/N8gDv3PqJV4mKWVfwbAYD1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDvVJM/dJMcacQiKVA/N8gDv3PqJV4mKWVfwbAYD1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDvVJM/dJMcacQiKVA/N8gDv3PqJV4mKWVfwbAYD1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDvVJM%2FdJMcacQiKVA%2FN8gDv3PqJV4mKWVfwbAYD1%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;1443&quot; height=&quot;847&quot; data-origin-width=&quot;1443&quot; data-origin-height=&quot;847&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;커스텀 필드(Custom Fields)&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;&lt;b&gt;Type&lt;/b&gt; (Feature, Task, Bug) / &lt;b&gt;Category&lt;/b&gt; (도메인 분류)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Priority:&lt;/b&gt; P0, P1, P2 (우선순위)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Estimate :&lt;/b&gt; 예상 소요 시간(스토리 포인트)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Iteration:&lt;/b&gt; 현재 진행 중인 스프린트(Iteration) 주차&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Start date / Target date:&lt;/b&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;이슈 템플릿 활용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이슈를 올릴 때마다 양식을 고민할 필요가 없도록 GitHub Issue Template을 적용했습니다. &lt;br /&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[Feature 템플릿 적용 예시]&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;##   상위 Epic
- Epic: #10 User 도메인 개발

##   개요
Apple ID를 이용한 소셜 로그인 기능을 개발합니다.

##   작업 목표
- iOS 사용자에게 간편한 로그인 경험 제공
- Apple 생태계 사용자 유입 확대

## ✅ 완료 조건
- [ ] Apple Developer 계정 설정 완료
- [ ] 백엔드 토큰 검증 로직 구현
- [ ] 로그인 성공/실패 케이스 테스트 완료

##   참고 자료
- [Apple Sign In 공식 문서](&amp;lt;https://developer.apple.com/sign-in-with-apple/&amp;gt;)
&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;p data-ke-size=&quot;size16&quot;&gt;매일 무거운 엔터프라이즈 환경의 툴만 만지다가, 사이드 프로젝트에 맞춰 가벼운 GitHub Projects의 기능으로 Jira와 유사하게 구성하는 생각보다 훨씬 재밌었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 기회로 다시금 Github의 기능에 대해 공부하면서, 프로젝트에 규모와 사용하는 툴을 고려하여 이슈 관리를 하는 것이 중요하다는 것을 느끼게 되었습니다.&lt;/p&gt;</description>
      <category>DevOps/Github</category>
      <category>agile</category>
      <category>feature</category>
      <category>GitHub</category>
      <category>Sprint</category>
      <category>워크플로우</category>
      <category>이슈 관리</category>
      <category>이슈 템플릿</category>
      <author>okbear3</author>
      <guid isPermaLink="true">https://okbear3.tistory.com/93</guid>
      <comments>https://okbear3.tistory.com/93#entry93comment</comments>
      <pubDate>Mon, 20 Apr 2026 17:38:36 +0900</pubDate>
    </item>
  </channel>
</rss>